diff --git a/app/Catalog/CategoryListing.php b/app/Catalog/CategoryListing.php new file mode 100644 index 0000000..8282a53 --- /dev/null +++ b/app/Catalog/CategoryListing.php @@ -0,0 +1,86 @@ +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 $overrides + * @return array + */ + 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; + } +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index dae9b4a..a6f2050 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -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, ]); } } diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-200.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-200.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-200.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-200.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-300.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-300.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-300.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-300.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-500.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-500.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-500.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-500.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-600.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-600.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-600.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-600.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-700.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-700.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-700.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-700.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-800.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-800.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-800.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-800.woff2 diff --git a/resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2 b/public/fonts/manrope/manrope-v20-greek_latin-regular.woff2 similarity index 100% rename from resources/fonts/manrope/manrope-v20-greek_latin-regular.woff2 rename to public/fonts/manrope/manrope-v20-greek_latin-regular.woff2 diff --git a/resources/css/app.css b/resources/css/app.css index b262fd1..022b9f9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -44,6 +44,12 @@ @layer base { button:not(:disabled) { cursor: pointer; } + + /* 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 { diff --git a/resources/css/fonts.css b/resources/css/fonts.css index a5804aa..38a9152 100644 --- a/resources/css/fonts.css +++ b/resources/css/fonts.css @@ -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+ */ } diff --git a/resources/js/app.js b/resources/js/app.js index 6748964..53024ee 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,12 @@ import "./bootstrap"; import "./utils/strip-accents"; +import "./utils/refresh-scroll"; + +// Frames only — no site-wide Turbo Drive. 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"; diff --git a/resources/js/stimulus/auto-submit-controller.js b/resources/js/stimulus/auto-submit-controller.js new file mode 100644 index 0000000..df51c6f --- /dev/null +++ b/resources/js/stimulus/auto-submit-controller.js @@ -0,0 +1,27 @@ +import { Controller } from '@hotwired/stimulus' + +// Submits the host
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 : +// +// (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 +// 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 +} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index dd71daf..4324db1 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -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) diff --git a/resources/js/stimulus/range-slider-controller.js b/resources/js/stimulus/range-slider-controller.js index 5c097e8..ebf5bd6 100644 --- a/resources/js/stimulus/range-slider-controller.js +++ b/resources/js/stimulus/range-slider-controller.js @@ -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) { diff --git a/resources/js/utils/refresh-scroll.js b/resources/js/utils/refresh-scroll.js new file mode 100644 index 0000000..1f199a4 --- /dev/null +++ b/resources/js/utils/refresh-scroll.js @@ -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,

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 }, + ) + } +} diff --git a/resources/views/category/partials/listing.blade.php b/resources/views/category/partials/listing.blade.php new file mode 100644 index 0000000..a884f5f --- /dev/null +++ b/resources/views/category/partials/listing.blade.php @@ -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 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 + +
+ +
+

+ {{ trans_choice('storefront.shop.showing_results', $products->total(), [ + 'first' => $products->firstItem() ?? 0, + 'last' => $products->lastItem() ?? 0, + 'total' => $products->total(), + ]) }} +

+ + {{-- 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. --}} + + @foreach ($sortLabels as $value => $label) + {{ $label }} + @endforeach + +
+
+
+ +
+ + {{-- Products --}} +
+ @if($products->isEmpty()) +

{{ __('storefront.shop.no_products') }}

+ @else + + +
+ +
+ @endif +
+ + {{-- Sidebar — sort, price and in-stock are wired to the back-end; the + search box is still a placeholder. --}} + + +
diff --git a/resources/views/category/show.blade.php b/resources/views/category/show.blade.php index 40ef983..430be0a 100644 --- a/resources/views/category/show.blade.php +++ b/resources/views/category/show.blade.php @@ -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. --}} + + @if($listing->isRefined()) + + @endif +@endpush + @section('content')
@@ -15,108 +25,16 @@ ]" />
- -
- -
- {{--

--}} -

- {{ trans_choice('storefront.shop.showing_results', $products->total(), [ - 'first' => $products->firstItem() ?? 0, - 'last' => $products->lastItem() ?? 0, - 'total' => $products->total(), - ]) }} -

- - {{-- 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. --}} - - @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) - {{ $label }} - @endforeach - -
-
-
-
- - {{-- Products --}} -
- - @if($products->isEmpty()) -

{{ __('storefront.shop.no_products') }}

- @else - - -
- -
- @endif -
- - {{-- Sidebar — search, price and availability filters are dummy for now --}} - - -
+ {{-- 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. --}} + + @include('category.partials.listing') + @endsection diff --git a/resources/views/components/ui/range-slider.blade.php b/resources/views/components/ui/range-slider.blade.php index 578757c..ba48aa6 100644 --- a/resources/views/components/ui/range-slider.blade.php +++ b/resources/views/components/ui/range-slider.blade.php @@ -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 - + {{ $slot }} diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 17a901e..0af48db 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -26,6 +26,16 @@ @stack('seo') + {{-- Preload the Manrope weights used in above-the-fold text (body 400, + category

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. --}} + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) @auth('staff')