Feat: Adding tags to product filters
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class ProductIndexer extends BaseProductIndexer
|
||||
'in_stock',
|
||||
'recommendations.id',
|
||||
'skus',
|
||||
'tags',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user