Feature: Reviews restructuring and Creating Collection Indexer and Services
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
# Collections
|
||||||
|
|
||||||
|
`Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND
|
||||||
|
single-collection lookup for a storefront — `list()`, `getById()`, `getBySlug()` —
|
||||||
|
all reading directly from the Meilisearch index, mirroring
|
||||||
|
`Modules\Core\Catalog\Services\ProductService` (see `product-listing.md`) exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why it reads from the index, not the database
|
||||||
|
|
||||||
|
Lunar's own `Lunar\Search\CollectionIndexer` only carries `id`/`name`/`created_at` —
|
||||||
|
nowhere near enough for a storefront category page or a nav tree.
|
||||||
|
`Modules\Core\Catalog\Services\CollectionIndexer` extends it to add everything
|
||||||
|
`CollectionService` needs:
|
||||||
|
|
||||||
|
| Field | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `parent_id` | `$model->parent_id` | Filterable. The nested-set tree's parent pointer — `null` for a top-level collection. |
|
||||||
|
| `_lft` | `$model->_lft` | Filterable and sortable. The nested-set tree position — lets `CollectionService` resolve tree order without a database read. |
|
||||||
|
| `collection_group_id` | `$model->collection_group_id` | Filterable. Mirrors `Collection::scopeInGroup()`. |
|
||||||
|
| `slugs` | `$model->urls->pluck('slug')` | Filterable. Every locale's `Url::slug`, so `getBySlug()` resolves purely from the index. |
|
||||||
|
| `thumbnail` | `$model->getThumbnailImage()` | Display only. `null` if the collection has no thumbnail image. |
|
||||||
|
|
||||||
|
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale
|
||||||
|
by Lunar's base indexer and resolved by `CollectionService` exactly like
|
||||||
|
`ProductService` does — see `product-listing.md`'s "Locale resolution" section, same
|
||||||
|
logic, same `LanguageCache::defaultLocale()` fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Modules\Core\Catalog\DTOs\CollectionFilters;
|
||||||
|
use Modules\Core\Catalog\Enums\CollectionSort;
|
||||||
|
use Modules\Core\Catalog\Services\CollectionService;
|
||||||
|
|
||||||
|
$service = app(CollectionService::class);
|
||||||
|
|
||||||
|
// Top-level collections only (parent_id IS NULL) — for building a nav tree
|
||||||
|
$roots = $service->list(
|
||||||
|
filters: new CollectionFilters(rootOnly: true),
|
||||||
|
sort: CollectionSort::Position,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Children of a specific collection
|
||||||
|
$children = $service->list(
|
||||||
|
filters: new CollectionFilters(parentId: 222),
|
||||||
|
sort: CollectionSort::Position,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by collection group
|
||||||
|
$collections = $service->list(filters: new CollectionFilters(groupId: 4));
|
||||||
|
|
||||||
|
// Single collection, by primary key or slug
|
||||||
|
$collection = $service->getById(223);
|
||||||
|
$collection = $service->getBySlug('keychains');
|
||||||
|
```
|
||||||
|
|
||||||
|
`CollectionFilters(parentId: ..., rootOnly: ...)` are mutually exclusive — if both are
|
||||||
|
set, `parentId` wins. There's no `parentId: null` shorthand for "root only", since
|
||||||
|
that would be ambiguous with "don't filter by parent at all" (the DTO's actual
|
||||||
|
default); `rootOnly` names the root-collections case explicitly instead.
|
||||||
|
|
||||||
|
`CollectionSort::Position` (`_lft:asc`) is the recommended default for any nav/tree
|
||||||
|
UI — it matches the order an admin arranges collections in Lunar's own Filament UI.
|
||||||
|
`Name` and `Newest` are also available, mirroring `ProductSort`'s shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
Like `ProductIndexer`, `CollectionIndexer` must be registered in the consuming app's
|
||||||
|
own `config/lunar/search.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'indexers' => [
|
||||||
|
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
New/changed fields aren't filterable/sortable 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. If `SCOUT_QUEUE` is enabled,
|
||||||
|
the queue worker also needs restarting after deploying changes to the indexer class —
|
||||||
|
see `docs/lunar.md` "Gotchas".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to still use Eloquent directly
|
||||||
|
|
||||||
|
A single collection's full detail page (breadcrumb via `$collection->breadcrumb`,
|
||||||
|
tree ancestors/descendants, route-model-bound `Collection $collection` in a
|
||||||
|
controller signature) should keep reading Eloquent directly rather than going through
|
||||||
|
`CollectionService` — the indexed document doesn't carry ancestor chains or the full
|
||||||
|
nested-set relations, and route-model binding already gives a controller the full
|
||||||
|
model for free. `CollectionService` is for browsing/listing and lightweight
|
||||||
|
by-id/by-slug lookups where a full Eloquent hydration would be wasteful, the same
|
||||||
|
tradeoff `ProductService` makes for products.
|
||||||
+14
-3
@@ -199,9 +199,20 @@ registered.
|
|||||||
### Seeding
|
### Seeding
|
||||||
|
|
||||||
A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`,
|
A starter set of common e-shop labels (`nav.*`, `cart.*`, `product.*`, `auth.*`, `search.*`,
|
||||||
English + Greek) is seeded by `Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own
|
`review.*`, `shop.*`, `pagination.*`, English + Greek) lives in
|
||||||
`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same
|
`Modules\Core\Localization\Services\StorefrontLabels::all()` — kept as its own class, separate
|
||||||
idempotent pattern as the rest of that command, safe to run unattended on every boot.
|
from the seeding logic, so the label list can be scanned/diffed without wading through the
|
||||||
|
seeding mechanics.
|
||||||
|
|
||||||
|
`Modules\Core\Command\InstallLunarCommand` (overrides Lunar's own `lunar:install`) seeds them via
|
||||||
|
a **per-key upsert**, not an all-or-nothing "only seed if the group is empty" guard: a key already
|
||||||
|
present in the database — including one an admin has since edited via the Filament **Language
|
||||||
|
Lines** resource — is left untouched; only keys missing entirely are created. This is what makes
|
||||||
|
it safe to add new keys to `StorefrontLabels::all()` later and re-run `lunar:install` on an
|
||||||
|
already-installed store, without either silently skipping the new keys (the old guard's behavior)
|
||||||
|
or reverting an admin's edits back to the hardcoded default (what a naive `updateOrCreate` would
|
||||||
|
do). New writes go through `TranslationService::create()`, so the usual cache-invalidation and
|
||||||
|
activity-log events fire for them too.
|
||||||
|
|
||||||
### Admin UI
|
### Admin UI
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ needs, listing and detail alike:
|
|||||||
| Field | Source | Notes |
|
| Field | Source | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
|
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
|
||||||
| `collections` | `$product->collections` | Array of `{id, name}` — `name` is the translated collection name. Filterable on the nested field `collections.id`, not `collections` itself. |
|
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
|
||||||
|
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
|
||||||
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\DTOs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter input for CollectionService::list(). All fields are optional — omitted
|
||||||
|
* filters are simply not added to the Meilisearch query. Values are matched
|
||||||
|
* against Modules\Core\Catalog\Services\CollectionIndexer's document fields, so
|
||||||
|
* filtering only works on stores where that indexer is registered and the index
|
||||||
|
* has been re-synced (see docs/product-listing.md).
|
||||||
|
*/
|
||||||
|
class CollectionFilters
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param $parentId children of this specific parent collection.
|
||||||
|
* @param $rootOnly top-level collections only (`parent_id IS NULL`) — mutually
|
||||||
|
* exclusive with $parentId; if both are set, $parentId wins.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?int $parentId = null,
|
||||||
|
public readonly ?int $groupId = null,
|
||||||
|
public readonly bool $rootOnly = false,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ namespace Modules\Core\Catalog\DTOs;
|
|||||||
*/
|
*/
|
||||||
class ProductFilters
|
class ProductFilters
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param $collectionId matches a product in this collection OR any of its
|
||||||
|
* descendant collections (filtered against ProductIndexer's `collection_ids`,
|
||||||
|
* not a direct-assignment-only match) — the right semantics for "products on
|
||||||
|
* this category page", since products are typically attached only to leaf
|
||||||
|
* collections.
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly ?int $collectionId = null,
|
public readonly ?int $collectionId = null,
|
||||||
public readonly ?string $brand = null,
|
public readonly ?string $brand = null,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort options for CollectionService::list(), each mapped to a Meilisearch `sort`
|
||||||
|
* clause against a field indexed as sortable by Modules\Core\Catalog\Services\
|
||||||
|
* CollectionIndexer (see its getSortableFields()).
|
||||||
|
*/
|
||||||
|
enum CollectionSort: string
|
||||||
|
{
|
||||||
|
case Position = 'position';
|
||||||
|
case Name = 'name';
|
||||||
|
case Newest = 'newest';
|
||||||
|
|
||||||
|
public function toMeilisearchSort(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Position => '_lft:asc',
|
||||||
|
self::Name => 'name:asc',
|
||||||
|
self::Newest => 'created_at:desc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\Services;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Lunar\Models\Collection;
|
||||||
|
use Lunar\Search\CollectionIndexer as BaseCollectionIndexer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\CollectionService can
|
||||||
|
* serve category browsing/nav AND single-collection lookups from Meilisearch alone,
|
||||||
|
* the same reasoning as Modules\Core\Catalog\Services\ProductIndexer. Lunar's base
|
||||||
|
* indexer only carries `id`/`name`/`created_at` — nowhere near enough for a storefront
|
||||||
|
* category page or a nav tree. Adds:
|
||||||
|
* - parent_id, _lft, _rgt (filterable/sortable) — the nested-set tree position, so
|
||||||
|
* CollectionService can resolve "children of X" or build a full tree without a
|
||||||
|
* database read
|
||||||
|
* - collection_group_id (filterable) — mirrors Collection::scopeInGroup()
|
||||||
|
* - slugs (filterable) — every locale's Url::slug, so getBySlug() resolves from the
|
||||||
|
* index directly, no database read
|
||||||
|
* - thumbnail (display) — the collection's thumbnail image URL
|
||||||
|
*
|
||||||
|
* New fields aren't filterable/sortable 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.
|
||||||
|
*/
|
||||||
|
class CollectionIndexer extends BaseCollectionIndexer
|
||||||
|
{
|
||||||
|
public function getFilterableFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::getFilterableFields(),
|
||||||
|
'id',
|
||||||
|
'parent_id',
|
||||||
|
'_lft',
|
||||||
|
'collection_group_id',
|
||||||
|
'slugs',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSortableFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::getSortableFields(),
|
||||||
|
'_lft',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeAllSearchableUsing(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return parent::makeAllSearchableUsing($query)->with(['urls', 'media']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toSearchableArray(Model $model): array
|
||||||
|
{
|
||||||
|
/** @var Collection $model */
|
||||||
|
$data = parent::toSearchableArray($model);
|
||||||
|
|
||||||
|
$data['parent_id'] = $model->parent_id;
|
||||||
|
$data['_lft'] = $model->_lft;
|
||||||
|
$data['_rgt'] = $model->_rgt;
|
||||||
|
$data['collection_group_id'] = $model->collection_group_id;
|
||||||
|
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||||
|
$data['thumbnail'] = $model->getThumbnailImage() ?: null;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Catalog\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\Collection as CollectionModel;
|
||||||
|
use Modules\Core\Catalog\DTOs\CollectionFilters;
|
||||||
|
use Modules\Core\Catalog\Enums\CollectionSort;
|
||||||
|
use Modules\Core\Localization\Services\LanguageCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category browsing (tree/nav) AND single-collection lookup, all reading directly
|
||||||
|
* from the Meilisearch index (Modules\Core\Catalog\Services\CollectionIndexer) — same
|
||||||
|
* shape and reasoning as Modules\Core\Catalog\Services\ProductService. Callers get
|
||||||
|
* plain arrays of the indexed document, not Eloquent models.
|
||||||
|
*/
|
||||||
|
class CollectionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly LanguageCache $languages,
|
||||||
|
private readonly AttributeManifest $attributes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result — see
|
||||||
|
* ProductService's "Meilisearch driver quirk" note) so a controller/view gets
|
||||||
|
* normal pagination behaviour without ever touching the raw Meilisearch response.
|
||||||
|
*/
|
||||||
|
public function list(?CollectionFilters $filters = null, int $perPage = 24, int $page = 1, ?CollectionSort $sort = null): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$options = ['filter' => $this->buildFilter($filters)];
|
||||||
|
|
||||||
|
if ($sort !== null) {
|
||||||
|
$options['sort'] = [$sort->toMeilisearchSort()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = CollectionModel::search('')
|
||||||
|
->options($options)
|
||||||
|
->paginateRaw(perPage: $perPage, page: $page);
|
||||||
|
|
||||||
|
$data = collect($this->hitsFrom($paginator))
|
||||||
|
->map(fn (array $collection) => $this->withLocalizedFields($collection))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: $data,
|
||||||
|
total: $paginator->total(),
|
||||||
|
perPage: $paginator->perPage(),
|
||||||
|
currentPage: $paginator->currentPage(),
|
||||||
|
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a single collection by its URL slug (any locale). Returns the full
|
||||||
|
* indexed collection document, or null if no collection has that slug.
|
||||||
|
*/
|
||||||
|
public function getBySlug(string $slug): ?array
|
||||||
|
{
|
||||||
|
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a single collection by its primary key. Returns the full indexed
|
||||||
|
* collection document, or null if no collection has that id.
|
||||||
|
*/
|
||||||
|
public function getById(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->findOneWhere("id = \"{$id}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOneWhere(string $filter): ?array
|
||||||
|
{
|
||||||
|
$paginator = CollectionModel::search('')
|
||||||
|
->options(['filter' => $filter])
|
||||||
|
->paginateRaw(perPage: 1, page: 1);
|
||||||
|
|
||||||
|
$collection = $this->hitsFrom($paginator)[0] ?? null;
|
||||||
|
|
||||||
|
return $collection !== null ? $this->withLocalizedFields($collection) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves every translated Collection attribute's current-locale value — same
|
||||||
|
* logic as ProductService::withLocalizedFields(), see there for the full
|
||||||
|
* reasoning (AttributeManifest-driven, store-default-locale fallback, raw
|
||||||
|
* per-locale keys stripped after resolving).
|
||||||
|
*/
|
||||||
|
private function withLocalizedFields(array $collection): array
|
||||||
|
{
|
||||||
|
$locale = App::getLocale();
|
||||||
|
$fallbackLocale = $this->languages->defaultLocale();
|
||||||
|
$availableLocales = $this->languages->availableLocales();
|
||||||
|
|
||||||
|
foreach ($this->translatedAttributeHandles() as $handle) {
|
||||||
|
$collection[$handle] = $collection[$handle.'_'.$locale] ?? $collection[$handle.'_'.$fallbackLocale] ?? null;
|
||||||
|
|
||||||
|
foreach ($availableLocales as $availableLocale) {
|
||||||
|
unset($collection[$handle.'_'.$availableLocale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function translatedAttributeHandles(): array
|
||||||
|
{
|
||||||
|
return $this->attributes->getSearchableAttributes((new CollectionModel)->getMorphClass())
|
||||||
|
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
|
||||||
|
->pluck('handle')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
|
||||||
|
* in items(), not a plain list of hits — see ProductService's identical note.
|
||||||
|
*/
|
||||||
|
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
|
||||||
|
{
|
||||||
|
$rawResponse = $paginator->items();
|
||||||
|
|
||||||
|
return collect($rawResponse['hits'] ?? [])->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFilter(?CollectionFilters $filters): ?string
|
||||||
|
{
|
||||||
|
if ($filters === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clauses = Collection::make([
|
||||||
|
$filters->parentId !== null ? "parent_id = \"{$filters->parentId}\""
|
||||||
|
: ($filters->rootOnly ? 'parent_id IS NULL' : null),
|
||||||
|
$filters->groupId !== null ? "collection_group_id = \"{$filters->groupId}\"" : null,
|
||||||
|
])->filter();
|
||||||
|
|
||||||
|
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,14 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|||||||
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can
|
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can
|
||||||
* serve both listing/filtering AND single-product lookups from Meilisearch alone —
|
* 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:
|
* one data source, no separate database read path for a product detail page. Adds:
|
||||||
* - collections: [{id, name}, ...] — filterable via `collections.id`
|
* - collections: [{id, name}, ...] — directly assigned collections only, for
|
||||||
|
* display (breadcrumbs, "also in"). Not filterable — see collection_ids below.
|
||||||
|
* - collection_ids (filterable): flat array of every directly-assigned collection's
|
||||||
|
* id UNIONED with all of its ancestors' ids. Products are typically attached only
|
||||||
|
* to leaf collections in a Shopify-imported tree, so a plain `collections.id`
|
||||||
|
* filter would never match a parent/root category page — ProductService::list()
|
||||||
|
* filters `collectionId` against this field instead, so "products in category X"
|
||||||
|
* also picks up every product attached only to one of X's subcategories.
|
||||||
* - 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
|
||||||
* no database read at all
|
* no database read at all
|
||||||
@@ -49,7 +56,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
...parent::getFilterableFields(),
|
...parent::getFilterableFields(),
|
||||||
'id',
|
'id',
|
||||||
'brand',
|
'brand',
|
||||||
'collections.id',
|
'collection_ids',
|
||||||
'price',
|
'price',
|
||||||
'slugs',
|
'slugs',
|
||||||
'channel_ids',
|
'channel_ids',
|
||||||
@@ -68,6 +75,7 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
{
|
{
|
||||||
return parent::makeAllSearchableUsing($query)->with([
|
return parent::makeAllSearchableUsing($query)->with([
|
||||||
'collections',
|
'collections',
|
||||||
|
'collections.ancestors',
|
||||||
'media',
|
'media',
|
||||||
'tags',
|
'tags',
|
||||||
'urls',
|
'urls',
|
||||||
@@ -89,6 +97,11 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
'id' => $collection->id,
|
'id' => $collection->id,
|
||||||
'name' => $collection->translateAttribute('name'),
|
'name' => $collection->translateAttribute('name'),
|
||||||
])->all();
|
])->all();
|
||||||
|
$data['collection_ids'] = $model->collections
|
||||||
|
->flatMap(fn ($collection) => [$collection->id, ...$collection->ancestors->pluck('id')])
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||||
$data['tags'] = $model->tags->pluck('value')->all();
|
$data['tags'] = $model->tags->pluck('value')->all();
|
||||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ class ProductService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$clauses = Collection::make([
|
$clauses = Collection::make([
|
||||||
$filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null,
|
$filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
|
||||||
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
|
||||||
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
|
||||||
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ use Lunar\Models\Product;
|
|||||||
use Lunar\Models\ProductType;
|
use Lunar\Models\ProductType;
|
||||||
use Lunar\Models\TaxClass;
|
use Lunar\Models\TaxClass;
|
||||||
use Lunar\Models\TaxZone;
|
use Lunar\Models\TaxZone;
|
||||||
use Spatie\TranslationLoader\LanguageLine;
|
use Modules\Core\Localization\Models\LanguageLine;
|
||||||
|
use Modules\Core\Localization\Services\StorefrontLabels;
|
||||||
|
use Modules\Core\Localization\Services\TranslationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
|
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
|
||||||
@@ -32,7 +34,7 @@ class InstallLunarCommand extends Command
|
|||||||
|
|
||||||
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
|
protected $description = 'Seed the default Lunar store data (countries, channel, currency, tax zone, attributes, product type)';
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(TranslationService $translations): void
|
||||||
{
|
{
|
||||||
$this->components->info('Seeding default Lunar store data...');
|
$this->components->info('Seeding default Lunar store data...');
|
||||||
|
|
||||||
@@ -242,10 +244,8 @@ class InstallLunarCommand extends Command
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (! LanguageLine::where('group', 'storefront')->exists()) {
|
|
||||||
$this->components->info('Seeding storefront label translations');
|
$this->components->info('Seeding storefront label translations');
|
||||||
$this->seedStorefrontLabels();
|
$this->seedStorefrontLabels($translations);
|
||||||
}
|
|
||||||
|
|
||||||
$this->components->info('Publishing Filament assets');
|
$this->components->info('Publishing Filament assets');
|
||||||
$this->call('filament:assets');
|
$this->call('filament:assets');
|
||||||
@@ -253,32 +253,29 @@ class InstallLunarCommand extends Command
|
|||||||
$this->components->info('Lunar default data seeded.');
|
$this->components->info('Lunar default data seeded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedStorefrontLabels(): void
|
/**
|
||||||
|
* Per-key upsert, not an all-or-nothing "only seed if the group is empty" guard —
|
||||||
|
* a key already present in the database (including one an admin has since edited
|
||||||
|
* via the Filament Languages resource) is left untouched; only keys missing
|
||||||
|
* entirely are created. This is what makes it safe to add new keys to
|
||||||
|
* StorefrontLabels later and re-run this on an already-installed store without
|
||||||
|
* either skipping the new keys (the old all-or-nothing guard) or reverting an
|
||||||
|
* admin's edits back to the hardcoded default (a naive updateOrCreate would).
|
||||||
|
*/
|
||||||
|
private function seedStorefrontLabels(TranslationService $translations): void
|
||||||
{
|
{
|
||||||
$labels = [
|
$labels = StorefrontLabels::all();
|
||||||
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
|
|
||||||
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
|
$existingKeys = LanguageLine::where('group', 'storefront')
|
||||||
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
|
->whereIn('key', array_keys($labels))
|
||||||
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
|
->pluck('key');
|
||||||
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
|
|
||||||
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
|
|
||||||
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
|
|
||||||
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
|
|
||||||
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
|
|
||||||
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
|
|
||||||
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
|
|
||||||
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
|
|
||||||
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
|
|
||||||
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
|
|
||||||
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($labels as $key => $text) {
|
foreach ($labels as $key => $text) {
|
||||||
LanguageLine::create([
|
if ($existingKeys->contains($key)) {
|
||||||
'group' => 'storefront',
|
continue;
|
||||||
'key' => $key,
|
}
|
||||||
'text' => $text,
|
|
||||||
]);
|
$translations->create('storefront', $key, $text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ use Modules\Core\Auth\Mail\InviteMail;
|
|||||||
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
|
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
|
||||||
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
use Modules\Core\Review\Extensions\ProductResourceExtension;
|
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
|
||||||
use Modules\Core\Review\Models\ProductReview;
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
class CorePlugin implements Plugin
|
class CorePlugin implements Plugin
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Localization\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default storefront UI label translations (group `storefront`), seeded by
|
||||||
|
* Modules\Core\Command\InstallLunarCommand. Kept as its own class, separate from
|
||||||
|
* the seeding logic, so the actual label list can be scanned/diffed without wading
|
||||||
|
* through the upsert mechanics — see InstallLunarCommand::seedStorefrontLabels()
|
||||||
|
* for how (and how safely) these get written.
|
||||||
|
*/
|
||||||
|
class StorefrontLabels
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, string>> keyed by `group.key` dot-notation,
|
||||||
|
* each value a locale => text map (`en`/`el`).
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nav.home' => ['en' => 'Home', 'el' => 'Αρχική'],
|
||||||
|
'nav.products' => ['en' => 'Products', 'el' => 'Προϊόντα'],
|
||||||
|
'nav.cart' => ['en' => 'Cart', 'el' => 'Καλάθι'],
|
||||||
|
'nav.account' => ['en' => 'Account', 'el' => 'Λογαριασμός'],
|
||||||
|
'nav.back' => ['en' => 'Back', 'el' => 'Πίσω'],
|
||||||
|
'nav.contact' => ['en' => 'Contact', 'el' => 'Επικοινωνία'],
|
||||||
|
'cart.empty' => ['en' => 'Your cart is empty', 'el' => 'Το καλάθι σας είναι άδειο'],
|
||||||
|
'cart.checkout' => ['en' => 'Checkout', 'el' => 'Ολοκλήρωση Παραγγελίας'],
|
||||||
|
'cart.total' => ['en' => 'Total', 'el' => 'Σύνολο'],
|
||||||
|
'cart.remove' => ['en' => 'Remove', 'el' => 'Αφαίρεση'],
|
||||||
|
'product.add_to_cart' => ['en' => 'Add to Cart', 'el' => 'Προσθήκη στο Καλάθι'],
|
||||||
|
'product.out_of_stock' => ['en' => 'Out of Stock', 'el' => 'Εξαντλήθηκε'],
|
||||||
|
'product.price' => ['en' => 'Price', 'el' => 'Τιμή'],
|
||||||
|
'product.description' => ['en' => 'Description', 'el' => 'Περιγραφή'],
|
||||||
|
'product.no_image' => ['en' => 'No image', 'el' => 'Χωρίς εικόνα'],
|
||||||
|
'product.read_more' => ['en' => 'Read more', 'el' => 'Περισσότερα'],
|
||||||
|
'product.reviews' => ['en' => 'Reviews', 'el' => 'Αξιολογήσεις'],
|
||||||
|
'auth.login' => ['en' => 'Log In', 'el' => 'Σύνδεση'],
|
||||||
|
'auth.logout' => ['en' => 'Log Out', 'el' => 'Αποσύνδεση'],
|
||||||
|
'search.placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτηση προϊόντων…'],
|
||||||
|
'customer_reviews' => [
|
||||||
|
'en' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
|
||||||
|
'el' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
|
||||||
|
],
|
||||||
|
'pagination.nav_label' => ['en' => 'Pagination', 'el' => 'Σελιδοποίηση'],
|
||||||
|
'pagination.next' => ['en' => 'Next page', 'el' => 'Επόμενη σελίδα'],
|
||||||
|
'pagination.previous' => ['en' => 'Previous page', 'el' => 'Προηγούμενη σελίδα'],
|
||||||
|
'pagination.page' => ['en' => 'Page :page', 'el' => 'Σελίδα :page'],
|
||||||
|
'review.rating' => ['en' => 'Rating', 'el' => 'Βαθμολογία'],
|
||||||
|
'review.write_label' => ['en' => 'Write a review', 'el' => 'Γράψε μια αξιολόγηση'],
|
||||||
|
'review.name' => ['en' => 'Name', 'el' => 'Όνομα'],
|
||||||
|
'review.name_optional' => ['en' => 'Optional', 'el' => 'Προαιρετικό'],
|
||||||
|
'review.email' => ['en' => 'Email', 'el' => 'Email'],
|
||||||
|
'review.email_not_published' => ['en' => 'Will not be published', 'el' => 'Δεν θα δημοσιευτεί'],
|
||||||
|
'review.save_info' => [
|
||||||
|
'en' => 'Save my name and email for the next time I comment.',
|
||||||
|
'el' => 'Αποθήκευσε το όνομα και το email μου για την επόμενη φορά που θα σχολιάσω.',
|
||||||
|
],
|
||||||
|
'review.submit' => ['en' => 'Submit', 'el' => 'Υποβολή'],
|
||||||
|
'review.stars_count' => ['en' => '{1} :count star|[2,*] :count stars', 'el' => '{1} :count αστέρι|[2,*] :count αστέρια'],
|
||||||
|
'review.no_reviews_yet' => ['en' => 'No reviews yet.', 'el' => 'Δεν υπάρχουν αξιολογήσεις ακόμα.'],
|
||||||
|
'review.write_first' => ['en' => 'Write the first review', 'el' => 'Γράψε την πρώτη'],
|
||||||
|
'review.write_new' => ['en' => 'Add a review', 'el' => 'Πρόσθεσε μια'],
|
||||||
|
'review.for_product' => ['en' => 'review for ":name"', 'el' => 'αξιολόγηση για το «:name»'],
|
||||||
|
'shop.showing_results' => [
|
||||||
|
'en' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
|
||||||
|
'el' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
|
||||||
|
],
|
||||||
|
'shop.sort_label' => ['en' => 'Sort products', 'el' => 'Ταξινόμηση προϊόντων'],
|
||||||
|
'shop.sort_default' => ['en' => 'Default sorting', 'el' => 'Προεπιλεγμένη ταξινόμηση'],
|
||||||
|
'shop.sort_popularity' => ['en' => 'Popularity', 'el' => 'Δημοφιλή'],
|
||||||
|
'shop.sort_price_asc' => ['en' => 'Price: Low to High', 'el' => 'Τιμή: Αύξουσα'],
|
||||||
|
'shop.sort_price_desc' => ['en' => 'Price: High to Low', 'el' => 'Τιμή: Φθίνουσα'],
|
||||||
|
'shop.sort_newest' => ['en' => 'Newest', 'el' => 'Νεότερα'],
|
||||||
|
'shop.no_products' => ['en' => 'No products found in this category.', 'el' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.'],
|
||||||
|
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
|
||||||
|
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
|
||||||
|
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
|
||||||
|
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
|
||||||
|
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
|
||||||
|
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Core\Review\Extensions;
|
namespace Modules\Core\Review\Filament\Extensions;
|
||||||
|
|
||||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
use Modules\Core\Review\Pages\ManageProductReviews;
|
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
||||||
|
|
||||||
class ProductResourceExtension extends ResourceExtension
|
class ProductResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Modules\Core\Review\Pages;
|
namespace Modules\Core\Review\Filament\Pages;
|
||||||
|
|
||||||
use Filament\Forms\Components\Group;
|
use Filament\Forms\Components\Group;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
Reference in New Issue
Block a user