Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds
This commit is contained in:
+44
-15
@@ -1,8 +1,9 @@
|
|||||||
# Product Listing
|
# Product Listing
|
||||||
|
|
||||||
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
|
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
|
||||||
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
|
lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
|
||||||
Meilisearch index rather than the database. One data source for everything this service does.
|
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
|
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.
|
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);
|
$service = app(ProductService::class);
|
||||||
|
|
||||||
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
|
// One call for everything a listing page needs — products AND the price slider's
|
||||||
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
|
// bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
|
||||||
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
|
// used to have to call list() and priceSliderBounds() (or the older priceRange())
|
||||||
$products = $service->list(perPage: 24, page: 1);
|
// 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
|
// 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),
|
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
|
||||||
perPage: 24,
|
perPage: 24,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -42,8 +44,13 @@ $products = $service->list(
|
|||||||
|
|
||||||
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
|
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
|
||||||
// relevance ordering (irrelevant here since the query is always empty).
|
// 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->items(); // array of Meilisearch documents (plain arrays, not models)
|
||||||
$products->total();
|
$products->total();
|
||||||
$products->perPage();
|
$products->perPage();
|
||||||
@@ -51,12 +58,32 @@ $products->currentPage();
|
|||||||
$products->lastPage();
|
$products->lastPage();
|
||||||
$products->links(); // in a Blade view — renders pagination links as usual
|
$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
|
// Single product, by primary key
|
||||||
$product = $service->getById(367); // array, or null if not found
|
$product = $service->getById(367); // array, or null if not found
|
||||||
|
|
||||||
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
// Single product, by URL slug (any locale — slugs are indexed across all languages)
|
||||||
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
|
$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
|
// 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
|
// $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,
|
// 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));
|
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
|
||||||
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
|
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
|
||||||
|
|
||||||
// Min/max price across matching products, for sizing a price-range slider.
|
// Min/max price across matching products — the raw, unrounded values list() itself
|
||||||
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
|
// uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
|
||||||
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
|
// filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
|
||||||
// to whatever range is currently selected on it. Other filters (collectionId,
|
// own bounds shouldn't shrink to whatever range is currently selected on it. Other
|
||||||
// brand, inStockOnly) still apply normally.
|
// 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));
|
$range = $service->priceRange(new ProductFilters(collectionId: 17));
|
||||||
// ['min' => 0.0, 'max' => 120.0]
|
// ['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
|
`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
|
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
|
`priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
|
||||||
feature from `facetDistribution`.
|
`facetStats` (min/max), a different feature from `facetDistribution`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+39
-13
@@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request).
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
use Modules\Core\Catalog\Services\ProductSearchService;
|
use Modules\Core\Catalog\Services\ProductSearchService;
|
||||||
|
|
||||||
$results = app(ProductSearchService::class)->search('running shoes');
|
$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
|
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
|
`->get()` hydrates real models from the database after the Meilisearch query, so relations
|
||||||
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
|
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
|
||||||
|
|
||||||
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
|
There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
|
||||||
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
|
every configured store language is always searched, regardless of the current request locale.
|
||||||
don't need to pass it explicitly.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
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
|
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
|
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
|
against the current request's locale field would make that product invisible whenever a shopper's
|
||||||
real catalog item.
|
locale doesn't match the language it happens to be translated into.
|
||||||
|
|
||||||
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
|
`ProductSearchService` avoids this by targeting **every configured store language's fields**
|
||||||
the resolved locale's fields **and** the default language's fields
|
(`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
|
||||||
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
|
store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
|
||||||
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
|
`description_el`, `description_en` together, regardless of which locale the shopper is browsing
|
||||||
A product missing an `el` translation still matches via its `en` fields.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\DTOs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A price range slider's bounds and whether it's currently narrowed —
|
||||||
|
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
|
||||||
|
* rounding and "is this actually a meaningful filter" comparison, so a
|
||||||
|
* controller (CategoryController, SearchController, ...) doesn't have to
|
||||||
|
* reimplement that rule itself.
|
||||||
|
*/
|
||||||
|
class PriceSliderBounds
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?int $floor,
|
||||||
|
public readonly ?int $ceil,
|
||||||
|
public readonly bool $filtered,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\DTOs;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a listing page needs from one ProductService::list() call —
|
||||||
|
* the product page itself plus the price slider's bounds, so a controller
|
||||||
|
* makes one service call instead of orchestrating list() and
|
||||||
|
* priceSliderBounds() separately. list() still issues two Meilisearch
|
||||||
|
* requests internally (the product search and the price facet stats — see
|
||||||
|
* priceSliderBounds()'s own docblock for why they can't be merged into one
|
||||||
|
* without changing the slider's UX), but that's this DTO's job to hide,
|
||||||
|
* not the controller's to know about.
|
||||||
|
*/
|
||||||
|
class ProductListingResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly LengthAwarePaginator $products,
|
||||||
|
public readonly PriceSliderBounds $priceBounds,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ use Lunar\Base\AttributeManifest;
|
|||||||
use Lunar\FieldTypes\TranslatedText;
|
use Lunar\FieldTypes\TranslatedText;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
use Modules\Core\Localization\Services\LanguageCache;
|
use Modules\Core\Localization\Services\LanguageCache;
|
||||||
|
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
|
||||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||||
|
use Modules\Core\Catalog\DTOs\ProductListingResult;
|
||||||
use Modules\Core\Catalog\Enums\ProductSort;
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||||
|
|
||||||
@@ -31,12 +33,27 @@ class ProductService
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
|
* One call for everything a listing page needs: the product page AND
|
||||||
* see "Meilisearch driver quirk" below) so a controller/view gets normal
|
* the price slider's bounds — a controller used to have to call this
|
||||||
* pagination behaviour ($products->links(), JSON serialization, etc.)
|
* plus priceSliderBounds() separately and glue the results together
|
||||||
* without ever touching the raw Meilisearch response directly.
|
* 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)];
|
$options = ['filter' => $this->filterBuilder->build($filters)];
|
||||||
|
|
||||||
@@ -52,13 +69,17 @@ class ProductService
|
|||||||
->map(fn (array $product) => $this->withLocalizedFields($product))
|
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
return new LengthAwarePaginator(
|
$products = new LengthAwarePaginator(
|
||||||
items: $data,
|
items: $data,
|
||||||
total: $paginator->total(),
|
total: $paginator->total(),
|
||||||
perPage: $paginator->perPage(),
|
perPage: $paginator->perPage(),
|
||||||
currentPage: $paginator->currentPage(),
|
currentPage: $paginator->currentPage(),
|
||||||
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
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
|
private function rawFacets(string $field, ?string $filter, string $query = ''): array
|
||||||
{
|
{
|
||||||
return Product::search($query)
|
return Product::search($query)
|
||||||
@@ -139,15 +187,87 @@ class ProductService
|
|||||||
return $this->findOneWhere("id = \"{$id}\"");
|
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<int, array{id: int, price: ?float, image: ?string}>
|
||||||
|
*/
|
||||||
|
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<int, 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
|
private function findOneWhere(string $filter): ?array
|
||||||
|
{
|
||||||
|
$products = $this->findAllWhere($filter, limit: 1);
|
||||||
|
|
||||||
|
return $products[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array>
|
||||||
|
*/
|
||||||
|
private function findAllWhere(string $filter, int $limit = 1000): array
|
||||||
{
|
{
|
||||||
$paginator = Product::search('')
|
$paginator = Product::search('')
|
||||||
->options(['filter' => $filter])
|
->options(['filter' => $filter])
|
||||||
->paginateRaw(perPage: 1, page: 1);
|
->paginateRaw(perPage: $limit, page: 1);
|
||||||
|
|
||||||
$product = $this->hitsFrom($paginator)[0] ?? null;
|
return collect($this->hitsFrom($paginator))
|
||||||
|
->map(fn (array $product) => $this->withLocalizedFields($product))
|
||||||
return $product !== null ? $this->withLocalizedFields($product) : null;
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user