The Front End
The site's look comes from Bloom, a template by BlatUI: soft clay cards, pastel shapes and rounded type. Bloom is a single static page; this chapter turns each of its sections into a view that reads Lynx content, so the staff change the site without touching the design.
Install BlatUI
BlatUI is shadcn/ui for Blade, Alpine and Tailwind: you copy its components into your app and own them. Lynx's admin is built with it too.
composer require anousss007/blatui --dev
php artisan vendor:publish --tag=blatui-foundations
php artisan blatui:add badge tabs accordion rating avatar carousel input button
npm install --save-dev alpinejs @alpinejs/anchor @alpinejs/collapse @alpinejs/focus @floating-ui/domThe foundations are BlatUI's theme (resources/css/blatui.css) and its Alpine helpers (resources/js/blatui.js). blatui:add also suggests gehrisandro/tailwind-merge-laravel for the twMerge() macro the components use; skip it, since Lynx's admin registers that macro already.
Point the app's CSS and JavaScript at them, and add Atlas's own styles, taken from Bloom:
/* BlatUI's foundation (Tailwind, theme tokens, sources), the forms module's styles, then Atlas's
Bloom look, which also colours the forms (--lynx-form-*). */
@import "./blatui.css";
@import "../../vendor/lynxcms/forms/resources/css/forms.css";
@import "./atlas.css";/*
* BlatUI's foundation (php artisan vendor:publish --tag=blatui-foundations): registers its Alpine
* helpers on the Alpine Livewire starts, or starts its own on pages without Livewire.
*/
import './blatui.js'blatui.js registers BlatUI on the Alpine that Livewire starts, or starts its own on pages without Livewire, so tabs and accordions work on every page.
The layout
Each page view renders inside one layout component: Bloom's floating navigation, the page, and the footer. The menus come from the menus module, so staff change the navigation in the admin, and the contact details from the settings page:
@props(['page', 'seo' => []])
{{-- The site's frame, from BlatUI's Bloom template: sticky pill navigation, page, footer. --}}
<!DOCTYPE html>
<html lang="{{ lynx()->languages->current()->appLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Title, description, canonical, robots, share cards, hreflang and JSON-LD (chapter 5) --}}
@seo($page, $seo)
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=fredoka:500,600,700|plus-jakarta-sans:400,500,600,700&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="atlas">
<a href="#main" class="sr-only focus:not-sr-only">@string('layout.skip', ['en' => 'Skip to content', 'pt' => 'Saltar para o conteúdo'])</a>
<header class="sticky top-0 z-40 flex justify-center px-4 py-3">
<div class="atlas-nav flex w-full max-w-5xl items-center gap-3 px-5 py-2.5">
<a href="{{ lynx()->pages->get('/')?->url }}" class="flex items-center gap-2 font-semibold">
<span class="atlas-icon-tile" style="background:var(--atlas-lilac)"><x-lucide-flower-2 class="size-5" aria-hidden="true" /></span>
<span class="atlas-logo">Atlas</span>
</a>
<x-lynx-menu name="main" view="menus.nav" class="ml-4 hidden md:flex" />
<div class="ml-auto flex items-center gap-2">
@include('partials.language-switcher', ['page' => $page])
<a href="{{ auth()->check() ? route('account') : route('login') }}" class="hidden rounded-full px-3 py-1.5 text-sm font-medium hover:bg-black/5 md:inline-block">
@auth @string('layout.account', ['en' => 'My bookings', 'pt' => 'As minhas reservas']) @else @string('layout.sign-in', ['en' => 'Sign in', 'pt' => 'Entrar']) @endauth
</a>
<a href="{{ lynx()->pages->get('/courses/')?->url }}" class="atlas-pill-btn atlas-pill-btn-primary hidden sm:inline-flex" style="padding:.55rem 1.4rem;font-size:.9rem">
@string('layout.cta', ['en' => 'Find a course', 'pt' => 'Encontrar um curso'])
</a>
</div>
</div>
</header>
<main id="main">
{{ $slot }}
</main>
<footer class="atlas-footer px-4 py-14">
<div class="mx-auto grid max-w-5xl gap-10 sm:grid-cols-3">
<div>
<p class="atlas-logo text-white">Atlas Training</p>
<p class="mt-2 text-sm opacity-60">@string('layout.tagline', ['en' => 'Certified wellness courses since 2011.', 'pt' => 'Cursos certificados de bem-estar desde 2011.'])</p>
</div>
<div class="grid gap-6">
<x-lynx-menu name="footer" view="menus.footer" />
<x-lynx-island livewire="lynx-form" :with="['form' => 'newsletter', 'pageId' => $page->id]" />
</div>
<address class="text-sm not-italic opacity-80">
@if ($settings = lynx()->settings())
<a href="mailto:{{ $settings->email }}">{{ $settings->email }}</a><br>
{{ $settings->phone }}
@endif
</address>
</div>
</footer>
</body>
</html>Two Lynx features carry the layout:
@stringdeclares a short text in the view, with a default per language. Staff change it under Site texts in the admin; texts in alayoutsfolder are shared by every page. A button label needs no field of its own.@hreflang($page)tells search engines about the page in each language, and the language switcher links to it:
{{-- Links to this page in each language it is published in, keeping filters in the query string. --}}
@if (lynx()->languages->isMultilingual())
<nav aria-label="@string('layout.languages', ['en' => 'Languages', 'pt' => 'Idiomas'], scope: 'site')" class="flex rounded-full bg-black/5 p-0.5 text-xs font-semibold">
@foreach (lynx()->languages->switcher($page) as $link)
<a href="{{ $link['url'] }}" hreflang="{{ $link['language']->appLocale() }}" lang="{{ $link['language']->appLocale() }}"
@if ($link['isCurrent']) aria-current="page" @endif
@class(['rounded-full px-2.5 py-1 uppercase', 'bg-white shadow-sm' => $link['isCurrent']])>{{ $link['language']->code }}</a>
@endforeach
</nav>
@endifThe main menu is rendered by a view of your own, with Bloom's pill links. Its items know whether they link to the current page:
{{-- The main menu as Bloom's pill links. Editors change it in the admin's Menus screen. --}}
<nav aria-label="{{ $menu->title }}" {{ $attributes->merge(['class' => 'items-center gap-0.5 text-sm']) }}>
@foreach ($menu->items as $item)
@if ($item->hasUrl())
<a href="{{ $item->url }}" @if ($item->isActive) aria-current="page" @endif
@class(['rounded-full px-3.5 py-1.5 font-medium transition-colors hover:bg-black/5', 'bg-black/5' => $item->isInActiveTrail])>{{ $item->label }}</a>
@endif
@endforeach
</nav>The home page
Bloom's sections map onto the home template's fields:
| Bloom section | Atlas field |
|---|---|
| Hero | title, summary |
| Stats row | stats (repeater) |
| Feature grid | highlights (repeater, with a Lucide icon) |
| Workout cards with tabs | featured courses, then a selector per level |
| How it works | steps (repeater) |
| Testimonials carousel | testimonials (repeater) |
| FAQ accordion | faqs (repeater) |
The view includes one partial per section. The hero:
<section class="relative overflow-hidden px-4 pb-16 pt-10">
<div class="atlas-blob" style="width:420px;height:420px;background:var(--atlas-lilac);top:-80px;left:-120px"></div>
<div class="atlas-blob" style="width:360px;height:360px;background:var(--atlas-peach);top:40px;right:-100px"></div>
<div class="relative mx-auto max-w-4xl text-center">
<x-ui.badge class="mb-5 rounded-full atlas-badge">
<x-lucide-sparkles class="size-3" aria-hidden="true" />
@string('home.badge', ['en' => 'New dates for next season', 'pt' => 'Novas datas para a próxima temporada'])
</x-ui.badge>
<h1 class="atlas-hero-title">@field($page->title)</h1>
<p class="atlas-lead mx-auto mt-4 max-w-2xl">@field($page->summary)</p>
<div class="mt-7 flex flex-wrap justify-center gap-3">
<a href="{{ lynx()->pages->get('/courses/')?->url }}" class="atlas-pill-btn atlas-pill-btn-primary">
@string('home.cta.courses', ['en' => 'See all courses', 'pt' => 'Ver todos os cursos'])
</a>
<a href="#faqs" class="atlas-pill-btn atlas-pill-btn-outline">
@string('home.cta.questions', ['en' => 'Questions?', 'pt' => 'Dúvidas?'])
</a>
</div>
</div>
</section>The course tabs are BlatUI tabs. The first tab shows the courses the staff picked; the others run a selector per level. lynx()->fields->choices('level') lists the field's options with labels in the visitor's language, so a new level in the admin adds a tab:
@php($levels = lynx()->fields->choices('level'))
<section class="px-4 py-16" id="courses">
<div class="mx-auto max-w-5xl">
<x-ui.tabs value="featured">
<div class="mb-8 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end">
<h2 class="atlas-section-title">@string('home.courses', ['en' => 'Find your course', 'pt' => 'Encontre o seu curso'])</h2>
<x-ui.tabs-list class="!rounded-full">
<x-ui.tabs-trigger value="featured">@string('home.tab.featured', ['en' => 'Featured', 'pt' => 'Em destaque'])</x-ui.tabs-trigger>
@foreach ($levels as $level)
<x-ui.tabs-trigger :value="$level->value">{{ $level }}</x-ui.tabs-trigger>
@endforeach
</x-ui.tabs-list>
</div>
<x-ui.tabs-content value="featured">
@include('partials.course-grid', ['courses' => $page->featured])
</x-ui.tabs-content>
@foreach ($levels as $level)
<x-ui.tabs-content :value="$level->value">
@include('partials.course-grid', ['courses' => lynx()->pages->find("template=course, level={$level->value}, sort=title, limit=6")])
</x-ui.tabs-content>
@endforeach
</x-ui.tabs>
</div>
</section>The questions are an accordion over a repeater:
@if ($page->faqs->count())
<section class="atlas-section-peach px-4 py-16" id="faqs">
<div class="mx-auto max-w-3xl">
<h2 class="atlas-section-title mb-10 text-center">@string('home.faqs', ['en' => 'Questions', 'pt' => 'Perguntas frequentes'])</h2>
<div class="atlas-clay p-6">
<x-ui.accordion type="single" collapsible>
@foreach ($page->faqs as $faq)
<x-ui.accordion-item :value="'faq-'.$loop->index">
<x-ui.accordion-trigger>@field($faq->heading)</x-ui.accordion-trigger>
<x-ui.accordion-content>@field($faq->text)</x-ui.accordion-content>
</x-ui.accordion-item>
@endforeach
</x-ui.accordion>
</div>
</div>
</section>
@endifEvery course appears as the same card, with its next open session:
{{-- A course as a clay card: level, title, summary, hours and price, and its next session. --}}
@php($next = $course->children('template=session, starts>=now, availability=scheduled, sort=starts, limit=1')->first())
<article class="atlas-clay relative flex flex-col overflow-hidden" data-level="{{ $course->level?->value }}">
<div class="atlas-card-band" data-level="{{ $course->level?->value }}">
@if ($course->level)
<x-ui.badge class="rounded-full atlas-badge">{{ $course->level }}</x-ui.badge>
@endif
</div>
<div class="flex flex-1 flex-col gap-2 p-5">
<h3 class="text-lg"><a href="{{ $course->url }}" class="after:absolute after:inset-0">@field($course->title)</a></h3>
<p class="atlas-muted flex-1 text-sm">@field($course->summary)</p>
<p class="flex items-center justify-between text-sm font-semibold">
<span>{{ $course->hours }} h</span>
<span>{{ Illuminate\Support\Number::currency((float) $course->price, 'EUR', lynx()->languages->current()->appLocale()) }}</span>
</p>
@if ($next)
<p class="atlas-muted text-xs">
@string('courses.next', ['en' => 'Next', 'pt' => 'Próxima'], scope: 'site'):
{{ $next->starts->locale(lynx()->languages->current()->appLocale())->isoFormat('D MMM') }} · {{ $next->location?->title }}
</p>
@endif
</div>
</article>The catalogue
The catalogue filters courses by level and by a search, with pagination. A view composer reads the query string, validates it, and builds one selector; the template only prints. Register it in a service provider, and add the provider to bootstrap/providers.php:
View::composer('templates.courses', CourseCatalogue::class);/**
* The course catalogue's filters: level and a search, read from the query string, validated, and
* turned into one selector with pagination. Registered for the catalogue's view, so the template
* only prints what it gets.
*/
final class CourseCatalogue
{
private const int PER_PAGE = 9;
public function compose(View $view): void
{
$levels = lynx()->fields->choices('level');
$filters = validator(request()->query(), [
'level' => ['nullable', Rule::in($levels->values())],
'q' => ['nullable', 'string', 'max:80'],
'page' => ['nullable', 'integer', 'min:1'],
])->valid();
$selector = ['template=course', 'sort=title'];
if (isset($filters['level'])) {
$selector[] = "level={$filters['level']}";
}
// Quotes, commas and pipes would end the value early, so they are taken out.
$search = trim(str_replace(['"', "'", ',', '|'], ' ', (string) ($filters['q'] ?? '')));
if ($search !== '') {
$selector[] = "title|summary*=\"{$search}\"";
}
$page = (int) ($filters['page'] ?? 1);
$courses = lynx()->pages->find(implode(', ', [...$selector, 'start='.(($page - 1) * self::PER_PAGE), 'limit='.self::PER_PAGE]));
$view->with([
'levels' => $levels,
'filters' => $filters,
'courses' => new LengthAwarePaginator($courses->all(), $courses->total(), self::PER_PAGE, $page, [
'path' => request()->url(),
'query' => array_filter(['level' => $filters['level'] ?? null, 'q' => $search ?: null]),
]),
]);
}
}Anything the composer doesn't know, such as ?level=nonsense, is dropped by validation, and the search loses the characters that would end a selector value early. The filters are links, so they work without JavaScript and keep the language:
<form method="get" class="mt-8 flex flex-wrap items-end gap-3" role="search">
<label class="grid gap-1 text-sm">
@string('courses.search', ['en' => 'Search', 'pt' => 'Pesquisar'])
<x-ui.input type="search" name="q" :value="$filters['q'] ?? ''" class="w-64 rounded-full" />
</label>
<nav class="flex flex-wrap gap-2" aria-label="@string('courses.levels', ['en' => 'Levels', 'pt' => 'Níveis'])">
<a href="{{ $page->url }}{{ isset($filters['q']) ? '?q='.urlencode($filters['q']) : '' }}" @class(['atlas-chip', 'is-active' => ! isset($filters['level'])])>@string('courses.all', ['en' => 'All', 'pt' => 'Todos'])</a>
@foreach ($levels as $level)
<a href="?{{ http_build_query(array_filter(['level' => $level->value, 'q' => $filters['q'] ?? null])) }}"
@class(['atlas-chip', 'is-active' => ($filters['level'] ?? null) === $level->value])
@if (($filters['level'] ?? null) === $level->value) aria-current="true" @endif>{{ $level }}</a>
@endforeach
</nav>
<button type="submit" class="atlas-pill-btn atlas-pill-btn-primary">@string('courses.go', ['en' => 'Search', 'pt' => 'Pesquisar'])</button>
</form>A course and its sessions
The course page shows its curriculum in an accordion, its instructors, and the upcoming sessions, each linking to its own page:
<aside class="atlas-clay h-fit p-6" aria-labelledby="sessions">
<p class="atlas-figure">{{ Illuminate\Support\Number::currency((float) $page->price, 'EUR', lynx()->languages->current()->appLocale()) }}</p>
<p class="atlas-muted text-sm">{{ $page->hours }} h</p>
<h2 id="sessions" class="mt-6 text-lg">@string('course.sessions', ['en' => 'Upcoming dates', 'pt' => 'Próximas datas'])</h2>
<ul class="mt-3 grid gap-3">
@forelse ($page->children('template=session, starts>=now, sort=starts') as $session)
<li>
<a href="{{ $session->url }}" class="atlas-session">
<span class="font-semibold">{{ $session->starts->locale(lynx()->languages->current()->appLocale())->isoFormat('D MMM YYYY, HH:mm') }}</span>
<span class="atlas-muted text-sm">{{ $session->location?->title }}</span>
@if ($session->availability?->value === 'cancelled')
<x-ui.badge variant="destructive">{{ $session->availability }}</x-ui.badge>
@endif
</a>
</li>
@empty
<li class="atlas-muted text-sm">@string('course.no-sessions', ['en' => 'New dates soon.', 'pt' => 'Novas datas em breve.'])</li>
@endforelse
</ul>
</aside>Prices print with Laravel's Number::currency() in the visitor's locale: €2,400.00 in English, 2 400,00 € in Portuguese. Dates use Carbon's isoFormat() the same way.
What you get
- The client: a finished-looking site in two languages, where every text, menu, figure, testimonial and question is theirs to change, and new courses appear in the catalogue, the tabs and the menus by themselves.
- You: BlatUI components you own, plain Blade views that read typed values, and a catalogue built from one selector. No JavaScript build of your own beyond Tailwind and BlatUI's Alpine helpers.