2026-07-12 05:34:43 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Search;
|
|
|
|
|
|
2026-08-24 12:41:28 +03:00
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
2026-07-12 05:34:43 +03:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
2026-08-24 12:41:28 +03:00
|
|
|
use Lunar\Models\Currency;
|
2026-08-24 14:41:50 +03:00
|
|
|
use Lunar\Models\Price;
|
2026-08-24 12:41:28 +03:00
|
|
|
use Lunar\Models\Product;
|
2026-08-24 14:41:50 +03:00
|
|
|
use Lunar\Models\ProductVariant;
|
2026-07-12 05:34:43 +03:00
|
|
|
use Lunar\Search\ProductIndexer as BaseProductIndexer;
|
2026-08-24 14:41:50 +03:00
|
|
|
use Modules\Core\Review\Models\ProductReview;
|
|
|
|
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
2026-07-12 05:34:43 +03:00
|
|
|
|
|
|
|
|
/**
|
2026-08-24 14:41:50 +03:00
|
|
|
* 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
|
2026-08-26 13:14:32 +03:00
|
|
|
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
|
|
|
|
|
* filterable, not channel assignment, so search results can't otherwise be
|
|
|
|
|
* scoped to products actually assigned+enabled on the current sales channel
|
2026-08-24 14:41:50 +03:00
|
|
|
*
|
|
|
|
|
* 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).
|
2026-07-12 05:34:43 +03:00
|
|
|
*/
|
|
|
|
|
class ProductIndexer extends BaseProductIndexer
|
|
|
|
|
{
|
2026-08-24 12:41:28 +03:00
|
|
|
public function getFilterableFields(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
...parent::getFilterableFields(),
|
2026-08-24 14:41:50 +03:00
|
|
|
'id',
|
2026-08-24 12:41:28 +03:00
|
|
|
'brand',
|
|
|
|
|
'collections',
|
|
|
|
|
'price',
|
2026-08-24 14:41:50 +03:00
|
|
|
'slugs',
|
2026-08-26 13:14:32 +03:00
|
|
|
'channel_ids',
|
2026-08-24 12:41:28 +03:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function makeAllSearchableUsing(Builder $query): Builder
|
|
|
|
|
{
|
2026-08-24 14:41:50 +03:00
|
|
|
return parent::makeAllSearchableUsing($query)->with([
|
|
|
|
|
'collections',
|
|
|
|
|
'media',
|
|
|
|
|
'tags',
|
|
|
|
|
'urls',
|
|
|
|
|
'variants.images',
|
|
|
|
|
'variants.prices',
|
|
|
|
|
'variants.values.option',
|
|
|
|
|
]);
|
2026-08-24 12:41:28 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 05:34:43 +03:00
|
|
|
public function toSearchableArray(Model $model): array
|
|
|
|
|
{
|
2026-08-24 12:41:28 +03:00
|
|
|
/** @var Product $model */
|
2026-07-12 05:34:43 +03:00
|
|
|
$data = parent::toSearchableArray($model);
|
|
|
|
|
|
2026-08-24 14:41:50 +03:00
|
|
|
$currency = Currency::getDefault();
|
|
|
|
|
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
|
|
|
|
|
|
2026-08-24 12:41:28 +03:00
|
|
|
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
|
2026-08-24 14:41:50 +03:00
|
|
|
$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);
|
2026-08-26 13:14:32 +03:00
|
|
|
$data['channel_ids'] = $model->channels()
|
|
|
|
|
->wherePivot('enabled', true)
|
|
|
|
|
->pluck('id')
|
|
|
|
|
->toArray();
|
2026-07-12 05:34:43 +03:00
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
}
|
2026-08-24 12:41:28 +03:00
|
|
|
|
2026-08-24 14:41:50 +03:00
|
|
|
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'),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 12:41:28 +03:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-24 14:41:50 +03:00
|
|
|
private function cheapestPrice(Product $model, Currency $currency): ?float
|
2026-08-24 12:41:28 +03:00
|
|
|
{
|
|
|
|
|
$price = $model->variants
|
|
|
|
|
->flatMap(fn ($variant) => $variant->prices)
|
|
|
|
|
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)
|
|
|
|
|
->min(fn ($price) => $price->price->value);
|
|
|
|
|
|
|
|
|
|
return $price !== null ? $price / (10 ** $currency->decimal_places) : null;
|
|
|
|
|
}
|
2026-07-12 05:34:43 +03:00
|
|
|
}
|