product category page: front-end sorting and filtering using turboframes

This commit is contained in:
elvira
2026-08-31 22:56:40 +03:00
parent b070a7d1e6
commit 8a07c772f8
20 changed files with 428 additions and 148 deletions
@@ -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>