diff --git a/docs/lunar.md b/docs/lunar.md index 24a84ec..a6a9e8d 100644 --- a/docs/lunar.md +++ b/docs/lunar.md @@ -1206,6 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious - **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. - **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. - **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. -- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`. -- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`. +- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`. +- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`. - **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed. diff --git a/docs/product-listing.md b/docs/product-listing.md index 2da31b3..5161635 100644 --- a/docs/product-listing.md +++ b/docs/product-listing.md @@ -1,10 +1,10 @@ # Product Listing -`Modules\Core\Product\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 Meilisearch index rather than the database. One data source for everything this service does. -This is separate from `Modules\Core\Product\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. --- @@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's `->get()`, which would re-hydrate Eloquent models from the database. This means the index has to carry everything a detail page needs (variants, prices, options, media, reviews — see below), not -just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to +just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to carry that full shape. --- @@ -22,9 +22,9 @@ carry that full shape. ## Usage ```php -use Modules\Core\Product\DTOs\ProductFilters; -use Modules\Core\Product\Services\ProductService; -use Modules\Core\Product\Enums\ProductSort; +use Modules\Core\Catalog\DTOs\ProductFilters; +use Modules\Core\Catalog\Services\ProductService; +use Modules\Core\Catalog\Enums\ProductSort; $service = app(ProductService::class); @@ -62,11 +62,11 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei --- -## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer` +## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer` Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description, status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as -filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService` +filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService` needs, listing and detail alike: | Field | Source | Notes | @@ -149,9 +149,9 @@ variants don't. ## Sorting -`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders — +`ProductSort` (`Modules\Core\Catalog\Enums\ProductSort`) is a fixed enum of supported sort orders — `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field -`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus +`Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see below) — sortable attributes are index settings, not computed per-query, same as filterable ones. @@ -168,7 +168,7 @@ Not automatic — an app opts in via its own `config/lunar/search.php`: ```php 'indexers' => [ - Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class, + Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, // ...other model indexers unchanged ], ``` diff --git a/docs/product-options.md b/docs/product-options.md index 9b9a436..7ff203e 100644 --- a/docs/product-options.md +++ b/docs/product-options.md @@ -6,7 +6,7 @@ Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing i Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex code to a "Red" value without editing the database directly. -`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category +`Modules\Core\Catalog\Contracts\ProductOptionTypeInterface` describes how a category of option behaves — what structured data its values carry in `meta`, and how an admin edits that data — without introducing a new model. `ProductOption`/ `ProductOptionValue` stay exactly as Lunar defines them. @@ -19,7 +19,7 @@ A shop registers a type class from its own service provider's `boot()`, the same shape as `Modules\Core\Notification\NotificationRegistry`: ```php -use Modules\Core\Product\Services\ProductOptionTypeManager; +use Modules\Core\Catalog\Services\ProductOptionTypeManager; ProductOptionTypeManager::get()->register([ \App\ProductOptions\ColorOptionType::class, @@ -44,7 +44,7 @@ name/position, no extra meta form. namespace App\ProductOptions; use Filament\Forms\Components\ColorPicker; -use Modules\Core\Product\Contracts\ProductOptionTypeInterface; +use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface; class ColorOptionType implements ProductOptionTypeInterface { @@ -70,8 +70,8 @@ plain jsonb column). `getKey()` is the identifier used in the admin's "Option Ty dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the `ProductOption::handle`. -A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`, -registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop +A reference implementation ships at `Modules\Core\Catalog\OptionTypes\ColorOptionType`, +registered automatically by `Modules\Core\Providers\CatalogServiceProvider` — no shop setup needed for it to appear in the "Option Type" dropdown, though an admin still has to pick it per-`ProductOption` for it to take effect. @@ -79,7 +79,7 @@ has to pick it per-`ProductOption` for it to take effect. ## How it's wired into the admin UI -`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry: +`Modules\Core\Catalog\Services\ProductOptionTypeManager` is a singleton registry: - `get(): static` — the shared instance. - `register(array $types): void` — registers one or more type classes, keyed internally by `getKey()`. @@ -93,11 +93,11 @@ Two extensions hook into Lunar's admin via its extension system (`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's classes needed: -- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends +- `Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension` extends `Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select` (`meta.option_type`) listing every enabled type's key. Shown only when at least one type is enabled. -- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends +- `Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension` extends the "Values" tab's form. Its `extendForm()` reads `$option->meta['option_type']` off the owning `ProductOption`, resolves it via `ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name diff --git a/docs/product-search.md b/docs/product-search.md index 5cb05a1..7445197 100644 --- a/docs/product-search.md +++ b/docs/product-search.md @@ -1,6 +1,6 @@ # Product Search -`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on +`Modules\Core\Catalog\Services\ProductSearchService` provides locale-aware full-text product search on top of Laravel Scout + Meilisearch. --- @@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request). ## Usage ```php -use Modules\Core\Product\Services\ProductSearchService; +use Modules\Core\Catalog\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); // or an explicit locale, bypassing App::getLocale(): diff --git a/src/Catalog/Contracts/ProductOptionTypeInterface.php b/src/Catalog/Contracts/ProductOptionTypeInterface.php new file mode 100644 index 0000000..49fb205 --- /dev/null +++ b/src/Catalog/Contracts/ProductOptionTypeInterface.php @@ -0,0 +1,34 @@ + + */ + public function getMetaForm(): array; +} diff --git a/src/Catalog/DTOs/ProductFilters.php b/src/Catalog/DTOs/ProductFilters.php new file mode 100644 index 0000000..7fc48b3 --- /dev/null +++ b/src/Catalog/DTOs/ProductFilters.php @@ -0,0 +1,20 @@ + 'price:asc', + self::PriceDesc => 'price:desc', + self::Newest => 'created_at:desc', + }; + } +} diff --git a/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php new file mode 100644 index 0000000..b816ce6 --- /dev/null +++ b/src/Catalog/Filament/Extensions/ProductOptionResourceExtension.php @@ -0,0 +1,39 @@ +all()) + ->keys() + ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) + ->all(); + + if ($options === []) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + Select::make('meta.option_type') + ->label('Option Type') + ->options($options) + ->helperText('Controls which meta fields appear when editing this option\'s values.') + ->native(false), + ]); + } +} diff --git a/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php new file mode 100644 index 0000000..1429d89 --- /dev/null +++ b/src/Catalog/Filament/Extensions/ValuesRelationManagerExtension.php @@ -0,0 +1,34 @@ +caller->getOwnerRecord(); + + $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); + + if ($type === null) { + return $form; + } + + return $form->schema([ + ...$form->getComponents(), + ...$type->getMetaForm(), + ]); + } +} diff --git a/src/Catalog/Observers/ProductOptionReindexObserver.php b/src/Catalog/Observers/ProductOptionReindexObserver.php new file mode 100644 index 0000000..5b47c45 --- /dev/null +++ b/src/Catalog/Observers/ProductOptionReindexObserver.php @@ -0,0 +1,68 @@ +reindexProductsForOption($option->id); + } + + public function optionDeleted(ProductOption $option): void + { + $this->reindexProductsForOption($option->id); + } + + public function valueSaved(ProductOptionValue $value): void + { + $this->reindexProductsForValues([$value->id]); + } + + public function valueDeleted(ProductOptionValue $value): void + { + $this->reindexProductsForValues([$value->id]); + } + + private function reindexProductsForOption(int $optionId): void + { + $valueIds = ProductOptionValue::where('product_option_id', $optionId)->pluck('id'); + + $this->reindexProductsForValues($valueIds->all()); + } + + private function reindexProductsForValues(array $valueIds): void + { + if ($valueIds === []) { + return; + } + + $prefix = config('lunar.database.table_prefix'); + + $variantIds = DB::table("{$prefix}product_option_value_product_variant") + ->whereIn('value_id', $valueIds) + ->pluck('variant_id'); + + if ($variantIds->isEmpty()) { + return; + } + + $productIds = ProductVariant::whereIn('id', $variantIds)->pluck('product_id')->unique(); + + Product::whereIn('id', $productIds)->get()->each->searchable(); + } +} diff --git a/src/Catalog/OptionTypes/ColorOptionType.php b/src/Catalog/OptionTypes/ColorOptionType.php new file mode 100644 index 0000000..10600b2 --- /dev/null +++ b/src/Catalog/OptionTypes/ColorOptionType.php @@ -0,0 +1,29 @@ +label('Color') + ->required(), + ]; + } +} diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php new file mode 100644 index 0000000..3640f27 --- /dev/null +++ b/src/Catalog/Services/ProductIndexer.php @@ -0,0 +1,198 @@ +with([ + 'collections', + 'media', + 'tags', + 'urls', + 'variants.images', + 'variants.prices', + 'variants.values.option', + ]); + } + + public function toSearchableArray(Model $model): array + { + /** @var Product $model */ + $data = parent::toSearchableArray($model); + + $currency = Currency::getDefault(); + $reviews = ProductReview::where('product_id', $model->id)->with('media')->get(); + + $data['collections'] = $model->collections->map(fn ($collection) => [ + 'id' => $collection->id, + 'name' => $collection->translateAttribute('name'), + ])->all(); + $data['slugs'] = $model->urls->pluck('slug')->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(); + $data['price'] = $this->cheapestPrice($model, $currency); + $data['reviews'] = [ + 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(), + 'count' => $reviews->count(), + 'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1), + ]; + $data['channel_ids'] = $model->channels() + ->wherePivot('enabled', true) + ->pluck('lunar_channels.id') + ->toArray(); + + return $data; + } + + private function mapVariant(ProductVariant $variant, Currency $currency): array + { + return [ + 'id' => $variant->id, + 'sku' => $variant->sku, + 'stock' => $variant->stock, + 'purchasable' => $variant->purchasable, + 'options' => $variant->values->map(fn ($value) => [ + 'option' => $this->translatedName($value->option->name), + 'handle' => $value->option->handle, + 'value' => $this->translatedName($value->name), + 'meta' => $value->meta, + ])->all(), + 'prices' => $variant->prices->map(fn (Price $price) => [ + 'currency_id' => $price->currency_id, + 'customer_group_id' => $price->customer_group_id, + 'price' => $price->price->decimal(), + 'compare_price' => $price->compare_price?->decimal(), + 'min_quantity' => $price->min_quantity, + ])->all(), + 'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(), + ]; + } + + /** + * Public-safe fields only — reviewer_email is PII with no storefront use and is + * deliberately excluded, unlike every other column on the review. reply/replied_at + * (the staff response) are included since they're meant to be shown alongside the + * review on the storefront. + */ + private function mapReview(ProductReview $review): array + { + return [ + 'id' => $review->id, + 'title' => $review->title, + 'body' => $review->body, + 'rating' => $review->rating, + 'reviewed_at' => $review->reviewed_at?->timestamp, + 'reviewer_name' => $review->reviewer_name, + 'reply' => $review->reply, + 'replied_at' => $review->replied_at?->timestamp, + 'location' => $review->location, + 'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(), + ]; + } + + /** + * ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast + * (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is + * not stored in attribute_data. Lunar's translateAttribute() only reads + * attribute_data, so it silently returns null for these two models; this reads + * the array directly instead. Falls back to the first available locale if the + * current one is missing. Not a general replacement for translateAttribute() — + * every other translated field in this indexer (product/collection name and + * description) genuinely is attribute_data-backed and translateAttribute() is + * correct for those. + */ + private function translatedName(mixed $name): ?string + { + $names = is_array($name) ? $name : (array) $name; + + return $names[app()->getLocale()] ?? reset($names) ?: null; + } + + private function mapMedia(Media $media): array + { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'thumb' => $media->getUrl('small'), + ]; + } + + /** + * The cheapest variant's base price (no customer group) in the default currency, + * as a float in major units — e.g. 19.99, not 1999. Null if the product has no + * variant with a price in that currency yet, so it's excluded from price filters + * rather than sorting to the bottom as if it were free. + */ + private function cheapestPrice(Product $model, Currency $currency): ?float + { + $price = $model->variants + ->flatMap(fn ($variant) => $variant->prices) + ->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null) + ->min(fn ($price) => $price->price->value); + + return $price !== null ? $price / (10 ** $currency->decimal_places) : null; + } +} diff --git a/src/Catalog/Services/ProductOptionTypeManager.php b/src/Catalog/Services/ProductOptionTypeManager.php new file mode 100644 index 0000000..2b45683 --- /dev/null +++ b/src/Catalog/Services/ProductOptionTypeManager.php @@ -0,0 +1,68 @@ +register([...])` from its + * own service provider `boot()`, rather than listing classes in a published config + * file. + */ +class ProductOptionTypeManager +{ + private static ?self $instance = null; + + /** @var array> */ + private array $types = []; + + private function __construct() {} + + public static function get(): static + { + if (static::$instance === null) { + static::$instance = new static(); + } + + return static::$instance; + } + + /** + * @param array> $types + */ + public function register(array $types): void + { + foreach ($types as $class) { + $this->types[$class::getKey()] = $class; + } + } + + public function unregister(string $key): void + { + unset($this->types[$key]); + } + + public function resolve(?string $key): ?ProductOptionTypeInterface + { + if ($key === null || ! isset($this->types[$key])) { + return null; + } + + return app($this->types[$key]); + } + + /** + * @return array> + */ + public function all(): array + { + return $this->types; + } +} diff --git a/src/Catalog/Services/ProductSearchService.php b/src/Catalog/Services/ProductSearchService.php new file mode 100644 index 0000000..0ae8e9e --- /dev/null +++ b/src/Catalog/Services/ProductSearchService.php @@ -0,0 +1,56 @@ + + */ + public function search(string $query, ?string $locale = null): Collection + { + $locale ??= App::getLocale(); + $defaultLocale = Language::getDefault()->code; + + return Product::search($query) + ->options([ + 'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale), + ]) + ->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. + * + * @return array + */ + private function searchableFields(string $locale, string $defaultLocale): array + { + $handles = AttributeManifest::getSearchableAttributes(Product::morphName()) + ->pluck('handle'); + + $locales = array_unique([$locale, $defaultLocale]); + + return $handles + ->crossJoin($locales) + ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") + ->values() + ->all(); + } +} diff --git a/src/Catalog/Services/ProductService.php b/src/Catalog/Services/ProductService.php new file mode 100644 index 0000000..f881d6c --- /dev/null +++ b/src/Catalog/Services/ProductService.php @@ -0,0 +1,168 @@ +get() model hydration anywhere in this service. Callers get plain arrays + * of the indexed document, not Eloquent models. + * + * Full-text query search lives separately in Modules\Core\Catalog\Services\ + * ProductSearchService; this service is for browsing/filtering without a search term. + */ +class ProductService +{ + public function __construct( + private readonly LanguageCache $languages, + private readonly AttributeManifest $attributes, + ) {} + + /** + * 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. + */ + public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator + { + $options = ['filter' => $this->buildFilter($filters)]; + + if ($sort !== null) { + $options['sort'] = [$sort->toMeilisearchSort()]; + } + + $paginator = Product::search('') + ->options($options) + ->paginateRaw(perPage: $perPage, page: $page); + + $data = collect($this->hitsFrom($paginator)) + ->map(fn (array $product) => $this->withLocalizedFields($product)) + ->all(); + + return new LengthAwarePaginator( + items: $data, + total: $paginator->total(), + perPage: $paginator->perPage(), + currentPage: $paginator->currentPage(), + options: ['path' => LengthAwarePaginator::resolveCurrentPath()], + ); + } + + /** + * Look up a single product by its URL slug (any locale - slugs are indexed across + * all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full + * indexed product document, or null if no product has that slug. + */ + public function getBySlug(string $slug): ?array + { + return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"'); + } + + /** + * Look up a single product by its primary key. Returns the full indexed product + * document, or null if no product has that id. + */ + public function getById(int $id): ?array + { + return $this->findOneWhere("id = \"{$id}\""); + } + + private function findOneWhere(string $filter): ?array + { + $paginator = Product::search('') + ->options(['filter' => $filter]) + ->paginateRaw(perPage: 1, page: 1); + + $product = $this->hitsFrom($paginator)[0] ?? null; + + return $product !== null ? $this->withLocalizedFields($product) : null; + } + + /** + * Resolves every translated Product attribute's current-locale value from the + * indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`, + * `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's + * default language (LanguageCache::defaultLocale()) when the current locale + * has no translation - e.g. a product with no English copy yet still shows its + * Greek name on /en/ rather than rendering blank. + * + * Which handles are translated is read from AttributeManifest - the same + * source Lunar's own ScoutIndexer reads when exploding a TranslatedText + * attribute into `{handle}_{locale}` keys at index time - rather than a fixed + * list, so a store's own custom translated attributes (e.g. `seo_title`) are + * picked up automatically with no change here. The raw per-locale keys are + * then stripped, since once resolved, callers only ever need the one that + * matched the current locale. + * + * Deliberately not config('app.locale') - App::setLocale() overwrites that + * config value on every request, so by request time it's just whatever the + * current locale already is, not a stable fallback. + */ + private function withLocalizedFields(array $product): array + { + $locale = App::getLocale(); + $fallbackLocale = $this->languages->defaultLocale(); + $availableLocales = $this->languages->availableLocales(); + + foreach ($this->translatedAttributeHandles() as $handle) { + $product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null; + + foreach ($availableLocales as $availableLocale) { + unset($product[$handle.'_'.$availableLocale]); + } + } + + return $product; + } + + /** + * @return array + */ + private function translatedAttributeHandles(): array + { + return $this->attributes->getSearchableAttributes((new Product)->getMorphClass()) + ->filter(fn ($attribute) => $attribute->type === TranslatedText::class) + ->pluck('handle') + ->all(); + } + + /** + * For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response + * (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the + * actual documents are under the 'hits' key. + */ + private function hitsFrom(LengthAwarePaginatorContract $paginator): array + { + $rawResponse = $paginator->items(); + + return collect($rawResponse['hits'] ?? [])->values()->all(); + } + + private function buildFilter(?ProductFilters $filters): ?string + { + if ($filters === null) { + return null; + } + + $clauses = Collection::make([ + $filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null, + $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, + $filters->minPrice !== null ? "price >= {$filters->minPrice}" : null, + $filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null, + ])->filter(); + + return $clauses->isEmpty() ? null : $clauses->join(' AND '); + } +} diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 00582c2..355748a 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -17,9 +17,9 @@ use Lunar\Shipping\ShippingPlugin; use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; +use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; +use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; -use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension; -use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; diff --git a/src/Localization/Services/LanguageCache.php b/src/Localization/Services/LanguageCache.php index 03446c1..21e7d96 100644 --- a/src/Localization/Services/LanguageCache.php +++ b/src/Localization/Services/LanguageCache.php @@ -9,7 +9,7 @@ use Lunar\Models\Language; /** * Cached read layer over Lunar's `languages` table — the single source both * Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and - * any other locale-aware code (e.g. Modules\Core\Product\Services\ProductService) read + * any other locale-aware code (e.g. Modules\Core\Catalog\Services\ProductService) read * from, so the language list is fetched once per cache lifetime rather than once * per caller. Cached forever, invalidated via forget() by * Modules\Core\Localization\Listeners\FlushLanguageCache on @@ -41,7 +41,7 @@ class LanguageCache /** * Every configured store locale code (e.g. ['el', 'en']) - for code that needs * to enumerate all locales a TranslatedText attribute was indexed under (see - * Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than + * Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than * hardcoding locale codes. * * @return array diff --git a/src/Providers/CatalogServiceProvider.php b/src/Providers/CatalogServiceProvider.php new file mode 100644 index 0000000..7354e13 --- /dev/null +++ b/src/Providers/CatalogServiceProvider.php @@ -0,0 +1,28 @@ +register([ + ColorOptionType::class, + ]); + + $observer = new ProductOptionReindexObserver; + + ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); + ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option)); + + ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); + ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); + } +} diff --git a/src/Providers/ReviewServiceProvider.php b/src/Providers/ReviewServiceProvider.php index c5a32e1..87bea35 100644 --- a/src/Providers/ReviewServiceProvider.php +++ b/src/Providers/ReviewServiceProvider.php @@ -9,7 +9,7 @@ use Modules\Core\Review\Models\ProductReview; * Keeps a product's Meilisearch document in sync with its reviews. A review is * created/edited independently of its product (customer submission, staff reply), * so the product's own save/update events never fire for it — without this listener, - * Modules\Core\Product\Services\ProductIndexer's review data would only refresh on + * Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on * the next full product reindex. */ class ReviewServiceProvider extends ServiceProvider diff --git a/src/Review/Models/ProductReview.php b/src/Review/Models/ProductReview.php index d2ab244..32a257e 100644 --- a/src/Review/Models/ProductReview.php +++ b/src/Review/Models/ProductReview.php @@ -38,7 +38,7 @@ class ProductReview extends Model implements HasMedia * Unlike Product/ProductVariant, this model sits outside Lunar's own * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is * what registers the 'small' conversion those models get automatically. Without - * this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across + * this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across * product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ * Exceptions\InvalidConversion the first time a review has an image, since * $media->getUrl('small') has no matching conversion to resolve.