Feature: Product option types to support multiple cases

This commit is contained in:
2026-08-27 01:55:58 +03:00
parent 409db9c7bf
commit eef473dbd2
10 changed files with 367 additions and 6 deletions
+6
View File
@@ -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 {
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Catalog;
namespace Modules\Core\Product\Contracts;
use Filament\Forms\Components\Component;
@@ -10,10 +10,10 @@ use Filament\Forms\Components\Component;
* carry in their free-form `meta` jsonb column, and how an admin edits that data.
*
* `ProductOption`/`ProductOptionValue` themselves stay exactly as Lunar defines
* them — this is not a new model, just a registry (ProductOptionTypeRegistry) that
* maps a `ProductOption::handle` to the type describing it, so adding a new kind of
* option (a new color-like or size-like concept) is a single new class, not scattered
* per-option special-casing across the admin UI or storefront.
* them — this is not a new model. `ProductOptionTypeManager` maps a
* `ProductOption::handle` to the type describing it (via `config('core.product_option_types')`,
* typed explicitly by the admin), so adding a new kind of option is a single new
* class, not scattered per-option special-casing across the admin UI or storefront.
*/
interface ProductOptionTypeInterface
{
@@ -26,7 +26,7 @@ interface ProductOptionTypeInterface
* Filament form components for editing a ProductOptionValue's `meta` under this
* option type — e.g. Color returns a color picker for `meta.hex`, Size returns a
* numeric input for `meta.sort_value`. Field names should be dot-notation under
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManager's form saves them.
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManagerExtension saves them.
*
* @return array<Component>
*/
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Product\Filament\Extensions;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Product\Services\ProductOptionTypeManager;
/**
* Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting
* an admin pick which registered `ProductOptionTypeInterface` (if any) describes this
* option's values — e.g. "Color" — independent of the option's own `handle`. The
* selection is saved to `ProductOption::meta['option_type']`.
*/
class ProductOptionResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
$options = app(ProductOptionTypeManager::class)->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),
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Product\Filament\Extensions;
use Filament\Forms\Form;
use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption;
use Modules\Core\Product\Services\ProductOptionTypeManager;
/**
* Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta
* form (if any) to Lunar's own ValuesRelationManager form, so e.g. a "color" option
* gets a hex-color picker for each value alongside the stock name field — without
* forking Lunar's relation manager.
*/
class ValuesRelationManagerExtension extends RelationManagerExtension
{
public function extendForm(Form $form): Form
{
/** @var ProductOption $option */
$option = $this->caller->getOwnerRecord();
$type = app(ProductOptionTypeManager::class)->resolve($option->meta['option_type'] ?? null);
if ($type === null) {
return $form;
}
return $form->schema([
...$form->getComponents(),
...$type->getMetaForm(),
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Product\Observers;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Lunar\Models\ProductVariant;
/**
* Keeps every product using a ProductOption/ProductOptionValue in sync with
* Meilisearch. ProductIndexer::mapVariant() embeds each option value's `meta`
* (e.g. a color's hex) directly into the product's indexed document — but saving
* the option or one of its values never fires the *product's* own save/update
* events, so without this, a changed option_type or a changed hex would only
* reach the index on that product's next unrelated reindex.
*/
class ProductOptionReindexObserver
{
public function optionSaved(ProductOption $option): void
{
$this->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();
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Product\OptionTypes;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
/**
* Reference implementation: describes a 'color' ProductOption's values as
* carrying a hex code in `meta.hex`, editable via a Filament color picker.
* Not auto-registered — a shop opts in via config('core.product_option_types').
*/
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Core\Product\Services;
use Illuminate\Support\Collection;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
/**
* Resolves an admin-selected option type key to the `ProductOptionTypeInterface`
* describing it. The selection (which key a given `Lunar\Models\ProductOption` uses)
* is stored per-option in `ProductOption::meta['option_type']` — deliberately not
* tied to the option's `handle`, since a shop's own handle naming (e.g. transliterated
* Greek, legacy imports) shouldn't have to match a type's key.
*
* The available keys come from `config('core.product_option_types')` — a plain list,
* not a config array, because the mapping from option to type is an admin's per-option
* choice made in the UI (see ValuesRelationManagerExtension/ProductOptionResourceExtension),
* not something config alone can express.
*/
class ProductOptionTypeManager
{
/**
* @return Collection<string, ProductOptionTypeInterface> 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);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\Product\Observers\ProductOptionReindexObserver;
class ProductServiceProvider extends ServiceProvider
{
public function boot(): void
{
$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));
}
}