generated from boboko/starter
product category page: front-end sorting and filtering using turboframes
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
{{--
|
||||
The reloadable body of the category listing — count + sort, product grid +
|
||||
pagination, and the filter sidebar. Rendered both on full page load and on
|
||||
every <turbo-frame id="category-listing"> navigation, always straight from
|
||||
the query string ($listing). Anything that must reflect the applied
|
||||
sort/filter/page state belongs in here.
|
||||
|
||||
Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing)
|
||||
--}}
|
||||
|
||||
@php
|
||||
// Keys are the `sort` URL param values — the ProductSort enum cases, plus
|
||||
// "popularity" for the default (no param). App\Catalog\CategoryListing is
|
||||
// the single place that validates them back into the enum.
|
||||
$sortLabels = [
|
||||
'popularity' => __('storefront.shop.sort_popularity'),
|
||||
'price_asc' => __('storefront.shop.sort_price_asc'),
|
||||
'price_desc' => __('storefront.shop.sort_price_desc'),
|
||||
'newest' => __('storefront.shop.sort_newest'),
|
||||
];
|
||||
$currentSort = $listing->sort?->value ?? 'popularity';
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||
<p>
|
||||
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
||||
'first' => $products->firstItem() ?? 0,
|
||||
'last' => $products->lastItem() ?? 0,
|
||||
'total' => $products->total(),
|
||||
]) }}
|
||||
</p>
|
||||
|
||||
{{-- Sort options are plain links carrying the rest of the applied state;
|
||||
following one navigates the frame and advances the URL. Changing
|
||||
sort drops back to page 1. --}}
|
||||
<x-ui.dropdown
|
||||
id="category-sort"
|
||||
:label="$sortLabels[$currentSort]"
|
||||
:ariaLabel="__('storefront.shop.sort_label')"
|
||||
triggerClass="min-w-48"
|
||||
>
|
||||
@foreach ($sortLabels as $value => $label)
|
||||
<x-ui.dropdown.item
|
||||
:href="route('category.show', ['id' => $collection['id']] + $listing->query([
|
||||
'sort' => $value === 'popularity' ? null : $value,
|
||||
'page' => null,
|
||||
]))"
|
||||
:current="$value === $currentSort"
|
||||
>{{ $label }}</x-ui.dropdown.item>
|
||||
@endforeach
|
||||
</x-ui.dropdown>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
{{-- Products --}}
|
||||
<div>
|
||||
@if($products->isEmpty())
|
||||
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
||||
@else
|
||||
<x-product-grid :products="$products->items()" cols="3" />
|
||||
|
||||
<div class="mt-12">
|
||||
<x-ui.pagination :paginator="$products" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar — sort, price and in-stock are wired to the back-end; the
|
||||
search box is still a placeholder. --}}
|
||||
<aside class="flex flex-col gap-10">
|
||||
|
||||
{{-- Placeholder — not wired yet. --}}
|
||||
<div class="-mt-2">
|
||||
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
||||
<div class="relative">
|
||||
<x-ui.input
|
||||
type="search"
|
||||
id="shop-search"
|
||||
:placeholder="__('storefront.shop.search_placeholder')"
|
||||
class="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-1/2 -translate-y-1/2"
|
||||
aria-label="{{ __('storefront.shop.search_label') }}"
|
||||
>
|
||||
<x-ui.icon name="search" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filter form: a plain GET form whose fields ARE the state. Changing
|
||||
any control auto-submits (auto-submit controller); Turbo captures
|
||||
the GET, navigates the frame, and advances the URL. `sort` rides
|
||||
along as a hidden field so it survives a filter change; `page` is
|
||||
deliberately absent, so filtering drops back to page 1. Without JS
|
||||
the sr-only submit button applies the range inputs. --}}
|
||||
<form
|
||||
method="get"
|
||||
action="{{ route('category.show', ['id' => $collection['id']]) }}"
|
||||
data-controller="auto-submit"
|
||||
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
|
||||
class="contents"
|
||||
>
|
||||
@if ($listing->sort)
|
||||
<input type="hidden" name="sort" value="{{ $listing->sort->value }}">
|
||||
@endif
|
||||
|
||||
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
|
||||
|
||||
<x-ui.range-slider
|
||||
name="price"
|
||||
:min="$priceFloor"
|
||||
:max="$priceCeil"
|
||||
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
|
||||
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
|
||||
prefix="€"
|
||||
separator=" - "
|
||||
:legend="__('storefront.shop.filter_price')"
|
||||
:min-label="__('storefront.shop.price_min')"
|
||||
:max-label="__('storefront.shop.price_max')"
|
||||
>
|
||||
{{-- Clear the price filter — a plain link back to the URL
|
||||
without price_*; only shown when actually filtered. --}}
|
||||
@if ($priceFiltered)
|
||||
<a
|
||||
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
|
||||
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
|
||||
>{{ __('storefront.shop.reset') }}</a>
|
||||
@endif
|
||||
</x-ui.range-slider>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-4 [&_label]:text-base">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
|
||||
<x-ui.checkbox id="shop-in-stock" name="in_stock" :checked="$listing->inStockOnly">
|
||||
{{ __('storefront.shop.in_stock_only') }}
|
||||
</x-ui.checkbox>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
|
||||
</form>
|
||||
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
@@ -3,6 +3,16 @@
|
||||
@section('title', $collection['name'] . ' — ' . config('app.name'))
|
||||
@section('description', strip_tags($collection['description'] ?? ''))
|
||||
|
||||
@push('seo')
|
||||
{{-- Filtered / sorted / paged variants all consolidate onto the bare
|
||||
category URL; the noindex keeps the near-duplicate variants out of the
|
||||
index while still letting crawlers follow through to the products. --}}
|
||||
<link rel="canonical" href="{{ route('category.show', ['id' => $collection['id']]) }}">
|
||||
@if($listing->isRefined())
|
||||
<meta name="robots" content="noindex,follow">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||
|
||||
@@ -15,108 +25,16 @@
|
||||
]" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||
{{-- <p class="text-neutral-600"> --}}
|
||||
<p>
|
||||
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
||||
'first' => $products->firstItem() ?? 0,
|
||||
'last' => $products->lastItem() ?? 0,
|
||||
'total' => $products->total(),
|
||||
]) }}
|
||||
</p>
|
||||
|
||||
{{-- Dummy — not wired to real sorting yet. Options carry data-action
|
||||
for a future `category` Stimulus controller; no-op until that
|
||||
controller exists and a [data-controller="category"] wraps this. --}}
|
||||
<x-ui.dropdown
|
||||
id="category-sort"
|
||||
:label="__('storefront.shop.sort_popularity')"
|
||||
:ariaLabel="__('storefront.shop.sort_label')"
|
||||
triggerClass="min-w-48"
|
||||
>
|
||||
@foreach ([
|
||||
'popularity' => __('storefront.shop.sort_popularity'),
|
||||
'price-asc' => __('storefront.shop.sort_price_asc'),
|
||||
'price-desc' => __('storefront.shop.sort_price_desc'),
|
||||
'newest' => __('storefront.shop.sort_newest'),
|
||||
] as $value => $label)
|
||||
<x-ui.dropdown.item
|
||||
data-action="category#sort"
|
||||
data-category-sort-param="{{ $value }}"
|
||||
:current="$value === 'popularity'"
|
||||
>{{ $label }}</x-ui.dropdown.item>
|
||||
@endforeach
|
||||
</x-ui.dropdown>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||
|
||||
{{-- Products --}}
|
||||
<div>
|
||||
|
||||
@if($products->isEmpty())
|
||||
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
||||
@else
|
||||
<x-product-grid :products="$products->items()" cols="3" />
|
||||
|
||||
<div class="mt-12">
|
||||
<x-ui.pagination :paginator="$products" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Sidebar — search, price and availability filters are dummy for now --}}
|
||||
<aside class="flex flex-col gap-10">
|
||||
|
||||
<div class="-mt-2">
|
||||
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
||||
<div class="relative">
|
||||
<x-ui.input
|
||||
type="search"
|
||||
id="shop-search"
|
||||
:placeholder="__('storefront.shop.search_placeholder')"
|
||||
class="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-1/2 -translate-y-1/2"
|
||||
aria-label="{{ __('storefront.shop.search_label') }}"
|
||||
>
|
||||
<x-ui.icon name="search" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
|
||||
|
||||
{{-- Dummy — not wired to real price bounds or filtering yet. --}}
|
||||
<x-ui.range-slider
|
||||
:min="10"
|
||||
:max="50"
|
||||
prefix="€"
|
||||
separator=" - "
|
||||
:legend="__('storefront.shop.filter_price')"
|
||||
:min-label="__('storefront.shop.price_min')"
|
||||
:max-label="__('storefront.shop.price_max')"
|
||||
:reset-label="__('storefront.shop.reset')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 [&_label]:text-base">
|
||||
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
|
||||
<x-ui.checkbox id="shop-in-stock" name="in_stock">
|
||||
{{ __('storefront.shop.in_stock_only') }}
|
||||
</x-ui.checkbox>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
{{-- Everything that reflects sort / filter / page state lives in this
|
||||
frame. A sort link or filter submit inside it navigates the frame;
|
||||
the controller re-renders it straight from the query string, and
|
||||
data-turbo-action="advance" keeps the address bar in sync so a
|
||||
refresh or bookmark reproduces the exact same view. With no JS the
|
||||
frame is just a block and the links do full-page navigations to the
|
||||
same URLs. --}}
|
||||
<turbo-frame id="category-listing" data-turbo-action="advance">
|
||||
@include('category.partials.listing')
|
||||
</turbo-frame>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
@props([
|
||||
'min',
|
||||
'max',
|
||||
'minValue' => null,
|
||||
'maxValue' => null,
|
||||
'step' => 1,
|
||||
'name' => null,
|
||||
'legend' => 'Range',
|
||||
'minLabel' => 'Minimum',
|
||||
'maxLabel' => 'Maximum',
|
||||
'prefix' => '',
|
||||
'suffix' => '',
|
||||
'separator' => ' – ',
|
||||
'resetLabel' => 'Reset',
|
||||
'minValue' => null,
|
||||
'maxValue' => null,
|
||||
'step' => 1,
|
||||
'name' => null,
|
||||
'legend' => 'Range',
|
||||
'minLabel' => 'Minimum',
|
||||
'maxLabel' => 'Maximum',
|
||||
'prefix' => '',
|
||||
'suffix' => '',
|
||||
'separator' => ' – ',
|
||||
])
|
||||
|
||||
{{-- The default slot sits on the readout row, to the right of the min–max
|
||||
text — use it for a "clear"/"reset" control when the slider is filtered.
|
||||
Left empty otherwise. --}}
|
||||
|
||||
@php
|
||||
$minValue ??= $min;
|
||||
$maxValue ??= $max;
|
||||
$uid = 'rs-' . uniqid();
|
||||
// Stable when a name is given so focus can survive a re-render; random
|
||||
// otherwise, just to keep the label/input association unique on the page.
|
||||
$uid = 'rs-' . ($name ?: uniqid());
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -46,7 +51,7 @@
|
||||
max="{{ $max }}"
|
||||
step="{{ $step }}"
|
||||
value="{{ $minValue }}"
|
||||
@if($name) name="{{ $name }}[min]" @endif
|
||||
@if($name) name="{{ $name }}_min" @endif
|
||||
data-range-slider-target="minInput"
|
||||
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
|
||||
>
|
||||
@@ -61,7 +66,7 @@
|
||||
max="{{ $max }}"
|
||||
step="{{ $step }}"
|
||||
value="{{ $maxValue }}"
|
||||
@if($name) name="{{ $name }}[max]" @endif
|
||||
@if($name) name="{{ $name }}_max" @endif
|
||||
data-range-slider-target="maxInput"
|
||||
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
|
||||
>
|
||||
@@ -111,12 +116,6 @@ class="absolute top-1/2 left-[var(--max)] grid h-8 w-6 -translate-x-1/2 -transla
|
||||
<span data-range-slider-target="output" aria-hidden="true" class="text-sm tabular-nums"
|
||||
>{{ $prefix }}{{ $minValue }}{{ $suffix }}{{ $separator }}{{ $prefix }}{{ $maxValue }}{{ $suffix }}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
hidden
|
||||
data-range-slider-target="reset"
|
||||
data-action="range-slider#reset"
|
||||
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
|
||||
>{{ $resetLabel }}</button>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
|
||||
@stack('seo')
|
||||
|
||||
{{-- Preload the Manrope weights used in above-the-fold text (body 400,
|
||||
category <h1> 500, generic headings 700, nav 800) so the swap-in
|
||||
doesn't reflow the header and headings on load — which otherwise
|
||||
throws off scroll restoration on refresh. Same URLs as the @font-face
|
||||
rules in fonts.css, so each is fetched once. --}}
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-500.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-700.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-800.woff2">
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@auth('staff')
|
||||
|
||||
Reference in New Issue
Block a user