): string $url * @param ?int $collectionId scope to a collection, or null for every product * @param ?string $query scope to a text search — when given, calls * @return array */ public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array { $filters = $listing->filters($collectionId); // 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, perPage: self::PER_PAGE, page: $listing->page, ) : $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 $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])), ), ]; } }