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 * not a direct-assignment-only match) — the right semantics for "products on
* this category page", since products are typically attached only to leaf * this category page", since products are typically attached only to leaf
* collections. * collections.
* @param $tag exactly one tag — no multi-select yet.
*/ */
public function __construct( public function __construct(
public readonly ?int $collectionId = null, public readonly ?int $collectionId = null,
public readonly ?string $brand = null, public readonly ?string $brand = null,
public readonly ?string $tag = null,
public readonly ?float $minPrice = null, public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null, public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false, 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 — * Everything a listing page needs from one ProductService::list() call —
* the product page itself plus the price slider's bounds, so a controller * the product page itself, the price slider's bounds, and the set of tags
* makes one service call instead of orchestrating list() and * actually present on matching products (for a tag filter sidebar) — so a
* priceSliderBounds() separately. list() still issues two Meilisearch * controller makes one service call instead of orchestrating list(),
* requests internally (the product search and the price facet stats — see * priceSliderBounds(), and facets('tags', ...) separately. list() still
* priceSliderBounds()'s own docblock for why they can't be merged into one * issues multiple Meilisearch requests internally (the product search, the
* without changing the slider's UX), but that's this DTO's job to hide, * price facet stats, the tag facet distribution — see priceSliderBounds()'s
* not the controller's to know about. * 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 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 function __construct(
public readonly LengthAwarePaginator $products, public readonly LengthAwarePaginator $products,
public readonly PriceSliderBounds $priceBounds, public readonly PriceSliderBounds $priceBounds,
public readonly array $availableTags = [],
) {} ) {}
} }
+1
View File
@@ -90,6 +90,7 @@ class ProductIndexer extends BaseProductIndexer
'in_stock', 'in_stock',
'recommendations.id', 'recommendations.id',
'skus', 'skus',
'tags',
]; ];
} }
+26 -4
View File
@@ -78,8 +78,27 @@ class ProductService
); );
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice); $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. * apply that field's own filter separately in the UI/query layer.
* *
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value * `$field` must be one of ProductIndexer's filterable fields; only discrete-value
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price` * fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like
* would return one "facet" per exact price, not a usable range bucket. Use * `price` would return one "facet" per exact price, not a usable range bucket.
* `priceRange()` for `price` instead. * 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 * @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 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. * filter fields to leave out even if set on $filters — e.g.
* ProductService::priceRange() excludes 'price' so a price slider's own * ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it. * bounds don't shrink to whatever range is already selected on it.
@@ -28,6 +28,7 @@ class ProductFilterBuilder
$clauses = Collection::make([ $clauses = Collection::make([
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null, 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : null,
'price' => Collection::make([ 'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,