Compare commits

...
7 Commits
35 changed files with 892 additions and 218 deletions
+26 -2
View File
@@ -4,11 +4,35 @@ 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.7.0] - 2026-08-27
### Added
- `Modules\Core\Catalog\Services\CollectionService` provides category browsing/nav AND single-collection lookup from Meilisearch, mirroring `ProductService` exactly (`list()`, `getById()`, `getBySlug()`, same locale-resolution logic). `Modules\Core\Catalog\Services\CollectionIndexer` extends Lunar's own `Lunar\Search\CollectionIndexer` (which only carried `id`/`name`/`created_at`) to add `parent_id`, `_lft`/`_rgt` (nested-set tree position, filterable/sortable), `collection_group_id`, `slugs`, and `thumbnail`. `Modules\Core\Catalog\DTOs\CollectionFilters` supports `parentId` (children of a specific collection), `groupId`, and `rootOnly` (top-level collections, `parent_id IS NULL` — mutually exclusive with `parentId`). `Modules\Core\Catalog\Enums\CollectionSort` adds `Position` (`_lft:asc`, the recommended default for nav/tree UIs — matches admin arrangement order), `Name`, `Newest`. Must be registered in a consuming app's `config/lunar/search.php` (`Lunar\Models\Collection::class => CollectionIndexer::class`), same as `ProductIndexer`. Documented in `docs/collections.md`.
- `Modules\Core\Localization\Services\StorefrontLabels::all()` extracts the default storefront UI label list out of `InstallLunarCommand` into its own class, and adds every previously-missing key (`nav.contact`, `product.description`/`no_image`/`read_more`/`reviews`, `customer_reviews`, `pagination.*`, `review.*`, `shop.*`) that had already been seeded manually in some stores but was absent from the command's own list — bringing the code-side default back in sync with what a real store actually has. `InstallLunarCommand::seedStorefrontLabels()` now does a **per-key upsert** instead of 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, and only missing keys are created via `TranslationService::create()`. This 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. Documented in `docs/localization.md` ("Seeding").
- `Modules\Core\Catalog\Services\CollectionIndexer` adds `ancestors` — `[{id, name}, ...]` ordered root-first (via the newly eager-loaded `ancestors` relation) — so a breadcrumb can render directly from `CollectionService::getById()`/`getBySlug()` with zero extra queries, and `product_count` — how many products are in a collection or any of its descendants, queried from the product Meilisearch index at collection-index time via the same `collection_ids` field `ProductFilters(collectionId:)` filters against. Documented in `docs/collections.md`, including the reindex-ordering gotcha (`product_count` needs the product index reindexed first).
- `Modules\Core\Catalog\Services\ProductIndexer` adds a filterable `in_stock` boolean — `true` if any variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` (Lunar's own purchasability rule, not a naive `stock > 0` check). `Modules\Core\Catalog\DTOs\ProductFilters` gets a matching `inStockOnly` flag. Reflects stock as of the last reindex only — nothing currently reindexes a product when an order decrements its stock, since that's a cart/checkout concern this doesn't attempt to solve; see `docs/product-listing.md` ("Stock goes stale between orders").
- `Modules\Core\Catalog\Services\ProductService::facets(string $field, ?ProductFilters $filters = null): array` returns Meilisearch facet value counts (e.g. `['Brand A' => 48, 'Brand B' => 135]`) for a discrete-value filterable field, scoped to the given filters. Uses Scout's plain `->options(['facets' => [...]])`, merged directly into the raw Meilisearch query the same way `filter`/`sort` already are — no adoption of Lunar's separate `SearchManager`/`Search` facade needed. `ProductService::priceRange(?ProductFilters $filters = null): array{min, max}` covers the numeric-field case `facets()` explicitly doesn't (`price` would otherwise return one "facet" per exact price) — backed by Meilisearch's `facetStats`, not `facetDistribution`. `priceRange()` always excludes `minPrice`/`maxPrice` from the filter it builds (via a new `$exclude` parameter on the private `buildFilter()`), so a price slider's own bounds don't shrink to whatever range is already selected on it; other filters (`collectionId`, `brand`, `inStockOnly`) still apply normally. Documented in `docs/product-listing.md`.
### Changed
- **Breaking:** Renamed the `Product` module to `Catalog`, flattened. Every class under `Modules\Core\Product\*` (`Contracts`, `DTOs`, `Enums`, `Services`, `Observers`, `Filament\Extensions`, `OptionTypes`) now lives under `Modules\Core\Catalog\*` at the same sub-path — e.g. `Modules\Core\Product\Services\ProductService` is now `Modules\Core\Catalog\Services\ProductService`, `Modules\Core\Product\DTOs\ProductFilters` is now `Modules\Core\Catalog\DTOs\ProductFilters`. Class names themselves are unchanged (still `ProductService`, `ProductIndexer`, `ProductFilters`, etc.) — only the namespace/folder moved, to make room for `Collection` as a sibling concern under the same `Catalog` umbrella rather than a disconnected top-level module. Consuming apps must update every `use Modules\Core\Product\...` import and any FQCN reference (`config/lunar/search.php`'s indexer registration, service provider bindings).
- **Breaking:** `Modules\Core\Providers\ProductServiceProvider` renamed to `Modules\Core\Providers\CatalogServiceProvider` (composer.json's provider list updated accordingly) — it now only wires `Catalog`-namespace classes (`ProductOptionTypeManager`, `ProductOptionReindexObserver`), so the name follows the same by-concern convention as `LocalizationServiceProvider`/`ReviewServiceProvider`.
- **Breaking:** `Modules\Core\Review`'s flat `Extensions/`/`Pages/` folders now nest under `Filament/`, matching the strict per-concern subfolder convention already applied to `Product`(now `Catalog`)/`Localization`. `Modules\Core\Review\Extensions\ProductResourceExtension` is now `Modules\Core\Review\Filament\Extensions\ProductResourceExtension`; `Modules\Core\Review\Pages\ManageProductReviews` is now `Modules\Core\Review\Filament\Pages\ManageProductReviews`. `Modules\Core\Review\Models\ProductReview` is unchanged.
- **Breaking:** `ProductFilters(collectionId: ...)` now matches a product in that collection **or any of its descendant collections**, not just direct assignment. Products in a Shopify-imported tree are typically attached only to leaf collections, so filtering strictly on direct assignment meant a parent/root category page (`CollectionFilters(rootOnly: true)`'s results, or any non-leaf collection) always returned zero products even though real products existed several levels down. `Modules\Core\Catalog\Services\ProductIndexer` adds a new filterable `collection_ids` field — every directly-assigned collection's id unioned with all of its ancestors' ids (via the newly eager-loaded `collections.ancestors`) — and `ProductService::buildFilter()` now filters `collectionId` against `collection_ids` instead of the old `collections.id`. The display-only `collections` field (`{id, name}`, direct assignments) is unchanged and no longer filterable.
## [0.6.1] - 2026-08-27
### Added
- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Registered via `Modules\Core\Product\Services\ProductOptionTypeManager::get()->register([...])` (a singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`) from a service provider's `boot()`. An admin then picks one per `ProductOption` from an "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType`, registered automatically by the new `Modules\Core\Providers\ProductServiceProvider`. Documented in `docs/product-options.md`.
- `Modules\Core\Product\Services\ProductIndexer::mapVariant()` now includes each option's `handle` (alongside its translated name) in a variant's indexed `options[]` — previously only the translated `option`/`value` names and `meta` were indexed, with no stable, locale-independent identifier for which option a value belongs to.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
### Changed
- **Breaking:** `Modules\Core\Product\Services\ProductIndexer`'s indexed `collections` field is now an array of `{id, name}` objects instead of two parallel arrays (`collections` as bare ID strings, `collection_names` as translated names joined only by array index). `collection_names` is removed. Filtering by collection now targets the nested field `collections.id` (Meilisearch supports filtering on nested object fields), not bare `collections` — `Modules\Core\Product\Services\ProductService::buildFilter()` updated accordingly; `ProductFilters(collectionId: ...)`'s public API is unchanged. Run `php artisan lunar:meilisearch:setup` then `lunar:search:index --refresh` after upgrading (see docs/product-listing.md "Gotchas").
- **Breaking:** `ProductIndexer`'s indexed `review_count`/`average_rating` top-level keys are folded into the existing `reviews` key: `reviews` is now `{items, count, average_rating}` instead of a bare array with `review_count`/`average_rating` as separate sibling keys. `reviews` (the array of review items) moved to `reviews.items`.
## [0.6.0] - 2026-08-27 ## [0.6.0] - 2026-08-27
### Added ### Added
- `Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category of `Lunar\Models\ProductOption` (e.g. "Color", "Size") behaves — what structured data its values carry in their free-form `meta` jsonb column, and how an admin edits it via Filament — without introducing a new model. Enabled per-shop as a plain list in `config('core.product_option_types')`; an admin then picks one per `ProductOption` from a "Option Type" dropdown on the option's own edit form (added by `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension`), stored in `ProductOption::meta['option_type']` — deliberately not tied to the option's `handle`, since a shop's own handle naming shouldn't have to match a type's key. `Modules\Core\Product\Services\ProductOptionTypeManager` resolves the selected key to its type (`all()`/`resolve()`). `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` hooks Lunar's own `ValuesRelationManager` (both extensions via `LunarPanel::extensions()`, registered in `CorePlugin`) to append the resolved type's meta form fields to the stock "Values" tab — no fork of Lunar's classes needed. Ships a reference implementation, `Modules\Core\Product\OptionTypes\ColorOptionType` (not auto-registered). Documented in `docs/product-options.md`.
- `Modules\Core\Product\Observers\ProductOptionReindexObserver`, wired in the new `Modules\Core\Providers\ProductServiceProvider`, keeps Meilisearch in sync when a `ProductOption` or `ProductOptionValue` is saved or deleted — e.g. picking an Option Type or editing a color's hex. `ProductIndexer::mapVariant()` embeds each option value's `meta` directly into a product's indexed document, but saving the option/value never fires the *product's* own save events, so without this a changed hex would only reach the index on that product's next unrelated reindex. The observer resolves every `Lunar\Models\Product` whose variants use the changed option (or option value) via the `product_option_value_product_variant` pivot, and calls `->searchable()` on each.
- `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language"). - `Modules\Core\Localization\Models\LanguageLine` extends `spatie/laravel-translation-loader`'s `LanguageLine` to fall back to the store's actual default language (`LanguageCache::defaultLocale()`, backed by Lunar's `languages.default` flag) instead of the package's stock behavior of falling back to the static `config('app.fallback_locale')` — the two were previously disconnected, so changing the default language via the Filament **Languages** resource had no effect on which locale an untranslated storefront label silently fell back to. Swapped in automatically via `config('translation-loader.model')` in `LocalizationServiceProvider::register()`; no consuming app changes needed. Documented in `docs/localization.md` ("Fallback locale follows the store's default language").
### Changed ### Changed
+2 -2
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.6.0", "version": "0.7.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -36,7 +36,7 @@
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\ProductServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider" "Modules\\Core\\Providers\\ReviewServiceProvider"
] ]
} }
-19
View File
@@ -16,23 +16,4 @@ return [
'auto_create_customer_for_user' => true, 'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Product Option Types
|--------------------------------------------------------------------------
|
| Enabled `Modules\Core\Product\Contracts\ProductOptionTypeInterface`
| implementations, describing what structured data a ProductOption's
| values carry in their `meta` jsonb column, and how an admin edits it.
| An admin picks one per ProductOption from a dropdown built from this
| list (stored in ProductOption::meta, not tied to the option's handle) —
| a ProductOption with none selected has no described meta behavior,
| plain name/position only.
|
| \App\ProductOptions\ColorOptionType::class,
|
*/
'product_option_types' => [],
]; ];
+116
View File
@@ -0,0 +1,116 @@
# 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. |
| `ancestors` | `$model->ancestors` | Display only. Array of `{id, name}`, ordered root-first — a breadcrumb (`Home > Apparel > Keychains`) can render directly from a single `getById()`/`getBySlug()` call, no extra queries. Empty array for a top-level collection. |
| `product_count` | Queried from the *product* Meilisearch index at collection-index time | Display only. How many products are in this collection **or any of its descendants** — matches what `ProductService::list(ProductFilters(collectionId: ...))` would return, not just direct assignment. Computed via `Product::search('')->options(['filter' => "collection_ids = \"{id}\""])`, so it depends on the product index already being current — reindex products *before* collections (see "Gotchas" below). |
`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".
**`product_count` needs the product index reindexed first.** `config/lunar/search.php`'s
`indexers` array is typically ordered `Collection` before `Product`, so a plain
`lunar:search:index --refresh` computes `product_count` against whatever the product
index held *before* this run — stale if products changed too. `lunar:search:index`
takes an explicit model list as its argument (`--ignore` restricts it to only those),
so reindex products first, then collections, when both need a fresh `--refresh` in the
same deploy:
```
php artisan lunar:search:index "Lunar\Models\Product" --ignore --refresh
php artisan lunar:search:index "Lunar\Models\Collection" --ignore --refresh
```
---
## 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
View File
@@ -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
+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\Product\Services\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\Catalog\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`. - **`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\Catalog\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.
+54 -21
View File
@@ -1,10 +1,10 @@
# Product Listing # Product Listing
`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product `Modules\Core\Catalog\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\Product\Services\ProductSearchService` (see `product-search.md`), which This is separate from `Modules\Core\Catalog\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\Product\Services\ProductIndexer` is built to just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to
carry that full shape. carry that full shape.
--- ---
@@ -22,9 +22,9 @@ carry that full shape.
## Usage ## Usage
```php ```php
use Modules\Core\Product\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Product\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Product\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
@@ -33,9 +33,9 @@ $service = app(ProductService::class);
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. // "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1); $products = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, and/or price range // Filter by collection, brand, price range, and/or stock
$products = $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, inStockOnly: true),
perPage: 24, perPage: 24,
page: 1, page: 1,
); );
@@ -56,31 +56,63 @@ $product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages) // Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
// Facet counts for a sidebar — value => matching product count, scoped to whatever
// $filters is passed. Does NOT exclude the faceted field itself from $filters — see
// facets()'s docblock for why, and how to build a standard "every option's count,
// unaffected by that option's own currently-selected value" sidebar.
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
// Min/max price across matching products, for sizing a price-range slider.
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink
// to whatever range is currently selected on it. Other filters (collectionId,
// brand, inStockOnly) still apply normally.
$range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0]
``` ```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query. All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric
field like `price` would return one "facet" per exact price, not a usable range bucket. Use
`priceRange()` for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different
feature from `facetDistribution`.
--- ---
## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer` ## Stock goes stale between orders
`in_stock` reflects `ProductVariant::stock`/`purchasable` as of the **last reindex**, not live
inventory. Nothing in this codebase currently reindexes a product when an order decrements its
stock — that's a cart/checkout concern, not something `ProductIndexer` can solve on its own (see
`Modules\Core\Catalog\Observers\ProductOptionReindexObserver` for the equivalent pattern once an
order → stock → reindex pipeline exists to hook into). Until then, `in_stock`/`product_count` can
drift from the database the same way every other indexed field already can between writes.
---
## Fields this depends on: `Modules\Core\Catalog\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\Product\Services\ProductIndexer` extends it to add everything `ProductService` filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService`
needs, listing and detail alike: 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->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. | | `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. | | `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. |
| `tags` | `$product->tags->pluck('value')` | Display only. | | `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. | | `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). | | `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` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
| `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" below. |
`name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see
"Locale resolution" below for how `ProductService` resolves them down to one value per request. "Locale resolution" below for how `ProductService` resolves them down to one value per request.
@@ -121,11 +153,12 @@ description sourced from `ProductService`'s results must treat it as trusted HTM
## 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 under
`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews,
product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately `average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe
excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no
included, since they're meant to be shown alongside the review. 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) 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. — its own save doesn't touch the `Product` row, so the product's own model events never fire.
@@ -149,9 +182,9 @@ variants don't.
## Sorting ## Sorting
`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders — `ProductSort` (`Modules\Core\Catalog\Enums\ProductSort`) is a fixed enum of supported sort orders —
`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field
`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus
`created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `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 `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. below) — sortable attributes are index settings, not computed per-query, same as filterable ones.
@@ -168,7 +201,7 @@ Not automatic — an app opts in via its own `config/lunar/search.php`:
```php ```php
'indexers' => [ 'indexers' => [
Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
// ...other model indexers unchanged // ...other model indexers unchanged
], ],
``` ```
+29 -21
View File
@@ -6,7 +6,7 @@ Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing i
Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex
code to a "Red" value without editing the database directly. code to a "Red" value without editing the database directly.
`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category `Modules\Core\Catalog\Contracts\ProductOptionTypeInterface` describes how a category
of option behaves — what structured data its values carry in `meta`, and how an of option behaves — what structured data its values carry in `meta`, and how an
admin edits that data — without introducing a new model. `ProductOption`/ admin edits that data — without introducing a new model. `ProductOption`/
`ProductOptionValue` stay exactly as Lunar defines them. `ProductOptionValue` stay exactly as Lunar defines them.
@@ -15,21 +15,23 @@ admin edits that data — without introducing a new model. `ProductOption`/
## Registering a type ## Registering a type
A shop enables a type class in `config/core.php`: A shop registers a type class from its own service provider's `boot()`, the same
shape as `Modules\Core\Notification\NotificationRegistry`:
```php ```php
// config/core.php use Modules\Core\Catalog\Services\ProductOptionTypeManager;
'product_option_types' => [
ProductOptionTypeManager::get()->register([
\App\ProductOptions\ColorOptionType::class, \App\ProductOptions\ColorOptionType::class,
], ]);
``` ```
This is a plain list, **not** keyed by `ProductOption::handle` — a shop's own handle Not a published config array — the mapping isn't per-`ProductOption`, so there's
naming (transliterated Greek, legacy import slugs, whatever an admin happened to type nothing for a shop to *key* by. Instead, an admin picks a type per-option from a
when creating the option) shouldn't have to match a type's key. Instead, an admin dropdown on the `ProductOption` edit form itself (see below); the choice is stored
picks a type per-option from a dropdown on the `ProductOption` edit form itself (see in `ProductOption::meta['option_type']`, deliberately **not** tied to the option's
below); the choice is stored in `ProductOption::meta['option_type']`, not inferred `handle` (a shop's own handle naming — transliterated Greek, legacy import slugs —
from anything else. shouldn't have to match a type's key).
A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain
name/position, no extra meta form. name/position, no extra meta form.
@@ -42,7 +44,7 @@ name/position, no extra meta form.
namespace App\ProductOptions; namespace App\ProductOptions;
use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface; use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
class ColorOptionType implements ProductOptionTypeInterface class ColorOptionType implements ProductOptionTypeInterface
{ {
@@ -68,28 +70,34 @@ plain jsonb column). `getKey()` is the identifier used in the admin's "Option Ty
dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the
`ProductOption::handle`. `ProductOption::handle`.
A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType` A reference implementation ships at `Modules\Core\Catalog\OptionTypes\ColorOptionType`,
— not auto-registered, since registration is always an explicit shop decision. registered automatically by `Modules\Core\Providers\CatalogServiceProvider` — no shop
setup needed for it to appear in the "Option Type" dropdown, though an admin still
has to pick it per-`ProductOption` for it to take effect.
--- ---
## How it's wired into the admin UI ## How it's wired into the admin UI
`Modules\Core\Product\Services\ProductOptionTypeManager`: `Modules\Core\Catalog\Services\ProductOptionTypeManager` is a singleton registry:
- `all(): Collection<string, ProductOptionTypeInterface>` — every enabled type, - `get(): static` — the shared instance.
keyed by `getKey()`. - `register(array $types): void` — registers one or more type classes, keyed
- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up one by key (or internally by `getKey()`.
`null` if no key / not found). - `unregister(string $key): void`
- `resolve(?string $key): ?ProductOptionTypeInterface` — looks up a registered type
by key (or `null` if no key / not found).
- `all(): array<string, class-string>` — every registered type's class, keyed by
`getKey()`.
Two extensions hook into Lunar's admin via its extension system Two extensions hook into Lunar's admin via its extension system
(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's (`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's
classes needed: classes needed:
- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends - `Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension` extends
`Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select` `Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select`
(`meta.option_type`) listing every enabled type's key. Shown only when at least one (`meta.option_type`) listing every enabled type's key. Shown only when at least one
type is enabled. type is enabled.
- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends - `Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension` extends
the "Values" tab's form. Its `extendForm()` reads the "Values" tab's form. Its `extendForm()` reads
`$option->meta['option_type']` off the owning `ProductOption`, resolves it via `$option->meta['option_type']` off the owning `ProductOption`, resolves it via
`ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name `ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name
+2 -2
View File
@@ -1,6 +1,6 @@
# Product Search # Product Search
`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on `Modules\Core\Catalog\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\Product\Services\ProductSearchService; use Modules\Core\Catalog\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():
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Product\Contracts; namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Component;
+24
View File
@@ -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,
) {}
}
@@ -1,20 +1,28 @@
<?php <?php
namespace Modules\Core\Product\DTOs; namespace Modules\Core\Catalog\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\Product\Services\ProductIndexer's document fields, so * against Modules\Core\Catalog\Services\ProductIndexer's document fields, so
* filtering only works on stores where that indexer is registered and the index * filtering only works on stores where that indexer is registered and the index
* has been re-synced (see docs/product-listing.md). * has been re-synced (see docs/product-listing.md).
*/ */
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,
public readonly ?float $minPrice = null, public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null, public readonly ?float $maxPrice = null,
public readonly bool $inStockOnly = false,
) {} ) {}
} }
+24
View File
@@ -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',
};
}
}
@@ -1,10 +1,10 @@
<?php <?php
namespace Modules\Core\Product\Enums; namespace Modules\Core\Catalog\Enums;
/** /**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort` * Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Product\Services\ * clause against a field indexed as sortable by Modules\Core\Catalog\Services\
* ProductIndexer (see its getSortableFields()). Adding a case here requires the * ProductIndexer (see its getSortableFields()). Adding a case here requires the
* matching field to also be sortable in the index, re-synced via * matching field to also be sortable in the index, re-synced via
* `php artisan lunar:meilisearch:setup`. * `php artisan lunar:meilisearch:setup`.
@@ -1,12 +1,12 @@
<?php <?php
namespace Modules\Core\Product\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form; use Filament\Forms\Form;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Product\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/** /**
* Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting * Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting
@@ -18,7 +18,7 @@ class ProductOptionResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Form $form): Form
{ {
$options = app(ProductOptionTypeManager::class)->all() $options = collect(ProductOptionTypeManager::get()->all())
->keys() ->keys()
->mapWithKeys(fn (string $key) => [$key => Str::headline($key)]) ->mapWithKeys(fn (string $key) => [$key => Str::headline($key)])
->all(); ->all();
@@ -1,11 +1,11 @@
<?php <?php
namespace Modules\Core\Product\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form; use Filament\Forms\Form;
use Lunar\Admin\Support\Extending\RelationManagerExtension; use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Modules\Core\Product\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/** /**
* Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta * Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta
@@ -20,7 +20,7 @@ class ValuesRelationManagerExtension extends RelationManagerExtension
/** @var ProductOption $option */ /** @var ProductOption $option */
$option = $this->caller->getOwnerRecord(); $option = $this->caller->getOwnerRecord();
$type = app(ProductOptionTypeManager::class)->resolve($option->meta['option_type'] ?? null); $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) { if ($type === null) {
return $form; return $form;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Product\Observers; namespace Modules\Core\Catalog\Observers;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Lunar\Models\Product; use Lunar\Models\Product;
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Catalog\OptionTypes;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
/**
* Describes a 'color' ProductOption's values as carrying a hex code in
* `meta.hex`, editable via a Filament color picker. Registered automatically by
* `Modules\Core\Providers\CatalogServiceProvider` — a shop's admin still has to
* pick "Color" from the Option Type dropdown per-ProductOption for it to apply.
*/
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
@@ -0,0 +1,91 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Collection;
use Lunar\Models\Product;
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
* - ancestors (display) — [{id, name}, ...] ordered root-first, so a breadcrumb can
* render directly from a single indexed document with zero extra queries
* - product_count (display) — how many products are in this collection or any of
* its descendants, read from the *product* Meilisearch index at collection-index
* time (via `collection_ids`, see Modules\Core\Catalog\Services\ProductIndexer) —
* matches what ProductService::list(ProductFilters(collectionId: ...)) would
* return, not just direct assignment. Reflects the product index's state as of
* the last collection reindex, so re-run `lunar:search:index --refresh` after a
* product reindex if this needs to be current.
*
* 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', 'ancestors']);
}
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;
$data['ancestors'] = $model->ancestors
->sortBy('_lft')
->map(fn ($ancestor) => [
'id' => $ancestor->id,
'name' => $ancestor->translateAttribute('name'),
])
->values()
->all();
$data['product_count'] = Product::search('')
->options(['filter' => "collection_ids = \"{$model->id}\""])
->paginateRaw(perPage: 1, page: 1)
->total();
return $data;
}
}
+147
View File
@@ -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 ');
}
}
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Product\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -13,10 +13,17 @@ 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\Product\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 (ids, filterable) and collection_names (display) * - 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
@@ -24,12 +31,18 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - variants: sku, stock, purchasable, option values, prices, media * - variants: sku, stock, purchasable, option values, prices, media
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends) * - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags * - tags
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately * - reviews: {items: [...], count, average_rating} — items are public-safe fields
* excluded, it's PII with no storefront use), including staff replies, plus an * only (see mapReview() — reviewer_email is deliberately excluded, it's PII with
* average rating * no storefront use), including staff replies
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as * - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be * filterable, not channel assignment, so search results can't otherwise be
* scoped to products actually assigned+enabled on the current sales channel * scoped to products actually assigned+enabled on the current sales channel
* - in_stock (filterable) — true if ANY variant can currently be purchased at
* quantity 1, via ProductVariant::canBeFulfilledAtQuantity() (Lunar's own
* purchasability rule: `purchasable === 'always'` is always true regardless of
* stock, `in_stock` checks stock alone, anything else checks stock+backorder).
* Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md).
* *
* 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,10 +62,11 @@ class ProductIndexer extends BaseProductIndexer
...parent::getFilterableFields(), ...parent::getFilterableFields(),
'id', 'id',
'brand', 'brand',
'collections', 'collection_ids',
'price', 'price',
'slugs', 'slugs',
'channel_ids', 'channel_ids',
'in_stock',
]; ];
} }
@@ -68,6 +82,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',
@@ -85,20 +100,32 @@ class ProductIndexer extends BaseProductIndexer
$currency = Currency::getDefault(); $currency = Currency::getDefault();
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get(); $reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all(); $data['collections'] = $model->collections->map(fn ($collection) => [
$data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all(); 'id' => $collection->id,
'name' => $collection->translateAttribute('name'),
])->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();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all(); $data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
$data['price'] = $this->cheapestPrice($model, $currency); $data['price'] = $this->cheapestPrice($model, $currency);
$data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(); $data['reviews'] = [
$data['review_count'] = $reviews->count(); 'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(),
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1); 'count' => $reviews->count(),
'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('lunar_channels.id') ->pluck('lunar_channels.id')
->toArray(); ->toArray();
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
return $data; return $data;
} }
@@ -112,6 +139,7 @@ class ProductIndexer extends BaseProductIndexer
'purchasable' => $variant->purchasable, 'purchasable' => $variant->purchasable,
'options' => $variant->values->map(fn ($value) => [ 'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name), 'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle,
'value' => $this->translatedName($value->name), 'value' => $this->translatedName($value->name),
'meta' => $value->meta, 'meta' => $value->meta,
])->all(), ])->all(),
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Catalog\Services;
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
/**
* Resolves an admin-selected option type key to the `ProductOptionTypeInterface`
* describing it. The selection (which key a given `Lunar\Models\ProductOption` uses)
* is stored per-option in `ProductOption::meta['option_type']` — deliberately not
* tied to the option's `handle`, since a shop's own handle naming (e.g. transliterated
* Greek, legacy imports) shouldn't have to match a type's key.
*
* A singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`
* — a consuming app calls `ProductOptionTypeManager::get()->register([...])` from its
* own service provider `boot()`, rather than listing classes in a published config
* file.
*/
class ProductOptionTypeManager
{
private static ?self $instance = null;
/** @var array<string, class-string<ProductOptionTypeInterface>> */
private array $types = [];
private function __construct() {}
public static function get(): static
{
if (static::$instance === null) {
static::$instance = new static();
}
return static::$instance;
}
/**
* @param array<class-string<ProductOptionTypeInterface>> $types
*/
public function register(array $types): void
{
foreach ($types as $class) {
$this->types[$class::getKey()] = $class;
}
}
public function unregister(string $key): void
{
unset($this->types[$key]);
}
public function resolve(?string $key): ?ProductOptionTypeInterface
{
if ($key === null || ! isset($this->types[$key])) {
return null;
}
return app($this->types[$key]);
}
/**
* @return array<string, class-string<ProductOptionTypeInterface>>
*/
public function all(): array
{
return $this->types;
}
}
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Product\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Product\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
@@ -10,16 +10,16 @@ use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Product\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Product\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
/** /**
* Storefront product listing/filtering AND single-product lookup, all reading directly * Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Product\Services\ProductIndexer) - one data * from the Meilisearch index (Modules\Core\Catalog\Services\ProductIndexer) - one data
* source, no ->get() model hydration anywhere in this service. Callers get plain arrays * source, no ->get() model hydration anywhere in this service. Callers get plain arrays
* of the indexed document, not Eloquent models. * of the indexed document, not Eloquent models.
* *
* Full-text query search lives separately in Modules\Core\Product\Services\ * Full-text query search lives separately in Modules\Core\Catalog\Services\
* ProductSearchService; this service is for browsing/filtering without a search term. * ProductSearchService; this service is for browsing/filtering without a search term.
*/ */
class ProductService class ProductService
@@ -60,9 +60,63 @@ class ProductService
); );
} }
/**
* Facet value counts for the given filter/field, scoped to the SAME filters
* `list()` would apply. Note this does NOT exclude `$field` itself from
* `$filters` — e.g. `facets('brand', new ProductFilters(brand: 'Acme'))` would
* scope the counts to only "Acme" already, collapsing every other brand's count
* to whatever remains under that filter. For a standard "faceted sidebar" (every
* brand's count reflecting collection/price/stock filters but NOT the brand
* filter itself), build a `$filters` that omits the field being faceted on and
* apply that field's own filter separately in the UI/query layer.
*
* `$field` must be one of ProductIndexer's filterable fields; only discrete-value
* fields make sense here (`brand`, `in_stock`) — a numeric field like `price`
* would return one "facet" per exact price, not a usable range bucket. Use
* `priceRange()` for `price` instead.
*
* @return array<string, int> facet value => matching product count
*/
public function facets(string $field, ?ProductFilters $filters = null): array
{
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? [];
}
/**
* The min/max `price` across products matching the given filters (minus
* `minPrice`/`maxPrice` themselves, same "scoped but not self-collapsing"
* reasoning as `facets()` — a price slider's own bounds shouldn't shrink to
* whatever range is currently selected). Backed by Meilisearch's `facetStats`,
* not `facetDistribution` — the right feature for a numeric field's range,
* where `facets('price')` would otherwise return one entry per exact price.
*
* @return array{min: ?float, max: ?float} null/null if no product matches
*/
public function priceRange(?ProductFilters $filters = null): array
{
$filter = $this->buildFilter($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null;
return [
'min' => $stats['min'] ?? null,
'max' => $stats['max'] ?? null,
];
}
private function rawFacets(string $field, ?string $filter): array
{
return Product::search('')
->options([
'filter' => $filter,
'facets' => [$field],
'hitsPerPage' => 0,
])
->raw();
}
/** /**
* Look up a single product by its URL slug (any locale - slugs are indexed across * 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 * all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
* indexed product document, or null if no product has that slug. * indexed product document, or null if no product has that slug.
*/ */
public function getBySlug(string $slug): ?array public function getBySlug(string $slug): ?array
@@ -150,18 +204,27 @@ class ProductService
return collect($rawResponse['hits'] ?? [])->values()->all(); return collect($rawResponse['hits'] ?? [])->values()->all();
} }
private function buildFilter(?ProductFilters $filters): ?string /**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude filter
* fields to leave out even if set on $filters — e.g. priceRange() excludes
* 'price' so a price slider's own bounds don't shrink to whatever range is
* already selected on it.
*/
private function buildFilter(?ProductFilters $filters, array $exclude = []): ?string
{ {
if ($filters === null) { if ($filters === null) {
return null; return null;
} }
$clauses = Collection::make([ $clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null, 'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null, 'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'price' => Collection::make([
$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,
])->filter(); ])->filter()->join(' AND ') ?: null,
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND '); return $clauses->isEmpty() ? null : $clauses->join(' AND ');
} }
+25 -28
View File
@@ -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);
} }
} }
} }
+3 -3
View File
@@ -17,10 +17,10 @@ use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
class CorePlugin implements Plugin class CorePlugin implements Plugin
+2 -2
View File
@@ -9,7 +9,7 @@ use Lunar\Models\Language;
/** /**
* Cached read layer over Lunar's `languages` table — the single source both * Cached read layer over Lunar's `languages` table — the single source both
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and * Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Product\Services\ProductService) read * any other locale-aware code (e.g. Modules\Core\Catalog\Services\ProductService) read
* from, so the language list is fetched once per cache lifetime rather than once * from, so the language list is fetched once per cache lifetime rather than once
* per caller. Cached forever, invalidated via forget() by * per caller. Cached forever, invalidated via forget() by
* Modules\Core\Localization\Listeners\FlushLanguageCache on * Modules\Core\Localization\Listeners\FlushLanguageCache on
@@ -41,7 +41,7 @@ class LanguageCache
/** /**
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs * Every configured store locale code (e.g. ['el', 'en']) - for code that needs
* to enumerate all locales a TranslatedText attribute was indexed under (see * to enumerate all locales a TranslatedText attribute was indexed under (see
* Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than * Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes. * hardcoding locale codes.
* *
* @return array<int, string> * @return array<int, string>
@@ -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' => 'Μόνο διαθέσιμα προϊόντα'],
];
}
}
@@ -1,28 +0,0 @@
<?php
namespace Modules\Core\Product\OptionTypes;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
/**
* Reference implementation: describes a 'color' ProductOption's values as
* carrying a hex code in `meta.hex`, editable via a Filament color picker.
* Not auto-registered — a shop opts in via config('core.product_option_types').
*/
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
@@ -1,40 +0,0 @@
<?php
namespace Modules\Core\Product\Services;
use Illuminate\Support\Collection;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface;
/**
* Resolves an admin-selected option type key to the `ProductOptionTypeInterface`
* describing it. The selection (which key a given `Lunar\Models\ProductOption` uses)
* is stored per-option in `ProductOption::meta['option_type']` — deliberately not
* tied to the option's `handle`, since a shop's own handle naming (e.g. transliterated
* Greek, legacy imports) shouldn't have to match a type's key.
*
* The available keys come from `config('core.product_option_types')` — a plain list,
* not a config array, because the mapping from option to type is an admin's per-option
* choice made in the UI (see ValuesRelationManagerExtension/ProductOptionResourceExtension),
* not something config alone can express.
*/
class ProductOptionTypeManager
{
/**
* @return Collection<string, ProductOptionTypeInterface> keyed by getKey()
*/
public function all(): Collection
{
return collect(config('core.product_option_types', []))
->map(fn (string $class) => app($class))
->keyBy(fn (ProductOptionTypeInterface $type) => $type::getKey());
}
public function resolve(?string $key): ?ProductOptionTypeInterface
{
if ($key === null) {
return null;
}
return $this->all()->get($key);
}
}
@@ -5,12 +5,18 @@ namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\Product\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
class ProductServiceProvider extends ServiceProvider class CatalogServiceProvider extends ServiceProvider
{ {
public function boot(): void public function boot(): void
{ {
ProductOptionTypeManager::get()->register([
ColorOptionType::class,
]);
$observer = new ProductOptionReindexObserver; $observer = new ProductOptionReindexObserver;
ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option)); ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option));
+1 -1
View File
@@ -9,7 +9,7 @@ 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\Product\Services\ProductIndexer's review data would only refresh on * Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on
* the next full product reindex. * the next full product reindex.
*/ */
class ReviewServiceProvider extends ServiceProvider class ReviewServiceProvider extends ServiceProvider
@@ -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,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;
+1 -1
View File
@@ -38,7 +38,7 @@ class ProductReview extends Model implements HasMedia
* Unlike Product/ProductVariant, this model sits outside Lunar's own * Unlike Product/ProductVariant, this model sits outside Lunar's own
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
* what registers the 'small' conversion those models get automatically. Without * what registers the 'small' conversion those models get automatically. Without
* this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across * this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ * product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
* Exceptions\InvalidConversion the first time a review has an image, since * Exceptions\InvalidConversion the first time a review has an image, since
* $media->getUrl('small') has no matching conversion to resolve. * $media->getUrl('small') has no matching conversion to resolve.