From 93469d033fee7e9d5114a94f4dda389d4ae16734 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 5 Aug 2026 23:58:53 +0300 Subject: [PATCH 1/3] Feature: Adding Locale Middleware --- docs/localization.md | 90 ++++++++++++++++++++++ src/Localization/LanguageCacheObserver.php | 18 +++++ src/Localization/LocaleMiddleware.php | 78 +++++++++++++++++++ src/Providers/CoreServiceProvider.php | 6 ++ 4 files changed, 192 insertions(+) create mode 100644 docs/localization.md create mode 100644 src/Localization/LanguageCacheObserver.php create mode 100644 src/Localization/LocaleMiddleware.php diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..ba3c80e --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,90 @@ +# Localization + +Storefront routes can be locale-prefixed (`/el/proionta`, `/en/products`) using a middleware +that reads directly from Lunar's `languages` table — the same table the Filament **Languages** +resource manages, so there's no separate locale config to keep in sync. + +--- + +## Why prefix every locale, including the default + +Leaving the default locale bare at the root (`/proionta` for Greek, `/en/products` for English) +creates ambiguity: is `/` the language-neutral homepage or specifically the Greek version? It +also complicates `hreflang` (needs a self-referencing tag on the root plus a possibly-duplicate +`x-default`) and risks duplicate content if a bot or campaign link reaches the root without a +language signal. + +Prefixing every locale avoids this: every URL unambiguously declares its language, `hreflang` +tags are symmetrical, and adding a locale later requires no URL restructuring. + +--- + +## Opt-in, not global + +The middleware is registered as a **named alias** (`locale`), not pushed onto the `web` +middleware group. Apply it explicitly to the route group(s) that make up your storefront: + +```php +// routes/web.php +use Illuminate\Support\Facades\Route; + +Route::middleware('locale')->group(function () { + Route::get('/{locale}', HomeController::class); + Route::get('/{locale}/proionta', ProductIndexController::class); + Route::get('/{locale}/proionta/{slug}', ProductShowController::class); +}); +``` + +It is **not** applied automatically because storefront routes aren't the only routes living +under `web` in a shop: + +- The Filament admin panel (`/boboko*`, see `PanelServiceProvider`) has its own routing/auth + concerns and must never be locale-redirected. +- Livewire's internal update endpoint (`/livewire/update`) must resolve without a locale prefix. +- Webhooks, health checks, and other non-storefront routes shouldn't be touched. + +If a shop's entire `web.php` *is* the storefront, wrapping the whole file in the group above is +fine — just keep admin/Livewire/webhook routes registered outside of it (as they already are). + +--- + +## Behavior + +`Modules\Core\Localization\LocaleMiddleware`: + +1. Reads the first path segment (`request()->segment(1)`). +2. Matches it against `Lunar\Models\Language::code`. + - **Match** — `App::setLocale($code)` is set, and `locale` / `language` request attributes + are populated for controllers/views to use. + - **No match** (missing, wrong, or unknown segment) — redirects to the same path prefixed + with a resolved locale: + - the best match from the `Accept-Language` header against available language codes, or + - the language flagged `default` in the `languages` table, or + - the first language row, as a last resort. + +The language list is cached with `Cache::rememberForever()` under `core.localization.languages` +and invalidated automatically by `Modules\Core\Localization\LanguageCacheObserver`, which +observes `Lunar\Models\Language` `saved`/`deleted` events. Adding, editing, or removing a +language via the Filament **Languages** resource clears the cache immediately — no TTL, no +stale reads. + +--- + +## Reading the resolved locale/language downstream + +```php +// In a controller or view composer +$locale = $request->attributes->get('locale'); // e.g. "el" +$language = $request->attributes->get('language'); // Lunar\Models\Language instance +``` + +Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`). + +--- + +## Single-language shops + +If a shop has only one row in `languages`, the middleware still enforces the prefix (e.g. every +URL under `/en/...`) rather than special-casing it away — this keeps behavior identical across +shops and avoids a silent restructuring if a second language is added later. If a shop genuinely +never wants locale prefixes, don't apply the `locale` middleware to its routes at all. diff --git a/src/Localization/LanguageCacheObserver.php b/src/Localization/LanguageCacheObserver.php new file mode 100644 index 0000000..2a4ce25 --- /dev/null +++ b/src/Localization/LanguageCacheObserver.php @@ -0,0 +1,18 @@ +availableLanguages(); + + if ($languages->isEmpty()) { + return $next($request); + } + + $segment = (string) $request->segment(1); + $language = $languages->firstWhere('code', $segment); + + if (! $language) { + return $this->redirectToLocalizedUrl($request, $languages); + } + + App::setLocale($language->code); + $request->attributes->set('locale', $language->code); + $request->attributes->set('language', $language); + + return $next($request); + } + + public static function forgetLanguagesCache(): void + { + Cache::forget(self::CACHE_KEY); + } + + private function redirectToLocalizedUrl(Request $request, Collection $languages): Response + { + $locale = $this->negotiateLocale($request, $languages); + + $path = trim($request->getPathInfo(), '/'); + $target = '/'.$locale.($path !== '' ? '/'.$path : ''); + + $query = $request->getQueryString(); + if ($query) { + $target .= '?'.$query; + } + + return redirect($target); + } + + private function negotiateLocale(Request $request, Collection $languages): string + { + $preferred = $request->getPreferredLanguage($languages->pluck('code')->all()); + + if ($preferred) { + return $preferred; + } + + return $languages->firstWhere('default', true)?->code + ?? $languages->first()->code; + } + + private function availableLanguages(): Collection + { + return Cache::rememberForever( + self::CACHE_KEY, + fn () => Language::query()->get(['id', 'code', 'name', 'default']), + ); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 0574c40..a1d3303 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -4,12 +4,15 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Lunar\Models\Language; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; +use Modules\Core\Localization\LanguageCacheObserver; +use Modules\Core\Localization\LocaleMiddleware; class CoreServiceProvider extends ServiceProvider { @@ -24,6 +27,9 @@ class CoreServiceProvider extends ServiceProvider Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); + Language::observe(LanguageCacheObserver::class); + $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/scout.php' => config_path('scout.php'), From 8646dca16abce21093da385fa27f53bfed5a42d1 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 6 Aug 2026 00:13:13 +0300 Subject: [PATCH 2/3] Feature: Adding Spatie's Translation Loader --- composer.json | 6 +- ..._05_210535_create_language_lines_table.php | 34 ++++++++++ docs/localization.md | 62 +++++++++++++++++++ src/Command/InstallLunarCommand.php | 35 +++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_08_05_210535_create_language_lines_table.php diff --git a/composer.json b/composer.json index 84768af..09a1774 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "symfony/yaml": "^7.0", "lunarphp/table-rate-shipping": "^1.3", "lunarphp/search": "*", - "lunarphp/meilisearch": "*" + "lunarphp/meilisearch": "*", + "spatie/laravel-translation-loader": "^2.8" }, "require-dev": { "fakerphp/faker": "^1.23", @@ -39,7 +40,8 @@ }, "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "php-http/discovery": true } } } diff --git a/database/migrations/2026_08_05_210535_create_language_lines_table.php b/database/migrations/2026_08_05_210535_create_language_lines_table.php new file mode 100644 index 0000000..d24d12a --- /dev/null +++ b/database/migrations/2026_08_05_210535_create_language_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('group')->index(); + $table->string('key'); + $table->json('text'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('language_lines'); + } +}; diff --git a/docs/localization.md b/docs/localization.md index ba3c80e..bf1a7b5 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -88,3 +88,65 @@ If a shop has only one row in `languages`, the middleware still enforces the pre URL under `/en/...`) rather than special-casing it away — this keeps behavior identical across shops and avoids a silent restructuring if a second language is added later. If a shop genuinely never wants locale prefixes, don't apply the `locale` middleware to its routes at all. + +--- + +## Storefront UI labels (`__('storefront.*')`) + +The `locale` middleware resolves *which* language a request is in — routing/redirects, +`Lunar\Models\Language`, and Lunar's own translatable product/collection content. It has nothing +to do with static UI chrome like "Cart", "Back", "Add to Cart". Those are handled separately by +[`spatie/laravel-translation-loader`](https://github.com/spatie/laravel-translation-loader), +stored in the `language_lines` table. + +### Why a separate system, not another `languages`-table lookup + +Lunar's translatable fields (`TranslatedText`, `Url`, etc.) are all tied to specific *model +records* — a product's name, a collection's description. UI labels aren't attached to any model; +they're static strings the app itself owns. `laravel-translation-loader` is Laravel's own +`__()`/`trans()` mechanism with a DB-backed source layered on top of the normal file-based one — +no new helper to learn, no bespoke table shape. + +**Nothing existing breaks.** The package's `TranslationLoaderManager` *extends* Laravel's +`FileLoader` and merges DB translations on top of file-based ones +(`array_replace_recursive()`) — Filament's own vendor `lang/en/product.php`-style strings +keep working exactly as before. The package registers itself via Laravel's standard Composer +package auto-discovery (`extra.laravel.providers` in its own `composer.json`) — nothing needed +in `CoreServiceProvider` to wire it up. + +### Usage + +```blade +{{ __('storefront.nav.cart') }} +{{ __('storefront.product.add_to_cart') }} +``` + +`group` is `storefront` for e-shop UI labels — kept separate from Lunar/Filament's own `lunar::` +namespaced groups so nothing collides. `__()` resolves the translation for whatever +`App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see +"Behavior" above) — no extra wiring needed between the two systems. + +### Seeding + +A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, +English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own +`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same +idempotent pattern as the rest of that command, safe to run unattended on every boot. + +**Editing existing labels or adding new ones is a normal Eloquent operation**, not a +re-seed: + +```php +use Spatie\TranslationLoader\LanguageLine; + +LanguageLine::create([ + 'group' => 'storefront', + 'key' => 'nav.wishlist', + 'text' => ['en' => 'Wishlist', 'el' => 'Λίστα Επιθυμιών'], +]); +``` + +There is currently no Filament resource for editing `language_lines` — labels are edited via +tinker/seeder until one is built. `LanguageLine` caches per group+locale +(`Cache::rememberForever`) and flushes itself automatically on `saved`/`deleted`, so edits take +effect immediately with no manual cache-clear step. diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index f74b925..e07d3a3 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -18,6 +18,7 @@ use Lunar\Models\Product; use Lunar\Models\ProductType; use Lunar\Models\TaxClass; use Lunar\Models\TaxZone; +use Spatie\TranslationLoader\LanguageLine; /** * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate @@ -241,9 +242,43 @@ class InstallLunarCommand extends Command } }); + if (! LanguageLine::where('group', 'storefront')->exists()) { + $this->components->info('Seeding storefront label translations'); + $this->seedStorefrontLabels(); + } + $this->components->info('Publishing Filament assets'); $this->call('filament:assets'); $this->components->info('Lunar default data seeded.'); } + + private function seedStorefrontLabels(): void + { + $labels = [ + 'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'], + 'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'], + 'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'], + 'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'], + 'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'], + 'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'], + 'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'], + 'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'], + 'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'], + 'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'], + 'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'], + 'product.price' => ['en' => 'Price', 'el' => 'Τιμή'], + 'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'], + 'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'], + 'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'], + ]; + + foreach ($labels as $key => $text) { + LanguageLine::create([ + 'group' => 'storefront', + 'key' => $key, + 'text' => $text, + ]); + } + } } From 1857ced3a27e31f71a132333f4f0c52f9bfde57b Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 6 Aug 2026 12:06:55 +0300 Subject: [PATCH 3/3] Feature: Adding Translation Service and Translation Seeder --- composer.json | 3 +- docs/localization.md | 93 ++++++++++++--- src/CorePlugin.php | 4 + src/Localization/Events/LanguageCreated.php | 12 ++ src/Localization/Events/LanguageDeleted.php | 12 ++ src/Localization/Events/LanguageUpdated.php | 16 +++ .../Events/TranslationCreated.php | 12 ++ .../Events/TranslationDeleted.php | 12 ++ .../Events/TranslationUpdated.php | 16 +++ .../Resources/LanguageLineResource.php | 107 ++++++++++++++++++ .../Pages/CreateLanguageLine.php | 22 ++++ .../Pages/EditLanguageLine.php | 53 +++++++++ .../Pages/ListLanguageLines.php | 19 ++++ src/Localization/LanguageCacheObserver.php | 21 +++- .../Listeners/FlushLanguageCache.php | 16 +++ .../Listeners/FlushTranslationCache.php | 36 ++++++ .../Listeners/LogTranslationActivity.php | 53 +++++++++ .../MigrateTranslationsForRenamedLanguage.php | 46 ++++++++ src/Localization/TranslationReader.php | 22 ++++ src/Localization/TranslationService.php | 57 ++++++++++ src/Providers/CoreServiceProvider.php | 6 - src/Providers/LocalizationServiceProvider.php | 39 +++++++ 22 files changed, 650 insertions(+), 27 deletions(-) create mode 100644 src/Localization/Events/LanguageCreated.php create mode 100644 src/Localization/Events/LanguageDeleted.php create mode 100644 src/Localization/Events/LanguageUpdated.php create mode 100644 src/Localization/Events/TranslationCreated.php create mode 100644 src/Localization/Events/TranslationDeleted.php create mode 100644 src/Localization/Events/TranslationUpdated.php create mode 100644 src/Localization/Filament/Resources/LanguageLineResource.php create mode 100644 src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php create mode 100644 src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php create mode 100644 src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php create mode 100644 src/Localization/Listeners/FlushLanguageCache.php create mode 100644 src/Localization/Listeners/FlushTranslationCache.php create mode 100644 src/Localization/Listeners/LogTranslationActivity.php create mode 100644 src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php create mode 100644 src/Localization/TranslationReader.php create mode 100644 src/Localization/TranslationService.php create mode 100644 src/Providers/LocalizationServiceProvider.php diff --git a/composer.json b/composer.json index 09a1774..0a36ed3 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "providers": [ "Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider", - "Modules\\Core\\Providers\\CustomerServiceProvider" + "Modules\\Core\\Providers\\CustomerServiceProvider", + "Modules\\Core\\Providers\\LocalizationServiceProvider" ] } }, diff --git a/docs/localization.md b/docs/localization.md index bf1a7b5..18fe74d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -63,10 +63,35 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th - the first language row, as a last resort. The language list is cached with `Cache::rememberForever()` under `core.localization.languages` -and invalidated automatically by `Modules\Core\Localization\LanguageCacheObserver`, which -observes `Lunar\Models\Language` `saved`/`deleted` events. Adding, editing, or removing a -language via the Filament **Languages** resource clears the cache immediately — no TTL, no -stale reads. +and invalidated automatically. Adding, editing, or removing a language via the Filament +**Languages** resource clears the cache immediately — no TTL, no stale reads. + +### How invalidation is wired (event-driven, not the observer itself) + +`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s +`created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any +invalidation work itself. It dispatches one of three events from +`Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old +`code` — or `LanguageDeleted`), and two listeners, wired in +`Modules\Core\Providers\LocalizationServiceProvider`, react: + +- **`FlushLanguageCache`** — flushes `core.localization.languages` on all three events. +- **`MigrateTranslationsForRenamedLanguage`** — `LanguageUpdated` only, and only when `code` + actually changed. A renamed `Language::code` (e.g. `el` → `gr`) would otherwise strand every + `LanguageLine`'s translated text under the old, now-unroutable key — + `getTranslationsForGroup('gr', ...)` would silently return nothing for that locale even though + the translated content still exists. This listener moves the `text.{oldCode}` key to + `text.{newCode}` on every affected `LanguageLine` row and flushes both the old and new code's + translation cache for every group touched. + +Deleting a `Language` only flushes the language-list cache — `LanguageLine.text` keys for the +deleted code are left in place rather than destructively erased, in case the language is ever +re-added under the same code. + +Splitting cache-flush and text-migration into separate listeners (rather than one +`LanguageCacheObserver` method doing both) mirrors the same event → listener pattern used for +`TranslationService`'s writes below — the observer only detects *what happened*, listeners own +*what to do about it*. --- @@ -133,20 +158,58 @@ English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overri `lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same idempotent pattern as the rest of that command, safe to run unattended on every boot. -**Editing existing labels or adding new ones is a normal Eloquent operation**, not a -re-seed: +### Admin UI + +`Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in +`CorePlugin`, under the panel's Settings group) lists/searches/filters `language_lines` and +edits each row's `group`, `key`, and one text input per row currently in `lunar_languages` — +the locale columns are generated dynamically from `Language::query()->pluck('code')`, so adding +a third language automatically adds a third input, no resource changes needed. + +### `TranslationService` — writes go through here, not the model directly + +`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and +dispatches a domain event after each write, following this project's standard event-driven +pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention — +the same shape as `Modules\Core\Auth\Events\UserCreated`): ```php -use Spatie\TranslationLoader\LanguageLine; +use Modules\Core\Localization\TranslationService; -LanguageLine::create([ - 'group' => 'storefront', - 'key' => 'nav.wishlist', - 'text' => ['en' => 'Wishlist', 'el' => 'Λίστα Επιθυμιών'], +app(TranslationService::class)->create('storefront', 'nav.wishlist', [ + 'en' => 'Wishlist', + 'el' => 'Λίστα Επιθυμιών', ]); + +app(TranslationService::class)->update( + $languageLine, + 'storefront', + 'nav.wishlist', + ['en' => 'Wishlist ♥', 'el' => 'Λίστα Επιθυμιών ♥'], +); + +app(TranslationService::class)->delete($languageLine); ``` -There is currently no Filament resource for editing `language_lines` — labels are edited via -tinker/seeder until one is built. `LanguageLine` caches per group+locale -(`Cache::rememberForever`) and flushes itself automatically on `saved`/`deleted`, so edits take -effect immediately with no manual cache-clear step. +`update()` takes the full `group`/`key`/`text` state, not just `text` — a rename is a normal +update, not a special case. `TranslationCreated`, `TranslationUpdated` (carries the full +`{group, key, text}` snapshot from *before* the update, so a listener can tell a rename from a +text edit), and `TranslationDeleted` are dispatched from `Modules\Core\Localization\Events`. Two +listeners are wired in `Modules\Core\Providers\LocalizationServiceProvider` for all three events: + +- **`FlushTranslationCache`** — `LanguageLine::boot()` already flushes the cache for the + *current* group's locales present after a save, but misses two cases on update: locales a save + *removed* from `text` (e.g. dropping the `el` key leaves `storefront.el` stale), and a changed + `group`/`key` (the *old* group's cached array is never told a row left it). This listener + flushes every group+locale combination touched by either the old or new state, so nothing — + including the group a row was renamed away from — can remain stale. +- **`LogTranslationActivity`** — records the change via `Modules\Core\Logging\ActivityLogService` + on the `lunar` activity log channel, same `created`/`updated`/`deleted` shape as every other + domain write in this project. A rename shows up in the log as an `old`/`attributes` diff across + `group`, `key`, and `text` together, not just a text diff. + +The Filament resource's Create/Edit/Delete pages route through `TranslationService` (via +`handleRecordCreation`/`handleRecordUpdate`/the delete action's `->action()` override) rather +than Filament's default direct-model calls, so **every** edit made in the admin UI — including a +bare `group`/`key` rename with no `text` change — dispatches `TranslationUpdated` and is both +cache-invalidated and audit-logged. diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 38a356f..ef0bd4a 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -15,6 +15,7 @@ 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\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; @@ -32,6 +33,9 @@ class CorePlugin implements Plugin ->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->login(Login::class) + ->resources([ + LanguageLineResource::class, + ]) ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ diff --git a/src/Localization/Events/LanguageCreated.php b/src/Localization/Events/LanguageCreated.php new file mode 100644 index 0000000..d9aabf0 --- /dev/null +++ b/src/Localization/Events/LanguageCreated.php @@ -0,0 +1,12 @@ +schema([ + Forms\Components\TextInput::make('group') + ->required() + ->maxLength(255) + ->default('storefront') + ->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'), + + Forms\Components\TextInput::make('key') + ->required() + ->maxLength(255) + ->helperText('Dot-notation key, e.g. "nav.cart".'), + + Forms\Components\Fieldset::make('Translations') + ->schema(static::localeInputs()), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('group') + ->badge() + ->sortable(), + Tables\Columns\TextColumn::make('key') + ->searchable() + ->sortable(), + ...static::localeColumns(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('group') + ->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')), + ]) + ->defaultSort('key'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListLanguageLines::route('/'), + 'create' => Pages\CreateLanguageLine::route('/create'), + 'edit' => Pages\EditLanguageLine::route('/{record}/edit'), + ]; + } + + /** + * @return array + */ + private static function localeInputs(): array + { + return static::localeCodes() + ->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}") + ->label(strtoupper($code)) + ->rows(2)) + ->all(); + } + + /** + * @return array + */ + private static function localeColumns(): array + { + return static::localeCodes() + ->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}") + ->label(strtoupper($code)) + ->limit(40) + ->toggleable()) + ->all(); + } + + private static function localeCodes(): \Illuminate\Support\Collection + { + return Language::query()->pluck('code'); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php new file mode 100644 index 0000000..7870fe2 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/CreateLanguageLine.php @@ -0,0 +1,22 @@ +create( + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php new file mode 100644 index 0000000..80c864a --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/EditLanguageLine.php @@ -0,0 +1,53 @@ +action(function (LanguageLine $record) { + app(TranslationService::class)->delete($record); + + $this->redirect($this->getResource()::getUrl('index')); + }), + ]; + } + + /** + * Filament's default Cancel button uses window.history.back(), which + * restores the browser's cached previous page instead of re-fetching — + * so an edit made just before clicking Cancel doesn't show up in the + * list until a manual refresh. Redirect through Livewire instead, which + * always re-queries. + */ + protected function getCancelFormAction(): Action + { + return Action::make('cancel') + ->label(__('filament-panels::resources/pages/edit-record.form.actions.cancel.label')) + ->url(static::getResource()::getUrl('index')) + ->color('gray'); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + return app(TranslationService::class)->update( + $record, + $data['group'], + $data['key'], + $data['text'] ?? [], + ); + } +} diff --git a/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php new file mode 100644 index 0000000..12c7b37 --- /dev/null +++ b/src/Localization/Filament/Resources/LanguageLineResource/Pages/ListLanguageLines.php @@ -0,0 +1,19 @@ + $language->getOriginal('code'), + ])); + } + + public function deleted(Language $language): void + { + Event::dispatch(new LanguageDeleted($language)); } } diff --git a/src/Localization/Listeners/FlushLanguageCache.php b/src/Localization/Listeners/FlushLanguageCache.php new file mode 100644 index 0000000..575811c --- /dev/null +++ b/src/Localization/Listeners/FlushLanguageCache.php @@ -0,0 +1,16 @@ +flush($event->languageLine->group, array_keys($event->languageLine->text ?? [])); + + if ($event instanceof TranslationUpdated) { + $this->flush($event->old['group'], array_keys($event->old['text'] ?? [])); + } + } + + private function flush(string $group, array $locales): void + { + foreach ($locales as $locale) { + Cache::forget(LanguageLine::getCacheKey($group, $locale)); + } + } +} diff --git a/src/Localization/Listeners/LogTranslationActivity.php b/src/Localization/Listeners/LogTranslationActivity.php new file mode 100644 index 0000000..3a83b7b --- /dev/null +++ b/src/Localization/Listeners/LogTranslationActivity.php @@ -0,0 +1,53 @@ +languageLine; + + match (true) { + $event instanceof TranslationCreated => $this->activityLog->created( + $languageLine, + $this->flatten($languageLine), + ), + $event instanceof TranslationUpdated => $this->activityLog->updated( + $languageLine, + Arr::dot($event->old), + $this->flatten($languageLine), + ), + $event instanceof TranslationDeleted => $this->activityLog->deleted( + $languageLine, + $this->flatten($languageLine), + ), + }; + } + + /** + * Filament's Activity resource renders `properties` with a flat KeyValue + * field, which can't display a nested value like `text: {en, el}` — it + * shows as "[object Object]". Flatten to dot-notation ("text.en", + * "text.el") so every property is a plain string, viewable as-is. + */ + private function flatten(LanguageLine $languageLine): array + { + return Arr::dot([ + 'group' => $languageLine->group, + 'key' => $languageLine->key, + 'text' => $languageLine->text, + ]); + } +} diff --git a/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php new file mode 100644 index 0000000..046fd23 --- /dev/null +++ b/src/Localization/Listeners/MigrateTranslationsForRenamedLanguage.php @@ -0,0 +1,46 @@ + "gr") would otherwise strand every + * LanguageLine's translated text under the old, now-unroutable key — + * getTranslationsForGroup($newCode, ...) would silently return nothing for + * that locale even though the translated content still exists. Move the + * text.{oldCode} key to text.{newCode} on every affected row instead. + */ +class MigrateTranslationsForRenamedLanguage +{ + public function handle(LanguageUpdated $event): void + { + $oldCode = $event->old['code']; + $newCode = $event->language->code; + + if ($oldCode === $newCode) { + return; + } + + $affectedGroups = []; + + LanguageLine::query() + ->whereJsonContainsKey('text->'.$oldCode) + ->each(function (LanguageLine $languageLine) use ($oldCode, $newCode, &$affectedGroups) { + $text = $languageLine->text; + $text[$newCode] = $text[$oldCode]; + unset($text[$oldCode]); + + $languageLine->update(['text' => $text]); + + $affectedGroups[$languageLine->group] = true; + }); + + foreach (array_keys($affectedGroups) as $group) { + Cache::forget(LanguageLine::getCacheKey($group, $oldCode)); + Cache::forget(LanguageLine::getCacheKey($group, $newCode)); + } + } +} diff --git a/src/Localization/TranslationReader.php b/src/Localization/TranslationReader.php new file mode 100644 index 0000000..4f57c70 --- /dev/null +++ b/src/Localization/TranslationReader.php @@ -0,0 +1,22 @@ + 'Cart', 'nav.home' => 'Home']. + * Backed by LanguageLine's own forever-cache, so this is a cache hit after + * the first call for a given group+locale. + */ + public function group(string $group = self::DEFAULT_GROUP, ?string $locale = null): array + { + return LanguageLine::getTranslationsForGroup($locale ?? App::getLocale(), $group); + } +} diff --git a/src/Localization/TranslationService.php b/src/Localization/TranslationService.php new file mode 100644 index 0000000..b6bf271 --- /dev/null +++ b/src/Localization/TranslationService.php @@ -0,0 +1,57 @@ + $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationCreated($languageLine)); + + return $languageLine; + } + + public function update(LanguageLine $languageLine, string $group, string $key, array $text): LanguageLine + { + // Callers (e.g. Filament's EditRecord) may hand us a model instance + // already filled with the new form values in memory — refresh from the + // database first so $old reflects what's actually persisted, not what's + // about to be written. + $persisted = $languageLine->fresh(); + + $old = [ + 'group' => $persisted->group, + 'key' => $persisted->key, + 'text' => $persisted->text, + ]; + + $languageLine->update([ + 'group' => $group, + 'key' => $key, + 'text' => $text, + ]); + + Event::dispatch(new TranslationUpdated($languageLine, $old)); + + return $languageLine; + } + + public function delete(LanguageLine $languageLine): void + { + $languageLine->delete(); + + Event::dispatch(new TranslationDeleted($languageLine)); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index a1d3303..0574c40 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -4,15 +4,12 @@ namespace Modules\Core\Providers; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; -use Lunar\Models\Language; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\ExportCleanupCommand; use Modules\Core\Command\ExportCommand; use Modules\Core\Command\ImportCommand; use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\MigrateImportCommand; -use Modules\Core\Localization\LanguageCacheObserver; -use Modules\Core\Localization\LocaleMiddleware; class CoreServiceProvider extends ServiceProvider { @@ -27,9 +24,6 @@ class CoreServiceProvider extends ServiceProvider Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); - Language::observe(LanguageCacheObserver::class); - $this->publishes([ __DIR__ . '/../../config/core.php' => config_path('core.php'), __DIR__ . '/../../config/scout.php' => config_path('scout.php'), diff --git a/src/Providers/LocalizationServiceProvider.php b/src/Providers/LocalizationServiceProvider.php new file mode 100644 index 0000000..d2392eb --- /dev/null +++ b/src/Providers/LocalizationServiceProvider.php @@ -0,0 +1,39 @@ +app['router']->aliasMiddleware('locale', LocaleMiddleware::class); + Language::observe(LanguageCacheObserver::class); + + foreach ([TranslationCreated::class, TranslationUpdated::class, TranslationDeleted::class] as $event) { + Event::listen($event, FlushTranslationCache::class); + Event::listen($event, LogTranslationActivity::class); + } + + foreach ([LanguageCreated::class, LanguageUpdated::class, LanguageDeleted::class] as $event) { + Event::listen($event, FlushLanguageCache::class); + } + + Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class); + } +}