From 7b46a83e5e1eca87ab50eb7673eb99e6d5c1e85b Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 4 Sep 2026 13:03:38 +0300 Subject: [PATCH] Feat: Product Search Service Restructure --- src/Catalog/Services/ProductSearchService.php | 50 +++++++++-- src/Catalog/Services/ProductService.php | 90 ++++--------------- .../Support/ProductDocumentLocalizer.php | 86 ++++++++++++++++++ 3 files changed, 145 insertions(+), 81 deletions(-) create mode 100644 src/Catalog/Support/ProductDocumentLocalizer.php diff --git a/src/Catalog/Services/ProductSearchService.php b/src/Catalog/Services/ProductSearchService.php index 8686699..b4790b6 100644 --- a/src/Catalog/Services/ProductSearchService.php +++ b/src/Catalog/Services/ProductSearchService.php @@ -2,12 +2,14 @@ namespace Modules\Core\Catalog\Services; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\LengthAwarePaginator; use Lunar\Facades\AttributeManifest; use Lunar\Models\Language; use Lunar\Models\Product; use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\DTOs\ProductListingResult; use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductDocumentLocalizer; use Modules\Core\Catalog\Support\ProductFilterBuilder; /** @@ -21,20 +23,37 @@ class ProductSearchService { public function __construct( private readonly ProductFilterBuilder $filterBuilder, + private readonly ProductDocumentLocalizer $localizer, + private readonly ProductService $products, ) {} /** + * Returns the exact same Modules\Core\Catalog\DTOs\ProductListingResult + * ProductService::list() does — a search results page and a category + * listing page consume identically shaped data, one call each. The + * paginator itself carries plain, localized indexed-document arrays + * (not hydrated Product models), same as list(). + * + * priceBounds/availableTags are delegated to ProductService's own + * priceSliderBounds()/availableTags() rather than reimplemented here — + * both already accept a $query param for exactly this reason (a search + * page's slider/tag sidebar should reflect only the products search + * actually matched, not the whole catalog). + * * $filters/$sort apply the exact same semantics ProductService::list() * uses for collection browsing (same ProductFilterBuilder, same * ProductSort::toMeilisearchSort()) — a shopper narrowing a text search * by price/brand/stock gets identical filter behavior to narrowing a * category listing, since both go through the same Meilisearch `filter` * clause underneath. - * - * @return Collection */ - public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection - { + public function search( + string $query, + ?ProductFilters $filters = null, + ?ProductSort $sort = null, + int $perPage = 24, + int $page = 1, + ): ProductListingResult { $options = [ 'attributesToSearchOn' => $this->searchableFields(), 'filter' => $this->filterBuilder->build($filters), @@ -44,9 +63,26 @@ class ProductSearchService $options['sort'] = [$sort->toMeilisearchSort()]; } - return Product::search($query) + $paginator = Product::search($query) ->options($options) - ->get(); + ->paginateRaw(perPage: $perPage, page: $page); + + $data = collect($this->localizer->hitsFrom($paginator)) + ->map(fn (array $product) => $this->localizer->withLocalizedFields($product)) + ->all(); + + $products = new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); + + $priceBounds = $this->products->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice, $query); + $availableTags = $this->products->availableTags($filters, $query); + + return new ProductListingResult($products, $priceBounds, $availableTags); } /** diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php index 326837d..c3ba915 100644 --- a/src/Catalog/Services/ProductService.php +++ b/src/Catalog/Services/ProductService.php @@ -2,17 +2,13 @@ namespace Modules\Core\Catalog\Services; -use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Pagination\LengthAwarePaginator; -use Illuminate\Support\Facades\App; -use Lunar\Base\AttributeManifest; -use Lunar\FieldTypes\TranslatedText; use Lunar\Models\Product; -use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Catalog\DTOs\PriceSliderBounds; use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductListingResult; use Modules\Core\Catalog\Enums\ProductSort; +use Modules\Core\Catalog\Support\ProductDocumentLocalizer; use Modules\Core\Catalog\Support\ProductFilterBuilder; /** @@ -27,8 +23,7 @@ use Modules\Core\Catalog\Support\ProductFilterBuilder; class ProductService { public function __construct( - private readonly LanguageCache $languages, - private readonly AttributeManifest $attributes, + private readonly ProductDocumentLocalizer $localizer, private readonly ProductFilterBuilder $filterBuilder, ) {} @@ -65,8 +60,8 @@ class ProductService ->options($options) ->paginateRaw(perPage: $perPage, page: $page); - $data = collect($this->hitsFrom($paginator)) - ->map(fn (array $product) => $this->withLocalizedFields($product)) + $data = collect($this->localizer->hitsFrom($paginator)) + ->map(fn (array $product) => $this->localizer->withLocalizedFields($product)) ->all(); $products = new LengthAwarePaginator( @@ -91,12 +86,20 @@ class ProductService * alphabetically; Meilisearch's facetDistribution has no defined order * of its own. * + * $query defaults to '' (every product, same as list()'s own default + * text query) — same reasoning as priceRange()'s own $query: pass the + * shopper's search text here too so a search page's own tag sidebar + * reflects only the products search actually matched. Public (not + * private, unlike the rest of this listing-only orchestration) so + * ProductSearchService::search() can reuse it directly rather than + * reimplementing the same facet call a second time. + * * @return array */ - private function availableTags(?ProductFilters $filters): array + public function availableTags(?ProductFilters $filters, string $query = ''): array { $filter = $this->filterBuilder->build($filters, exclude: ['tag']); - $tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? []; + $tags = $this->rawFacets('tags', $filter, $query)['facetDistribution']['tags'] ?? []; return collect($tags)->keys()->sort()->values()->all(); } @@ -287,69 +290,8 @@ class ProductService ->options(['filter' => $filter]) ->paginateRaw(perPage: $limit, page: 1); - return collect($this->hitsFrom($paginator)) - ->map(fn (array $product) => $this->withLocalizedFields($product)) + return collect($this->localizer->hitsFrom($paginator)) + ->map(fn (array $product) => $this->localizer->withLocalizedFields($product)) ->all(); } - - /** - * 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 - * current locale already is, not a stable fallback. - */ - private function withLocalizedFields(array $product): array - { - $locale = App::getLocale(); - $fallbackLocale = $this->languages->defaultLocale(); - $availableLocales = $this->languages->availableLocales(); - - foreach ($this->translatedAttributeHandles() as $handle) { - $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; - - 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(LengthAwarePaginatorContract $paginator): array - { - $rawResponse = $paginator->items(); - - return collect($rawResponse['hits'] ?? [])->values()->all(); - } - } diff --git a/src/Catalog/Support/ProductDocumentLocalizer.php b/src/Catalog/Support/ProductDocumentLocalizer.php new file mode 100644 index 0000000..fb43ebd --- /dev/null +++ b/src/Catalog/Support/ProductDocumentLocalizer.php @@ -0,0 +1,86 @@ +languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); + + foreach ($this->translatedAttributeHandles() as $handle) { + $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; + + foreach ($availableLocales as $availableLocale) { + unset($product[$handle.'_'.$availableLocale]); + } + } + + return $product; + } + + /** + * 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. + */ + public function hitsFrom(LengthAwarePaginatorContract $paginator): array + { + $rawResponse = $paginator->items(); + + return collect($rawResponse['hits'] ?? [])->values()->all(); + } + + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } +} \ No newline at end of file