generated from boboko/starter
product category page: front-end sorting and filtering using turboframes
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* The parsed state of a category listing request. The query string is the single
|
||||
* source of truth for sort / filters / page — build one of these from the
|
||||
* request, read the applied values off it, and use query() to build links (sort
|
||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
||||
*
|
||||
* A param is only ever emitted when it differs from its default, so a pristine
|
||||
* listing is just `/category/{id}` with no query string.
|
||||
*/
|
||||
final class CategoryListing
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ProductSort $sort,
|
||||
public readonly ?int $minPrice,
|
||||
public readonly ?int $maxPrice,
|
||||
public readonly bool $inStockOnly,
|
||||
public readonly int $page,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): self
|
||||
{
|
||||
return new self(
|
||||
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
||||
minPrice: self::intOrNull($request->query('price_min')),
|
||||
maxPrice: self::intOrNull($request->query('price_max')),
|
||||
inStockOnly: $request->boolean('in_stock'),
|
||||
page: max(1, (int) $request->query('page', 1)),
|
||||
);
|
||||
}
|
||||
|
||||
public function filters(int $collectionId): ProductFilters
|
||||
{
|
||||
return new ProductFilters(
|
||||
collectionId: $collectionId,
|
||||
minPrice: $this->minPrice,
|
||||
maxPrice: $this->maxPrice,
|
||||
inStockOnly: $this->inStockOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
|
||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
||||
*
|
||||
* @param array<string, string|int|null> $overrides
|
||||
* @return array<string, string|int>
|
||||
*/
|
||||
public function query(array $overrides = []): array
|
||||
{
|
||||
return array_filter([
|
||||
'sort' => $this->sort?->value,
|
||||
'price_min' => $this->minPrice,
|
||||
'price_max' => $this->maxPrice,
|
||||
'in_stock' => $this->inStockOnly ? 1 : null,
|
||||
'page' => $this->page > 1 ? $this->page : null,
|
||||
...$overrides,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||
* indexed as its own page (the canonical still points at the bare category
|
||||
* URL either way). A plain in-stock toggle is left indexable.
|
||||
*/
|
||||
public function isRefined(): bool
|
||||
{
|
||||
return $this->sort !== null
|
||||
|| $this->minPrice !== null
|
||||
|| $this->maxPrice !== null
|
||||
|| $this->page > 1;
|
||||
}
|
||||
|
||||
private static function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\CategoryListing;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
@@ -19,27 +19,46 @@ public function show(string $locale, int $collection)
|
||||
$collectionData = $this->collections->getById($collection);
|
||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$listing = CategoryListing::fromRequest(request());
|
||||
$filters = $listing->filters($collectionData['id']);
|
||||
$perPage = 12;
|
||||
$page = (int) request('page', 1);
|
||||
|
||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
||||
// not Product models.
|
||||
// not Product models. Sort/filter/page all come from the query string via
|
||||
// CategoryListing, which is the single source of truth for that state.
|
||||
$products = $this->products->list(
|
||||
filters: new ProductFilters(collectionId: $collectionData['id']),
|
||||
filters: $filters,
|
||||
perPage: $perPage,
|
||||
page: $page,
|
||||
page: $listing->page,
|
||||
sort: $listing->sort,
|
||||
)->through(fn (array $product) => [
|
||||
'name' => $product['name'],
|
||||
'price' => $product['price'],
|
||||
'image' => $product['media'][0]['url'] ?? null,
|
||||
'href' => route('product.show', ['id' => $product['id']]),
|
||||
]);
|
||||
])->appends($listing->query(['page' => null]));
|
||||
|
||||
// Slider bounds — the price span of everything matching the *other*
|
||||
// filters (priceRange() drops the price filter itself, so the handles
|
||||
// don't collapse to whatever's already selected). Whole euros.
|
||||
$priceRange = $this->products->priceRange($filters);
|
||||
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
||||
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
||||
|
||||
// A price param is only a real filter if it's tighter than the bounds —
|
||||
// drives whether the "clear" link shows.
|
||||
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|
||||
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
|
||||
|
||||
return view('category.show', [
|
||||
'collection' => $collectionData,
|
||||
'products' => $products,
|
||||
'listing' => $listing,
|
||||
'priceFloor' => $priceFloor,
|
||||
'priceCeil' => $priceCeil,
|
||||
'priceFiltered' => $priceFiltered,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user