From 63caaf55c7dbb8cfa878d2e1a602e8ed8edf7a92 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 27 Aug 2026 11:39:43 +0300 Subject: [PATCH] Bump Version to 0.6.1 --- CHANGELOG.md | 13 +- composer.json | 4 +- .../Contracts/ProductOptionTypeInterface.php | 34 --- src/Product/DTOs/ProductFilters.php | 20 -- src/Product/Enums/ProductSort.php | 26 --- .../ProductOptionResourceExtension.php | 39 ---- .../ValuesRelationManagerExtension.php | 34 --- .../ProductOptionReindexObserver.php | 68 ------ src/Product/OptionTypes/ColorOptionType.php | 29 --- src/Product/Services/ProductIndexer.php | 198 ------------------ .../Services/ProductOptionTypeManager.php | 68 ------ src/Product/Services/ProductSearchService.php | 56 ----- src/Product/Services/ProductService.php | 168 --------------- src/Providers/ProductServiceProvider.php | 28 --- 14 files changed, 13 insertions(+), 772 deletions(-) delete mode 100644 src/Product/Contracts/ProductOptionTypeInterface.php delete mode 100644 src/Product/DTOs/ProductFilters.php delete mode 100644 src/Product/Enums/ProductSort.php delete mode 100644 src/Product/Filament/Extensions/ProductOptionResourceExtension.php delete mode 100644 src/Product/Filament/Extensions/ValuesRelationManagerExtension.php delete mode 100644 src/Product/Observers/ProductOptionReindexObserver.php delete mode 100644 src/Product/OptionTypes/ColorOptionType.php delete mode 100644 src/Product/Services/ProductIndexer.php delete mode 100644 src/Product/Services/ProductOptionTypeManager.php delete mode 100644 src/Product/Services/ProductSearchService.php delete mode 100644 src/Product/Services/ProductService.php delete mode 100644 src/Providers/ProductServiceProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 261da9b..3950e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,20 @@ 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/). +## [0.6.1] - 2026-08-27 + +### Added +- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`. +- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to. +- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. + +### Changed +- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas"). +- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`. + ## [0.6.0] - 2026-08-27 ### Added -- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Enabled per-shop as a plain list in `config('core.product_option_types')`; an admin then picks one per `ProductOption` from a "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Services\ProductOptionTypeManager` resolves the selected key to its type (`all()`/`resolve()`). `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType` (not auto-registered). Documented in `docs/product-options.md`. -- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each. - `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). ### Changed diff --git a/composer.json b/composer.json index 8822380..24fd660 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "boboko/core", "description": "Core module — authentication and shared panel behaviour", "type": "library", - "version": "0.6.0", + "version": "0.6.1", "autoload": { "psr-4": { "Modules\\Core\\": "src/" @@ -36,7 +36,7 @@ "Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", - "Modules\\Core\\Providers\\ProductServiceProvider", + "Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider" ] } diff --git a/src/Product/Contracts/ProductOptionTypeInterface.php b/src/Product/Contracts/ProductOptionTypeInterface.php deleted file mode 100644 index e14d1e0..0000000 --- a/src/Product/Contracts/ProductOptionTypeInterface.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function getMetaForm(): array; -} diff --git a/src/Product/DTOs/ProductFilters.php b/src/Product/DTOs/ProductFilters.php deleted file mode 100644 index e1257d8..0000000 --- a/src/Product/DTOs/ProductFilters.php +++ /dev/null @@ -1,20 +0,0 @@ - 'price:asc', - self::PriceDesc => 'price:desc', - self::Newest => 'created_at:desc', - }; - } -} diff --git a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php deleted file mode 100644 index 8b2f500..0000000 --- a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php +++ /dev/null @@ -1,39 +0,0 @@ -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/Product/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php deleted file mode 100644 index 6fdcaa5..0000000 --- a/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php +++ /dev/null @@ -1,34 +0,0 @@ -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/Product/Observers/ProductOptionReindexObserver.php b/src/Product/Observers/ProductOptionReindexObserver.php deleted file mode 100644 index e34b1ca..0000000 --- a/src/Product/Observers/ProductOptionReindexObserver.php +++ /dev/null @@ -1,68 +0,0 @@ -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/Product/OptionTypes/ColorOptionType.php b/src/Product/OptionTypes/ColorOptionType.php deleted file mode 100644 index 06b70d5..0000000 --- a/src/Product/OptionTypes/ColorOptionType.php +++ /dev/null @@ -1,29 +0,0 @@ -label('Color') - ->required(), - ]; - } -} diff --git a/src/Product/Services/ProductIndexer.php b/src/Product/Services/ProductIndexer.php deleted file mode 100644 index 7bb019c..0000000 --- a/src/Product/Services/ProductIndexer.php +++ /dev/null @@ -1,198 +0,0 @@ -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/Product/Services/ProductOptionTypeManager.php b/src/Product/Services/ProductOptionTypeManager.php deleted file mode 100644 index 1d73ee7..0000000 --- a/src/Product/Services/ProductOptionTypeManager.php +++ /dev/null @@ -1,68 +0,0 @@ -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/Product/Services/ProductSearchService.php b/src/Product/Services/ProductSearchService.php deleted file mode 100644 index 6419c4d..0000000 --- a/src/Product/Services/ProductSearchService.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - 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/Product/Services/ProductService.php b/src/Product/Services/ProductService.php deleted file mode 100644 index dcb4440..0000000 --- a/src/Product/Services/ProductService.php +++ /dev/null @@ -1,168 +0,0 @@ -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\Product\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\Product\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/Providers/ProductServiceProvider.php b/src/Providers/ProductServiceProvider.php deleted file mode 100644 index 712d9ec..0000000 --- a/src/Providers/ProductServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -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)); - } -}