Merge branch 'master' into Payments
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -17,10 +17,12 @@ class ProductFilters
|
||||
* not a direct-assignment-only match) — the right semantics for "products on
|
||||
* this category page", since products are typically attached only to leaf
|
||||
* collections.
|
||||
* @param $tag exactly one tag — no multi-select yet.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?int $collectionId = null,
|
||||
public readonly ?string $brand = null,
|
||||
public readonly ?string $tag = null,
|
||||
public readonly ?float $minPrice = null,
|
||||
public readonly ?float $maxPrice = null,
|
||||
public readonly bool $inStockOnly = false,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\DTOs;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Everything a listing page needs from one ProductService::list() call —
|
||||
* the product page itself, the price slider's bounds, and the set of tags
|
||||
* actually present on matching products (for a tag filter sidebar) — so a
|
||||
* controller makes one service call instead of orchestrating list(),
|
||||
* priceSliderBounds(), and facets('tags', ...) separately. list() still
|
||||
* issues multiple Meilisearch requests internally (the product search, the
|
||||
* price facet stats, the tag facet distribution — see priceSliderBounds()'s
|
||||
* own docblock for why the price ones 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
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $availableTags every distinct tag value
|
||||
* present on at least one product matching the listing's OTHER
|
||||
* filters (collection/price/stock — never the tag filter itself, so
|
||||
* selecting a tag doesn't collapse the list down to just that tag).
|
||||
* Sorted alphabetically. Empty if no product in scope has any tag.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly LengthAwarePaginator $products,
|
||||
public readonly PriceSliderBounds $priceBounds,
|
||||
public readonly array $availableTags = [],
|
||||
) {}
|
||||
}
|
||||
@@ -27,8 +27,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
* - slugs (every locale's Url::slug for the product, filterable) — lets
|
||||
* ProductService::getBySlug() resolve a product from the index directly, with
|
||||
* no database read at all
|
||||
* - skus (every variant's sku, deduplicated, filterable) — same "resolve from the
|
||||
* index alone" reasoning as slugs, for a future SKU-based lookup/filter
|
||||
* - price (cheapest variant, filterable) and full per-variant pricing
|
||||
* - variants: sku, stock, purchasable, option values, prices, media
|
||||
* - variants: sku, gtin, mpn, ean, stock, backorder, unit_quantity, purchasable,
|
||||
* shippable, tax_ref, dimensions (length/width/height/weight/volume, each
|
||||
* {value, unit}), option values, prices, media — the variant's own images
|
||||
* (ProductVariant::images(), separate from the product's gallery below), not
|
||||
* the product's own media repeated per variant
|
||||
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
|
||||
* - tags
|
||||
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
|
||||
@@ -83,6 +89,8 @@ class ProductIndexer extends BaseProductIndexer
|
||||
'channel_ids',
|
||||
'in_stock',
|
||||
'recommendations.id',
|
||||
'skus',
|
||||
'tags',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,6 +134,7 @@ class ProductIndexer extends BaseProductIndexer
|
||||
->values()
|
||||
->all();
|
||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
|
||||
$data['tags'] = $model->tags->pluck('value')->all();
|
||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||
@@ -161,8 +170,22 @@ class ProductIndexer extends BaseProductIndexer
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'sku' => $variant->sku,
|
||||
'gtin' => $variant->gtin,
|
||||
'mpn' => $variant->mpn,
|
||||
'ean' => $variant->ean,
|
||||
'stock' => $variant->stock,
|
||||
'backorder' => $variant->backorder,
|
||||
'unit_quantity' => $variant->unit_quantity,
|
||||
'purchasable' => $variant->purchasable,
|
||||
'shippable' => $variant->shippable,
|
||||
'tax_ref' => $variant->tax_ref,
|
||||
'dimensions' => [
|
||||
'length' => ['value' => $variant->length_value, 'unit' => $variant->length_unit],
|
||||
'width' => ['value' => $variant->width_value, 'unit' => $variant->width_unit],
|
||||
'height' => ['value' => $variant->height_value, 'unit' => $variant->height_unit],
|
||||
'weight' => ['value' => $variant->weight_value, 'unit' => $variant->weight_unit],
|
||||
'volume' => ['value' => $variant->volume_value, 'unit' => $variant->volume_unit],
|
||||
],
|
||||
'options' => $variant->values->map(fn ($value) => [
|
||||
'option' => $this->translatedName($value->option->name),
|
||||
'handle' => $value->option->handle,
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Facades\AttributeManifest;
|
||||
use Lunar\Models\Language;
|
||||
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
|
||||
@@ -17,39 +19,69 @@ use Lunar\Models\Product;
|
||||
*/
|
||||
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>
|
||||
*/
|
||||
public function search(string $query, ?string $locale = null): Collection
|
||||
public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
|
||||
{
|
||||
$locale ??= App::getLocale();
|
||||
$defaultLocale = Language::getDefault()->code;
|
||||
$options = [
|
||||
'attributesToSearchOn' => $this->searchableFields(),
|
||||
'filter' => $this->filterBuilder->build($filters),
|
||||
];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
}
|
||||
|
||||
return Product::search($query)
|
||||
->options([
|
||||
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
|
||||
])
|
||||
->options($options)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Target the resolved locale's fields plus the default locale's fields, so a
|
||||
* product that's only ever been translated into the default language still
|
||||
* surfaces when searched in another locale, instead of becoming invisible
|
||||
* until every product is fully translated.
|
||||
* Targets every configured store language's fields, not just the current
|
||||
* request locale plus the store default — a shopper browsing in Greek
|
||||
* typing an English word (or vice versa) should still match a product
|
||||
* 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>
|
||||
*/
|
||||
private function searchableFields(string $locale, string $defaultLocale): array
|
||||
private function searchableFields(): array
|
||||
{
|
||||
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
|
||||
->pluck('handle');
|
||||
|
||||
$locales = array_unique([$locale, $defaultLocale]);
|
||||
$locales = Language::all()->pluck('code');
|
||||
|
||||
return $handles
|
||||
$attributeFields = $handles
|
||||
->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()
|
||||
->all();
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Lunar\Base\AttributeManifest;
|
||||
use Lunar\FieldTypes\TranslatedText;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\DTOs\ProductListingResult;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
use Modules\Core\Catalog\Support\ProductFilterBuilder;
|
||||
|
||||
/**
|
||||
* Storefront product listing/filtering AND single-product lookup, all reading directly
|
||||
@@ -27,17 +29,33 @@ class ProductService
|
||||
public function __construct(
|
||||
private readonly LanguageCache $languages,
|
||||
private readonly AttributeManifest $attributes,
|
||||
private readonly ProductFilterBuilder $filterBuilder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
|
||||
* see "Meilisearch driver quirk" below) so a controller/view gets normal
|
||||
* pagination behaviour ($products->links(), JSON serialization, etc.)
|
||||
* without ever touching the raw Meilisearch response directly.
|
||||
* 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->buildFilter($filters)];
|
||||
$options = ['filter' => $this->filterBuilder->build($filters)];
|
||||
|
||||
if ($sort !== null) {
|
||||
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||
@@ -51,13 +69,36 @@ 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);
|
||||
$availableTags = $this->availableTags($filters);
|
||||
|
||||
return new ProductListingResult($products, $priceBounds, $availableTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct `tags` value present on a product matching $filters,
|
||||
* excluding $filters->tag itself — same "scoped but not self-collapsing"
|
||||
* reasoning as priceRange() excluding `price` — so selecting a tag
|
||||
* doesn't shrink the sidebar down to just that one tag. Sorted
|
||||
* alphabetically; Meilisearch's facetDistribution has no defined order
|
||||
* of its own.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function availableTags(?ProductFilters $filters): array
|
||||
{
|
||||
$filter = $this->filterBuilder->build($filters, exclude: ['tag']);
|
||||
$tags = $this->rawFacets('tags', $filter)['facetDistribution']['tags'] ?? [];
|
||||
|
||||
return collect($tags)->keys()->sort()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,15 +112,18 @@ class ProductService
|
||||
* apply that field's own filter separately in the UI/query layer.
|
||||
*
|
||||
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value
|
||||
* fields make sense here (`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.
|
||||
* fields make sense here (`brand`, `tags`, `in_stock`) — a numeric field like
|
||||
* `price` would return one "facet" per exact price, not a usable range bucket.
|
||||
* Use `priceRange()` for `price` instead. `facets('tags', $filters)` is how a
|
||||
* category page gets "which tags actually appear on products in this category" —
|
||||
* pass a $filters that omits `tag` (see `build()`'s $exclude) so the tag list
|
||||
* itself doesn't collapse to whichever tag is already selected.
|
||||
*
|
||||
* @return array<string, int> facet value => matching product count
|
||||
*/
|
||||
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 +134,17 @@ class ProductService
|
||||
* not `facetDistribution` — the right feature for a numeric field's range,
|
||||
* 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
|
||||
*/
|
||||
public function priceRange(?ProductFilters $filters = null): array
|
||||
public function priceRange(?ProductFilters $filters = null, string $query = ''): array
|
||||
{
|
||||
$filter = $this->buildFilter($filters, exclude: ['price']);
|
||||
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
|
||||
$filter = $this->filterBuilder->build($filters, exclude: ['price']);
|
||||
$stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
|
||||
|
||||
return [
|
||||
'min' => $stats['min'] ?? null,
|
||||
@@ -103,9 +152,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([
|
||||
'filter' => $filter,
|
||||
'facets' => [$field],
|
||||
@@ -133,15 +209,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<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
|
||||
{
|
||||
$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('')
|
||||
->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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,28 +352,4 @@ class ProductService
|
||||
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,41 @@
|
||||
<?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'|'tag'|'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,
|
||||
'tag' => $filters->tag !== null ? 'tags = "'.addcslashes($filters->tag, '"\\').'"' : 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.');
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,16 @@ class ProductOptionResolver
|
||||
// the same option instead of creating a near-duplicate.
|
||||
$handle = Str::slug($name) ?: 'option';
|
||||
|
||||
// 'label' must be set even though nothing here reads it back — a null
|
||||
// label crashes Lunar's own ProductOptionIndexer::toSearchableArray()
|
||||
// (foreach (null as ...)) the moment this option gets reindexed, since
|
||||
// it assumes every ProductOption always has one. Same value as 'name'
|
||||
// is a reasonable default; Shopify's CSV has no separate "label" concept.
|
||||
return ProductOption::query()->firstOrCreate(
|
||||
['handle' => $handle],
|
||||
[
|
||||
'name' => [DefaultLocale::code() => $name],
|
||||
'label' => [DefaultLocale::code() => $name],
|
||||
'shared' => true,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ use Modules\Core\MigrateImport\Shopify\Resolvers\ProductOptionResolver;
|
||||
use Modules\Core\MigrateImport\Shopify\Resolvers\ProductTypeResolver;
|
||||
use Modules\Core\MigrateImport\Shopify\Resolvers\TagResolver;
|
||||
use Modules\Core\MigrateImport\Shopify\Resolvers\TaxClassResolver;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class ShopifyExportImporter implements Importer
|
||||
{
|
||||
@@ -113,7 +114,7 @@ class ShopifyExportImporter implements Importer
|
||||
$options = $this->attachOptions($product, $row);
|
||||
|
||||
foreach ($group->variantRows as $index => $variantRow) {
|
||||
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options);
|
||||
$this->importVariant($product, $group->handle, $index, $variantRow, $taxClass, $currency, $options, $imagesPath);
|
||||
}
|
||||
|
||||
foreach ($group->imageRows as $index => $imageRow) {
|
||||
@@ -151,6 +152,7 @@ class ShopifyExportImporter implements Importer
|
||||
TaxClass $taxClass,
|
||||
Currency $currency,
|
||||
array $options,
|
||||
string $imagesPath,
|
||||
): void {
|
||||
$externalId = "{$handle}#{$index}";
|
||||
$existing = ImportMapping::resolve(self::SOURCE, 'variant', $externalId);
|
||||
@@ -182,6 +184,26 @@ class ShopifyExportImporter implements Importer
|
||||
: null;
|
||||
|
||||
$this->priceResolver->resolve($variant, $currency, $price, $comparePrice);
|
||||
|
||||
// Shopify's own "Variant Image" column — the one image a variant picker
|
||||
// actually swaps to when that variant is selected — distinct from the
|
||||
// product's full gallery (imageRows below). Often the same file as one
|
||||
// of the product's own image rows, sometimes not yet imported at all
|
||||
// (e.g. a variant-only image never listed as its own image row) — either
|
||||
// way resolveOrImportImage() handles both via the same Image Src dedup
|
||||
// key, so whichever of importVariant()/importImage() runs first for a
|
||||
// given src does the actual import.
|
||||
$variantImageSrc = trim((string) ($row['Variant Image'] ?? ''));
|
||||
|
||||
if ($variantImageSrc !== '') {
|
||||
$media = $this->resolveOrImportImage($product, $handle, $variantImageSrc, 1, $imagesPath);
|
||||
|
||||
if ($media) {
|
||||
$variant->images()->syncWithoutDetaching([
|
||||
$media->id => ['primary' => true, 'position' => 1],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function importImage(
|
||||
@@ -191,29 +213,54 @@ class ShopifyExportImporter implements Importer
|
||||
array $row,
|
||||
string $imagesPath,
|
||||
): void {
|
||||
$externalId = $row['Image Src'] ?: "{$handle}#image-{$index}";
|
||||
$position = (int) ($row['Image Position'] ?? $index + 1);
|
||||
|
||||
if (ImportMapping::resolve(self::SOURCE, 'image', $externalId)) {
|
||||
return;
|
||||
$this->resolveOrImportImage($product, $handle, $row['Image Src'], $position, $imagesPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Media already imported for $imageSrc (recorded under
|
||||
* source_type 'image', keyed by Image Src — the same URL Shopify repeats
|
||||
* across a product's own image rows and any variant's "Variant Image"
|
||||
* column), importing it via AssetResolver if this is the first time this
|
||||
* src has been seen. Shared by importImage() (product gallery) and
|
||||
* importVariant() (variant-specific image) so the same physical file is
|
||||
* never uploaded to Spatie MediaLibrary twice just because Shopify's flat
|
||||
* CSV format repeats the URL on multiple rows.
|
||||
*/
|
||||
private function resolveOrImportImage(
|
||||
Product $product,
|
||||
string $handle,
|
||||
string $imageSrc,
|
||||
int $position,
|
||||
string $imagesPath,
|
||||
): ?Media {
|
||||
$externalId = $imageSrc ?: "{$handle}#image-{$position}";
|
||||
|
||||
$existing = ImportMapping::resolve(self::SOURCE, 'image', $externalId);
|
||||
|
||||
if ($existing instanceof Media) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$localFile = $this->findLocalFile($imagesPath, $row['Image Src']);
|
||||
$localFile = $this->findLocalFile($imagesPath, $imageSrc);
|
||||
|
||||
if ($localFile === null) {
|
||||
Log::warning('Shopify import: image file not found', [
|
||||
'handle' => $handle,
|
||||
'image_src' => $row['Image Src'],
|
||||
'image_src' => $imageSrc,
|
||||
]);
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$media = $this->assetResolver->resolve($product, $localFile, $position);
|
||||
|
||||
if ($media) {
|
||||
ImportMapping::record(self::SOURCE, 'image', $externalId, $product);
|
||||
ImportMapping::record(self::SOURCE, 'image', $externalId, $media);
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
private function findLocalFile(string $imagesPath, string $imageSrc): ?string
|
||||
|
||||
@@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand;
|
||||
use Modules\Core\Command\ImportCommand;
|
||||
use Modules\Core\Command\InstallLunarCommand;
|
||||
use Modules\Core\Command\MigrateImportCommand;
|
||||
use Modules\Core\Command\TuneProductSearchCommand;
|
||||
|
||||
class CoreServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider
|
||||
], 'core-assets');
|
||||
|
||||
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
|
||||
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
|
||||
|
||||
Reference in New Issue
Block a user