From 215f43f3ef5d2bbf34b22ed5b4536285d1ce8878 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 3 Sep 2026 13:34:04 +0300 Subject: [PATCH] Feat: Adding tags to product filters --- src/Catalog/DTOs/ProductFilters.php | 2 ++ src/Catalog/DTOs/ProductListingResult.php | 24 +++++++++++----- src/Catalog/Services/ProductIndexer.php | 1 + src/Catalog/Services/ProductService.php | 30 +++++++++++++++++--- src/Catalog/Support/ProductFilterBuilder.php | 3 +- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/Catalog/DTOs/ProductFilters.php b/src/Catalog/DTOs/ProductFilters.php index 6bdcaf0..bfae470 100644 --- a/src/Catalog/DTOs/ProductFilters.php +++ b/src/Catalog/DTOs/ProductFilters.php @@ -17,10 +17,12 @@ class ProductFilters * not a direct-assignment-only match) — the right semantics for "products on * this category page", since products are typically attached only to leaf * collections. + * @param $tag exactly one tag — no multi-select yet. */ public function __construct( public readonly ?int $collectionId = null, public readonly ?string $brand = null, + public readonly ?string $tag = null, public readonly ?float $minPrice = null, public readonly ?float $maxPrice = null, public readonly bool $inStockOnly = false, diff --git a/src/Catalog/DTOs/ProductListingResult.php b/src/Catalog/DTOs/ProductListingResult.php index 144a7cd..5e562bc 100644 --- a/src/Catalog/DTOs/ProductListingResult.php +++ b/src/Catalog/DTOs/ProductListingResult.php @@ -6,18 +6,28 @@ use Illuminate\Pagination\LengthAwarePaginator; /** * Everything a listing page needs from one ProductService::list() call — - * the product page itself plus the price slider's bounds, so a controller - * makes one service call instead of orchestrating list() and - * priceSliderBounds() separately. list() still issues two Meilisearch - * requests internally (the product search and the price facet stats — see - * priceSliderBounds()'s own docblock for why they can't be merged into one - * without changing the slider's UX), but that's this DTO's job to hide, - * not the controller's to know about. + * the product page itself, the price slider's bounds, and the set of tags + * actually present on matching products (for a tag filter sidebar) — so a + * controller makes one service call instead of orchestrating list(), + * priceSliderBounds(), and facets('tags', ...) separately. list() still + * issues multiple Meilisearch requests internally (the product search, the + * price facet stats, the tag facet distribution — see priceSliderBounds()'s + * own docblock for why the price ones can't be merged into one without + * changing the slider's UX), but that's this DTO's job to hide, not the + * controller's to know about. */ class ProductListingResult { + /** + * @param array $availableTags every distinct tag value + * present on at least one product matching the listing's OTHER + * filters (collection/price/stock — never the tag filter itself, so + * selecting a tag doesn't collapse the list down to just that tag). + * Sorted alphabetically. Empty if no product in scope has any tag. + */ public function __construct( public readonly LengthAwarePaginator $products, public readonly PriceSliderBounds $priceBounds, + public readonly array $availableTags = [], ) {} } diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index 8f9176b..bf97f38 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -90,6 +90,7 @@ class ProductIndexer extends BaseProductIndexer 'in_stock', 'recommendations.id', 'skus', + 'tags', ]; } diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index 45b8d55..326837d 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -78,8 +78,27 @@ class ProductService ); $priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice); + $availableTags = $this->availableTags($filters); - return new ProductListingResult($products, $priceBounds); + return new ProductListingResult($products, $priceBounds, $availableTags); + } + + /** + * Every distinct `tags` value present on a product matching $filters, + * excluding $filters->tag itself — same "scoped but not self-collapsing" + * reasoning as priceRange() excluding `price` — so selecting a tag + * doesn't shrink the sidebar down to just that one tag. Sorted + * alphabetically; Meilisearch's facetDistribution has no defined order + * of its own. + * + * @return array + */ + private function availableTags(?ProductFilters $filters): array + { + $filter = $this->filterBuilder->build($filters, exclude: ['tag']); + $tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? []; + + return collect($tags)->keys()->sort()->values()->all(); } /** @@ -93,9 +112,12 @@ class ProductService * apply that field's own filter separately in the UI/query layer. * * `$field` must be one of ProductIndexer's filterable fields; only discrete-value - * fields make sense here (`brand`, `in_stock`) — a numeric field like `price` - * would return one "facet" per exact price, not a usable range bucket. Use - * `priceRange()` for `price` instead. + * fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like + * `price` would return one "facet" per exact price, not a usable range bucket. + * Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a + * category page gets "which tags actually appear on products in this category" — + * pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list + * itself doesn't collapse to whichever tag is already selected. * * @return array facet value => matching product count */ diff --git a/src/Catalog/Support/ProductFilterBuilder.php b/src/Catalog/Support/ProductFilterBuilder.php index ba1cdf3..dddf401 100644 --- a/src/Catalog/Support/ProductFilterBuilder.php +++ b/src/Catalog/Support/ProductFilterBuilder.php @@ -14,7 +14,7 @@ use Modules\Core\Catalog\DTOs\ProductFilters; class ProductFilterBuilder { /** - * @param array $exclude + * @param array $exclude * filter fields to leave out even if set on $filters — e.g. * ProductService::priceRange() excludes 'price' so a price slider's own * bounds don't shrink to whatever range is already selected on it. @@ -28,6 +28,7 @@ class ProductFilterBuilder $clauses = Collection::make([ 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + 'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : null, 'price' => Collection::make([ $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,