generated from boboko/starter
product category page: front-end sorting and filtering using turboframes
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* The parsed state of a category listing request. The query string is the single
|
||||
* source of truth for sort / filters / page — build one of these from the
|
||||
* request, read the applied values off it, and use query() to build links (sort
|
||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
||||
*
|
||||
* A param is only ever emitted when it differs from its default, so a pristine
|
||||
* listing is just `/category/{id}` with no query string.
|
||||
*/
|
||||
final class CategoryListing
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ProductSort $sort,
|
||||
public readonly ?int $minPrice,
|
||||
public readonly ?int $maxPrice,
|
||||
public readonly bool $inStockOnly,
|
||||
public readonly int $page,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): self
|
||||
{
|
||||
return new self(
|
||||
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
||||
minPrice: self::intOrNull($request->query('price_min')),
|
||||
maxPrice: self::intOrNull($request->query('price_max')),
|
||||
inStockOnly: $request->boolean('in_stock'),
|
||||
page: max(1, (int) $request->query('page', 1)),
|
||||
);
|
||||
}
|
||||
|
||||
public function filters(int $collectionId): ProductFilters
|
||||
{
|
||||
return new ProductFilters(
|
||||
collectionId: $collectionId,
|
||||
minPrice: $this->minPrice,
|
||||
maxPrice: $this->maxPrice,
|
||||
inStockOnly: $this->inStockOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
|
||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
||||
*
|
||||
* @param array<string, string|int|null> $overrides
|
||||
* @return array<string, string|int>
|
||||
*/
|
||||
public function query(array $overrides = []): array
|
||||
{
|
||||
return array_filter([
|
||||
'sort' => $this->sort?->value,
|
||||
'price_min' => $this->minPrice,
|
||||
'price_max' => $this->maxPrice,
|
||||
'in_stock' => $this->inStockOnly ? 1 : null,
|
||||
'page' => $this->page > 1 ? $this->page : null,
|
||||
...$overrides,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||
* indexed as its own page (the canonical still points at the bare category
|
||||
* URL either way). A plain in-stock toggle is left indexable.
|
||||
*/
|
||||
public function isRefined(): bool
|
||||
{
|
||||
return $this->sort !== null
|
||||
|| $this->minPrice !== null
|
||||
|| $this->maxPrice !== null
|
||||
|| $this->page > 1;
|
||||
}
|
||||
|
||||
private static function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\CategoryListing;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
@@ -19,27 +19,46 @@ public function show(string $locale, int $collection)
|
||||
$collectionData = $this->collections->getById($collection);
|
||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$listing = CategoryListing::fromRequest(request());
|
||||
$filters = $listing->filters($collectionData['id']);
|
||||
$perPage = 12;
|
||||
$page = (int) request('page', 1);
|
||||
|
||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
||||
// not Product models.
|
||||
// not Product models. Sort/filter/page all come from the query string via
|
||||
// CategoryListing, which is the single source of truth for that state.
|
||||
$products = $this->products->list(
|
||||
filters: new ProductFilters(collectionId: $collectionData['id']),
|
||||
filters: $filters,
|
||||
perPage: $perPage,
|
||||
page: $page,
|
||||
page: $listing->page,
|
||||
sort: $listing->sort,
|
||||
)->through(fn (array $product) => [
|
||||
'name' => $product['name'],
|
||||
'price' => $product['price'],
|
||||
'image' => $product['media'][0]['url'] ?? null,
|
||||
'href' => route('product.show', ['id' => $product['id']]),
|
||||
]);
|
||||
])->appends($listing->query(['page' => null]));
|
||||
|
||||
// Slider bounds — the price span of everything matching the *other*
|
||||
// filters (priceRange() drops the price filter itself, so the handles
|
||||
// don't collapse to whatever's already selected). Whole euros.
|
||||
$priceRange = $this->products->priceRange($filters);
|
||||
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
||||
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
||||
|
||||
// A price param is only a real filter if it's tighter than the bounds —
|
||||
// drives whether the "clear" link shows.
|
||||
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|
||||
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
|
||||
|
||||
return view('category.show', [
|
||||
'collection' => $collectionData,
|
||||
'products' => $products,
|
||||
'listing' => $listing,
|
||||
'priceFloor' => $priceFloor,
|
||||
'priceCeil' => $priceCeil,
|
||||
'priceFiltered' => $priceFiltered,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ @layer base {
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* <turbo-frame> is a custom element — inline by default. Give it a box so
|
||||
the grid it wraps on the category page lays out normally. */
|
||||
turbo-frame {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
||||
@@ -4,7 +4,7 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-300.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-300.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-regular - greek_latin */
|
||||
@@ -13,7 +13,7 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-regular.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-regular.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-500 - greek_latin */
|
||||
@@ -22,7 +22,7 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-500.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-500.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-600 - greek_latin */
|
||||
@@ -31,7 +31,7 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-600.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-600.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-700 - greek_latin */
|
||||
@@ -40,7 +40,7 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-700.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-700.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* manrope-800 - greek_latin */
|
||||
@@ -49,6 +49,6 @@ @font-face {
|
||||
font-family: "Manrope";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url("../fonts/manrope/manrope-v20-greek_latin-800.woff2")
|
||||
src: url("/fonts/manrope/manrope-v20-greek_latin-800.woff2")
|
||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import "./bootstrap";
|
||||
import "./utils/strip-accents";
|
||||
import "./utils/refresh-scroll";
|
||||
|
||||
// Frames only — no site-wide Turbo Drive. <turbo-frame> navigations still work
|
||||
// (that's how the category listing reloads); every other link and form on the
|
||||
// site keeps its normal full-page browser behaviour.
|
||||
import "@hotwired/turbo";
|
||||
window.Turbo.session.drive = false;
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Submits the host <form> a short beat after a control inside it changes,
|
||||
// coalescing a burst — rapid slider nudges, or holding an arrow key on a range
|
||||
// input — into a single submit. Wire it on the <form>:
|
||||
//
|
||||
// <form data-controller="auto-submit"
|
||||
// data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
|
||||
// data-auto-submit-delay-value="300"> (delay optional, ms)
|
||||
//
|
||||
// `change` covers native inputs (checkbox, select); the range slider emits its
|
||||
// own `range-slider:change` on commit. Uses requestSubmit() (not submit()) so a
|
||||
// <turbo-frame> around the form still captures the navigation and validation runs.
|
||||
export default class extends Controller {
|
||||
static values = { delay: { type: Number, default: 300 } }
|
||||
|
||||
submit() {
|
||||
clearTimeout(this.#timer)
|
||||
this.#timer = setTimeout(() => this.element.requestSubmit(), this.delayValue)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this.#timer)
|
||||
}
|
||||
|
||||
#timer
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
// application.register('hello', HelloController);
|
||||
|
||||
import AppearController from './appear-controller'
|
||||
import AutoSubmitController from './auto-submit-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
@@ -16,6 +17,7 @@ import TabsController from './tabs-controller'
|
||||
|
||||
export function registerControllers(application) {
|
||||
application.register('appear', AppearController)
|
||||
application.register('auto-submit', AutoSubmitController)
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('dropdown', DropdownController)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Controller } from '@hotwired/stimulus'
|
||||
// max and vice versa. Emits `range-slider:input` while dragging and
|
||||
// `range-slider:change` on commit, both with { min, max }.
|
||||
export default class extends Controller {
|
||||
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output', 'reset']
|
||||
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
|
||||
static values = {
|
||||
min: Number,
|
||||
max: Number,
|
||||
@@ -26,7 +26,6 @@ export default class extends Controller {
|
||||
|
||||
connect() {
|
||||
this.#clamp()
|
||||
this.defaults = { min: this.#lo, max: this.#hi }
|
||||
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
|
||||
this.#render()
|
||||
}
|
||||
@@ -78,13 +77,6 @@ export default class extends Controller {
|
||||
this.#startDrag(event, input)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.minInputTarget.value = this.defaults.min
|
||||
this.maxInputTarget.value = this.defaults.max
|
||||
this.#render()
|
||||
this.#emit('change')
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
#startDrag(event, input) {
|
||||
@@ -158,10 +150,6 @@ export default class extends Controller {
|
||||
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
|
||||
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
|
||||
}
|
||||
|
||||
if (this.hasResetTarget) {
|
||||
this.resetTarget.hidden = lo === this.defaults.min && hi === this.defaults.max
|
||||
}
|
||||
}
|
||||
|
||||
#emit(name) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Make a refresh land back where you were — accurately.
|
||||
//
|
||||
// Turbo Drive is off site-wide (see app.js), so a refresh is a full browser
|
||||
// load. The browser restores the scroll position early in that load — before
|
||||
// the Manrope web fonts swap in and reflow the header, <h1> and result count
|
||||
// above the product grid — so it settles a bit too low. We record the position
|
||||
// ourselves and re-apply it once the layout has actually stopped moving.
|
||||
//
|
||||
// Separately: drop focus on the way out. Otherwise the browser re-focuses
|
||||
// whatever filter control was active and scrolls it into view on reload, and
|
||||
// that sidebar stacks below the grid on narrow screens — hence the jump to the
|
||||
// bottom.
|
||||
//
|
||||
// The real fix for the drift is preloading the above-the-fold font weights so
|
||||
// there's no reflow to chase; this keeps the restore correct until then, and
|
||||
// harmless after.
|
||||
|
||||
const key = 'scrollY:' + location.pathname + location.search
|
||||
|
||||
let frame = 0
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
try {
|
||||
sessionStorage.setItem(key, String(Math.round(window.scrollY)))
|
||||
} catch {}
|
||||
})
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
const el = document.activeElement
|
||||
if (el && el !== document.body) el.blur()
|
||||
})
|
||||
|
||||
// Only reloads and back/forward should resume a position; a fresh visit to the
|
||||
// page starts where it naturally would.
|
||||
const [nav] = performance.getEntriesByType('navigation')
|
||||
if (nav && (nav.type === 'reload' || nav.type === 'back_forward')) {
|
||||
let saved = null
|
||||
try {
|
||||
saved = sessionStorage.getItem(key)
|
||||
} catch {}
|
||||
|
||||
if (saved !== null) {
|
||||
const y = Number(saved)
|
||||
const apply = () => window.scrollTo(0, y)
|
||||
|
||||
window.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
apply()
|
||||
// Fonts (and any late above-the-fold image) can still nudge
|
||||
// layout a frame or two after load — re-apply once they settle.
|
||||
document.fonts?.ready.then(() => requestAnimationFrame(apply))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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