with([ 'collections', 'media', 'tags', 'urls', 'variants.images', 'variants.prices', 'variants.values.option', ]); } public function toSearchableArray(Model $model): array { /** @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['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); $data['channel_ids'] = $model->channels() ->wherePivot('enabled', true) ->pluck('id') ->toArray(); 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, Currency $currency): ?float { $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; } }