From b3b5ca740d52e980a685c8c7ff317deb39da75fe Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 00:48:38 +0300 Subject: [PATCH] Feature: Resolving translated fields based on current locale, while also resturning a correct length aware paginator for the results --- docs/product-listing.md | 53 ++++++++++++++++----- src/Catalog/ProductService.php | 86 ++++++++++++++++++++++------------ 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/docs/product-listing.md b/docs/product-listing.md index c417bdc..d52287a 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -28,11 +28,13 @@ use Modules\Core\Catalog\ProductSort; $service = app(ProductService::class); -// List everything, paginated -$result = $service->list(perPage: 24, page: 1); +// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, +// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see +// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. +$products = $service->list(perPage: 24, page: 1); // Filter by collection, brand, and/or price range -$result = $service->list( +$products = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0), perPage: 24, page: 1, @@ -40,13 +42,14 @@ $result = $service->list( // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // relevance ordering (irrelevant here since the query is always empty). -$result = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); -$result['data']; // array of Meilisearch documents (plain arrays, not models) -$result['meta']['total']; -$result['meta']['per_page']; -$result['meta']['current_page']; -$result['meta']['last_page']; +$products->items(); // array of Meilisearch documents (plain arrays, not models) +$products->total(); +$products->perPage(); +$products->currentPage(); +$products->lastPage(); +$products->links(); // in a Blade view — renders pagination links as usual // Single product, by primary key $product = $service->getById(367); // array, or null if not found @@ -79,9 +82,8 @@ needs, listing and detail alike: | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. | -`description` and other translated attributes are indexed as-is, including any HTML markup -(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description -sourced from `ProductService`'s results must treat it as trusted HTML. +`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see +"Locale resolution" below for how `ProductService` resolves them down to one value per request. **`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike `Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not @@ -90,6 +92,33 @@ indexer's `translatedName()` reads the array directly instead. See `docs/lunar.m --- +## Locale resolution: `name`, `description`, and any other translated attribute + +Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}` +field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same +for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw +document in Meilisearch carries all of them side by side, since a document is written once but +read across many different-locale requests. + +`ProductService` resolves these back down to a single value per request. For every result it +returns (`list()`'s items, `getById()`, `getBySlug()`), it: + +1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the + same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list, + so a store's own custom translated attributes are picked up automatically with no change here. +2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}` + (`LanguageCache::defaultLocale()`) if the current locale has no translation — e.g. a product with + no English copy yet still shows its Greek name on `/en/` rather than rendering blank. +3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** — + callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale + fields the index actually stores. + +`description` and other translated attributes are otherwise indexed as-is, including any HTML +markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a +description sourced from `ProductService`'s results must treat it as trusted HTML. + +--- + ## Reviews `Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as diff --git a/src/Catalog/ProductService.php b/src/Catalog/ProductService.php index f464d31..802d4b1 100644 --- a/src/Catalog/ProductService.php +++ b/src/Catalog/ProductService.php @@ -2,9 +2,12 @@ namespace Modules\Core\Catalog; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; +use Lunar\Base\AttributeManifest; +use Lunar\FieldTypes\TranslatedText; use Lunar\Models\Product; use Modules\Core\Localization\Services\LanguageCache; @@ -19,12 +22,18 @@ use Modules\Core\Localization\Services\LanguageCache; */ class ProductService { - public function __construct(private readonly LanguageCache $languages) {} + public function __construct( + private readonly LanguageCache $languages, + private readonly AttributeManifest $attributes, + ) {} /** - * @return array{data: array, meta: array} + * Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - + * see "Meilisearch driver quirk" below) so a controller/view gets normal + * pagination behaviour ($products->links(), JSON serialization, etc.) + * without ever touching the raw Meilisearch response directly. */ - public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): array + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator { $options = ['filter' => $this->buildFilter($filters)]; @@ -36,17 +45,17 @@ class ProductService ->options($options) ->paginateRaw(perPage: $perPage, page: $page); - return [ - 'data' => collect($this->hitsFrom($paginator)) - ->map(fn (array $product) => $this->withLocalizedFields($product)) - ->all(), - 'meta' => [ - 'total' => $paginator->total(), - 'per_page' => $paginator->perPage(), - 'current_page' => $paginator->currentPage(), - 'last_page' => $paginator->lastPage(), - ], - ]; + $data = collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); + + return new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); } /** @@ -80,16 +89,20 @@ class ProductService } /** - * Resolves the current-locale `name`/`description` from the indexer's - * per-locale `name_{locale}`/`description_{locale}` fields, falling back to - * the store's default language (LanguageCache::defaultLocale()) when the - * current locale has no translation - e.g. a product with no English copy - * yet still shows its Greek name/description on /en/ rather than rendering - * blank. The raw per-locale keys are then stripped - every configured - * locale's translation is indexed in Meilisearch (Lunar's base indexer - * explodes every TranslatedText attribute into name_{locale}/ - * description_{locale} per store language), but once resolved into `name`/ - * `description`, callers only ever need the one that matched. + * Resolves every translated Product attribute's current-locale value from the + * indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`, + * `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's + * default language (LanguageCache::defaultLocale()) when the current locale + * has no translation - e.g. a product with no English copy yet still shows its + * Greek name on /en/ rather than rendering blank. + * + * Which handles are translated is read from AttributeManifest - the same + * source Lunar's own ScoutIndexer reads when exploding a TranslatedText + * attribute into `{handle}_{locale}` keys at index time - rather than a fixed + * list, so a store's own custom translated attributes (e.g. `seo_title`) are + * picked up automatically with no change here. The raw per-locale keys are + * then stripped, since once resolved, callers only ever need the one that + * matched the current locale. * * Deliberately not config('app.locale') - App::setLocale() overwrites that * config value on every request, so by request time it's just whatever the @@ -99,23 +112,36 @@ class ProductService { $locale = App::getLocale(); $fallbackLocale = $this->languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); - $product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null; - $product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null; + foreach ($this->translatedAttributeHandles() as $handle) { + $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; - foreach ($this->languages->availableLocales() as $availableLocale) { - unset($product['name_'.$availableLocale], $product['description_'.$availableLocale]); + foreach ($availableLocales as $availableLocale) { + unset($product[$handle.'_'.$availableLocale]); + } } return $product; } + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } + /** * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the * actual documents are under the 'hits' key. */ - private function hitsFrom(LengthAwarePaginator $paginator): array + private function hitsFrom(LengthAwarePaginatorContract $paginator): array { $rawResponse = $paginator->items();