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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\CategoryListing;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
|
||||||
use Modules\Core\Catalog\Services\CollectionService;
|
use Modules\Core\Catalog\Services\CollectionService;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
@@ -19,27 +19,46 @@ public function show(string $locale, int $collection)
|
|||||||
$collectionData = $this->collections->getById($collection);
|
$collectionData = $this->collections->getById($collection);
|
||||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
|
$listing = CategoryListing::fromRequest(request());
|
||||||
|
$filters = $listing->filters($collectionData['id']);
|
||||||
$perPage = 12;
|
$perPage = 12;
|
||||||
$page = (int) request('page', 1);
|
|
||||||
|
|
||||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
// Listing/filtering reads from the Meilisearch index via ProductService,
|
||||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
||||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
// 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(
|
$products = $this->products->list(
|
||||||
filters: new ProductFilters(collectionId: $collectionData['id']),
|
filters: $filters,
|
||||||
perPage: $perPage,
|
perPage: $perPage,
|
||||||
page: $page,
|
page: $listing->page,
|
||||||
|
sort: $listing->sort,
|
||||||
)->through(fn (array $product) => [
|
)->through(fn (array $product) => [
|
||||||
'name' => $product['name'],
|
'name' => $product['name'],
|
||||||
'price' => $product['price'],
|
'price' => $product['price'],
|
||||||
'image' => $product['media'][0]['url'] ?? null,
|
'image' => $product['media'][0]['url'] ?? null,
|
||||||
'href' => route('product.show', ['id' => $product['id']]),
|
'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', [
|
return view('category.show', [
|
||||||
'collection' => $collectionData,
|
'collection' => $collectionData,
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
|
'listing' => $listing,
|
||||||
|
'priceFloor' => $priceFloor,
|
||||||
|
'priceCeil' => $priceCeil,
|
||||||
|
'priceFiltered' => $priceFiltered,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ @layer base {
|
|||||||
button:not(:disabled) {
|
button:not(:disabled) {
|
||||||
cursor: pointer;
|
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 {
|
@keyframes spin {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-regular - greek_latin */
|
/* manrope-regular - greek_latin */
|
||||||
@@ -13,7 +13,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-500 - greek_latin */
|
/* manrope-500 - greek_latin */
|
||||||
@@ -22,7 +22,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-600 - greek_latin */
|
/* manrope-600 - greek_latin */
|
||||||
@@ -31,7 +31,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-700 - greek_latin */
|
/* manrope-700 - greek_latin */
|
||||||
@@ -40,7 +40,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-800 - greek_latin */
|
/* manrope-800 - greek_latin */
|
||||||
@@ -49,6 +49,6 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 800;
|
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+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import "./bootstrap";
|
import "./bootstrap";
|
||||||
import "./utils/strip-accents";
|
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 { Application } from "@hotwired/stimulus";
|
||||||
import { registerControllers } from "./stimulus/index";
|
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);
|
// application.register('hello', HelloController);
|
||||||
|
|
||||||
import AppearController from './appear-controller'
|
import AppearController from './appear-controller'
|
||||||
|
import AutoSubmitController from './auto-submit-controller'
|
||||||
import BackToTopController from './back-to-top-controller'
|
import BackToTopController from './back-to-top-controller'
|
||||||
import CarouselController from './carousel-controller'
|
import CarouselController from './carousel-controller'
|
||||||
import DropdownController from './dropdown-controller'
|
import DropdownController from './dropdown-controller'
|
||||||
@@ -16,6 +17,7 @@ import TabsController from './tabs-controller'
|
|||||||
|
|
||||||
export function registerControllers(application) {
|
export function registerControllers(application) {
|
||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
|
application.register('auto-submit', AutoSubmitController)
|
||||||
application.register('back-to-top', BackToTopController)
|
application.register('back-to-top', BackToTopController)
|
||||||
application.register('carousel', CarouselController)
|
application.register('carousel', CarouselController)
|
||||||
application.register('dropdown', DropdownController)
|
application.register('dropdown', DropdownController)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Controller } from '@hotwired/stimulus'
|
|||||||
// max and vice versa. Emits `range-slider:input` while dragging and
|
// max and vice versa. Emits `range-slider:input` while dragging and
|
||||||
// `range-slider:change` on commit, both with { min, max }.
|
// `range-slider:change` on commit, both with { min, max }.
|
||||||
export default class extends Controller {
|
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 = {
|
static values = {
|
||||||
min: Number,
|
min: Number,
|
||||||
max: Number,
|
max: Number,
|
||||||
@@ -26,7 +26,6 @@ export default class extends Controller {
|
|||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.#clamp()
|
this.#clamp()
|
||||||
this.defaults = { min: this.#lo, max: this.#hi }
|
|
||||||
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
|
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
|
||||||
this.#render()
|
this.#render()
|
||||||
}
|
}
|
||||||
@@ -78,13 +77,6 @@ export default class extends Controller {
|
|||||||
this.#startDrag(event, input)
|
this.#startDrag(event, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.minInputTarget.value = this.defaults.min
|
|
||||||
this.maxInputTarget.value = this.defaults.max
|
|
||||||
this.#render()
|
|
||||||
this.#emit('change')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── internals ──────────────────────────────────────────────────────
|
// ── internals ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
#startDrag(event, input) {
|
#startDrag(event, input) {
|
||||||
@@ -158,10 +150,6 @@ export default class extends Controller {
|
|||||||
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
|
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
|
||||||
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
|
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) {
|
#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('title', $collection['name'] . ' — ' . config('app.name'))
|
||||||
@section('description', strip_tags($collection['description'] ?? ''))
|
@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')
|
@section('content')
|
||||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||||
|
|
||||||
@@ -15,108 +25,16 @@
|
|||||||
]" />
|
]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Everything that reflects sort / filter / page state lives in this
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
frame. A sort link or filter submit inside it navigates the frame;
|
||||||
|
the controller re-renders it straight from the query string, and
|
||||||
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
data-turbo-action="advance" keeps the address bar in sync so a
|
||||||
{{-- <p class="text-neutral-600"> --}}
|
refresh or bookmark reproduces the exact same view. With no JS the
|
||||||
<p>
|
frame is just a block and the links do full-page navigations to the
|
||||||
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
same URLs. --}}
|
||||||
'first' => $products->firstItem() ?? 0,
|
<turbo-frame id="category-listing" data-turbo-action="advance">
|
||||||
'last' => $products->lastItem() ?? 0,
|
@include('category.partials.listing')
|
||||||
'total' => $products->total(),
|
</turbo-frame>
|
||||||
]) }}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
@props([
|
@props([
|
||||||
'min',
|
'min',
|
||||||
'max',
|
'max',
|
||||||
'minValue' => null,
|
'minValue' => null,
|
||||||
'maxValue' => null,
|
'maxValue' => null,
|
||||||
'step' => 1,
|
'step' => 1,
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'legend' => 'Range',
|
'legend' => 'Range',
|
||||||
'minLabel' => 'Minimum',
|
'minLabel' => 'Minimum',
|
||||||
'maxLabel' => 'Maximum',
|
'maxLabel' => 'Maximum',
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'suffix' => '',
|
'suffix' => '',
|
||||||
'separator' => ' – ',
|
'separator' => ' – ',
|
||||||
'resetLabel' => 'Reset',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
{{-- 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
|
@php
|
||||||
$minValue ??= $min;
|
$minValue ??= $min;
|
||||||
$maxValue ??= $max;
|
$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
|
@endphp
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
max="{{ $max }}"
|
max="{{ $max }}"
|
||||||
step="{{ $step }}"
|
step="{{ $step }}"
|
||||||
value="{{ $minValue }}"
|
value="{{ $minValue }}"
|
||||||
@if($name) name="{{ $name }}[min]" @endif
|
@if($name) name="{{ $name }}_min" @endif
|
||||||
data-range-slider-target="minInput"
|
data-range-slider-target="minInput"
|
||||||
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
|
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
|
||||||
>
|
>
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
max="{{ $max }}"
|
max="{{ $max }}"
|
||||||
step="{{ $step }}"
|
step="{{ $step }}"
|
||||||
value="{{ $maxValue }}"
|
value="{{ $maxValue }}"
|
||||||
@if($name) name="{{ $name }}[max]" @endif
|
@if($name) name="{{ $name }}_max" @endif
|
||||||
data-range-slider-target="maxInput"
|
data-range-slider-target="maxInput"
|
||||||
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
|
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"
|
<span data-range-slider-target="output" aria-hidden="true" class="text-sm tabular-nums"
|
||||||
>{{ $prefix }}{{ $minValue }}{{ $suffix }}{{ $separator }}{{ $prefix }}{{ $maxValue }}{{ $suffix }}</span>
|
>{{ $prefix }}{{ $minValue }}{{ $suffix }}{{ $separator }}{{ $prefix }}{{ $maxValue }}{{ $suffix }}</span>
|
||||||
|
|
||||||
<button
|
{{ $slot }}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
|
|
||||||
@stack('seo')
|
@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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
@auth('staff')
|
@auth('staff')
|
||||||
|
|||||||
Reference in New Issue
Block a user