Feat: Adding tags to product filters

This commit is contained in:
2026-09-03 13:34:04 +03:00
parent c439299144
commit 215f43f3ef
5 changed files with 48 additions and 12 deletions
+2
View File
@@ -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,
+17 -7
View File
@@ -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<int, string> $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 = [],
) {}
}
+1
View File
@@ -90,6 +90,7 @@ class ProductIndexer extends BaseProductIndexer
'in_stock',
'recommendations.id',
'skus',
'tags',
];
}
+26 -4
View File
@@ -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<int, string>
*/
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<string, int> facet value => matching product count
*/
+2 -1
View File
@@ -14,7 +14,7 @@ use Modules\Core\Catalog\DTOs\ProductFilters;
class ProductFilterBuilder
{
/**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude
* @param array<int, 'collectionId'|'brand'|'tag'|'price'|'inStockOnly'> $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,