Compare commits

..
10 Commits
25 changed files with 446 additions and 209 deletions
+10
View File
@@ -4,6 +4,16 @@ 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.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 ## [0.5.2] - 2026-08-26
### Fixed ### Fixed
+1 -1
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.5.2", "version": "0.5.4",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+4 -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
@@ -195,13 +195,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',
+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. - **`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\Catalog\ProductService` / `docs/product-listing.md`. - **`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\Search\ProductIndexer::translatedName()`. - **`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. - **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 # 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 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. 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. 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 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 `->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 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. carry that full shape.
--- ---
@@ -22,26 +22,34 @@ carry that full shape.
## Usage ## Usage
```php ```php
use Modules\Core\Catalog\ProductFilters; use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Catalog\ProductService; use Modules\Core\Product\Services\ProductService;
use Modules\Core\Product\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
// List everything, paginated // List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
$result = $service->list(perPage: 24, page: 1); // 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 // 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), filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
perPage: 24, perPage: 24,
page: 1, page: 1,
); );
$result['data']; // array of Meilisearch documents (plain arrays, not models) // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
$result['meta']['total']; // relevance ordering (irrelevant here since the query is always empty).
$result['meta']['per_page']; $products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$result['meta']['current_page'];
$result['meta']['last_page']; $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 // Single product, by primary key
$product = $service->getById(367); // array, or null if not found $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, 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 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: needs, listing and detail alike:
| Field | Source | Notes | | 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). | | `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. | | `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 `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see
(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description "Locale resolution" below for how `ProductService` resolves them down to one value per request.
sourced from `ProductService`'s results must treat it as trusted HTML.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike **`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not `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 ## Reviews
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as `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 ## Registering the indexer
Not automatic — an app opts in via its own `config/lunar/search.php`: Not automatic — an app opts in via its own `config/lunar/search.php`:
```php ```php
'indexers' => [ 'indexers' => [
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class,
// ...other model indexers unchanged // ...other model indexers unchanged
], ],
``` ```
+3 -3
View File
@@ -1,6 +1,6 @@
# Product Search # 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. top of Laravel Scout + Meilisearch.
--- ---
@@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request).
## Usage ## Usage
```php ```php
use Modules\Core\Search\ProductSearchService; use Modules\Core\Product\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes'); $results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale(): // 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. (`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by `$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. 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 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();
} }
} }
@@ -1,24 +1,24 @@
<?php <?php
namespace Modules\Core\Localization; namespace Modules\Core\Localization\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Lunar\Models\Language; use Lunar\Models\Language;
use Modules\Core\Localization\Services\LanguageCache;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware class LocaleMiddleware
{ {
private const CACHE_KEY = 'core.localization.languages'; public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$languages = $this->availableLanguages(); $languages = $this->languages->all();
if ($languages->isEmpty()) { if ($languages->isEmpty()) {
return $next($request); return $next($request);
@@ -45,22 +45,6 @@ class LocaleMiddleware
return $next($request); 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 locale and every OTHER available locale (each with its * 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 * own URL for the current page) with all views, so the header language
@@ -120,12 +104,4 @@ class LocaleMiddleware
return $languages->firstWhere('default', true)?->code return $languages->firstWhere('default', true)?->code
?? $languages->first()->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 <?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;
} }
} }
@@ -1,13 +1,13 @@
<?php <?php
namespace Modules\Core\Catalog; namespace Modules\Core\Product\DTOs;
/** /**
* Filter input for ProductService::list(). All fields are optional — omitted * Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched * filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer's document fields, so filtering * against Modules\Core\Product\Services\ProductIndexer's document fields, so
* only works on stores where that indexer is registered and the index has * filtering only works on stores where that indexer is registered and the index
* been re-synced (see docs/product-listing.md). * has been re-synced (see docs/product-listing.md).
*/ */
class ProductFilters 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 <?php
namespace Modules\Core\Search; namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -13,9 +13,9 @@ use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
/** /**
* Extends Lunar's own indexer so Modules\Core\Catalog\ProductService can serve both * Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can
* listing/filtering AND single-product lookups from Meilisearch alone — one data * serve both listing/filtering AND single-product lookups from Meilisearch alone —
* source, no separate database read path for a product detail page. Adds: * one data source, no separate database read path for a product detail page. Adds:
* - collections (ids, filterable) and collection_names (display) * - collections (ids, filterable) and collection_names (display)
* - slugs (every locale's Url::slug for the product, filterable) — lets * - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with * ProductService::getBySlug() resolve a product from the index directly, with
@@ -56,6 +56,14 @@ class ProductIndexer extends BaseProductIndexer
]; ];
} }
public function getSortableFields(): array
{
return [
...parent::getSortableFields(),
'price',
];
}
public function makeAllSearchableUsing(Builder $query): Builder public function makeAllSearchableUsing(Builder $query): Builder
{ {
return parent::makeAllSearchableUsing($query)->with([ return parent::makeAllSearchableUsing($query)->with([
@@ -89,7 +97,7 @@ class ProductIndexer extends BaseProductIndexer
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); $data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1);
$data['channel_ids'] = $model->channels() $data['channel_ids'] = $model->channels()
->wherePivot('enabled', true) ->wherePivot('enabled', true)
->pluck('id') ->pluck('lunar_channels.id')
->toArray(); ->toArray();
return $data; return $data;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Search; namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App; 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\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\Observers\LanguageCacheObserver;
class LocalizationServiceProvider extends ServiceProvider 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 * Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply), * 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, * 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 * Modules\Core\Product\Services\ProductIndexer's review data would only refresh on
* full product reindex. * the next full product reindex.
*/ */
class ReviewServiceProvider extends ServiceProvider 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\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();
}
} }