From 1f0612861b9053977bfa863969d67cdb89594000 Mon Sep 17 00:00:00 2001 From: elvira Date: Thu, 3 Sep 2026 16:28:10 +0300 Subject: [PATCH] general products page and small rewrite of search and category controllers etc --- app/Catalog/ProductCard.php | 37 ++++++-- ...CategoryListing.php => ProductListing.php} | 23 +++-- app/Catalog/ProductListingPage.php | 68 ++++++++++++++ app/Catalog/ProductSortOptions.php | 40 ++++++++ app/Http/Controllers/CategoryController.php | 38 ++------ app/Http/Controllers/ProductController.php | 23 ++++- app/Http/Controllers/SearchController.php | 94 +++++++++++++++++++ resources/css/app.css | 22 +++++ .../js/stimulus/frame-scroll-controller.js | 24 +++++ resources/js/stimulus/index.js | 4 + .../js/stimulus/nav-search-controller.js | 28 ++++++ resources/views/category/show.blade.php | 9 +- resources/views/components/header.blade.php | 12 ++- .../views/components/search-overlay.blade.php | 41 ++++++++ .../components/shop/result-count.blade.php | 11 +++ .../views/components/shop/sort.blade.php | 24 +++++ resources/views/home.blade.php | 2 +- resources/views/product/show.blade.php | 2 +- resources/views/products/index.blade.php | 38 ++++++++ resources/views/search/index.blade.php | 36 +++++++ .../views/search/partials/results.blade.php | 22 +++++ .../partials/listing.blade.php | 69 ++++---------- routes/web.php | 5 + 23 files changed, 564 insertions(+), 108 deletions(-) rename app/Catalog/{CategoryListing.php => ProductListing.php} (76%) create mode 100644 app/Catalog/ProductListingPage.php create mode 100644 app/Catalog/ProductSortOptions.php create mode 100644 app/Http/Controllers/SearchController.php create mode 100644 resources/js/stimulus/frame-scroll-controller.js create mode 100644 resources/js/stimulus/nav-search-controller.js create mode 100644 resources/views/components/search-overlay.blade.php create mode 100644 resources/views/components/shop/result-count.blade.php create mode 100644 resources/views/components/shop/sort.blade.php create mode 100644 resources/views/products/index.blade.php create mode 100644 resources/views/search/index.blade.php create mode 100644 resources/views/search/partials/results.blade.php rename resources/views/{category => shop}/partials/listing.blade.php (55%) diff --git a/app/Catalog/ProductCard.php b/app/Catalog/ProductCard.php index 371df75..b558e5b 100644 --- a/app/Catalog/ProductCard.php +++ b/app/Catalog/ProductCard.php @@ -2,20 +2,39 @@ namespace App\Catalog; +use Lunar\Models\Product; + /** - * Presentation shaping — how a product listing/grid card is built from - * ProductService's localized array shape (list()/getById()/random() all - * return it). Deliberately not in boboko-core: `href` depends on this - * storefront's own routes, and another app built on the same core package - * could want an entirely different card shape. Kept in one place so - * HomeController/CategoryController don't each hand-write the same - * name/price/image/href mapping. + * Presentation shaping — how a storefront product listing/grid card is built: + * name, price, image, href. Deliberately not in boboko-core: `href` depends on + * this storefront's own routes, and another app on the same core package could + * want an entirely different card shape. One place, so HomeController / + * CategoryController / ProductController / SearchController don't each + * hand-write the same name/price/image/href mapping. + * + * Two sources, because the storefront reads products both ways: a hydrated + * Eloquent model (full-text search via ProductSearchService), or a localized + * index array (ProductService::list()/getById()/random()). Model callers must + * eager-load `variants.prices` and `media`. */ final class ProductCard { /** - * @param array $product One item from ProductService's localized array shape. - * @return array{name: ?string, price: ?float, image: ?string, href: string} + * @return array{name: ?string, price: ?string, image: ?string, href: string} + */ + public static function fromModel(Product $product): array + { + return [ + 'name' => $product->translateAttribute('name'), //@todo check this + 'price' => $product->variants->first()?->prices->first()?->price->decimal, //@todo check this + 'image' => $product->media->first()?->getUrl(), + 'href' => route('product.show', ['id' => $product->id]), + ]; + } + + /** + * @param array $product one item from ProductService's localized array shape + * @return array{name: ?string, price: ?string, image: ?string, href: string} */ public static function fromIndexed(array $product): array { diff --git a/app/Catalog/CategoryListing.php b/app/Catalog/ProductListing.php similarity index 76% rename from app/Catalog/CategoryListing.php rename to app/Catalog/ProductListing.php index 5315dd8..709a408 100644 --- a/app/Catalog/CategoryListing.php +++ b/app/Catalog/ProductListing.php @@ -7,15 +7,16 @@ 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. + * The parsed filter/sort/page state of a product-listing request — used by the + * category page (scoped to a collection) and the all-products page. The query + * string is the single source of truth; 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. + * listing has no query string at all. */ -final class CategoryListing +final class ProductListing { private function __construct( public readonly ?ProductSort $sort, @@ -36,7 +37,10 @@ public static function fromRequest(Request $request): self ); } - public function filters(int $collectionId): ProductFilters + /** + * @param ?int $collectionId scope to a collection (category page); null = every product + */ + public function filters(?int $collectionId = null): ProductFilters { return new ProductFilters( collectionId: $collectionId, @@ -48,8 +52,7 @@ public function filters(int $collectionId): ProductFilters /** * 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([...])). + * merged on top — pass `['key' => null]` to drop one. * * @param array $overrides * @return array @@ -68,7 +71,7 @@ public function query(array $overrides = []): array /** * 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 + * indexed as its own page (the canonical still points at the bare listing * URL either way). A plain in-stock toggle is left indexable. */ public function isRefined(): bool diff --git a/app/Catalog/ProductListingPage.php b/app/Catalog/ProductListingPage.php new file mode 100644 index 0000000..cd08a03 --- /dev/null +++ b/app/Catalog/ProductListingPage.php @@ -0,0 +1,68 @@ +): string $url + * @param ?int $collectionId scope to a collection, or null for every product + * @return array + */ + public function build(ProductListing $listing, Closure $url, ?int $collectionId = null): array + { + $filters = $listing->filters($collectionId); + + // Listing reads from the Meilisearch index via ProductService, not + // Eloquent. list() returns a ProductListingResult — the product page + // plus the price-slider bounds from one call; the controller no longer + // stitches list() + priceRange() together itself. Sort/filter/page all + // come from $listing (the query string). + $result = $this->products->list( + filters: $filters, + perPage: self::PER_PAGE, + page: $listing->page, + sort: $listing->sort, + ); + + $products = $result->products + ->through(ProductCard::fromIndexed(...)) + ->appends($listing->query(['page' => null])); + + // Slider bounds — the price span of everything matching the *other* + // filters, rounded to whole euros, plus whether the current price params + // actually narrow that span. All computed in core now + // (ProductService::priceSliderBounds()). + $priceBounds = $result->priceBounds; + + return [ + 'listing' => $listing, + 'products' => $products, + 'listingAction' => $url([]), + 'priceFloor' => $priceBounds->floor, + 'priceCeil' => $priceBounds->ceil, + 'clearPriceUrl' => $priceBounds->filtered + ? $url($listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) + : null, + 'sortOptions' => ProductSortOptions::build( + $listing->sort, + fn (?ProductSort $sort) => $url($listing->query(['sort' => $sort?->value, 'page' => null])), + ), + ]; + } +} diff --git a/app/Catalog/ProductSortOptions.php b/app/Catalog/ProductSortOptions.php new file mode 100644 index 0000000..5550c8c --- /dev/null +++ b/app/Catalog/ProductSortOptions.php @@ -0,0 +1,40 @@ + dropdown, so the label + * map and "the default sort has no URL param" rule live in one place. Each page + * supplies a `$url` closure that turns a sort (or null = default/relevance) + * into the right href for that page — category vs. search build their URLs + * differently. + */ +final class ProductSortOptions +{ + /** + * @param Closure(?ProductSort): string $url + * @return array + */ + public static function build(?ProductSort $current, Closure $url): array + { + $sorts = [ + null => 'storefront.shop.sort_popularity', + ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc', + ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc', + ProductSort::Newest->value => 'storefront.shop.sort_newest', + ]; + + return array_map(function (string $key, string $label) use ($current, $url) { + $sort = $key === '' ? null : ProductSort::from($key); + + return [ + 'label' => __($label), + 'href' => $url($sort), + 'current' => $sort === $current, + ]; + }, array_keys($sorts), array_values($sorts)); + } +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index acd3fa8..1cb5cab 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -2,16 +2,15 @@ namespace App\Http\Controllers; -use App\Catalog\CategoryListing; -use App\Catalog\ProductCard; +use App\Catalog\ProductListing; +use App\Catalog\ProductListingPage; use Illuminate\Http\Response; use Modules\Core\Catalog\Services\CollectionService; -use Modules\Core\Catalog\Services\ProductService; class CategoryController extends Controller { public function __construct( - private readonly ProductService $products, + private readonly ProductListingPage $listingPage, private readonly CollectionService $collections, ) {} @@ -20,33 +19,14 @@ 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; + $listing = ProductListing::fromRequest(request()); - // One call for both the product page and the price slider's bounds — - // see ProductService::list()'s own docblock for why a controller no - // longer orchestrates list() + priceSliderBounds() itself. - $listingResult = $this->products->list( - filters: $filters, - perPage: $perPage, - page: $listing->page, - sort: $listing->sort, + $data = $this->listingPage->build( + $listing, + fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query), + $collectionData['id'], ); - $products = $listingResult->products - ->through(fn (array $product) => ProductCard::fromIndexed($product)) - ->appends($listing->query(['page' => null])); - - $priceBounds = $listingResult->priceBounds; - - return view('category.show', [ - 'collection' => $collectionData, - 'products' => $products, - 'listing' => $listing, - 'priceFloor' => $priceBounds->floor, - 'priceCeil' => $priceBounds->ceil, - 'priceFiltered' => $priceBounds->filtered, - ]); + return view('category.show', [...$data, 'collection' => $collectionData]); } } diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 66e88a2..e390712 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -2,12 +2,33 @@ namespace App\Http\Controllers; +use App\Catalog\ProductListing; +use App\Catalog\ProductListingPage; use Illuminate\Http\Response; use Modules\Core\Catalog\Services\ProductService; class ProductController extends Controller { - public function __construct(private readonly ProductService $products) {} + public function __construct( + private readonly ProductService $products, + private readonly ProductListingPage $listingPage, + ) {} + + /** + * All products — the category page without a collection scope. Filters, + * sort, pagination and the shared listing body all work identically. + */ + public function index(string $locale) + { + $listing = ProductListing::fromRequest(request()); + + $data = $this->listingPage->build( + $listing, + fn (array $query) => route('products', $query), + ); + + return view('products.index', $data); + } public function show(string $locale, int $id) { diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php new file mode 100644 index 0000000..46fc875 --- /dev/null +++ b/app/Http/Controllers/SearchController.php @@ -0,0 +1,94 @@ +query('q', '')); + + // Nothing to search for — send them to the all-products page rather than + // render an empty results page. + if ($query === '') { + return redirect()->route('products'); + } + + $sort = ProductSort::tryFrom((string) request()->query('sort')); + $page = max(1, (int) request()->query('page', 1)); + + $cards = $this->cardsFor($query, $sort); + + $products = new LengthAwarePaginator( + items: $cards->forPage($page, self::PER_PAGE)->values(), + total: $cards->count(), + perPage: self::PER_PAGE, + currentPage: $page, + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); + $products->appends(array_filter( + ['q' => $query, 'sort' => $sort?->value], + fn ($value) => $value !== null, + )); + + // Sort dropdown links: same query, swapped sort (default sort => no param). + $sortOptions = ProductSortOptions::build( + $sort, + fn (?ProductSort $option) => route('search', array_filter( + ['q' => $query, 'sort' => $option?->value], + fn ($value) => $value !== null, + )), + ); + + return view('search.index', [ + 'query' => $query, + 'products' => $products, + 'sortOptions' => $sortOptions, + ]); + } + + /** + * Full-text matches as product-card arrays. ProductSearchService returns + * hydrated models in relevance order with no sort or pagination, so ordering + * is done here in PHP and the controller paginates the mapped collection. + * + * @return Collection> + */ + private function cardsFor(string $query, ?ProductSort $sort): Collection + { + $results = $this->search->search($query)->load(['variants.prices', 'media']); + + return $this->sortResults($results, $sort) + ->map(ProductCard::fromModel(...)) + ->values(); + } + + /** + * @param EloquentCollection $results + * @return EloquentCollection + */ + private function sortResults(EloquentCollection $results, ?ProductSort $sort): EloquentCollection + { + $price = fn (Product $product) => $product->variants->first()?->prices->first()?->price->value ?? 0; + + return match ($sort) { + ProductSort::PriceAsc => $results->sortBy($price)->values(), + ProductSort::PriceDesc => $results->sortByDesc($price)->values(), + ProductSort::Newest => $results->sortByDesc('created_at')->values(), + default => $results, // Meilisearch relevance order + }; + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 022b9f9..58f8243 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -213,6 +213,28 @@ @layer components { } } + /* ── Search overlay (popover) — opacity fade over the header ──── */ + #search-overlay { + /* Fallback height until the `nav-search` controller measures the real + header on open; the header has no fixed height below `lg`. */ + --search-overlay-h: 6.5rem; + opacity: 0; + transition: + opacity 0.2s ease, + display 0.2s allow-discrete, + overlay 0.2s allow-discrete; + } + + #search-overlay:popover-open { + opacity: 1; + } + + @starting-style { + #search-overlay:popover-open { + opacity: 0; + } + } + /* ── Shared underline-slide animation ────────────────────────── */ .underline-slide { background-image: linear-gradient(currentColor, currentColor); diff --git a/resources/js/stimulus/frame-scroll-controller.js b/resources/js/stimulus/frame-scroll-controller.js new file mode 100644 index 0000000..4431adf --- /dev/null +++ b/resources/js/stimulus/frame-scroll-controller.js @@ -0,0 +1,24 @@ +import { Controller } from '@hotwired/stimulus' + +// Turbo doesn't scroll for navigations, so after the frame swaps +// its contents (a sort, filter, or pagination link) this brings the top of the +// frame back into view — otherwise clicking pagination at the bottom of the +// list leaves you stranded down there. The frame's own scroll-margin-top keeps +// it clear of the sticky header. +// +// `turbo:frame-render` fires only on a content swap, not on the initial page +// render, and the frame element itself persists across swaps — so the listener +// is bound once in connect(). +export default class extends Controller { + connect() { + this.element.addEventListener('turbo:frame-render', this.#toTop) + } + + disconnect() { + this.element.removeEventListener('turbo:frame-render', this.#toTop) + } + + #toTop = () => { + this.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } +} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index 4324db1..4b152d2 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -8,6 +8,8 @@ import AutoSubmitController from './auto-submit-controller' import BackToTopController from './back-to-top-controller' import CarouselController from './carousel-controller' import DropdownController from './dropdown-controller' +import FrameScrollController from './frame-scroll-controller' +import NavSearchController from './nav-search-controller' import ProductFormController from './product-form-controller' import ProductGalleryController from './product-gallery-controller' import QuantityController from './quantity-controller' @@ -21,6 +23,8 @@ export function registerControllers(application) { application.register('back-to-top', BackToTopController) application.register('carousel', CarouselController) application.register('dropdown', DropdownController) + application.register('frame-scroll', FrameScrollController) + application.register('nav-search', NavSearchController) application.register('product-form', ProductFormController) application.register('product-gallery', ProductGalleryController) application.register('quantity', QuantityController) diff --git a/resources/js/stimulus/nav-search-controller.js b/resources/js/stimulus/nav-search-controller.js new file mode 100644 index 0000000..7f8afa7 --- /dev/null +++ b/resources/js/stimulus/nav-search-controller.js @@ -0,0 +1,28 @@ +import { Controller } from '@hotwired/stimulus' + +// The search overlay is a [popover] that must sit exactly over the header. The +// header has no fixed height below `lg`, so on open we copy its current height +// onto --search-overlay-h and move focus into the field. Escape / click-away +// close come from the Popover API; the fade is CSS (#search-overlay). +export default class extends Controller { + static targets = ['input'] + + connect() { + this.element.addEventListener('toggle', this.#onToggle) + } + + disconnect() { + this.element.removeEventListener('toggle', this.#onToggle) + } + + #onToggle = (event) => { + if (event.newState !== 'open') return + + const header = this.element.closest('header') + if (header) { + this.element.style.setProperty('--search-overlay-h', `${header.offsetHeight}px`) + } + + requestAnimationFrame(() => this.inputTarget.focus()) + } +} diff --git a/resources/views/category/show.blade.php b/resources/views/category/show.blade.php index 430be0a..9b36b80 100644 --- a/resources/views/category/show.blade.php +++ b/resources/views/category/show.blade.php @@ -32,8 +32,13 @@ 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') + + @include('shop.partials.listing') diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index b840870..f514bab 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -11,7 +11,7 @@ {{-- Products (CSS-only hover dropdown) --}} + + diff --git a/resources/views/components/search-overlay.blade.php b/resources/views/components/search-overlay.blade.php new file mode 100644 index 0000000..ccd6ae8 --- /dev/null +++ b/resources/views/components/search-overlay.blade.php @@ -0,0 +1,41 @@ +{{-- + Full-width search bar that covers the header. A [popover] (top layer, so it + sits over the sticky header with no z-index juggling; Escape and click-away + close come for free). The header's search button opens it via popovertarget. + The `nav-search` controller measures the header on open (→ --search-overlay-h, + since the header isn't a fixed height below `lg`) and focuses the field. + Fade transition lives in app.css (#search-overlay). +--}} +
+
+ + + +
+
diff --git a/resources/views/components/shop/result-count.blade.php b/resources/views/components/shop/result-count.blade.php new file mode 100644 index 0000000..6fa0fc0 --- /dev/null +++ b/resources/views/components/shop/result-count.blade.php @@ -0,0 +1,11 @@ +@props(['paginator']) + +{{-- "Showing 1–12 of 48 products" — takes any LengthAwarePaginator. Shared by + the category listing and the search results page. --}} +

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

diff --git a/resources/views/components/shop/sort.blade.php b/resources/views/components/shop/sort.blade.php new file mode 100644 index 0000000..526aebf --- /dev/null +++ b/resources/views/components/shop/sort.blade.php @@ -0,0 +1,24 @@ +@props([ + 'options', // [['label' => string, 'href' => string, 'current' => bool], ...] — build with App\Catalog\ProductSortOptions + 'id' => 'sort', +]) + +{{-- Product sort dropdown. Options are plain links (built per page, so each can + carry its own state); following one navigates the enclosing turbo-frame and + advances the URL. Shared by the category listing and search results. --}} +@php + $active = collect($options)->firstWhere('current', true) ?? ($options[0] ?? ['label' => '']); +@endphp + + + @foreach ($options as $option) + + {{ $option['label'] }} + + @endforeach + diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index d9be54c..63773a0 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -138,7 +138,7 @@ class="max-w-xs"

{{ $page->classics_title }}

- Όλα τα προϊόντα + Όλα τα προϊόντα
diff --git a/resources/views/product/show.blade.php b/resources/views/product/show.blade.php index 5ec24d5..9365c96 100644 --- a/resources/views/product/show.blade.php +++ b/resources/views/product/show.blade.php @@ -9,7 +9,7 @@ diff --git a/resources/views/products/index.blade.php b/resources/views/products/index.blade.php new file mode 100644 index 0000000..579e867 --- /dev/null +++ b/resources/views/products/index.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('title', __('storefront.shop.all_products') . ' — ' . config('app.name')) + +@push('seo') + {{-- The bare listing is indexable; filtered / sorted / paged variants + consolidate onto it and are kept out of the index. --}} + + @if($listing->isRefined()) + + @endif +@endpush + +@section('content') +
+ +
+

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

+ + +
+ + {{-- Same reloadable listing body as the category page; sort/filter/page + navigate this frame and advance the URL. --}} + + @include('shop.partials.listing') + + +
+@endsection diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php new file mode 100644 index 0000000..bec63bf --- /dev/null +++ b/resources/views/search/index.blade.php @@ -0,0 +1,36 @@ +@extends('layouts.app') + +@php + // $heading = __('storefront.search.results_for') . ' "' . $query . '"'; + $heading = '"' . $query . '"'; +@endphp + +@section('title', $query . ' — ' . config('app.name')) + +@push('seo') + +@endpush + +@section('content') +
+ +
+

{{ $heading }}

+ + +
+ + + @include('search.partials.results') + + +
+@endsection diff --git a/resources/views/search/partials/results.blade.php b/resources/views/search/partials/results.blade.php new file mode 100644 index 0000000..0d6443a --- /dev/null +++ b/resources/views/search/partials/results.blade.php @@ -0,0 +1,22 @@ +{{-- + Reloadable body of the search results page — result count + sort, product + grid, pagination. Re-rendered on full load and on every + navigation, straight from ?q= and ?sort=. + + Vars: $query (non-empty string), $products (LengthAwarePaginator), $sortOptions (array) +--}} + +@if ($products->isEmpty()) +

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

+@else +
+ + +
+ + + +
+ +
+@endif diff --git a/resources/views/category/partials/listing.blade.php b/resources/views/shop/partials/listing.blade.php similarity index 55% rename from resources/views/category/partials/listing.blade.php rename to resources/views/shop/partials/listing.blade.php index 1d02056..418d7ee 100644 --- a/resources/views/category/partials/listing.blade.php +++ b/resources/views/shop/partials/listing.blade.php @@ -1,56 +1,20 @@ {{-- - 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. + Reloadable body of a product listing — result count + sort, product grid + + pagination, and the filter sidebar. Shared by the category page (scoped to a + collection) and the all-products page. Rendered on full load and on every + turbo-frame navigation, straight from the query string. - Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing) + Vars (all from App\Catalog\ProductListingPage::build): + $products (LengthAwarePaginator), $sortOptions (array), + $listing (App\Catalog\ProductListing), $listingAction (string, GET form target), + $clearPriceUrl (?string), $priceFloor / $priceCeil (?int) --}} -@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 - + +
@@ -70,11 +34,10 @@ @endif
- {{-- Sidebar — sort, price and in-stock are wired to the back-end; the - search box is still a placeholder. --}} + {{-- Sidebar — sort, price and in-stock are wired to the back-end; product + search lives in the nav overlay, not here. --}}