diff --git a/config/core.php b/config/core.php index 5e0f027..bcb16f1 100644 --- a/config/core.php +++ b/config/core.php @@ -16,4 +16,23 @@ return [ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Product Option Types + |-------------------------------------------------------------------------- + | + | Enabled `Modules\Core\Product\Contracts\ProductOptionTypeInterface` + | implementations, describing what structured data a ProductOption's + | values carry in their `meta` jsonb column, and how an admin edits it. + | An admin picks one per ProductOption from a dropdown built from this + | list (stored in ProductOption::meta, not tied to the option's handle) — + | a ProductOption with none selected has no described meta behavior, + | plain name/position only. + | + | \App\ProductOptions\ColorOptionType::class, + | + */ + + 'product_option_types' => [], + ]; diff --git a/docs/product-options.md b/docs/product-options.md new file mode 100644 index 0000000..f1043fb --- /dev/null +++ b/docs/product-options.md @@ -0,0 +1,105 @@ +# Product Option Types + +Lunar's `ProductOption`/`ProductOptionValue` are generic by design — a "Color" option +and a "Size" option are both just a handle, a translated name, and a list of values. +Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing in +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 +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. + +--- + +## Registering a type + +A shop enables a type class in `config/core.php`: + +```php +// config/core.php +'product_option_types' => [ + \App\ProductOptions\ColorOptionType::class, +], +``` + +This is a plain list, **not** keyed by `ProductOption::handle` — a shop's own handle +naming (transliterated Greek, legacy import slugs, whatever an admin happened to type +when creating the option) shouldn't have to match a type's key. Instead, an admin +picks a type per-option from a dropdown on the `ProductOption` edit form itself (see +below); the choice is stored in `ProductOption::meta['option_type']`, not inferred +from anything else. + +A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain +name/position, no extra meta form. + +--- + +## Writing a type + +```php +namespace App\ProductOptions; + +use Filament\Forms\Components\ColorPicker; +use Modules\Core\Product\Contracts\ProductOptionTypeInterface; + +class ColorOptionType implements ProductOptionTypeInterface +{ + public static function getKey(): string + { + return 'color'; + } + + public function getMetaForm(): array + { + return [ + ColorPicker::make('meta.hex') + ->label('Color') + ->required(), + ]; + } +} +``` + +`getMetaForm()` returns Filament form components, keyed under `meta.*` dot notation +— the path they save to on `ProductOptionValue::meta` (cast as `AsArrayObject`, a +plain jsonb column). `getKey()` is the identifier used in the admin's "Option Type" +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` +— not auto-registered, since registration is always an explicit shop decision. + +--- + +## How it's wired into the admin UI + +`Modules\Core\Product\Services\ProductOptionTypeManager`: +- `all(): Collection` — every enabled type, + keyed by `getKey()`. +- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up one by key (or + `null` if no key / not found). + +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 + `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 + 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 + field. A `ProductOption` with no type selected gets the stock form unchanged. + +--- + +## Reading the value back + +Storefront code reads `ProductOptionValue::meta` like any other jsonb column — e.g. +`$value->meta['hex']` for a color swatch. `ProductOptionTypeManager` is an admin-side +concern only (describing *how to edit* the meta); nothing requires the storefront to +go through it to *read* the meta. diff --git a/src/CorePlugin.php b/src/CorePlugin.php index ef0bd4a..00582c2 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -6,6 +6,8 @@ use Filament\Contracts\Plugin; use Filament\Panel; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use Lunar\Admin\Filament\Resources\ProductOptionResource; +use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager; use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Models\Staff as LunarStaff; @@ -16,6 +18,8 @@ use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Mail\InviteMail; 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; @@ -41,6 +45,8 @@ class CorePlugin implements Plugin LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, ProductResource::class => ProductResourceExtension::class, + ProductOptionResource::class => ProductOptionResourceExtension::class, + ValuesRelationManager::class => ValuesRelationManagerExtension::class, ]); Product::macro('reviews', function (): HasMany { diff --git a/src/Catalog/ProductOptionTypeInterface.php b/src/Product/Contracts/ProductOptionTypeInterface.php similarity index 71% rename from src/Catalog/ProductOptionTypeInterface.php rename to src/Product/Contracts/ProductOptionTypeInterface.php index 248459d..e14d1e0 100644 --- a/src/Catalog/ProductOptionTypeInterface.php +++ b/src/Product/Contracts/ProductOptionTypeInterface.php @@ -1,6 +1,6 @@ */ diff --git a/src/Product/Filament/Extensions/ProductOptionResourceExtension.php b/src/Product/Filament/Extensions/ProductOptionResourceExtension.php new file mode 100644 index 0000000..0c9635f --- /dev/null +++ b/src/Product/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/Product/Filament/Extensions/ValuesRelationManagerExtension.php b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php new file mode 100644 index 0000000..17833a4 --- /dev/null +++ b/src/Product/Filament/Extensions/ValuesRelationManagerExtension.php @@ -0,0 +1,34 @@ +caller->getOwnerRecord(); + + $type = app(ProductOptionTypeManager::class)->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 new file mode 100644 index 0000000..e34b1ca --- /dev/null +++ b/src/Product/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/Product/OptionTypes/ColorOptionType.php b/src/Product/OptionTypes/ColorOptionType.php new file mode 100644 index 0000000..e4c6fec --- /dev/null +++ b/src/Product/OptionTypes/ColorOptionType.php @@ -0,0 +1,28 @@ +label('Color') + ->required(), + ]; + } +} diff --git a/src/Product/Services/ProductOptionTypeManager.php b/src/Product/Services/ProductOptionTypeManager.php new file mode 100644 index 0000000..258a900 --- /dev/null +++ b/src/Product/Services/ProductOptionTypeManager.php @@ -0,0 +1,40 @@ + keyed by getKey() + */ + public function all(): Collection + { + return collect(config('core.product_option_types', [])) + ->map(fn (string $class) => app($class)) + ->keyBy(fn (ProductOptionTypeInterface $type) => $type::getKey()); + } + + public function resolve(?string $key): ?ProductOptionTypeInterface + { + if ($key === null) { + return null; + } + + return $this->all()->get($key); + } +} diff --git a/src/Providers/ProductServiceProvider.php b/src/Providers/ProductServiceProvider.php new file mode 100644 index 0000000..b45cd56 --- /dev/null +++ b/src/Providers/ProductServiceProvider.php @@ -0,0 +1,22 @@ + $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)); + } +}