Compare commits

...
7 Commits
28 changed files with 1214 additions and 4 deletions
+22
View File
@@ -4,6 +4,28 @@ 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.4.0] - 2026-08-06
### Added
- **Locale-prefixed routing** (`Modules\Core\Localization\LocaleMiddleware`): a `locale` route-middleware alias, opt-in per shop (not pushed onto the `web` group globally, since admin/Livewire/webhook routes must not be locale-redirected). Reads the first URL segment against Lunar's own `languages` table, sets `App::setLocale()`, and redirects unprefixed/unknown-locale requests to a resolved locale (`Accept-Language` match → default language → first language). Every locale is prefixed, including the default (`/el/...`, `/en/...`), never a bare root — avoids the hreflang/duplicate-content ambiguity of a bare-root default locale.
- Language list cached with `Cache::rememberForever()`, invalidated via `Modules\Core\Localization\LanguageCacheObserver` dispatching `LanguageCreated`/`LanguageUpdated`/`LanguageDeleted` events (see below) rather than doing the work itself.
- **Language rename safety**: renaming a `Language::code` (e.g. `el` → `gr`) no longer strands existing translations. `MigrateTranslationsForRenamedLanguage` (listening on `LanguageUpdated`) migrates every affected `LanguageLine.text` key from the old code to the new one and flushes both codes' translation caches — closing a real data-loss gap where a rename would otherwise make existing `LanguageLine` translations permanently unreachable.
- **Storefront UI label translations**: pulled in `spatie/laravel-translation-loader` (self-registers via Composer package auto-discovery; its loader *extends* Laravel's file-based `FileLoader` and merges DB translations on top — existing Filament/Lunar vendor `lang/` strings are unaffected). Labels are looked up via Laravel's native `__('storefront.nav.cart')`, kept in its own `storefront` group so nothing collides with Lunar/Filament's own translation groups.
- `Modules\Core\Command\InstallLunarCommand` (overriding `lunar:install`) seeds a starter set of ~15 common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, English + Greek), idempotently guarded so it's safe on every boot.
- `Modules\Core\Localization\TranslationReader::group('storefront')` returns the whole reduced/cached label array for a locale (backed by `LanguageLine`'s own forever-cache) — for sharing to a view as `$labels` or `@json()`-ing to JS, on top of `__()` for single-key Blade lookups.
- **Admin UI**: `Modules\Core\Localization\Filament\Resources\LanguageLineResource` (registered in `CorePlugin`) lists/searches/filters `language_lines` and edits each row's `group`, `key`, and one text input per locale currently in `lunar_languages` — locale columns/inputs are generated dynamically from the language list, so a new language needs no resource changes.
- **Event-driven writes**: `Modules\Core\Localization\TranslationService` (`create`/`update`/`delete`) is the single write path for `LanguageLine` — the Filament resource's Create/Edit/Delete pages route through it rather than Filament's default direct-model writes. Dispatches `TranslationCreated`/`TranslationUpdated` (carries the full pre-update `{group, key, text}` snapshot, so a bare rename is tracked the same as a text edit)/`TranslationDeleted`, each handled by two listeners:
- `FlushTranslationCache` — closes a real gap in `LanguageLine`'s own self-invalidation, which only flushes locales/groups present *after* a save. Flushes the union of old and new group+locale combinations, so a locale removed from `text`, or a `group`/`key` rename, can't leave a stale cached array behind.
- `LogTranslationActivity` — audits every write via the existing `Modules\Core\Logging\ActivityLogService` (`lunar` activity log channel), same `created`/`updated`/`deleted` shape as every other domain write in this project. Properties are flattened with `Arr::dot()` before logging (`text.en`, `text.el` instead of a nested `text` object) since Filament's Activity resource renders `properties` with a flat `KeyValue` field that can't display nested arrays.
- `Modules\Core\Providers\LocalizationServiceProvider` — split out of the growing `CoreServiceProvider` (per this project's own "split when a provider does too much" convention) to own all locale/translation middleware, observer, and event-listener registration.
## [0.3.0] - 2026-07-12
### Added
- **Meilisearch product search**: pulled in `lunarphp/search` (Lunar's driver-agnostic search abstraction — `database`/`meilisearch`/`typesense` engines, selectable via Scout's own `SCOUT_DRIVER` config) and `lunarphp/meilisearch`, wiring Meilisearch in as the search engine for products.
- `Search\ProductIndexer` overrides Lunar's own indexer to strip HTML tags from string fields (e.g. `name_en`, `description_en`) before they reach the search index — Lunar's default indexer sends raw attribute HTML straight through, which pollutes relevance ranking and highlighting with markup.
- Meilisearch itself is treated as app-level infrastructure, not a `boboko-core` concern: the actual Meilisearch container, host port, and master key live in each consuming app's own `docker-compose.yml`/`.env` (e.g. `3dealer`), the same way Postgres and Valkey do — `boboko-core` only declares the PHP package dependency and the indexing code.
## [0.2.0] - 2026-07-10
### Added
+9 -4
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.2.0",
"version": "0.4.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -14,7 +14,10 @@
"laravel/framework": "^12.0",
"laravel/tinker": "^3.0",
"symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3"
"lunarphp/table-rate-shipping": "^1.3",
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -31,13 +34,15 @@
"providers": [
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider"
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider"
]
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
// 'users' => [
// 'filterableAttributes'=> ['id', 'name', 'email'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
'import_action' => env('TYPESENSE_IMPORT_ACTION', 'upsert'),
],
];
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('language_lines', function (Blueprint $table) {
$table->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');
}
};
+215
View File
@@ -0,0 +1,215 @@
# 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. 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*.
---
## 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.
---
## 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.
### 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 Modules\Core\Localization\TranslationService;
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);
```
`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.
+35
View File
@@ -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,
]);
}
}
}
+4
View File
@@ -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([
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageCreated
{
public function __construct(
public readonly Language $language,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageDeleted
{
public function __construct(
public readonly Language $language,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Localization\Events;
use Lunar\Models\Language;
class LanguageUpdated
{
/**
* @param array{code: string} $old Snapshot of watched attributes before the update.
*/
public function __construct(
public readonly Language $language,
public readonly array $old,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationCreated
{
public function __construct(
public readonly LanguageLine $languageLine,
) {}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationDeleted
{
public function __construct(
public readonly LanguageLine $languageLine,
) {}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Localization\Events;
use Spatie\TranslationLoader\LanguageLine;
class TranslationUpdated
{
/**
* @param array{group: string, key: string, text: array} $old Snapshot before the update.
*/
public function __construct(
public readonly LanguageLine $languageLine,
public readonly array $old,
) {}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Localization\Filament\Resources;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Lunar\Models\Language;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Spatie\TranslationLoader\LanguageLine;
class LanguageLineResource extends Resource
{
protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form
{
return $form->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<Forms\Components\Textarea>
*/
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<Tables\Columns\TextColumn>
*/
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');
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
class CreateLanguageLine extends CreateRecord
{
protected static string $resource = LanguageLineResource::class;
protected function handleRecordCreation(array $data): Model
{
return app(TranslationService::class)->create(
$data['group'],
$data['key'],
$data['text'] ?? [],
);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
{
protected static string $resource = LanguageLineResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->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'] ?? [],
);
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
class ListLanguageLines extends ListRecords
{
protected static string $resource = LanguageLineResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
class LanguageCacheObserver
{
public function created(Language $language): void
{
Event::dispatch(new LanguageCreated($language));
}
public function updated(Language $language): void
{
Event::dispatch(new LanguageUpdated($language, [
'code' => $language->getOriginal('code'),
]));
}
public function deleted(Language $language): void
{
Event::dispatch(new LanguageDeleted($language));
}
}
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\LocaleMiddleware;
class FlushLanguageCache
{
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Spatie\TranslationLoader\LanguageLine;
/**
* LanguageLine::boot() already flushes the cache for the current group's locales
* present after a save, but misses two cases on update: locales removed from
* `text` (e.g. dropping the "el" key leaves `{group}.el` stale), and a changed
* `group`/`key` (the old group's cached array never gets told a row left it).
* This listener flushes every group+locale combination touched by either the
* old or new state so nothing can remain stale.
*/
class FlushTranslationCache
{
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
$this->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));
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Arr;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Logging\ActivityLogService;
use Spatie\TranslationLoader\LanguageLine;
class LogTranslationActivity
{
public function __construct(
private readonly ActivityLogService $activityLog,
) {}
public function handle(TranslationCreated|TranslationUpdated|TranslationDeleted $event): void
{
$languageLine = $event->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,
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Core\Localization\Listeners;
use Illuminate\Support\Facades\Cache;
use Modules\Core\Localization\Events\LanguageUpdated;
use Spatie\TranslationLoader\LanguageLine;
/**
* A renamed Language::code (e.g. "el" -> "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));
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Modules\Core\Localization;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Language;
use Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware
{
private const CACHE_KEY = 'core.localization.languages';
public function handle(Request $request, Closure $next): Response
{
$languages = $this->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']),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
class TranslationReader
{
private const DEFAULT_GROUP = 'storefront';
/**
* All labels in a group for the given (or current) locale, keyed by their
* dot-notation key — e.g. ['nav.cart' => '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);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Localization;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Spatie\TranslationLoader\LanguageLine;
class TranslationService
{
public function create(string $group, string $key, array $text): LanguageLine
{
$languageLine = LanguageLine::create([
'group' => $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));
}
}
+1
View File
@@ -26,6 +26,7 @@ class CoreServiceProvider extends ServiceProvider
$this->publishes([
__DIR__ . '/../../config/core.php' => config_path('core.php'),
__DIR__ . '/../../config/scout.php' => config_path('scout.php'),
], 'core-config');
$this->publishes([
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Localization\LanguageCacheObserver;
use Modules\Core\Localization\Listeners\FlushLanguageCache;
use Modules\Core\Localization\Listeners\FlushTranslationCache;
use Modules\Core\Localization\Listeners\LogTranslationActivity;
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
use Modules\Core\Localization\LocaleMiddleware;
class LocalizationServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Model;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
/**
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
* the search index, which pollutes relevance ranking and highlighting with markup.
* Strip tags from string fields before they reach Meilisearch.
*/
class ProductIndexer extends BaseProductIndexer
{
public function toSearchableArray(Model $model): array
{
$data = parent::toSearchableArray($model);
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = trim(strip_tags($value));
}
}
return $data;
}
}