Compare commits

..
14 Commits
Author SHA1 Message Date
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
25 changed files with 513 additions and 219 deletions
+20
View File
@@ -4,6 +4,26 @@ 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.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
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.5.0",
"version": "0.5.4",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+31 -4
View File
@@ -50,7 +50,7 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th
## Behavior
`Modules\Core\Localization\LocaleMiddleware`:
`Modules\Core\Localization\Middleware\LocaleMiddleware`:
1. Reads the first path segment (`request()->segment(1)`).
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)
`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
invalidation work itself. It dispatches one of three events from
`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', ...)`).
### 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
@@ -168,13 +195,13 @@ 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
`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
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;
use Modules\Core\Localization\Services\TranslationService;
app(TranslationService::class)->create('storefront', 'nav.wishlist', [
'en' => 'Wishlist',
+2 -2
View File
@@ -1206,6 +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.
- **`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.
- **`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\Catalog\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\Search\ProductIndexer::translatedName()`.
- **`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.
+68 -19
View File
@@ -1,10 +1,10 @@
# Product Listing
`Modules\Core\Catalog\ProductService` provides catalog browsing/filtering AND single-product
`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\Search\ProductSearchService` (see `product-search.md`), which
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.
---
@@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without
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\Search\ProductIndexer` is built to
just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to
carry that full shape.
---
@@ -22,26 +22,34 @@ carry that full shape.
## Usage
```php
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
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
$result = $service->list(perPage: 24, page: 1);
// 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
$result = $service->list(
$products = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
perPage: 24,
page: 1,
);
$result['data']; // array of Meilisearch documents (plain arrays, not models)
$result['meta']['total'];
$result['meta']['per_page'];
$result['meta']['current_page'];
$result['meta']['last_page'];
// 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
@@ -54,11 +62,11 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
---
## Fields this depends on: `Modules\Core\Search\ProductIndexer`
## 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\Search\ProductIndexer` extends it to add everything `ProductService`
filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService`
needs, listing and detail alike:
| Field | Source | Notes |
@@ -74,9 +82,8 @@ needs, listing and detail alike:
| `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`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. |
`description` and other translated attributes are 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.
`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
@@ -85,6 +92,33 @@ indexer's `translatedName()` reads the array directly instead. See `docs/lunar.m
---
## 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 as
@@ -113,13 +147,28 @@ 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\Search\ProductIndexer::class,
Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class,
// ...other model indexers unchanged
],
```
+3 -3
View File
@@ -1,6 +1,6 @@
# Product Search
`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on
`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on
top of Laravel Scout + Meilisearch.
---
@@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request).
## Usage
```php
use Modules\Core\Search\ProductSearchService;
use Modules\Core\Product\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
@@ -36,7 +36,7 @@ Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product`
(`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\LocaleMiddleware` (see `localization.md`), so callers in controllers
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
---
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Catalog;
use Filament\Forms\Components\Component;
/**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
* "Color", "Size", "Material") behaves — namely, what structured data its values
* carry in their free-form `meta` jsonb column, and how an admin edits that data.
*
* `ProductOption`/`ProductOptionValue` themselves stay exactly as Lunar defines
* them — this is not a new model, just a registry (ProductOptionTypeRegistry) that
* maps a `ProductOption::handle` to the type describing it, so adding a new kind of
* option (a new color-like or size-like concept) is a single new class, not scattered
* per-option special-casing across the admin UI or storefront.
*/
interface ProductOptionTypeInterface
{
/**
* Matches the ProductOption::handle this type describes (e.g. 'color', 'size').
*/
public static function getKey(): string;
/**
* Filament form components for editing a ProductOptionValue's `meta` under this
* option type — e.g. Color returns a color picker for `meta.hex`, Size returns a
* numeric input for `meta.sort_value`. Field names should be dot-notation under
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManager's form saves them.
*
* @return array<Component>
*/
public function getMetaForm(): array;
}
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Models\Product;
use Modules\Core\Localization\LocaleMiddleware;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Search\ProductIndexer) - one data source, no
* ->get() model hydration anywhere in this service. Callers get plain arrays of the
* indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
return [
'data' => collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Search\ProductIndexer). Returns the full indexed
* product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves the current-locale `name`/`description` from the indexer's
* per-locale `name_{locale}`/`description_{locale}` fields, falling back to
* the store's default language (Language::default, see
* LocaleMiddleware::defaultLocale()) when the current locale has no
* translation - e.g. a product with no English copy yet still shows its
* Greek name/description on /en/ rather than rendering blank.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = LocaleMiddleware::defaultLocale();
$product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null;
$product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null;
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginator $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
@@ -5,7 +5,7 @@ namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Page
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
class CreateLanguageLine extends CreateRecord
{
@@ -7,7 +7,7 @@ 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 Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
@@ -5,12 +5,14 @@ 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;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
{
public function __construct(private readonly LanguageCache $languages) {}
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
$this->languages->forget();
}
}
@@ -1,24 +1,24 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
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
{
private const CACHE_KEY = 'core.localization.languages';
public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response
{
$languages = $this->availableLanguages();
$languages = $this->languages->all();
if ($languages->isEmpty()) {
return $next($request);
@@ -45,41 +45,37 @@ class LocaleMiddleware
return $next($request);
}
public static function forgetLanguagesCache(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code (Modules\Core\Catalog\ProductService) should use, as
* opposed to config('app.locale') which App::setLocale() mutates per
* request and so can't serve as a stable fallback.
*/
public static function defaultLocale(): ?string
{
return (new self)->availableLanguages()->firstWhere('default', true)?->code;
}
/**
* Shares the current/alternate locale (and the alternate's URL) with all
* views, so the header language switcher and layout hreflang tags don't
* have to recompute it.
* 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
{
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
$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('altLocale', $altLanguage?->code);
View::share(
'altLocaleUrl',
$altLanguage && $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $altLanguage->code]))
: ($altLanguage ? url('/'.$altLanguage->code) : null),
);
View::share('altLocales', $altLocales);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
@@ -108,12 +104,4 @@ class LocaleMiddleware
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']),
);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Observers;
use Illuminate\Support\Facades\Event;
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
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
@@ -7,13 +7,23 @@ use Lunar\Models\Url;
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
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
return Product::query()
->join('lunar_urls', 'lunar_urls.element_id', '=', 'lunar_products.id')
->where('lunar_urls.slug', $handle)
->where('lunar_urls.element_type', (new Product)->getMorphClass())
->select('lunar_products.*')
->first();
return $url?->element;
}
}
@@ -1,13 +1,13 @@
<?php
namespace Modules\Core\Catalog;
namespace Modules\Core\Product\DTOs;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer's document fields, so filtering
* only works on stores where that indexer is registered and the index has
* been re-synced (see docs/product-listing.md).
* against Modules\Core\Product\Services\ProductIndexer's document fields, so
* filtering only works on stores where that indexer is registered and the index
* has been re-synced (see docs/product-listing.md).
*/
class ProductFilters
{
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Product\Enums;
/**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Product\Services\
* ProductIndexer (see its getSortableFields()). Adding a case here requires the
* matching field to also be sortable in the index, re-synced via
* `php artisan lunar:meilisearch:setup`.
*/
enum ProductSort: string
{
case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc';
case Newest = 'newest';
public function toMeilisearchSort(): string
{
return match ($this) {
self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc',
};
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Search;
namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -13,9 +13,9 @@ use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/**
* Extends Lunar's own indexer so Modules\Core\Catalog\ProductService can serve both
* listing/filtering AND single-product lookups from Meilisearch alone — one data
* source, no separate database read path for a product detail page. Adds:
* Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can
* serve both listing/filtering AND single-product lookups from Meilisearch alone —
* one data source, no separate database read path for a product detail page. Adds:
* - collections (ids, filterable) and collection_names (display)
* - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with
@@ -27,6 +27,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately
* excluded, it's PII with no storefront use), including staff replies, plus an
* average rating
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be
* scoped to products actually assigned+enabled on the current sales channel
*
* A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
@@ -49,6 +52,15 @@ class ProductIndexer extends BaseProductIndexer
'collections',
'price',
'slugs',
'channel_ids',
];
}
public function getSortableFields(): array
{
return [
...parent::getSortableFields(),
'price',
];
}
@@ -83,6 +95,10 @@ class ProductIndexer extends BaseProductIndexer
$data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all();
$data['review_count'] = $reviews->count();
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1);
$data['channel_ids'] = $model->channels()
->wherePivot('enabled', true)
->pluck('lunar_channels.id')
->toArray();
return $data;
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Search;
namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace Modules\Core\Product\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Product\Enums\ProductSort;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Product\Services\ProductIndexer) - one data
* source, no ->get() model hydration anywhere in this service. Callers get plain arrays
* of the indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Product\Services\
* ProductSearchService; this service is for browsing/filtering without a search term.
*/
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
* see "Meilisearch driver quirk" below) so a controller/view gets normal
* pagination behaviour ($products->links(), JSON serialization, etc.)
* without ever touching the raw Meilisearch response directly.
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
{
$options = ['filter' => $this->buildFilter($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search('')
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all();
return new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Product\Services\ProductIndexer). Returns the full
* indexed product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when 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.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
@@ -11,12 +11,12 @@ 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;
use Modules\Core\Localization\Middleware\LocaleMiddleware;
use Modules\Core\Localization\Observers\LanguageCacheObserver;
class LocalizationServiceProvider extends ServiceProvider
{
+2 -2
View File
@@ -9,8 +9,8 @@ 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\Search\ProductIndexer's review data would only refresh on the next
* full product reindex.
* Modules\Core\Product\Services\ProductIndexer's review data would only refresh on
* the next full product reindex.
*/
class ReviewServiceProvider extends ServiceProvider
{
+22
View File
@@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\Image\Enums\BorderType;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductReview extends Model implements HasMedia
{
@@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia
{
$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();
}
}