general products page and small rewrite of search and category controllers etc

This commit is contained in:
elvira
2026-09-03 21:32:40 +03:00
parent b2207a622c
commit 1f0612861b
23 changed files with 564 additions and 108 deletions
+28 -9
View File
@@ -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<string, mixed> $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
{
@@ -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<string, string|int|null> $overrides
* @return array<string, string|int>
@@ -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
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Catalog;
use Closure;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductService;
/**
* Assembles the data the shared shop listing body (shop/partials/listing.blade.php)
* needs — the product page, price-slider bounds, sort links and the "clear price"
* link. Used by both the category page (scoped to a collection) and the
* all-products page; the `$url` closure turns a query-param array into a URL for
* whichever page is calling, so this class never has to know the route.
*/
final class ProductListingPage
{
private const PER_PAGE = 12;
public function __construct(private readonly ProductService $products) {}
/**
* @param Closure(array<string, string|int>): string $url
* @param ?int $collectionId scope to a collection, or null for every product
* @return array<string, mixed>
*/
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])),
),
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Catalog;
use Closure;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* Builds the option list for the shared <x-shop.sort> 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<int, array{label: string, href: string, current: bool}>
*/
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));
}
}
+9 -29
View File
@@ -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]);
}
}
+22 -1
View File
@@ -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)
{
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Catalog\ProductSortOptions;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService;
class SearchController extends Controller
{
private const PER_PAGE = 12;
public function __construct(private readonly ProductSearchService $search) {}
public function show(string $locale)
{
$query = trim((string) request()->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<int, array<string, mixed>>
*/
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<int, Product> $results
* @return EloquentCollection<int, Product>
*/
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
};
}
}
+22
View File
@@ -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);
@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus'
// Turbo doesn't scroll for <turbo-frame> 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' })
}
}
+4
View File
@@ -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)
@@ -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())
}
}
+7 -2
View File
@@ -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. --}}
<turbo-frame id="category-listing" data-turbo-action="advance">
@include('category.partials.listing')
<turbo-frame
id="category-listing"
data-turbo-action="advance"
data-controller="frame-scroll"
class="scroll-mt-28"
>
@include('shop.partials.listing')
</turbo-frame>
</div>
+10 -2
View File
@@ -11,7 +11,7 @@
{{-- Products (CSS-only hover dropdown) --}}
<div class="group relative h-full flex items-center">
<a href="{{ url('/'.app()->getLocale().'/products') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
<a href="{{ route('products') }}" class="nav-link uppercase inline-flex items-center gap-1 relative font-display font-extrabold italic text-black no-underline py-8">
<span>{{ __('storefront.nav.products') }}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 not-italic transition-transform group-hover:rotate-180">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
@@ -47,10 +47,18 @@
</a>
{{-- Search --}}
<button type="button" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Search">
<button
type="button"
popovertarget="search-overlay"
popovertargetaction="toggle"
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
aria-label="{{ __('storefront.shop.search_label') }}"
>
<x-ui.icon name="search" :size="40" />
</button>
</div>
</div>
<x-search-overlay />
</header>
@@ -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).
--}}
<div
id="search-overlay"
popover
data-controller="nav-search"
class="fixed w-full inset-x-0 top-0 bottom-auto m-0 h-[var(--search-overlay-h)] border-0 bg-brand p-0"
>
<form
method="get"
action="{{ route('search') }}"
class="flex h-full items-center gap-6 px-10"
>
<label for="search-overlay-input" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
<input
id="search-overlay-input"
type="search"
name="q"
required
autocomplete="off"
data-nav-search-target="input"
placeholder="{{ __('storefront.search.placeholder') }}"
class="min-w-0 flex-1 appearance-none border-0 border-b border-black bg-transparent pb-2 placeholder:text-black/60 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
>
<button
type="button"
popovertarget="search-overlay"
popovertargetaction="hide"
aria-label="{{ __('storefront.nav.close') }}"
class="shrink-0 text-black transition-opacity hover:opacity-70"
>
<x-ui.icon name="close" :size="44" />
</button>
</form>
</div>
@@ -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. --}}
<p {{ $attributes }}>
{{ trans_choice('storefront.shop.showing_results', $paginator->total(), [
'first' => $paginator->firstItem() ?? 0,
'last' => $paginator->lastItem() ?? 0,
'total' => $paginator->total(),
]) }}
</p>
@@ -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
<x-ui.dropdown
:id="$id"
:label="$active['label']"
:ariaLabel="__('storefront.shop.sort_label')"
triggerClass="min-w-48"
>
@foreach ($options as $option)
<x-ui.dropdown.item :href="$option['href']" :current="$option['current'] ?? false">
{{ $option['label'] }}
</x-ui.dropdown.item>
@endforeach
</x-ui.dropdown>
+1 -1
View File
@@ -138,7 +138,7 @@ class="max-w-xs"
<div class="border-b border-black" data-controller="carousel">
<div class="flex items-center justify-between gap-4 px-8 py-12 border-b border-black">
<h2 class="font-display font-extrabold text-h2" data-stoic="pages/home#classics_title">{{ $page->classics_title }}</h2>
<x-ui.button size="lg" :href="url('/'.app()->getLocale().'/products')">Όλα τα προϊόντα</x-ui.button>
<x-ui.button size="lg" :href="route('products')">Όλα τα προϊόντα</x-ui.button>
</div>
<div class="flex items-center gap-6 px-8 py-12">
+1 -1
View File
@@ -9,7 +9,7 @@
<x-breadcrumb class="mb-14 justify-end" :items="[
$collection
? ['label' => $collection['name'], 'href' => route('category.show', ['id' => $collection['id']])]
: ['label' => __('storefront.nav.products'), 'href' => '/products'],
: ['label' => __('storefront.nav.products'), 'href' => route('products')],
['label' => $product['name']],
]" />
+38
View File
@@ -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. --}}
<link rel="canonical" href="{{ route('products') }}">
@if($listing->isRefined())
<meta name="robots" content="noindex,follow">
@endif
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ __('storefront.shop.all_products') }}</h1>
<x-breadcrumb :items="[
['label' => __('storefront.nav.home'), 'href' => route('home')],
['label' => __('storefront.shop.all_products')],
]" />
</div>
{{-- Same reloadable listing body as the category page; sort/filter/page
navigate this frame and advance the URL. --}}
<turbo-frame
id="products-listing"
data-turbo-action="advance"
data-controller="frame-scroll"
class="scroll-mt-28"
>
@include('shop.partials.listing')
</turbo-frame>
</div>
@endsection
+36
View File
@@ -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')
<meta name="robots" content="noindex,follow">
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $heading }}</h1>
<x-breadcrumb :items="[
['label' => __('storefront.nav.home'), 'href' => route('home')],
['label' => __('storefront.search.results_for') . $heading],
]" />
</div>
<turbo-frame
id="search-listing"
data-turbo-action="advance"
data-controller="frame-scroll"
class="scroll-mt-28"
>
@include('search.partials.results')
</turbo-frame>
</div>
@endsection
@@ -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
<turbo-frame id="search-listing"> navigation, straight from ?q= and ?sort=.
Vars: $query (non-empty string), $products (LengthAwarePaginator), $sortOptions (array)
--}}
@if ($products->isEmpty())
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
@else
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
<x-shop.result-count :paginator="$products" />
<x-shop.sort :options="$sortOptions" id="search-sort" />
</div>
<x-product-grid :products="$products->items()" cols="4" />
<div class="mt-12">
<x-ui.pagination :paginator="$products" />
</div>
@endif
@@ -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 <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.
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
<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>
<x-shop.result-count :paginator="$products" />
<x-shop.sort :options="$sortOptions" id="shop-sort" />
</div>
<div></div>
</div>
@@ -70,11 +34,10 @@
@endif
</div>
{{-- 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. --}}
<aside class="flex flex-col gap-10">
{{-- 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
@@ -83,7 +46,7 @@
the sr-only submit button applies the range inputs. --}}
<form
method="get"
action="{{ route('category.show', ['id' => $collection['id']]) }}"
action="{{ $listingAction }}"
data-controller="auto-submit"
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
class="contents"
@@ -93,7 +56,7 @@ class="contents"
@endif
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
<div class="flex flex-col gap-4 -mt-2">
<div class="flex flex-col gap-4 -mt-2">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
<x-ui.range-slider
@@ -108,9 +71,9 @@ class="contents"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
>
@if ($priceFiltered)
@if ($clearPriceUrl)
<a
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
href="{{ $clearPriceUrl }}"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ __('storefront.shop.reset') }}</a>
@endif
+5
View File
@@ -5,6 +5,7 @@
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LegalPageController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\SearchController;
use Illuminate\Support\Facades\Route;
// Bare `/` has no {locale} segment to prefix-match against, so it's declared outside
@@ -22,6 +23,8 @@
->group(function () {
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/products', [ProductController::class, 'index'])->name('products');
Route::get('/products/{id}', [ProductController::class, 'show'])->name(
'product.show',
);
@@ -30,6 +33,8 @@
'category.show',
);
Route::get('/search', [SearchController::class, 'show'])->name('search');
Route::get('/contact', [ContactController::class, 'index'])->name('contact');
Route::get('/terms-and-conditions', [LegalPageController::class, 'terms'])->name(