Compare commits

..
9 Commits
37 changed files with 1649 additions and 22 deletions
+33
View File
@@ -4,6 +4,39 @@ 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
- **Product reviews** (`Modules\Core\Review`): a new `ProductReview` model + `product_reviews` table (plain, unprefixed — same convention as `import_mappings`), linked to Lunar's `Product` via a `Product::reviews()` macro (registered in `CorePlugin`, since `Lunar\Models\Product` is a vendor model and can't be edited directly).
- **JudgeMe CSV review importer** (`MigrateImport\JudgeMe\JudgeMeExportImporter`), wired into the existing `boboko:migrate:import --source=judgeme --type=export` command: reads a Judge.me review export, resolves each row's `product_handle` to a Lunar product via `Lunar\Models\Url`, and creates/updates `ProductReview` rows idempotently via `import_mappings` (`source=judgeme`, `source_type=review`, keyed on Judge.me's `metaobject_handle`). Rows with no matching product are skipped with a logged warning rather than failing the whole import.
- Review images (`picture_urls` in the CSV) are downloaded and stored as real media via Spatie MediaLibrary (`ProductReview::IMAGES_COLLECTION`), not just linked by URL — consistent with how product images are handled.
- **Admin UI**: a new "Reviews" sub-navigation page on the product edit screen (`Review\Pages\ManageProductReviews`, wired via `Review\Extensions\ProductResourceExtension`), listing rating/title/reviewer with View, Reply, and Delete actions. The Reply action lets staff write/edit a reply directly from the table, setting `replied_at`. The View modal shows full review detail (body, reviewer email, location, source, dates, reply, downloaded images).
### Fixed
- `Shopify\ShopifyExportImporter` never wrote a Lunar `Url` (slug) row for imported products, despite `docs/shopify-import.md` specifying it should — meaning no code outside the importer itself could resolve "which Lunar product has handle X" (only the importer's own private `import_mappings` bookkeeping could). It now creates/updates a default `Url` row (`slug` = Shopify handle) per product on every import, which the new JudgeMe review importer depends on for product resolution.
## [0.1.0] - 2026-07-09
### Added
+9 -4
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.1.2",
"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,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained(config('lunar.database.table_prefix').'products');
$table->string('title')->nullable();
$table->text('body')->nullable();
$table->unsignedTinyInteger('rating');
$table->timestamp('reviewed_at')->nullable();
$table->string('reviewer_name')->nullable();
$table->string('reviewer_email')->nullable();
$table->text('reply')->nullable();
$table->timestamp('replied_at')->nullable();
$table->string('location')->nullable();
$table->string('source')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('product_reviews');
}
};
@@ -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,
]);
}
}
}
+18 -17
View File
@@ -8,43 +8,44 @@ use Modules\Core\MigrateImport\RunMigrateImportJob;
class MigrateImportCommand extends Command
{
protected $signature = "boboko:migrate:import
protected $signature = 'boboko:migrate:import
{--source= : The source platform to import from}
{--type= : The import mechanism for the chosen source}
{--file= : Path to the export file (when type is export)}";
{--file= : Path to the export file (when type is export)}';
protected $description = "Migrate data (products, customers, etc.) from an external source";
protected $description = 'Migrate data (products, customers, etc.) from an external source';
// Supported sources. Only "shopify" is wired up for now.
// Supported sources. Only "shopify" and "judgeme" are wired up for now.
private const SOURCES = [
"shopify",
'shopify',
'judgeme',
// "woocommerce",
];
// Supported import types per source. Only "export" is wired up for now.
private const TYPES = [
"export",
'export',
// "api",
];
public function handle(): void
{
$source = $this->option("source")
?? $this->choice("Select a source:", self::SOURCES, 0);
$source = $this->option('source')
?? $this->choice('Select a source:', self::SOURCES, 0);
$type = $this->option("type")
?? $this->choice("Select an import type:", self::TYPES, 0);
$type = $this->option('type')
?? $this->choice('Select an import type:', self::TYPES, 0);
if ($type === "api") {
if ($type === 'api') {
$credentials = [
"api_key" => $this->secret("API key:"),
"api_secret" => $this->secret("API secret:"),
'api_key' => $this->secret('API key:'),
'api_secret' => $this->secret('API secret:'),
];
$filePath = null;
} else {
$importsPath = storage_path("app/private/imports");
$importsPath = storage_path('app/private/imports');
$filePath = $this->option("file")
$filePath = $this->option('file')
?? $this->resolveImportPath($importsPath, $this->ask("Path to the export file, relative to {$importsPath}:"));
while (blank($filePath) || ! is_file($filePath)) {
@@ -70,7 +71,7 @@ class MigrateImportCommand extends Command
RunMigrateImportJob::dispatch($spec);
$this->info("Import queued.");
$this->info('Import queued.');
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or
@@ -82,7 +83,7 @@ class MigrateImportCommand extends Command
return $answer;
}
$path = str_starts_with($answer, "/") ? $answer : "{$importsPath}/{$answer}";
$path = str_starts_with($answer, '/') ? $answer : "{$importsPath}/{$answer}";
if (is_dir($path)) {
$csv = collect(glob("{$path}/*.csv"))->first();
+15
View File
@@ -4,14 +4,20 @@ namespace Modules\Core;
use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
use Lunar\Admin\Support\Facades\LunarPanel;
use Lunar\Models\Product;
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;
class CorePlugin implements Plugin
{
@@ -27,12 +33,21 @@ 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([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
]);
Product::macro('reviews', function (): HasMany {
/** @var Product $this */
return $this->hasMany(ProductReview::class);
});
LunarStaff::addActivitylogExcept([
'otp_code',
'otp_expires_at',
@@ -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));
}
}
+3 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
class ImporterFactory
@@ -9,8 +10,9 @@ class ImporterFactory
public static function make(ImportSpec $spec): Importer
{
return match ([$spec->source, $spec->type]) {
["shopify", "export"] => new ShopifyExportImporter(),
['shopify', 'export'] => new ShopifyExportImporter,
// ["shopify", "api"] => new ShopifyApiImporter(),
['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException(
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
class JudgeMeCsvReader
{
/**
* @return array<int, array<string, string>>
*/
public function read(string $csvPath): array
{
$handle = fopen($csvPath, 'r');
if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}");
}
$headers = fgetcsv($handle);
$rows = [];
while (($row = fgetcsv($handle)) !== false) {
$rows[] = array_combine($headers, $row);
}
fclose($handle);
return $rows;
}
}
@@ -0,0 +1,105 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver;
use Modules\Core\MigrateImport\Models\ImportMapping;
use Modules\Core\Review\Models\ProductReview;
class JudgeMeExportImporter implements Importer
{
private const SOURCE = 'judgeme';
public function __construct(
private readonly JudgeMeCsvReader $csvReader = new JudgeMeCsvReader,
private readonly ProductResolver $productResolver = new ProductResolver,
) {}
public function import(ImportSpec $spec): void
{
foreach ($this->csvReader->read($spec->filePath) as $row) {
$this->importReview($row);
}
}
private function importReview(array $row): void
{
$handle = trim((string) ($row['product_handle'] ?? ''));
$externalId = $row['metaobject_handle'] ?? '';
$product = $handle !== '' ? $this->productResolver->resolve($handle) : null;
if (! $product) {
Log::warning('JudgeMe import: no product found for handle, skipping review', [
'product_handle' => $handle,
'metaobject_handle' => $externalId,
]);
return;
}
$existing = ImportMapping::resolve(self::SOURCE, 'review', $externalId);
$review = $existing instanceof ProductReview ? $existing : new ProductReview;
$review->product_id = $product->id;
$review->title = trim((string) ($row['title'] ?? '')) ?: null;
$review->body = $row['body'] ?? null;
$review->rating = (int) ($row['rating'] ?? 0);
$review->reviewed_at = $this->parseDate($row['review_date'] ?? null);
$review->reviewer_name = $row['reviewer_name'] ?? null;
$review->reviewer_email = trim((string) ($row['reviewer_email'] ?? '')) ?: null;
$review->reply = trim((string) ($row['reply'] ?? '')) ?: null;
$review->replied_at = $this->parseDate($row['reply_date'] ?? null);
$review->location = trim((string) ($row['location'] ?? '')) ?: null;
$review->source = trim((string) ($row['source'] ?? '')) ?: null;
$review->save();
ImportMapping::record(self::SOURCE, 'review', $externalId, $review);
$pictureUrls = $this->parsePictureUrls($row['picture_urls'] ?? null);
if ($pictureUrls && $review->getMedia(ProductReview::IMAGES_COLLECTION)->isEmpty()) {
$this->importImages($review, $pictureUrls);
}
}
/**
* @param array<int, string> $urls
*/
private function importImages(ProductReview $review, array $urls): void
{
foreach ($urls as $url) {
try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id,
'url' => $url,
'error' => $e->getMessage(),
]);
}
}
}
private function parseDate(?string $value): ?Carbon
{
$value = trim((string) $value);
return $value !== '' ? Carbon::parse($value) : null;
}
private function parsePictureUrls(?string $value): ?array
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return array_map('trim', explode(',', $value));
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\MigrateImport\JudgeMe\Resolvers;
use Lunar\Models\Product;
use Lunar\Models\Url;
class ProductResolver
{
public function resolve(string $handle): ?Product
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
->first();
return $url?->element;
}
}
+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);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Review\Extensions;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Pages\ManageProductReviews;
class ProductResourceExtension extends ResourceExtension
{
public function extendPages(array $pages): array
{
$pages['reviews'] = ManageProductReviews::route('/{record}/reviews');
return $pages;
}
public function extendSubNavigation(array $pages): array
{
return [...$pages, ManageProductReviews::class];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class ProductReview extends Model implements HasMedia
{
use InteractsWithMedia;
public const IMAGES_COLLECTION = 'review-images';
protected $guarded = [];
protected $casts = [
'reviewed_at' => 'datetime',
'replied_at' => 'datetime',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection(self::IMAGES_COLLECTION);
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace Modules\Core\Review\Pages;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Support\Pages\BaseManageRelatedRecords;
use Modules\Core\Review\Models\ProductReview;
class ManageProductReviews extends BaseManageRelatedRecords
{
protected static string $resource = ProductResource::class;
protected static string $relationship = 'reviews';
public static function getNavigationIcon(): ?string
{
return 'heroicon-o-star';
}
public function getTitle(): string
{
return 'Reviews';
}
public static function getNavigationLabel(): string
{
return 'Reviews';
}
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('rating')
->label('Rating')
->disabled(),
TextInput::make('title')
->label('Title')
->disabled(),
Textarea::make('body')
->label('Body')
->disabled()
->columnSpanFull(),
Group::make([
TextInput::make('reviewer_name')
->label('Reviewer name')
->disabled(),
TextInput::make('reviewer_email')
->label('Reviewer email')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('location')
->label('Location')
->disabled(),
TextInput::make('source')
->label('Source')
->disabled(),
])->columns(2),
Group::make([
TextInput::make('reviewed_at')
->label('Reviewed at')
->disabled(),
TextInput::make('replied_at')
->label('Replied at')
->disabled(),
])->columns(2),
Textarea::make('reply')
->label('Reply')
->disabled()
->columnSpanFull(),
Placeholder::make('images')
->label('Images')
->content(function (?ProductReview $record) {
if (! $record) {
return null;
}
$media = $record->getMedia(ProductReview::IMAGES_COLLECTION);
if ($media->isEmpty()) {
return 'No images';
}
$tags = $media->map(fn ($item) => sprintf(
'<a href="%1$s" target="_blank"><img src="%1$s" style="height:6rem;width:6rem;object-fit:cover;border-radius:0.5rem;display:inline-block;margin:0.25rem" /></a>',
$item->getUrl(),
))->implode('');
return new HtmlString($tags);
})
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
TextColumn::make('rating')
->label('Rating')
->formatStateUsing(fn (int $state) => str_repeat('★', $state).str_repeat('☆', 5 - $state))
->sortable(),
TextColumn::make('title')
->label('Title')
->limit(40)
->searchable(),
TextColumn::make('reviewer_name')
->label('Reviewer')
->searchable(),
])
->defaultSort('reviewed_at', 'desc')
->filters([
//
])
->actions([
ViewAction::make(),
Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right')
->form([
Textarea::make('reply')
->label('Reply')
->required(),
])
->fillForm(fn (ProductReview $record) => ['reply' => $record->reply])
->action(function (ProductReview $record, array $data) {
$record->update([
'reply' => $data['reply'],
'replied_at' => Carbon::now(),
]);
}),
DeleteAction::make(),
])
->bulkActions([
DeleteBulkAction::make(),
]);
}
}
+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;
}
}