2026-09-03 16:28:10 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Catalog;
|
|
|
|
|
|
|
|
|
|
use Closure;
|
|
|
|
|
use Modules\Core\Catalog\Enums\ProductSort;
|
2026-09-04 13:07:57 +03:00
|
|
|
use Modules\Core\Catalog\Services\ProductSearchService;
|
2026-09-03 16:28:10 +03:00
|
|
|
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"
|
2026-09-04 13:07:57 +03:00
|
|
|
* link. Used by the category page (scoped to a collection), the all-products
|
|
|
|
|
* page, and the search page (scoped to a query — see $query below); 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.
|
2026-09-03 16:28:10 +03:00
|
|
|
*/
|
|
|
|
|
final class ProductListingPage
|
|
|
|
|
{
|
2026-09-17 21:52:27 +03:00
|
|
|
private const PER_PAGE = 40;
|
|
|
|
|
|
|
|
|
|
/** Category pages rarely have enough products to fill a page, so this
|
|
|
|
|
* ceiling is high enough to act as "no pagination" in practice — the
|
|
|
|
|
* pagination component self-hides via hasPages() when everything fits.
|
|
|
|
|
* If a category ever exceeds it, pagination reappears as a safety net
|
|
|
|
|
* rather than silently truncating results. */
|
|
|
|
|
private const CATEGORY_PER_PAGE = 200;
|
2026-09-03 16:28:10 +03:00
|
|
|
|
2026-09-04 13:07:57 +03:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly ProductService $products,
|
|
|
|
|
private readonly ProductSearchService $search,
|
|
|
|
|
) {}
|
2026-09-03 16:28:10 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param Closure(array<string, string|int>): string $url
|
|
|
|
|
* @param ?int $collectionId scope to a collection, or null for every product
|
2026-09-04 13:07:57 +03:00
|
|
|
* @param ?string $query scope to a text search — when given, calls
|
2026-09-03 16:28:10 +03:00
|
|
|
* @return array<string, mixed>
|
|
|
|
|
*/
|
2026-09-04 13:07:57 +03:00
|
|
|
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
2026-09-03 16:28:10 +03:00
|
|
|
{
|
|
|
|
|
$filters = $listing->filters($collectionId);
|
2026-09-17 21:52:27 +03:00
|
|
|
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
2026-09-03 16:28:10 +03:00
|
|
|
|
2026-09-04 13:07:57 +03:00
|
|
|
// Listing reads from the Meilisearch index via ProductService/
|
|
|
|
|
// ProductSearchService, not Eloquent. Both return a
|
|
|
|
|
// ProductListingResult — the product page plus the price-slider
|
|
|
|
|
// bounds (and available tags) from one call; the controller no
|
|
|
|
|
// longer stitches list()/search() + priceRange() together itself.
|
|
|
|
|
// Sort/filter/page all come from $listing (the query string).
|
|
|
|
|
$result = $query !== null
|
|
|
|
|
? $this->search->search(
|
|
|
|
|
query: $query,
|
|
|
|
|
filters: $filters,
|
|
|
|
|
sort: $listing->sort,
|
2026-09-17 21:52:27 +03:00
|
|
|
perPage: $perPage,
|
2026-09-04 13:07:57 +03:00
|
|
|
page: $listing->page,
|
|
|
|
|
)
|
|
|
|
|
: $this->products->list(
|
|
|
|
|
filters: $filters,
|
2026-09-17 21:52:27 +03:00
|
|
|
perPage: $perPage,
|
2026-09-04 13:07:57 +03:00
|
|
|
page: $listing->page,
|
|
|
|
|
sort: $listing->sort,
|
|
|
|
|
);
|
2026-09-03 16:28:10 +03:00
|
|
|
|
|
|
|
|
$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
|
2026-09-04 13:19:24 +03:00
|
|
|
// actually narrow that span. All computed in core
|
2026-09-03 16:28:10 +03:00
|
|
|
$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])),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|