Compare commits

..
11 Commits
12 changed files with 472 additions and 44 deletions
+35
View File
@@ -4,6 +4,41 @@ 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
- **`Modules\Core\Catalog\ProductService`**: storefront product listing/filtering (`list()`) and single-product lookup (`getById()`, `getBySlug()`), reading directly from the Meilisearch index rather than the database — one data source, no `->get()` model hydration. Returns plain arrays (not Eloquent models), meant to be called directly from a consuming app's controllers.
- `ProductFilters` DTO: optional `collectionId`, `brand`, `minPrice`, `maxPrice`, translated into a Meilisearch `filter` expression.
- Listing results are locale-aware: `withLocalizedFields()` resolves `name`/`description` from the indexer's per-locale fields, falling back to the store's default language (via `LocaleMiddleware::defaultLocale()`) when the current locale has no translation yet, instead of rendering blank.
- `Modules\Core\Search\ProductIndexer` expanded well beyond its original collection/price additions to carry everything a detail page needs: `id`/`slugs` (filterable — `getById()`/`getBySlug()` resolve purely from the index, no database read), `collection_names`, `tags`, the full media gallery, per-variant data (`sku`, `stock`, `purchasable`, translated option/value names + `meta` for swatches, per-currency prices, variant media), and reviews (`reviews`, `review_count`, `average_rating` — public-safe fields only, `reviewer_email` deliberately excluded).
- `Modules\Core\Providers\ReviewServiceProvider` (newly registered): re-indexes a product whenever one of its reviews is created/updated/deleted, since a review write doesn't touch the `Product` row and so never fires the product's own model events.
- **`Modules\Core\Search\ProductSearchService`**: locale-aware full-text product search on top of the same Meilisearch index, for use by a storefront's search bar — separate from `ProductService`, which is for browsing/filtering without a query term.
- `docs/product-listing.md` and `docs/product-search.md` — usage, full field reference, and design notes for the two services above.
- `docs/lunar.md` "Gotchas": three new entries hit while building this — `ProductOption`/`ProductOptionValue::name` isn't `attribute_data` (so `translateAttribute()` silently returns `null` for it), a running `queue:work` process not picking up an edited Scout indexer class, and Scout's `paginateRaw()->items()` on the Meilisearch driver returning the whole raw response rather than a hit list.
### Fixed
- The admin login form (`Modules\Core\Auth\Filament\Pages\Login`) had no way back from the OTP-entry step to the email step short of reloading the page. A `back()` method resets to the email step; a "← Back" link/button is shown on the OTP step only.
## [0.4.0] - 2026-08-06
### Added
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.4.0",
"version": "0.5.4",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -35,7 +35,8 @@
"Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider"
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
]
}
},
+27
View File
@@ -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
+1
View File
@@ -1207,4 +1207,5 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`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()`.
- **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.
+72 -17
View File
@@ -1,24 +1,21 @@
# Product Listing
`Modules\Core\Catalog\ProductService` provides browsing/filtering of the product catalog
(list all, filter by collection/brand/price range) for a storefront, reading directly from the
Meilisearch index rather than the database.
`Modules\Core\Catalog\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
handles free-text query search. `ProductService` is for browsing without a search term.
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
---
## Why it reads from the index, not the database
`ProductService::list()` calls `Product::search('')->paginateRaw(...)` and returns the raw
Meilisearch hits directly — it never calls Scout's `->get()`, which would re-hydrate Eloquent
models from the database per result. This avoids an extra database round-trip on every listing
request, but it means **callers only get whatever fields are in the indexed document**, not the
full `Product` model or its relations.
A single-product detail view needs the full record (all attributes, media, variants, etc.) and
should look the product up directly via `Lunar\Models\Product`, not through `ProductService`.
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
carry that full shape.
---
@@ -27,6 +24,7 @@ should look the product up directly via `Lunar\Models\Product`, not through `Pro
```php
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
use Modules\Core\Catalog\ProductSort;
$service = app(ProductService::class);
@@ -40,11 +38,21 @@ $result = $service->list(
page: 1,
);
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$result = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$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'];
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
@@ -53,20 +61,52 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
## Fields this depends on: `Modules\Core\Search\ProductIndexer`
Lunar's own `Lunar\Search\ProductIndexer` doesn't index collection membership or a comparable
price, and only marks `__soft_deleted`, `skus`, `status` as filterable — none of what
`ProductFilters` needs. `Modules\Core\Search\ProductIndexer` extends it to add:
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`
needs, listing and detail alike:
| Field | Source | Notes |
|---|---|---|
| `collections` | `$product->collections->pluck('id')` | Array of collection IDs (as strings). Filtering matches by ID, not slug — the caller resolves whichever collection it means before calling `ProductService`. |
| `price` | Cheapest variant's base price | Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
| `collections` | `$product->collections->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. |
| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
| `reviews`, `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.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
`attribute_data` — Lunar's `translateAttribute('name')` silently returns `null` for them. The
indexer's `translatedName()` reads the array directly instead. See `docs/lunar.md` "Gotchas".
---
## Reviews
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as
`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the
product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately
excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are
included, since they're meant to be shown alongside the review.
A review is created/edited independently of its product (a customer submission, a staff reply)
— its own save doesn't touch the `Product` row, so the product's own model events never fire.
`Modules\Core\Providers\ReviewServiceProvider` listens on `ProductReview`'s `created`/`updated`/
`deleted` events and calls `$review->product->searchable()`, so the parent product's document
stays current without waiting for the next full reindex. This provider must be registered in
`composer.json`'s `extra.laravel.providers` (already done in this repo) — see `docs/modules.md`
"Provider Registration Pitfalls" for what happens if a provider like this is ever added but not
registered.
---
## Multi-variant products and price
@@ -78,6 +118,21 @@ variants don't.
---
## Sorting
`ProductSort` (`Modules\Core\Catalog\ProductSort`) is a fixed enum of supported sort orders —
`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field
`Modules\Core\Search\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`:
@@ -46,6 +46,10 @@
<x-filament::button type="submit" class="w-full">
Sign in
</x-filament::button>
<x-filament::link wire:click="back" tag="button" type="button" class="mx-auto">
&larr; Back
</x-filament::link>
</div>
</form>
@endif
+6
View File
@@ -35,6 +35,12 @@ class Login extends SimplePage
}
}
public function back(): void
{
$this->otpSent = false;
$this->otp = '';
}
public function requestOtp(): void
{
$this->validate(['email' => 'required|email']);
+83 -15
View File
@@ -2,15 +2,17 @@
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, reading directly from the Meilisearch index
* (Modules\Core\Search\ProductIndexer) rather than the database — no ->get() model
* hydration, so callers get plain arrays of the indexed document, not Eloquent models.
* Consumers needing the full record (e.g. a product detail page) should look the
* product up directly via Lunar's Product model instead.
* 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.
@@ -20,21 +22,22 @@ class ProductService
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): array
{
$options = ['filter' => $this->buildFilter($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
// 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.
$rawResponse = $paginator->items();
return [
'data' => collect($rawResponse['hits'] ?? [])->values()->all(),
'data' => collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
@@ -44,6 +47,71 @@ class ProductService
];
}
/**
* 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) {
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Catalog;
/**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Search\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',
};
}
}
+53
View File
@@ -7,6 +7,8 @@ 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 Symfony\Component\HttpFoundation\Response;
@@ -33,6 +35,13 @@ class LocaleMiddleware
$request->attributes->set('locale', $language->code);
$request->attributes->set('language', $language);
// Lets route() calls omit {locale} anywhere in the request lifecycle
// (controllers, views) — without this, every route() call would need
// locale passed explicitly every time.
URL::defaults(['locale' => $language->code]);
$this->shareLocaleViewData($request, $language, $languages);
return $next($request);
}
@@ -41,6 +50,50 @@ class LocaleMiddleware
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
* own URL for the current page) with all views, so the header language
* switcher and layout hreflang tags don't have to recompute it.
*
* `altLocales` is a collection, not a single value — firstWhere('code', '!=',
* ...) would only ever surface one alternate, which happens to look correct
* with exactly 2 configured languages (there's only one "other" to find) but
* silently drops every locale past the first for a 3+ language store, with no
* error, just fewer switcher options than actually configured. A view iterates
* `$altLocales` to render as many links/dropdown entries as there are
* alternates, whether that's 1 or 10.
*/
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
{
$route = $request->route();
$routeName = $route?->getName();
$altLocales = $languages
->reject(fn (Language $other) => $other->code === $language->code)
->map(fn (Language $other) => [
'code' => $other->code,
'name' => $other->name,
'url' => $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $other->code]))
: url('/'.$other->code),
])
->values();
View::share('currentLocale', $language->code);
View::share('altLocales', $altLocales);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
{
$locale = $this->negotiateLocale($request, $languages);
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Review\Models\ProductReview;
/**
* Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply),
* so the product's own save/update events never fire for it — without this listener,
* Modules\Core\Search\ProductIndexer's review data would only refresh on the next
* full product reindex.
*/
class ReviewServiceProvider extends ServiceProvider
{
public function boot(): void
{
ProductReview::created(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::updated(fn (ProductReview $review) => $review->product?->searchable());
ProductReview::deleted(fn (ProductReview $review) => $review->product?->searchable());
}
}
+140 -10
View File
@@ -5,15 +5,41 @@ namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/**
* Extends Lunar's own indexer to add fields needed for storefront listing/filtering
* (Modules\Core\Catalog\ProductService) that aren't part of Lunar's default document:
* collection membership and a comparable price. Neither is filterable in Meilisearch
* until `php artisan lunar:meilisearch:setup` re-syncs index settings and the index is
* refreshed — see docs/product-listing.md.
* 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:
* - 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
* no database read at all
* - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, stock, purchasable, option values, prices, media
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags
* - 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
* this data doesn't go stale between full reindexes.
*
* New fields aren't filterable in Meilisearch until `php artisan lunar:meilisearch:setup`
* re-syncs index settings, and existing documents need `lunar:search:index --refresh` to
* pick up the new shape — see docs/product-listing.md. If SCOUT_QUEUE is enabled, the
* queue worker also needs restarting after deploying changes to this class (see
* docs/lunar.md "Gotchas" — a running worker keeps stale indexer code in memory).
*/
class ProductIndexer extends BaseProductIndexer
{
@@ -21,15 +47,34 @@ class ProductIndexer extends BaseProductIndexer
{
return [
...parent::getFilterableFields(),
'id',
'brand',
'collections',
'price',
'slugs',
'channel_ids',
];
}
public function getSortableFields(): array
{
return [
...parent::getSortableFields(),
'price',
];
}
public function makeAllSearchableUsing(Builder $query): Builder
{
return parent::makeAllSearchableUsing($query)->with(['collections', 'variants.prices']);
return parent::makeAllSearchableUsing($query)->with([
'collections',
'media',
'tags',
'urls',
'variants.images',
'variants.prices',
'variants.values.option',
]);
}
public function toSearchableArray(Model $model): array
@@ -37,22 +82,107 @@ class ProductIndexer extends BaseProductIndexer
/** @var Product $model */
$data = parent::toSearchableArray($model);
$currency = Currency::getDefault();
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
$data['price'] = $this->cheapestPrice($model);
$data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
$data['price'] = $this->cheapestPrice($model, $currency);
$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;
}
private function mapVariant(ProductVariant $variant, Currency $currency): array
{
return [
'id' => $variant->id,
'sku' => $variant->sku,
'stock' => $variant->stock,
'purchasable' => $variant->purchasable,
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'value' => $this->translatedName($value->name),
'meta' => $value->meta,
])->all(),
'prices' => $variant->prices->map(fn (Price $price) => [
'currency_id' => $price->currency_id,
'customer_group_id' => $price->customer_group_id,
'price' => $price->price->decimal(),
'compare_price' => $price->compare_price?->decimal(),
'min_quantity' => $price->min_quantity,
])->all(),
'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* Public-safe fields only — reviewer_email is PII with no storefront use and is
* deliberately excluded, unlike every other column on the review. reply/replied_at
* (the staff response) are included since they're meant to be shown alongside the
* review on the storefront.
*/
private function mapReview(ProductReview $review): array
{
return [
'id' => $review->id,
'title' => $review->title,
'body' => $review->body,
'rating' => $review->rating,
'reviewed_at' => $review->reviewed_at?->timestamp,
'reviewer_name' => $review->reviewer_name,
'reply' => $review->reply,
'replied_at' => $review->replied_at?->timestamp,
'location' => $review->location,
'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast
* (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is
* not stored in attribute_data. Lunar's translateAttribute() only reads
* attribute_data, so it silently returns null for these two models; this reads
* the array directly instead. Falls back to the first available locale if the
* current one is missing. Not a general replacement for translateAttribute() —
* every other translated field in this indexer (product/collection name and
* description) genuinely is attribute_data-backed and translateAttribute() is
* correct for those.
*/
private function translatedName(mixed $name): ?string
{
$names = is_array($name) ? $name : (array) $name;
return $names[app()->getLocale()] ?? reset($names) ?: null;
}
private function mapMedia(Media $media): array
{
return [
'id' => $media->id,
'url' => $media->getUrl(),
'thumb' => $media->getUrl('small'),
];
}
/**
* The cheapest variant's base price (no customer group) in the default currency,
* as a float in major units — e.g. 19.99, not 1999. Null if the product has no
* variant with a price in that currency yet, so it's excluded from price filters
* rather than sorting to the bottom as if it were free.
*/
private function cheapestPrice(Product $model): ?float
private function cheapestPrice(Product $model, Currency $currency): ?float
{
$currency = Currency::getDefault();
$price = $model->variants
->flatMap(fn ($variant) => $variant->prices)
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)