Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448da869a9 | ||
|
|
1287b513cd | ||
|
|
b2919f1f4b |
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-09-03
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one.
|
||||||
|
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can.
|
||||||
|
- `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`.
|
||||||
|
- Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call.
|
||||||
|
- `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog.
|
||||||
|
- `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own).
|
||||||
|
- `Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)` — the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into `$product['variants'][n]['prices'][0]`/`['media'][0]` itself.
|
||||||
|
- `Modules\Core\Catalog\Services\ProductSearchService::search()` now also targets `variants.options.value` — a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description.
|
||||||
|
- `php artisan lunar:meilisearch:tune-product-search` (`Modules\Core\Command\TuneProductSearchCommand`) — tightens `minWordSizeForTypos` (1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch's `prefixSearch` on the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case was `prefixSearch`'s default `indexingTime` behavior on a shared word-start, not typo tolerance, via `showMatchesPosition`). Consuming apps should run this after `lunar:meilisearch:setup` whenever the product index needs (re)provisioning — **requires Meilisearch v1.12+** (`prefixSearch` didn't exist as a configurable setting before then).
|
||||||
|
|
||||||
## [0.11.1] - 2026-09-01
|
## [0.11.1] - 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"version": "0.11.1",
|
"version": "0.12.0",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
+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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace Modules\Core\Catalog\Services;
|
namespace Modules\Core\Catalog\Services;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Lunar\Facades\AttributeManifest;
|
use Lunar\Facades\AttributeManifest;
|
||||||
use Lunar\Models\Language;
|
use Lunar\Models\Language;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
|
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
|
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
|
||||||
@@ -17,39 +19,69 @@ use Lunar\Models\Product;
|
|||||||
*/
|
*/
|
||||||
class ProductSearchService
|
class ProductSearchService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductFilterBuilder $filterBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* $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<int, Product>
|
* @return Collection<int, Product>
|
||||||
*/
|
*/
|
||||||
public function search(string $query, ?string $locale = null): Collection
|
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
|
||||||
{
|
{
|
||||||
$locale ??= App::getLocale();
|
$options = [
|
||||||
$defaultLocale = Language::getDefault()->code;
|
'attributesToSearchOn' => $this->searchableFields(),
|
||||||
|
'filter' => $this->filterBuilder->build($filters),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($sort !== null) {
|
||||||
|
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||||
|
}
|
||||||
|
|
||||||
return Product::search($query)
|
return Product::search($query)
|
||||||
->options([
|
->options($options)
|
||||||
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
|
|
||||||
])
|
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Target the resolved locale's fields plus the default locale's fields, so a
|
* Targets every configured store language's fields, not just the current
|
||||||
* product that's only ever been translated into the default language still
|
* request locale plus the store default — a shopper browsing in Greek
|
||||||
* surfaces when searched in another locale, instead of becoming invisible
|
* typing an English word (or vice versa) should still match a product
|
||||||
* until every product is fully translated.
|
* whose only translation for that text happens to be in a third
|
||||||
|
* language. There's no per-request "current locale" concept in this
|
||||||
|
* method any more: which fields exist to search on is a property of the
|
||||||
|
* store's configured languages, not of who's asking.
|
||||||
|
*
|
||||||
|
* Also targets variants.options.value directly — a variant's option
|
||||||
|
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
|
||||||
|
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
|
||||||
|
* attributes, so it can't come from AttributeManifest the way name/
|
||||||
|
* description do; it's a structural field of the document, added here
|
||||||
|
* directly instead. Not locale-suffixed like the attribute-manifest
|
||||||
|
* fields — option values are stored as one already-resolved string per
|
||||||
|
* variant (see ProductIndexer::translatedName()), not per-locale.
|
||||||
*
|
*
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function searchableFields(string $locale, string $defaultLocale): array
|
private function searchableFields(): array
|
||||||
{
|
{
|
||||||
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
|
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
|
||||||
->pluck('handle');
|
->pluck('handle');
|
||||||
|
|
||||||
$locales = array_unique([$locale, $defaultLocale]);
|
$locales = Language::all()->pluck('code');
|
||||||
|
|
||||||
return $handles
|
$attributeFields = $handles
|
||||||
->crossJoin($locales)
|
->crossJoin($locales)
|
||||||
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
|
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}");
|
||||||
|
|
||||||
|
return $attributeFields
|
||||||
|
->push('variants.options.value')
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ namespace Modules\Core\Catalog\Services;
|
|||||||
|
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Lunar\Base\AttributeManifest;
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storefront product listing/filtering AND single-product lookup, all reading directly
|
* Storefront product listing/filtering AND single-product lookup, all reading directly
|
||||||
@@ -27,17 +29,33 @@ class ProductService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LanguageCache $languages,
|
private readonly LanguageCache $languages,
|
||||||
private readonly AttributeManifest $attributes,
|
private readonly AttributeManifest $attributes,
|
||||||
|
private readonly ProductFilterBuilder $filterBuilder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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->buildFilter($filters)];
|
$options = ['filter' => $this->filterBuilder->build($filters)];
|
||||||
|
|
||||||
if ($sort !== null) {
|
if ($sort !== null) {
|
||||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||||
@@ -51,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +101,7 @@ class ProductService
|
|||||||
*/
|
*/
|
||||||
public function facets(string $field, ?ProductFilters $filters = null): array
|
public function facets(string $field, ?ProductFilters $filters = null): array
|
||||||
{
|
{
|
||||||
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
|
return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,12 +112,17 @@ class ProductService
|
|||||||
* not `facetDistribution` — the right feature for a numeric field's range,
|
* not `facetDistribution` — the right feature for a numeric field's range,
|
||||||
* where `facets('price')` would otherwise return one entry per exact price.
|
* where `facets('price')` would otherwise return one entry per exact price.
|
||||||
*
|
*
|
||||||
|
* $query defaults to '' (every product, same as list()'s own default text
|
||||||
|
* query) — pass the shopper's search text here too so a search page's own
|
||||||
|
* price slider spans only the products that search actually matched,
|
||||||
|
* rather than the whole catalog's price range.
|
||||||
|
*
|
||||||
* @return array{min: ?float, max: ?float} null/null if no product matches
|
* @return array{min: ?float, max: ?float} null/null if no product matches
|
||||||
*/
|
*/
|
||||||
public function priceRange(?ProductFilters $filters = null): array
|
public function priceRange(?ProductFilters $filters = null, string $query = ''): array
|
||||||
{
|
{
|
||||||
$filter = $this->buildFilter($filters, exclude: ['price']);
|
$filter = $this->filterBuilder->build($filters, exclude: ['price']);
|
||||||
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
|
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'min' => $stats['min'] ?? null,
|
'min' => $stats['min'] ?? null,
|
||||||
@@ -103,9 +130,36 @@ class ProductService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rawFacets(string $field, ?string $filter): array
|
/**
|
||||||
|
* 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('')
|
return Product::search($query)
|
||||||
->options([
|
->options([
|
||||||
'filter' => $filter,
|
'filter' => $filter,
|
||||||
'facets' => [$field],
|
'facets' => [$field],
|
||||||
@@ -133,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,28 +330,4 @@ class ProductService
|
|||||||
return collect($rawResponse['hits'] ?? [])->values()->all();
|
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter
|
|
||||||
* fields to leave out even if set on $filters — e.g. priceRange() excludes
|
|
||||||
* 'price' so a price slider's own bounds don't shrink to whatever range is
|
|
||||||
* already selected on it.
|
|
||||||
*/
|
|
||||||
private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string
|
|
||||||
{
|
|
||||||
if ($filters === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clauses = Collection::make([
|
|
||||||
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
|
||||||
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
|
||||||
'price' => Collection::make([
|
|
||||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
|
||||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
|
||||||
])->filter()->join(' AND ') ?: null,
|
|
||||||
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
|
||||||
])->except($exclude)->filter();
|
|
||||||
|
|
||||||
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Meilisearch `filter` clause from a ProductFilters DTO — extracted
|
||||||
|
* out of ProductService (where it originated, scoped to browsing/filtering
|
||||||
|
* without a search term) so ProductSearchService can apply the exact same
|
||||||
|
* filter semantics to a text query too, rather than reimplementing it.
|
||||||
|
*/
|
||||||
|
class ProductFilterBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude
|
||||||
|
* filter fields to leave out even if set on $filters — e.g.
|
||||||
|
* ProductService::priceRange() excludes 'price' so a price slider's own
|
||||||
|
* bounds don't shrink to whatever range is already selected on it.
|
||||||
|
*/
|
||||||
|
public function build(?ProductFilters $filters, array $exclude = []): ?string
|
||||||
|
{
|
||||||
|
if ($filters === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clauses = Collection::make([
|
||||||
|
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||||
|
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||||
|
'price' => Collection::make([
|
||||||
|
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||||
|
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
||||||
|
])->filter()->join(' AND ') ?: null,
|
||||||
|
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
|
||||||
|
])->except($exclude)->filter();
|
||||||
|
|
||||||
|
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Command;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Laravel\Scout\EngineManager;
|
||||||
|
use Laravel\Scout\Engines\MeilisearchEngine;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lunarphp/meilisearch's own `lunar:meilisearch:setup` only pushes
|
||||||
|
* filterableAttributes/sortableAttributes (see MeilisearchSetup::handle())
|
||||||
|
* — it has no notion of typo tolerance or prefix search, and Meilisearch's
|
||||||
|
* defaults for both are loose enough to produce bad matches on short Greek
|
||||||
|
* words. Confirmed via showMatchesPosition that a query for "Κάπτεν" was
|
||||||
|
* matching "κανένας" purely through prefixSearch's default 'indexingTime'
|
||||||
|
* behavior (their edit distance is far past anything typo tolerance would
|
||||||
|
* bridge) — fixed by disabling prefix search below, verified afterward with
|
||||||
|
* "Super"/"Superheroes"-style prefix probes returning no results for a
|
||||||
|
* partial word. minWordSizeForTypos is tightened defensively alongside it
|
||||||
|
* so short words in general get less typo-tolerant fuzzing, even though a
|
||||||
|
* separate short-word collision case ("Κάπτεν" vs "κάποτε", high letter
|
||||||
|
* overlap despite real edit distance) persisted after both settings were
|
||||||
|
* confirmed live and wasn't fully root-caused — treated as a known,
|
||||||
|
* narrow edge case rather than a blocker. Run this after
|
||||||
|
* `lunar:meilisearch:setup`, whenever Product's index needs
|
||||||
|
* (re)provisioning.
|
||||||
|
*
|
||||||
|
* Disabling prefix search here is a deliberate tradeoff: it also turns off
|
||||||
|
* legitimate partial-word matching (typing "car" matching "cart" before
|
||||||
|
* you finish the word) — useful for a future autocomplete/search-as-you-
|
||||||
|
* type UI. If that's built later, re-enable prefixSearch deliberately then,
|
||||||
|
* informed by real UX needs, rather than leaving it on by accident today.
|
||||||
|
*/
|
||||||
|
class TuneProductSearchCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'lunar:meilisearch:tune-product-search';
|
||||||
|
|
||||||
|
protected $description = 'Tighten typo-tolerance and disable prefix search on the product search index';
|
||||||
|
|
||||||
|
public function handle(EngineManager $engineManager): void
|
||||||
|
{
|
||||||
|
/** @var MeilisearchEngine $engine */
|
||||||
|
$engine = $engineManager->createMeilisearchDriver();
|
||||||
|
|
||||||
|
$index = $engine->getIndex((new Product)->searchableAs());
|
||||||
|
|
||||||
|
$this->components->info('Updating typo tolerance for product search...');
|
||||||
|
|
||||||
|
$task = $index->updateTypoTolerance([
|
||||||
|
'minWordSizeForTypos' => [
|
||||||
|
'oneTypo' => 8,
|
||||||
|
'twoTypos' => 12,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$engine->waitForTask($task['taskUid']);
|
||||||
|
|
||||||
|
$this->components->info('Disabling prefix search for product search...');
|
||||||
|
|
||||||
|
$task = $index->updatePrefixSearch('disabled');
|
||||||
|
|
||||||
|
$engine->waitForTask($task['taskUid']);
|
||||||
|
|
||||||
|
$this->components->info('Product search index tuned.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand;
|
|||||||
use Modules\Core\Command\ImportCommand;
|
use Modules\Core\Command\ImportCommand;
|
||||||
use Modules\Core\Command\InstallLunarCommand;
|
use Modules\Core\Command\InstallLunarCommand;
|
||||||
use Modules\Core\Command\MigrateImportCommand;
|
use Modules\Core\Command\MigrateImportCommand;
|
||||||
|
use Modules\Core\Command\TuneProductSearchCommand;
|
||||||
|
|
||||||
class CoreServiceProvider extends ServiceProvider
|
class CoreServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider
|
|||||||
], 'core-assets');
|
], 'core-assets');
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
|
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
|
||||||
|
|
||||||
//Overriding lunar:install
|
//Overriding lunar:install
|
||||||
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||||
|
|||||||
Reference in New Issue
Block a user