From dca6944153c59ffdd25b4cec4a54c9448b0458e2 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 28 Sep 2026 11:30:15 +0300 Subject: [PATCH] Feat: Adding Store Details Page --- composer.json | 3 +- ...9_28_000002_create_store_details_table.php | 44 ++++++ src/CorePlugin.php | 4 + src/Providers/StoreServiceProvider.php | 16 +++ src/Store/Events/StoreDetailsUpdated.php | 19 +++ .../Filament/Pages/ManageStoreDetails.php | 128 ++++++++++++++++++ .../Listeners/FlushStoreDetailsCache.php | 26 ++++ src/Store/Models/StoreDetails.php | 25 ++++ src/Store/Services/StoreDetailsService.php | 77 +++++++++++ 9 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_09_28_000002_create_store_details_table.php create mode 100644 src/Providers/StoreServiceProvider.php create mode 100644 src/Store/Events/StoreDetailsUpdated.php create mode 100644 src/Store/Filament/Pages/ManageStoreDetails.php create mode 100644 src/Store/Listeners/FlushStoreDetailsCache.php create mode 100644 src/Store/Models/StoreDetails.php create mode 100644 src/Store/Services/StoreDetailsService.php diff --git a/composer.json b/composer.json index 8737ad1..df7e54c 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,8 @@ "Modules\\Core\\Providers\\ShippingServiceProvider", "Modules\\Core\\Providers\\OrderServiceProvider", "Modules\\Core\\Providers\\PrivacyServiceProvider", - "Modules\\Core\\Providers\\WishlistServiceProvider" + "Modules\\Core\\Providers\\WishlistServiceProvider", + "Modules\\Core\\Providers\\StoreServiceProvider" ] } }, diff --git a/database/migrations/2026_09_28_000002_create_store_details_table.php b/database/migrations/2026_09_28_000002_create_store_details_table.php new file mode 100644 index 0000000..2498b4b --- /dev/null +++ b/database/migrations/2026_09_28_000002_create_store_details_table.php @@ -0,0 +1,44 @@ +translate('name'). tax_identifier + * (ΑΦΜ) and registration_number (ΓΕΜΗ) are legal identifiers, not + * locale-dependent text, so they stay plain strings — same for phone. + * + * No seeder inserting the singleton row — StoreDetailsService::current() + * lazily creates it (all-null) on first read, same shape as any other + * firstOrCreate()-backed singleton in this codebase. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('store_details', function (Blueprint $table) { + $table->id(); + $table->json('name')->nullable(); + $table->json('address')->nullable(); + $table->string('phone')->nullable(); + $table->string('tax_identifier')->nullable(); + $table->string('registration_number')->nullable(); + $table->json('bank_transfer_instructions')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_details'); + } +}; diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 35777b5..afd9a47 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -50,6 +50,7 @@ use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource; +use Modules\Core\Store\Filament\Pages\ManageStoreDetails; class CorePlugin implements Plugin { @@ -74,6 +75,9 @@ class CorePlugin implements Plugin ShipmentResource::class, ManifestResource::class, ]) + ->pages([ + ManageStoreDetails::class, + ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ diff --git a/src/Providers/StoreServiceProvider.php b/src/Providers/StoreServiceProvider.php new file mode 100644 index 0000000..af603af --- /dev/null +++ b/src/Providers/StoreServiceProvider.php @@ -0,0 +1,16 @@ +form->fill( + app(StoreDetailsService::class)->current()->attributesToArray() + ); + } + + public function content(Schema $schema): Schema + { + return $schema->components([ + Form::make([EmbeddedSchema::make('form')]) + ->id('form') + ->livewireSubmitHandler('save') + ->footer([ + Actions::make($this->getFormActions()) + ->key('form-actions'), + ]), + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->statePath('data') + ->components([ + Section::make('Store') + ->schema([ + // Deliberately not ->required(): TranslatedText's own + // state is the whole locale-keyed array, and its + // required-rule generation validates that array + // itself rather than deferring to its per-locale + // children — it fires "required" even when every + // locale sub-field is genuinely filled in. The + // column is nullable and nothing reads it yet, so + // there's no real need to enforce this here. + TranslatedText::make('name') + ->label('Store name'), + TranslatedText::make('address') + ->label('Address'), + TextInput::make('phone') + ->label('Phone') + ->tel(), + ]), + Section::make('Legal') + ->description('Shown on invoices and terms pages.') + ->schema([ + TextInput::make('tax_identifier') + ->label('Tax ID (ΑΦΜ)'), + TextInput::make('registration_number') + ->label('Company registration number (ΓΕΜΗ)'), + ]), + Section::make('Bank transfer') + ->description('Shown to a shopper on the order confirmation page when they chose to pay by bank transfer.') + ->schema([ + // Rich, not plain Textarea — a shop owner may want a + // formatted table (bank name / IBAN / BIC columns) or + // bold text, not just line breaks. RichEditor's + // 'table' toolbar button ships in its default toolbar + // (RichEditor::getDefaultToolbarButtons()), so this + // needs no extra config to get table insert/edit. + TranslatedText::make('bank_transfer_instructions') + ->label('Instructions') + ->optionRichtext(true), + ]), + ]); + } + + protected function getFormActions(): array + { + return [ + Action::make('save') + ->label('Save') + ->submit('save'), + ]; + } + + public function save(): void + { + $state = $this->form->getState(); + + app(StoreDetailsService::class)->update($state); + + Notification::make() + ->title('Store details saved') + ->success() + ->send(); + } +} diff --git a/src/Store/Listeners/FlushStoreDetailsCache.php b/src/Store/Listeners/FlushStoreDetailsCache.php new file mode 100644 index 0000000..a316eaa --- /dev/null +++ b/src/Store/Listeners/FlushStoreDetailsCache.php @@ -0,0 +1,26 @@ + 'array', + 'address' => 'array', + 'bank_transfer_instructions' => 'array', + ]; +} diff --git a/src/Store/Services/StoreDetailsService.php b/src/Store/Services/StoreDetailsService.php new file mode 100644 index 0000000..c5d9452 --- /dev/null +++ b/src/Store/Services/StoreDetailsService.php @@ -0,0 +1,77 @@ +update(...) or $storeDetails->save() directly — a + * write bypassing this service leaves current()'s forever-cache stale. + */ +class StoreDetailsService +{ + public const CACHE_KEY = 'store-details'; + + /** + * Forever-cached — read on every storefront request that shows store + * details (e.g. the checkout confirmation page's bank transfer + * instructions), so this should never re-query the database on a normal + * request. Only ever invalidated by update() below, via + * FlushStoreDetailsCache reacting to StoreDetailsUpdated. + */ + public function current(): StoreDetails + { + return Cache::rememberForever( + self::CACHE_KEY, + fn () => $this->firstOrCreate(), + ); + } + + public function update(array $attributes): StoreDetails + { + $storeDetails = $this->firstOrCreate(); + + $storeDetails->update($attributes); + + Event::dispatch(new StoreDetailsUpdated($storeDetails)); + + return $storeDetails; + } + + /** + * A freshly-created row must never leave a translatable column + * genuinely NULL — Lunar's own TranslatedText component (Modules\Core\ + * Store\Filament\Pages\ManageStoreDetails's `name`/`address`/ + * `bank_transfer_instructions` fields) silently drops every keystroke + * on re-render when the field it's editing starts out NULL rather than + * an empty per-locale array. Real-world precedent (PaymentMethod's own + * translatable `name` column) never hits this, because every + * PaymentMethod row is created THROUGH the same Filament form that + * immediately fills `name` — this singleton is instead created blank + * and opened for editing in the same visit, which is exactly the gap + * that surfaces the bug. Caught and fixed after the fact, verified via + * tinker: seeding a real (non-null) array made typing into the field + * persist correctly, confirming NULL was the trigger. + */ + private function firstOrCreate(): StoreDetails + { + return StoreDetails::query()->firstOrCreate([], [ + 'name' => $this->emptyPerLocale(), + 'address' => $this->emptyPerLocale(), + 'bank_transfer_instructions' => $this->emptyPerLocale(), + ]); + } + + private function emptyPerLocale(): array + { + return Language::query()->pluck('code')->mapWithKeys(fn (string $code) => [$code => ''])->all(); + } +}