Feature: Updating Products Service and Locale MIddleware
This commit is contained in:
+124
-10
@@ -5,15 +5,38 @@ namespace Modules\Core\Search;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Lunar\Models\Currency;
|
||||
use Lunar\Models\Price;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
/**
|
||||
* Extends Lunar's own indexer to add fields needed for storefront listing/filtering
|
||||
* (Modules\Core\Catalog\ProductService) that aren't part of Lunar's default document:
|
||||
* collection membership and a comparable price. Neither is filterable in Meilisearch
|
||||
* until `php artisan lunar:meilisearch:setup` re-syncs index settings and the index is
|
||||
* refreshed — see docs/product-listing.md.
|
||||
* Extends Lunar's own indexer so Modules\Core\Catalog\ProductService can serve both
|
||||
* listing/filtering AND single-product lookups from Meilisearch alone — one data
|
||||
* source, no separate database read path for a product detail page. Adds:
|
||||
* - collections (ids, filterable) and collection_names (display)
|
||||
* - slugs (every locale's Url::slug for the product, filterable) — lets
|
||||
* ProductService::getBySlug() resolve a product from the index directly, with
|
||||
* no database read at all
|
||||
* - price (cheapest variant, filterable) and full per-variant pricing
|
||||
* - variants: sku, stock, purchasable, option values, prices, media
|
||||
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
|
||||
* - tags
|
||||
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately
|
||||
* excluded, it's PII with no storefront use), including staff replies, plus an
|
||||
* average rating
|
||||
*
|
||||
* A review is created/edited independently of its product (Modules\Core\Providers\
|
||||
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
||||
* this data doesn't go stale between full reindexes.
|
||||
*
|
||||
* New fields aren't filterable in Meilisearch until `php artisan lunar:meilisearch:setup`
|
||||
* re-syncs index settings, and existing documents need `lunar:search:index --refresh` to
|
||||
* pick up the new shape — see docs/product-listing.md. If SCOUT_QUEUE is enabled, the
|
||||
* queue worker also needs restarting after deploying changes to this class (see
|
||||
* docs/lunar.md "Gotchas" — a running worker keeps stale indexer code in memory).
|
||||
*/
|
||||
class ProductIndexer extends BaseProductIndexer
|
||||
{
|
||||
@@ -21,15 +44,25 @@ class ProductIndexer extends BaseProductIndexer
|
||||
{
|
||||
return [
|
||||
...parent::getFilterableFields(),
|
||||
'id',
|
||||
'brand',
|
||||
'collections',
|
||||
'price',
|
||||
'slugs',
|
||||
];
|
||||
}
|
||||
|
||||
public function makeAllSearchableUsing(Builder $query): Builder
|
||||
{
|
||||
return parent::makeAllSearchableUsing($query)->with(['collections', 'variants.prices']);
|
||||
return parent::makeAllSearchableUsing($query)->with([
|
||||
'collections',
|
||||
'media',
|
||||
'tags',
|
||||
'urls',
|
||||
'variants.images',
|
||||
'variants.prices',
|
||||
'variants.values.option',
|
||||
]);
|
||||
}
|
||||
|
||||
public function toSearchableArray(Model $model): array
|
||||
@@ -37,22 +70,103 @@ class ProductIndexer extends BaseProductIndexer
|
||||
/** @var Product $model */
|
||||
$data = parent::toSearchableArray($model);
|
||||
|
||||
$currency = Currency::getDefault();
|
||||
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
|
||||
|
||||
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
|
||||
$data['price'] = $this->cheapestPrice($model);
|
||||
$data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all();
|
||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||
$data['tags'] = $model->tags->pluck('value')->all();
|
||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||
$data['price'] = $this->cheapestPrice($model, $currency);
|
||||
$data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all();
|
||||
$data['review_count'] = $reviews->count();
|
||||
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function mapVariant(ProductVariant $variant, Currency $currency): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'sku' => $variant->sku,
|
||||
'stock' => $variant->stock,
|
||||
'purchasable' => $variant->purchasable,
|
||||
'options' => $variant->values->map(fn ($value) => [
|
||||
'option' => $this->translatedName($value->option->name),
|
||||
'value' => $this->translatedName($value->name),
|
||||
'meta' => $value->meta,
|
||||
])->all(),
|
||||
'prices' => $variant->prices->map(fn (Price $price) => [
|
||||
'currency_id' => $price->currency_id,
|
||||
'customer_group_id' => $price->customer_group_id,
|
||||
'price' => $price->price->decimal(),
|
||||
'compare_price' => $price->compare_price?->decimal(),
|
||||
'min_quantity' => $price->min_quantity,
|
||||
])->all(),
|
||||
'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-safe fields only — reviewer_email is PII with no storefront use and is
|
||||
* deliberately excluded, unlike every other column on the review. reply/replied_at
|
||||
* (the staff response) are included since they're meant to be shown alongside the
|
||||
* review on the storefront.
|
||||
*/
|
||||
private function mapReview(ProductReview $review): array
|
||||
{
|
||||
return [
|
||||
'id' => $review->id,
|
||||
'title' => $review->title,
|
||||
'body' => $review->body,
|
||||
'rating' => $review->rating,
|
||||
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||
'reviewer_name' => $review->reviewer_name,
|
||||
'reply' => $review->reply,
|
||||
'replied_at' => $review->replied_at?->timestamp,
|
||||
'location' => $review->location,
|
||||
'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast
|
||||
* (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is
|
||||
* not stored in attribute_data. Lunar's translateAttribute() only reads
|
||||
* attribute_data, so it silently returns null for these two models; this reads
|
||||
* the array directly instead. Falls back to the first available locale if the
|
||||
* current one is missing. Not a general replacement for translateAttribute() —
|
||||
* every other translated field in this indexer (product/collection name and
|
||||
* description) genuinely is attribute_data-backed and translateAttribute() is
|
||||
* correct for those.
|
||||
*/
|
||||
private function translatedName(mixed $name): ?string
|
||||
{
|
||||
$names = is_array($name) ? $name : (array) $name;
|
||||
|
||||
return $names[app()->getLocale()] ?? reset($names) ?: null;
|
||||
}
|
||||
|
||||
private function mapMedia(Media $media): array
|
||||
{
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'url' => $media->getUrl(),
|
||||
'thumb' => $media->getUrl('small'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest variant's base price (no customer group) in the default currency,
|
||||
* as a float in major units — e.g. 19.99, not 1999. Null if the product has no
|
||||
* variant with a price in that currency yet, so it's excluded from price filters
|
||||
* rather than sorting to the bottom as if it were free.
|
||||
*/
|
||||
private function cheapestPrice(Product $model): ?float
|
||||
private function cheapestPrice(Product $model, Currency $currency): ?float
|
||||
{
|
||||
$currency = Currency::getDefault();
|
||||
|
||||
$price = $model->variants
|
||||
->flatMap(fn ($variant) => $variant->prices)
|
||||
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)
|
||||
|
||||
Reference in New Issue
Block a user