Feature: Adding Product Service to Lunar

This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
This commit is contained in:
2026-08-24 12:41:28 +03:00
parent f7da26b487
commit 5cb6c529a0
7 changed files with 387 additions and 8 deletions
+44 -8
View File
@@ -2,26 +2,62 @@
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\Product;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
/**
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
* the search index, which pollutes relevance ranking and highlighting with markup.
* Strip tags from string fields before they reach Meilisearch.
* 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.
*/
class ProductIndexer extends BaseProductIndexer
{
public function getFilterableFields(): array
{
return [
...parent::getFilterableFields(),
'brand',
'collections',
'price',
];
}
public function makeAllSearchableUsing(Builder $query): Builder
{
return parent::makeAllSearchableUsing($query)->with(['collections', 'variants.prices']);
}
public function toSearchableArray(Model $model): array
{
/** @var Product $model */
$data = parent::toSearchableArray($model);
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = trim(strip_tags($value));
}
}
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
$data['price'] = $this->cheapestPrice($model);
return $data;
}
/**
* 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
{
$currency = Currency::getDefault();
$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;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
/**
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
* fields on a single document (name_en, name_el, description_en, description_el —
* see Lunar\Search\ScoutIndexer::mapSearchableAttributes()), not separate indexes
* or a filterable locale field. Locale-aware search means choosing which fields
* to search on, not filtering results by locale.
*/
class ProductSearchService
{
/**
* @return Collection<int, Product>
*/
public function search(string $query, ?string $locale = null): Collection
{
$locale ??= App::getLocale();
$defaultLocale = Language::getDefault()->code;
return Product::search($query)
->options([
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get();
}
/**
* Target the resolved locale's fields plus the default locale's fields, so a
* product that's only ever been translated into the default language still
* surfaces when searched in another locale, instead of becoming invisible
* until every product is fully translated.
*
* @return array<int, string>
*/
private function searchableFields(string $locale, string $defaultLocale): array
{
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]);
return $handles
->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
->values()
->all();
}
}