Compare commits

..
24 Commits
Author SHA1 Message Date
arvanitakis 63caaf55c7 Bump Version to 0.6.1 2026-08-27 11:39:43 +03:00
arvanitakis e4342da44a Fix: Correcting shape of indexed products 2026-08-27 11:32:20 +03:00
arvanitakis e95ea4a43c Fix: Updating Product Options Registry and adding handle to the meilisearch index 2026-08-27 11:14:56 +03:00
arvanitakis cb3fe095d9 Bump Version to 0.6.0 2026-08-27 01:57:43 +03:00
arvanitakis eef473dbd2 Feature: Product option types to support multiple cases 2026-08-27 01:55:58 +03:00
arvanitakis 409db9c7bf Fix: Correcting Language Line fallback resolver 2026-08-27 01:28:37 +03:00
arvanitakis 594fa41527 Feature: Localization Restructuring to follow a stricter pattern 2026-08-27 01:08:34 +03:00
arvanitakis ef356a6397 Feature: Products Restructuring to follow a strict rule 2026-08-27 01:05:04 +03:00
arvanitakis b3b5ca740d Feature: Resolving translated fields based on current locale, while also resturning a correct length aware paginator for the results 2026-08-27 00:48:38 +03:00
arvanitakis d01d27f7ea Hotfix: Correcting ProductResolver when importing Products 2026-08-27 00:46:43 +03:00
arvanitakis b86fe78852 Hotfix: Adding a correct media conversions to handle small review images 2026-08-27 00:34:39 +03:00
arvanitakis 0edf7b156d Feature: Updating Product Service to handle multiple locales, fetching the correct locale, or, the default locale as fallback 2026-08-26 23:52:33 +03:00
arvanitakis ef9e9daab0 Bump version to 0.5.4 2026-08-26 18:34:26 +03:00
arvanitakis 751aff5939 Feature: Adding Sorting To Product List 2026-08-26 18:05:15 +03:00
arvanitakis 09844ec2b5 Bump Version to 0.5.3 2026-08-26 14:29:29 +03:00
arvanitakis 885923380d Hotfix: Selcting correct table name for channel Id 2026-08-26 14:29:08 +03:00
arvanitakis 235fdda4a7 Bump Version to 0.5.2 2026-08-26 13:28:08 +03:00
arvanitakis c2eb9bd66a Hotfix: Updating LocaleMiddleware to handle more than 2 languages 2026-08-26 13:26:29 +03:00
arvanitakis 356fbd73c5 Bump Version to 0.5.1 2026-08-26 13:16:07 +03:00
arvanitakis fa137c9a79 Feature: Adding back ChannelIds on Product Indexer 2026-08-26 13:14:32 +03:00
arvanitakis e31de1b4e3 Bump version to 0.5.0 2026-08-24 14:47:59 +03:00
arvanitakis 113fa35da7 Hotfix: Adding a Back Button to Boboko's Login Form 2026-08-24 14:45:44 +03:00
arvanitakis 7c199cc3bd Feature: Updating Products Service and Locale MIddleware 2026-08-24 14:41:50 +03:00
arvanitakis 5cb6c529a0 Feature: Adding Product Service to Lunar
This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
2026-08-24 12:41:28 +03:00
25 changed files with 828 additions and 125 deletions
+77
View File
@@ -4,6 +4,83 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.6.1] - 2026-08-27
### Added
- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`.
- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
### Changed
- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas").
- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`.
## [0.6.0] - 2026-08-27
### Added
- `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language").
### Changed
- **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`).
- `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`.
- Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly.
### Fixed
- `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves.
- `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`.
### Breaking
- Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward:
- `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService`
- `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters`
- `Modules\Core\Catalog\ProductSort` → `Modules\Core\Product\Enums\ProductSort`
- `Modules\Core\Search\ProductIndexer` → `Modules\Core\Product\Services\ProductIndexer`
- `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService`
Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved.
- Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role:
- `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware`
- `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver`
- `Modules\Core\Localization\TranslationReader` → `Modules\Core\Localization\Services\TranslationReader`
- `Modules\Core\Localization\TranslationService` → `Modules\Core\Localization\Services\TranslationService`
`Modules\Core\Localization\Services\LanguageCache` (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The `'locale'` route-middleware alias (registered in `LocalizationServiceProvider`) is unaffected for consuming apps using it by string alias rather than FQCN.
## [0.5.4] - 2026-08-26
### Added
- `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting").
## [0.5.3] - 2026-08-26
### Fixed
- `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`.
## [0.5.2] - 2026-08-26
### Fixed
- `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags").
## [0.5.1] - 2026-08-25
### Added
- `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`).
## [0.5.0] - 2026-08-24
### Added
- **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.
- `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression.
- Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank.
- `Modules\Core\Search\ProductIndexer` expanded well beyond its original collection/price additions to carry everything a detail page needs: `id`/`slugs` (filterable — `getById()`/`getBySlug()` resolve purely from the index, no database read), `collection_names`, `tags`, the full media gallery, per-variant data (`sku`, `stock`, `purchasable`, translated option/value names + `meta` for swatches, per-currency prices, variant media), and reviews (`reviews`, `review_count`, `average_rating` — public-safe fields only, `reviewer_email` deliberately excluded).
- `Modules\Core\Providers\ReviewServiceProvider` (newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch the `Product` row and so never fires the product's own model events.
- **`Modules\Core\Search\ProductSearchService`**: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate from `ProductService`, which is for browsing/filtering without a query term.
- `docs/product-listing.md` and `docs/product-search.md` — usage, full field reference, and design notes for the two services above.
- `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list.
### Fixed
- The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only.
## [0.4.0] - 2026-08-06 ## [0.4.0] - 2026-08-06
### Added ### Added
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.4.0", "version": "0.6.1",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -35,7 +35,9 @@
"Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider" "Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
] ]
} }
}, },
+49 -4
View File
@@ -50,7 +50,7 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th
## Behavior ## Behavior
`Modules\Core\Localization\LocaleMiddleware`: `Modules\Core\Localization\Middleware\LocaleMiddleware`:
1. Reads the first path segment (`request()->segment(1)`). 1. Reads the first path segment (`request()->segment(1)`).
2. Matches it against `Lunar\Models\Language::code`. 2. Matches it against `Lunar\Models\Language::code`.
@@ -68,7 +68,7 @@ and invalidated automatically. Adding, editing, or removing a language via the F
### How invalidation is wired (event-driven, not the observer itself) ### How invalidation is wired (event-driven, not the observer itself)
`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s `Modules\Core\Localization\Observers\LanguageCacheObserver` observes `Lunar\Models\Language`'s
`created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any `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 invalidation work itself. It dispatches one of three events from
`Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old `Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old
@@ -105,6 +105,33 @@ $language = $request->attributes->get('language'); // Lunar\Models\Language in
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`). Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
### Shared view data — language switcher and `hreflang` tags
The middleware also shares two variables with every view, via `View::share()`, so a layout's
language switcher or `hreflang` tags don't have to recompute the language list themselves:
```blade
{{-- current locale --}}
{{ $currentLocale }} {{-- e.g. "el" --}}
{{-- every OTHER configured language, each with its own URL for the current page --}}
@foreach ($altLocales as $altLocale)
<a href="{{ $altLocale['url'] }}" hreflang="{{ $altLocale['code'] }}">{{ $altLocale['name'] }}</a>
@endforeach
```
`$altLocales` is a **collection**, not a single value — deliberately, so it scales to any number
of configured languages rather than assuming exactly two. Each entry is a plain array:
| Key | Description |
|---|---|
| `code` | The language's `Lunar\Models\Language::code` (e.g. `en`) |
| `name` | The language's display name |
| `url` | The **current route**, re-generated with that language's code — via `route($routeName, [...])` when the current request matched a named route, or a bare `/{code}` fallback otherwise |
A 3+ language store gets one `$altLocales` entry per additional language automatically — nothing
about this shape assumes or special-cases a two-language store.
--- ---
## Single-language shops ## Single-language shops
@@ -151,6 +178,24 @@ namespaced groups so nothing collides. `__()` resolves the translation for whate
`App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see `App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see
"Behavior" above) — no extra wiring needed between the two systems. "Behavior" above) — no extra wiring needed between the two systems.
### Fallback locale follows the store's default language, not `config('app.fallback_locale')`
`spatie/laravel-translation-loader`'s stock `LanguageLine::getTranslation()` falls back to
`config('app.fallback_locale')` — a static `.env` value — when a key has no text for the current
locale. That's a second, disconnected "default language" concept: an admin changing the default
language via the Filament **Languages** resource has no effect on it, so an untranslated label
could silently fall back to the wrong language.
`Modules\Core\Localization\Models\LanguageLine` overrides `getTranslation()` to fall back to
`LanguageCache::defaultLocale()` instead — the same `languages.default` flag `LocaleMiddleware`
already treats as the single source of truth. It's swapped in via
`config('translation-loader.model')` (the package's own documented extension point for
"any model that extends `LanguageLine`"), set in `LocalizationServiceProvider::register()` so it
wins regardless of provider boot order (Laravel's `mergeConfigFrom()` only fills in config keys
not already set, so an explicit `register()`-time set always beats the package's own default).
No consuming app configuration needed — this is automatic once `LocalizationServiceProvider` is
registered.
### Seeding ### Seeding
A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`, A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`,
@@ -168,13 +213,13 @@ a third language automatically adds a third input, no resource changes needed.
### `TranslationService` — writes go through here, not the model directly ### `TranslationService` — writes go through here, not the model directly
`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and `Modules\Core\Localization\Services\TranslationService` wraps create/update/delete on `LanguageLine` and
dispatches a domain event after each write, following this project's standard event-driven 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 — pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention —
the same shape as `Modules\Core\Auth\Events\UserCreated`): the same shape as `Modules\Core\Auth\Events\UserCreated`):
```php ```php
use Modules\Core\Localization\TranslationService; use Modules\Core\Localization\Services\TranslationService;
app(TranslationService::class)->create('storefront', 'nav.wishlist', [ app(TranslationService::class)->create('storefront', 'nav.wishlist', [
'en' => 'Wishlist', 'en' => 'Wishlist',
+3
View File
@@ -1206,3 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. - **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. - **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. - **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`.
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
+206
View File
@@ -0,0 +1,206 @@
# Product Listing
`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
Meilisearch index rather than the database. One data source for everything this service does.
This is separate from `Modules\Core\Product\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
---
## Why it reads from the index, not the database
Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's
`->get()`, which would re-hydrate Eloquent models from the database. This means the index has to
carry everything a detail page needs (variants, prices, options, media, reviews — see below), not
just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to
carry that full shape.
---
## Usage
```php
use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Product\Services\ProductService;
use Modules\Core\Product\Enums\ProductSort;
$service = app(ProductService::class);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, and/or price range
$products = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
perPage: 24,
page: 1,
);
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total();
$products->perPage();
$products->currentPage();
$products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
---
## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer`
Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description,
status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as
filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService`
needs, listing and detail alike:
| Field | Source | Notes |
|---|---|---|
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see
"Locale resolution" below for how `ProductService` resolves them down to one value per request.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
`attribute_data` — Lunar's `translateAttribute('name')` silently returns `null` for them. The
indexer's `translatedName()` reads the array directly instead. See `docs/lunar.md` "Gotchas".
---
## Locale resolution: `name`, `description`, and any other translated attribute
Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}`
field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same
for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw
document in Meilisearch carries all of them side by side, since a document is written once but
read across many different-locale requests.
`ProductService` resolves these back down to a single value per request. For every result it
returns (`list()`'s items, `getById()`, `getBySlug()`), it:
1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the
same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list,
so a store's own custom translated attributes are picked up automatically with no change here.
2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}`
(`LanguageCache::defaultLocale()`) if the current locale has no translation — e.g. a product with
no English copy yet still shows its Greek name on `/en/` rather than rendering blank.
3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** —
callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale
fields the index actually stores.
`description` and other translated attributes are otherwise indexed as-is, including any HTML
markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a
description sourced from `ProductService`'s results must treat it as trusted HTML.
---
## Reviews
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under
a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews,
`average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe
fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no
storefront use. `reply`/`replied_at` (the staff response) are included, since they're meant to be
shown alongside the review.
A review is created/edited independently of its product (a customer submission, a staff reply)
— its own save doesn't touch the `Product` row, so the product's own model events never fire.
`Modules\Core\Providers\ReviewServiceProvider` listens on `ProductReview`'s `created`/`updated`/
`deleted` events and calls `$review->product->searchable()`, so the parent product's document
stays current without waiting for the next full reindex. This provider must be registered in
`composer.json`'s `extra.laravel.providers` (already done in this repo) — see `docs/modules.md`
"Provider Registration Pitfalls" for what happens if a provider like this is ever added but not
registered.
---
## Multi-variant products and price
A product's `price` is its *cheapest* variant's price ("from €19.99" style), not every variant's
price. A price-range filter matches based on that single minimum — a product with one cheap
variant and several expensive ones will match a low-price-range filter even though most of its
variants don't.
---
## Sorting
`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders —
`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field
`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus
`created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new
`ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see
below) — sortable attributes are index settings, not computed per-query, same as filterable ones.
Omitting `sort` leaves Meilisearch's default ordering, which is meaningless here since `list()`
always searches with an empty query string (`Product::search('')`) — there's no relevance score to
rank by, so results come back in whatever order the index returns them absent an explicit sort.
---
## Registering the indexer
Not automatic — an app opts in via its own `config/lunar/search.php`:
```php
'indexers' => [
Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class,
// ...other model indexers unchanged
],
```
## Re-syncing after this change
Filterable attributes are Meilisearch index settings, not computed per-query — changing them
requires re-syncing settings and reindexing existing documents:
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
**If `SCOUT_QUEUE=true`, restart the queue worker after deploying an indexer change.** A running
`queue:work` process loads PHP classes once at boot and keeps that code in memory for its entire
lifetime — it does not pick up an edited/newly-deployed indexer class. Symptoms: reindexing
commands succeed with no errors, `Product::toSearchableArray()` returns the new fields correctly
when called directly (e.g. via `artisan tinker`, which always boots fresh), but documents written
via `$model->searchable()` through the live queue are still missing the new fields. Restarting the
queue worker (`docker compose restart queue`, or equivalent) resolves it — no code change needed.
---
## Meilisearch driver quirk: `paginateRaw()`'s `items()` is not a list of hits
For the Meilisearch engine specifically, Scout's `Builder::paginateRaw()` puts the **entire raw
response** (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`)
into the paginator's `items()`, not a plain array of documents. Calling `$paginator->items()`
and treating it as a list (e.g. `collect($paginator->items())->values()`) silently produces a
7-element array whose first element happens to be the real hits and the rest are stray scalars
from the other response keys — no error, just wrong data leaking into what looks like a normal
list. `ProductService::list()` pulls `$paginator->items()['hits']` explicitly to avoid this;
`$paginator->total()`/`perPage()`/`currentPage()`/`lastPage()` are unaffected and safe to use
as-is.
+113
View File
@@ -0,0 +1,113 @@
# Product Option Types
Lunar's `ProductOption`/`ProductOptionValue` are generic by design — a "Color" option
and a "Size" option are both just a handle, a translated name, and a list of values.
Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing in
Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex
code to a "Red" value without editing the database directly.
`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category
of option behaves — what structured data its values carry in `meta`, and how an
admin edits that data — without introducing a new model. `ProductOption`/
`ProductOptionValue` stay exactly as Lunar defines them.
---
## Registering a type
A shop registers a type class from its own service provider's `boot()`, the same
shape as `Modules\Core\Notification\NotificationRegistry`:
```php
use Modules\Core\Product\Services\ProductOptionTypeManager;
ProductOptionTypeManager::get()->register([
\App\ProductOptions\ColorOptionType::class,
]);
```
Not a published config array — the mapping isn't per-`ProductOption`, so there's
nothing for a shop to *key* by. Instead, an admin picks a type per-option from a
dropdown on the `ProductOption` edit form itself (see below); the choice is stored
in `ProductOption::meta['option_type']`, deliberately **not** tied to the option's
`handle` (a shop's own handle naming — transliterated Greek, legacy import slugs —
shouldn't have to match a type's key).
A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain
name/position, no extra meta form.
---
## Writing a type
```php
namespace App\ProductOptions;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
```
`getMetaForm()` returns Filament form components, keyed under `meta.*` dot notation
— the path they save to on `ProductOptionValue::meta` (cast as `AsArrayObject`, a
plain jsonb column). `getKey()` is the identifier used in the admin's "Option Type"
dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the
`ProductOption::handle`.
A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`,
registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop
setup needed for it to appear in the "Option Type" dropdown, though an admin still
has to pick it per-`ProductOption` for it to take effect.
---
## How it's wired into the admin UI
`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry:
- `get(): static` — the shared instance.
- `register(array $types): void` — registers one or more type classes, keyed
internally by `getKey()`.
- `unregister(string $key): void`
- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up a registered type
by key (or `null` if no key / not found).
- `all(): array<string, class-string>` — every registered type's class, keyed by
`getKey()`.
Two extensions hook into Lunar's admin via its extension system
(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's
classes needed:
- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends
`Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select`
(`meta.option_type`) listing every enabled type's key. Shown only when at least one
type is enabled.
- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends
the "Values" tab's form. Its `extendForm()` reads
`$option->meta['option_type']` off the owning `ProductOption`, resolves it via
`ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name
field. A `ProductOption` with no type selected gets the stock form unchanged.
---
## Reading the value back
Storefront code reads `ProductOptionValue::meta` like any other jsonb column — e.g.
`$value->meta['hex']` for a color swatch. `ProductOptionTypeManager` is an admin-side
concern only (describing *how to edit* the meta); nothing requires the storefront to
go through it to *read* the meta.
+81
View File
@@ -0,0 +1,81 @@
# Product Search
`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on
top of Laravel Scout + Meilisearch.
---
## Why locale-aware search isn't a filter
Lunar's Meilisearch indexer (`Lunar\Search\ScoutIndexer::mapSearchableAttributes()`) flattens
every translated attribute into **locale-suffixed fields on a single document** — a product with
a translated `name` produces `name_en`, `name_el`, etc. as separate top-level fields, not
separate documents per locale and not a filterable `locale` field.
That means "search in Greek" isn't a `->filter('locale = el')` — Meilisearch has no such field to
filter on. It's a choice of **which fields the query targets**: `name_el`/`description_el`
instead of `name_en`/`description_en`. This is what Meilisearch's `attributesToSearchOn` search
parameter controls, exposed through Scout via `Builder::options()`, which passes straight through
to the underlying Meilisearch client call (`Laravel\Scout\Engines\MeilisearchEngine::performSearch()`
merges `$builder->options` directly into the search request).
---
## Usage
```php
use Modules\Core\Product\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el');
```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
`->get()` hydrates real models from the database after the Meilisearch query, so relations
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
---
## Missing-translation fallback
If a product was only ever given an English name, `name_el` doesn't exist on that document at
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against `name_el` would make that product invisible to Greek-locale search, even though it's a
real catalog item.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
the resolved locale's fields **and** the default language's fields
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
A product missing an `el` translation still matches via its `en` fields.
---
## Field list is dynamic, not hardcoded
The set of attribute handles searched (`name`, `description`, or whatever else) comes from
`Lunar\Facades\AttributeManifest::getSearchableAttributes(Product::morphName())` — the same
source `ScoutIndexer` itself uses to decide what gets indexed. If an admin marks a new attribute
searchable in the panel, `ProductSearchService` picks it up automatically; nothing in this class
needs to change.
---
## Re-syncing after indexer changes
Changing which attributes are searchable, or `ProductIndexer`'s filterable/sortable fields,
requires re-syncing Meilisearch's index settings and re-indexing existing documents:
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
`ProductSearchService` itself needs no re-sync when locales change — `attributesToSearchOn` is
computed per-query from the live language list, not baked into index settings.
@@ -46,6 +46,10 @@
<x-filament::button type="submit" class="w-full"> <x-filament::button type="submit" class="w-full">
Sign in Sign in
</x-filament::button> </x-filament::button>
<x-filament::link wire:click="back" tag="button" type="button" class="mx-auto">
&larr; Back
</x-filament::link>
</div> </div>
</form> </form>
@endif @endif
+6
View File
@@ -35,6 +35,12 @@ class Login extends SimplePage
} }
} }
public function back(): void
{
$this->otpSent = false;
$this->otp = '';
}
public function requestOtp(): void public function requestOtp(): void
{ {
$this->validate(['email' => 'required|email']); $this->validate(['email' => 'required|email']);
+6
View File
@@ -6,6 +6,8 @@ use Filament\Contracts\Plugin;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\ProductResource; use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource; use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff; use Lunar\Admin\Models\Staff as LunarStaff;
@@ -16,6 +18,8 @@ use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
@@ -41,6 +45,8 @@ class CorePlugin implements Plugin
LunarPanel::extensions([ LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class, StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class, ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
]); ]);
Product::macro('reviews', function (): HasMany { Product::macro('reviews', function (): HasMany {
@@ -5,7 +5,7 @@ namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Page
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService; use Modules\Core\Localization\Services\TranslationService;
class CreateLanguageLine extends CreateRecord class CreateLanguageLine extends CreateRecord
{ {
@@ -7,7 +7,7 @@ use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService; use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine; use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord class EditLanguageLine extends EditRecord
@@ -5,12 +5,14 @@ namespace Modules\Core\Localization\Listeners;
use Modules\Core\Localization\Events\LanguageCreated; use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted; use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\LocaleMiddleware; use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache class FlushLanguageCache
{ {
public function __construct(private readonly LanguageCache $languages) {}
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{ {
LocaleMiddleware::forgetLanguagesCache(); $this->languages->forget();
} }
} }
-78
View File
@@ -1,78 +0,0 @@
<?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']),
);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Modules\Core\Localization\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Lunar\Models\Language;
use Modules\Core\Localization\Services\LanguageCache;
use Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware
{
public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response
{
$languages = $this->languages->all();
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);
// Lets route() calls omit {locale} anywhere in the request lifecycle
// (controllers, views) — without this, every route() call would need
// locale passed explicitly every time.
URL::defaults(['locale' => $language->code]);
$this->shareLocaleViewData($request, $language, $languages);
return $next($request);
}
/**
* Shares the current locale and every OTHER available locale (each with its
* own URL for the current page) with all views, so the header language
* switcher and layout hreflang tags don't have to recompute it.
*
* `altLocales` is a collection, not a single value — firstWhere('code', '!=',
* ...) would only ever surface one alternate, which happens to look correct
* with exactly 2 configured languages (there's only one "other" to find) but
* silently drops every locale past the first for a 3+ language store, with no
* error, just fewer switcher options than actually configured. A view iterates
* `$altLocales` to render as many links/dropdown entries as there are
* alternates, whether that's 1 or 10.
*/
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
{
$route = $request->route();
$routeName = $route?->getName();
$altLocales = $languages
->reject(fn (Language $other) => $other->code === $language->code)
->map(fn (Language $other) => [
'code' => $other->code,
'name' => $other->name,
'url' => $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $other->code]))
: url('/'.$other->code),
])
->values();
View::share('currentLocale', $language->code);
View::share('altLocales', $altLocales);
}
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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Localization\Models;
use Modules\Core\Localization\Services\LanguageCache;
use Spatie\TranslationLoader\LanguageLine as BaseLanguageLine;
/**
* Overrides the base package's locale fallback (config('app.fallback_locale'), a
* static .env value) with the store's actual default language — Lunar's
* `languages.default` flag, the same source LocaleMiddleware/LanguageCache already
* treat as the single source of truth for "this store's default language".
*
* Without this, changing the default language via the Filament Languages resource
* has no effect on which locale an untranslated storefront label falls back to —
* two disconnected "default locale" concepts silently drifting apart. Swapped in
* via config('translation-loader.model') (see LocalizationServiceProvider), the
* package's own documented extension point for this.
*/
class LanguageLine extends BaseLanguageLine
{
public function getTranslation(string $locale): ?string
{
if (isset($this->text[$locale])) {
return $this->text[$locale];
}
$fallback = app(LanguageCache::class)->defaultLocale();
return $fallback !== null ? ($this->text[$fallback] ?? null) : null;
}
}
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Localization; namespace Modules\Core\Localization\Observers;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Models\Language; use Lunar\Models\Language;
@@ -0,0 +1,58 @@
<?php
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Language;
/**
* Cached read layer over Lunar's `languages` table — the single source both
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Product\Services\ProductService) read
* from, so the language list is fetched once per cache lifetime rather than once
* per caller. Cached forever, invalidated via forget() by
* Modules\Core\Localization\Listeners\FlushLanguageCache on
* LanguageCreated/LanguageUpdated/LanguageDeleted.
*/
class LanguageCache
{
private const CACHE_KEY = 'core.localization.languages';
public function all(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code should use, as opposed to config('app.locale') which
* App::setLocale() mutates per request and so can't serve as a stable
* fallback.
*/
public function defaultLocale(): ?string
{
return $this->all()->firstWhere('default', true)?->code;
}
/**
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs
* to enumerate all locales a TranslatedText attribute was indexed under (see
* Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes.
*
* @return array<int, string>
*/
public function availableLocales(): array
{
return $this->all()->pluck('code')->all();
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
}
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Localization; namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine; use Spatie\TranslationLoader\LanguageLine;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Localization; namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
@@ -7,13 +7,23 @@ use Lunar\Models\Url;
class ProductResolver class ProductResolver
{ {
/**
* A slug can have more than one `lunar_urls` row pointing at it across import
* batches — e.g. a product soft-deleted and re-imported leaves its old URL row
* behind, still matching the same slug. Picking "whichever Url row matches
* first" (as a plain Url::where('slug', ...)->first() would) can resolve to a
* soft-deleted product, silently failing every downstream write for that
* product (e.g. JudgeMeExportImporter logging "no product found" for a handle
* that, in isolation, clearly exists). Join against `lunar_products` directly
* so only a URL pointing at a live (non-deleted) product resolves.
*/
public function resolve(string $handle): ?Product public function resolve(string $handle): ?Product
{ {
$url = Url::query() return Product::query()
->where('slug', $handle) ->join('lunar_urls', 'lunar_urls.element_id', '=', 'lunar_products.id')
->where('element_type', (new Product)->getMorphClass()) ->where('lunar_urls.slug', $handle)
->where('lunar_urls.element_type', (new Product)->getMorphClass())
->select('lunar_products.*')
->first(); ->first();
return $url?->element;
} }
} }
+13 -2
View File
@@ -11,15 +11,26 @@ use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Events\TranslationCreated; use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted; use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated; use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Localization\LanguageCacheObserver;
use Modules\Core\Localization\Listeners\FlushLanguageCache; use Modules\Core\Localization\Listeners\FlushLanguageCache;
use Modules\Core\Localization\Listeners\FlushTranslationCache; use Modules\Core\Localization\Listeners\FlushTranslationCache;
use Modules\Core\Localization\Listeners\LogTranslationActivity; use Modules\Core\Localization\Listeners\LogTranslationActivity;
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage; use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
use Modules\Core\Localization\LocaleMiddleware; use Modules\Core\Localization\Middleware\LocaleMiddleware;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Observers\LanguageCacheObserver;
class LocalizationServiceProvider extends ServiceProvider class LocalizationServiceProvider extends ServiceProvider
{ {
public function register(): void
{
// Must run before Spatie\TranslationLoader\TranslationServiceProvider's
// register() merges its own config defaults - mergeConfigFrom() only fills
// in keys not already set, so setting this here (regardless of provider
// boot order) makes it win over the package's default
// Spatie\TranslationLoader\LanguageLine::class.
config(['translation-loader.model' => LanguageLine::class]);
}
public function boot(): void public function boot(): void
{ {
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class); $this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Review\Models\ProductReview;
/**
* Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply),
* so the product's own save/update events never fire for it — without this listener,
* Modules\Core\Product\Services\ProductIndexer's review data would only refresh on
* the next full product reindex.
*/
class ReviewServiceProvider extends ServiceProvider
{
public function boot(): void
{
ProductReview::created(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::updated(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::deleted(fn (ProductReview $review) => $review->product?->searchable());
}
}
+22
View File
@@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product; use Lunar\Models\Product;
use Spatie\Image\Enums\BorderType;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductReview extends Model implements HasMedia class ProductReview extends Model implements HasMedia
{ {
@@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia
{ {
$this->addMediaCollection(self::IMAGES_COLLECTION); $this->addMediaCollection(self::IMAGES_COLLECTION);
} }
/**
* Unlike Product/ProductVariant, this model sits outside Lunar's own
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
* what registers the 'small' conversion those models get automatically. Without
* this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
* Exceptions\InvalidConversion the first time a review has an image, since
* $media->getUrl('small') has no matching conversion to resolve.
*/
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('small')
->fit(Fit::Fill, 300, 300)
->border(0, BorderType::Overlay, color: '#FFF')
->background('#FFF')
->sharpen(10)
->keepOriginalImageFormat();
}
} }
-27
View File
@@ -1,27 +0,0 @@
<?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;
}
}