Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9e9daab0 | ||
|
|
751aff5939 | ||
|
|
09844ec2b5 | ||
|
|
885923380d | ||
|
|
235fdda4a7 | ||
|
|
c2eb9bd66a | ||
|
|
356fbd73c5 | ||
|
|
fa137c9a79 |
@@ -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/).
|
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
|
## [0.5.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -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.0",
|
"version": "0.5.4",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Modules\\Core\\": "src/"
|
"Modules\\Core\\": "src/"
|
||||||
|
|||||||
@@ -105,6 +105,33 @@ $language = $request->attributes->get('language'); // Lunar\Models\Language in
|
|||||||
|
|
||||||
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
|
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
|
||||||
|
|
||||||
|
### Shared view data — language switcher and `hreflang` tags
|
||||||
|
|
||||||
|
The middleware also shares two variables with every view, via `View::share()`, so a layout's
|
||||||
|
language switcher or `hreflang` tags don't have to recompute the language list themselves:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- current locale --}}
|
||||||
|
{{ $currentLocale }} {{-- e.g. "el" --}}
|
||||||
|
|
||||||
|
{{-- every OTHER configured language, each with its own URL for the current page --}}
|
||||||
|
@foreach ($altLocales as $altLocale)
|
||||||
|
<a href="{{ $altLocale['url'] }}" hreflang="{{ $altLocale['code'] }}">{{ $altLocale['name'] }}</a>
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
`$altLocales` is a **collection**, not a single value — deliberately, so it scales to any number
|
||||||
|
of configured languages rather than assuming exactly two. Each entry is a plain array:
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|---|---|
|
||||||
|
| `code` | The language's `Lunar\Models\Language::code` (e.g. `en`) |
|
||||||
|
| `name` | The language's display name |
|
||||||
|
| `url` | The **current route**, re-generated with that language's code — via `route($routeName, [...])` when the current request matched a named route, or a bare `/{code}` fallback otherwise |
|
||||||
|
|
||||||
|
A 3+ language store gets one `$altLocales` entry per additional language automatically — nothing
|
||||||
|
about this shape assumes or special-cases a two-language store.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Single-language shops
|
## Single-language shops
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ carry that full shape.
|
|||||||
```php
|
```php
|
||||||
use Modules\Core\Catalog\ProductFilters;
|
use Modules\Core\Catalog\ProductFilters;
|
||||||
use Modules\Core\Catalog\ProductService;
|
use Modules\Core\Catalog\ProductService;
|
||||||
|
use Modules\Core\Catalog\ProductSort;
|
||||||
|
|
||||||
$service = app(ProductService::class);
|
$service = app(ProductService::class);
|
||||||
|
|
||||||
@@ -37,6 +38,10 @@ $result = $service->list(
|
|||||||
page: 1,
|
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['data']; // array of Meilisearch documents (plain arrays, not models)
|
||||||
$result['meta']['total'];
|
$result['meta']['total'];
|
||||||
$result['meta']['per_page'];
|
$result['meta']['per_page'];
|
||||||
@@ -113,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
|
## 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`:
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ class ProductService
|
|||||||
/**
|
/**
|
||||||
* @return array{data: array<int, array>, meta: array}
|
* @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('')
|
$paginator = Product::search('')
|
||||||
->options([
|
->options($options)
|
||||||
'filter' => $this->buildFilter($filters),
|
|
||||||
])
|
|
||||||
->paginateRaw(perPage: $perPage, page: $page);
|
->paginateRaw(perPage: $perPage, page: $page);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,24 +62,36 @@ class LocaleMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shares the current/alternate locale (and the alternate's URL) with all
|
* Shares the current locale and every OTHER available locale (each with its
|
||||||
* views, so the header language switcher and layout hreflang tags don't
|
* own URL for the current page) with all views, so the header language
|
||||||
* have to recompute it.
|
* 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
|
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
|
||||||
{
|
{
|
||||||
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
|
|
||||||
$route = $request->route();
|
$route = $request->route();
|
||||||
$routeName = $route?->getName();
|
$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('currentLocale', $language->code);
|
||||||
View::share('altLocale', $altLanguage?->code);
|
View::share('altLocales', $altLocales);
|
||||||
View::share(
|
|
||||||
'altLocaleUrl',
|
|
||||||
$altLanguage && $routeName
|
|
||||||
? route($routeName, array_merge($route->parameters(), ['locale' => $altLanguage->code]))
|
|
||||||
: ($altLanguage ? url('/'.$altLanguage->code) : null),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
|
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||||||
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately
|
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately
|
||||||
* excluded, it's PII with no storefront use), including staff replies, plus an
|
* excluded, it's PII with no storefront use), including staff replies, plus an
|
||||||
* average rating
|
* 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\
|
* A review is created/edited independently of its product (Modules\Core\Providers\
|
||||||
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
||||||
@@ -49,6 +52,15 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
'collections',
|
'collections',
|
||||||
'price',
|
'price',
|
||||||
'slugs',
|
'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['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all();
|
||||||
$data['review_count'] = $reviews->count();
|
$data['review_count'] = $reviews->count();
|
||||||
$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()
|
||||||
|
->wherePivot('enabled', true)
|
||||||
|
->pluck('lunar_channels.id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user