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));
}
}