From 1287b513cdff72d54129088be556a949943d4ae7 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 3 Sep 2026 11:44:23 +0300 Subject: [PATCH] Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds --- docs/product-listing.md | 59 ++++++--- docs/product-search.md | 52 ++++++-- src/Catalog/DTOs/PriceSliderBounds.php | 19 +++ src/Catalog/DTOs/ProductListingResult.php | 23 ++++ src/Catalog/Services/ProductService.php | 140 ++++++++++++++++++++-- 5 files changed, 255 insertions(+), 38 deletions(-) create mode 100644 src/Catalog/DTOs/PriceSliderBounds.php create mode 100644 src/Catalog/DTOs/ProductListingResult.php diff --git a/docs/product-listing.md b/docs/product-listing.md index d422d69..9931c4f 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -1,8 +1,9 @@ # Product Listing `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product -lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the -Meilisearch index rather than the database. One data source for everything this service does. +lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` — +all reading directly from the Meilisearch index rather than the database. One data source for +everything this service does. This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which handles free-text query search. `ProductService` is for browsing/lookup without a search term. @@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort; $service = app(ProductService::class); -// 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); +// One call for everything a listing page needs — products AND the price slider's +// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller +// used to have to call list() and priceSliderBounds() (or the older priceRange()) +// separately and glue the results together itself; that's now list()'s own job. +$listing = $service->list(perPage: 24, page: 1); // Filter by collection, brand, price range, and/or stock -$products = $service->list( +$listing = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), perPage: 24, page: 1, @@ -42,8 +44,13 @@ $products = $service->list( // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // relevance ordering (irrelevant here since the query is always empty). -$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); +$products = $listing->products; // 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->items(); // array of Meilisearch documents (plain arrays, not models) $products->total(); $products->perPage(); @@ -51,12 +58,32 @@ $products->currentPage(); $products->lastPage(); $products->links(); // in a Blade view — renders pagination links as usual +$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds +$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units +$bounds->ceil; // ?int — ceil() of the matching range's maximum +$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually + // narrow the slider below/above these bounds (drives whether a + // "clear filter" control should show) + // Single product, by primary key $product = $service->getById(367); // array, or null if not found // Single product, by URL slug (any locale — slugs are indexed across all languages) $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found +// $limit random products — still scoped to the index's own default channel/status +// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of +// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this +// pulls every matching id only, shuffles in PHP, then fetches the full localized +// documents for just the ids picked — see random()'s own docblock. +$randomProducts = $service->random(13); // array of documents, same shape as list()'s items + +// The id/price/image of every variant on a product document — the base price and +// thumbnail a variant picker/swatch list needs, without reaching into +// $product['variants'][n]['prices'][0]/['media'][0] yourself. +$variants = $service->variantSummaries($product); +// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...] + // Facet counts for a sidebar — value => matching product count, scoped to whatever // $filters is passed. Does NOT exclude the faceted field itself from $filters — see // facets()'s docblock for why, and how to build a standard "every option's count, @@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo $brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); // ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] -// Min/max price across matching products, for sizing a price-range slider. -// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike -// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink -// to whatever range is currently selected on it. Other filters (collectionId, -// brand, inStockOnly) still apply normally. +// Min/max price across matching products — the raw, unrounded values list() itself +// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the +// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's +// own bounds shouldn't shrink to whatever range is currently selected on it. Other +// filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too +// to scope the range to a text search's own matches (see product-search.md) rather +// than the whole catalog. $range = $service->priceRange(new ProductFilters(collectionId: 17)); // ['min' => 0.0, 'max' => 120.0] ``` @@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei `facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric field like `price` would return one "facet" per exact price, not a usable range bucket. Use -`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different -feature from `facetDistribution`. +`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's +`facetStats` (min/max), a different feature from `facetDistribution`. --- diff --git a/docs/product-search.md b/docs/product-search.md index 7445197..aceb157 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request). ## Usage ```php +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); -// or an explicit locale, bypassing App::getLocale(): -$results = app(ProductSearchService::class)->search('running shoes', 'el'); + +// Filters/sort apply the exact same semantics ProductService::list() uses for +// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper +// narrowing a text search by price/brand/stock gets identical filter behavior +// to narrowing a category listing. +$results = app(ProductSearchService::class)->search( + 'running shoes', + filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true), + sort: ProductSort::PriceAsc, +); ``` Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's `->get()` hydrates real models from the database after the Meilisearch query, so relations (`variants`, `brand`, `media`, etc.) are available on the results as normal. -`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by -`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers -don't need to pass it explicitly. +There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why +every configured store language is always searched, regardless of the current request locale. --- -## Missing-translation fallback +## Missing-translation fallback, in both directions If a product was only ever given an English name, `name_el` doesn't exist on that document at all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly -against `name_el` would make that product invisible to Greek-locale search, even though it's a -real catalog item. +against the current request's locale field would make that product invisible whenever a shopper's +locale doesn't match the language it happens to be translated into. -To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both** -the resolved locale's fields **and** the default language's fields -(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`, -`name_en`, `description_el`, `description_en` together (assuming `en` is the default language). -A product missing an `el` translation still matches via its `en` fields. +`ProductSearchService` avoids this by targeting **every configured store language's fields** +(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the +store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`, +`description_el`, `description_en` together, regardless of which locale the shopper is browsing +in. This is deliberately not scoped to "current locale + default locale": if the current locale +already equals the default (a single-language store, or a shopper browsing in the default +language), that pairing collapses to one locale and stops catching anything else — always +searching every configured language avoids that gap in both directions, at the cost of a larger +`attributesToSearchOn` list as the store's language count grows. + +--- + +## Variant option values are searched too + +Alongside the locale-suffixed attribute fields, every search also targets +`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option +matches a search for that text, even though it never appears in the product's own name or +description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it), +so it can't be discovered the way `name`/`description` are — it's a structural field of +`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`), +added here directly. Not locale-suffixed — each option value is stored as one already-resolved +string per variant. --- diff --git a/src/Catalog/DTOs/PriceSliderBounds.php b/src/Catalog/DTOs/PriceSliderBounds.php new file mode 100644 index 0000000..e6ab419 --- /dev/null +++ b/src/Catalog/DTOs/PriceSliderBounds.php @@ -0,0 +1,19 @@ +links(), JSON serialization, etc.) - * without ever touching the raw Meilisearch response directly. + * One call for everything a listing page needs: the product page AND + * the price slider's bounds — a controller used to have to call this + * plus priceSliderBounds() separately and glue the results together + * itself; that orchestration now happens in here instead. Still issues + * two Meilisearch requests under the hood (the product search, and a + * separate price-facet-stats query — see priceSliderBounds()'s + * docblock for why they can't be merged into one without changing the + * slider's own UX), but the caller only ever makes one call. + * + * $filters->minPrice/$filters->maxPrice double as both the applied + * product filter AND the "is the slider actually narrowed" comparison + * in priceSliderBounds() — the same values, used two ways, so nothing + * new needs to be threaded through separately. + * + * The paginator itself is 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): LengthAwarePaginator + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult { $options = ['filter' => $this->filterBuilder->build($filters)]; @@ -52,13 +69,17 @@ class ProductService ->map(fn (array $product) => $this->withLocalizedFields($product)) ->all(); - return new LengthAwarePaginator( + $products = new LengthAwarePaginator( items: $data, total: $paginator->total(), perPage: $paginator->perPage(), currentPage: $paginator->currentPage(), options: ['path' => LengthAwarePaginator::resolveCurrentPath()], ); + + $priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice); + + return new ProductListingResult($products, $priceBounds); } /** @@ -109,6 +130,33 @@ class ProductService ]; } + /** + * priceRange() rounded to whole euros (floor/ceil, so the slider's ends + * are never tighter than what's actually in range) plus whether + * $selectedMinPrice/$selectedMaxPrice actually narrow it — the same + * "floor/ceil + is this a real filter" rule CategoryController and + * SearchController each used to duplicate inline. $selectedMinPrice/ + * $selectedMaxPrice are the currently-applied filter values (e.g. + * CategoryListing::$minPrice), not part of $filters itself, since + * $filters here must already exclude price the way priceRange() expects. + */ + public function priceSliderBounds( + ?ProductFilters $filters, + ?float $selectedMinPrice, + ?float $selectedMaxPrice, + string $query = '', + ): PriceSliderBounds { + $priceRange = $this->priceRange($filters, $query); + + $floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null; + $ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null; + + $filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN)) + || ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX)); + + return new PriceSliderBounds($floor, $ceil, $filtered); + } + private function rawFacets(string $field, ?string $filter, string $query = ''): array { return Product::search($query) @@ -139,15 +187,87 @@ class ProductService return $this->findOneWhere("id = \"{$id}\""); } + /** + * The id/price/image of every variant on a product document (from + * getById()/getBySlug()'s own 'variants' array) — the base price and + * thumbnail a variant picker/swatch list needs, without a caller + * reaching into $product['variants'][n]['prices'][0]/['media'][0] + * itself. Domain shaping (which price/image represents a variant), + * not presentation — a card's href/layout stays a storefront concern + * (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price + * is its first price row" is a rule about the data, true regardless of + * which app renders it. + * + * @param array $product A document from getById()/getBySlug(). + * @return array + */ + public function variantSummaries(array $product): array + { + return collect($product['variants'] ?? []) + ->map(fn (array $variant) => [ + 'id' => $variant['id'], + 'price' => $variant['prices'][0]['price'] ?? null, + 'image' => $variant['media'][0]['url'] ?? null, + ]) + ->values() + ->all(); + } + + /** + * $limit random products, still scoped to the index's own default + * visibility (channel/status), unlike Eloquent's Product::inRandomOrder() + * which has no notion of that filtering at all — a random pick can never + * surface a hidden/unpublished product this way. Meilisearch itself has + * no ORDER BY RANDOM() equivalent, so this pulls every matching id only + * (attributesToRetrieve: ['id'], the lightest possible request — no + * name/media/variants/etc. for documents that will mostly be discarded), + * shuffles in PHP, then fetches the full localized documents for just + * the $limit ids actually picked. + * + * @return array + */ + public function random(int $limit): array + { + $raw = Product::search('') + ->options(['attributesToRetrieve' => ['id']]) + ->raw(); + + $ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values(); + + if ($ids->isEmpty()) { + return []; + } + + // Meilisearch's `id IN [...]` doesn't preserve the given order — it's + // an unordered set filter, not a list to iterate — so the shuffle + // above would otherwise be silently undone by whatever order the + // re-fetch comes back in. Re-sort the fetched documents back into + // $ids's already-shuffled order instead of trusting the response's. + $products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']')) + ->keyBy('id'); + + return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all(); + } + private function findOneWhere(string $filter): ?array + { + $products = $this->findAllWhere($filter, limit: 1); + + return $products[0] ?? null; + } + + /** + * @return array + */ + private function findAllWhere(string $filter, int $limit = 1000): array { $paginator = Product::search('') ->options(['filter' => $filter]) - ->paginateRaw(perPage: 1, page: 1); + ->paginateRaw(perPage: $limit, page: 1); - $product = $this->hitsFrom($paginator)[0] ?? null; - - return $product !== null ? $this->withLocalizedFields($product) : null; + return collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); } /**