Compare commits

...
26 Commits
Author SHA1 Message Date
arvanitakis f6ef0761d6 Feature: Creating Cart Service, Cart Events, Save For Later functionality, Adding Filament Views for viewing abandoned carts 2026-08-28 13:14:47 +03:00
arvanitakis a8ddbb8056 Feature: Creating Cart Views, rules for abandonment 2026-08-28 00:27:46 +03:00
arvanitakis e1299fafee Bump Version to 0.7.0 2026-08-27 23:09:52 +03:00
arvanitakis 1c14fabf44 Feature: Adding Facets, Updating Indexers 2026-08-27 23:09:33 +03:00
arvanitakis a2c3fd5457 Feature: Reviews restructuring and Creating Collection Indexer and Services 2026-08-27 12:11:01 +03:00
arvanitakis ba5a9523c8 Feat: Restructuring Products into the Concern Catalog, for future Collection services 2026-08-27 11:42:13 +03:00
arvanitakis 63caaf55c7 Bump Version to 0.6.1 2026-08-27 11:39:43 +03:00
arvanitakis e4342da44a Fix: Correcting shape of indexed products 2026-08-27 11:32:20 +03:00
arvanitakis e95ea4a43c Fix: Updating Product Options Registry and adding handle to the meilisearch index 2026-08-27 11:14:56 +03:00
arvanitakis cb3fe095d9 Bump Version to 0.6.0 2026-08-27 01:57:43 +03:00
arvanitakis eef473dbd2 Feature: Product option types to support multiple cases 2026-08-27 01:55:58 +03:00
arvanitakis 409db9c7bf Fix: Correcting Language Line fallback resolver 2026-08-27 01:28:37 +03:00
arvanitakis 594fa41527 Feature: Localization Restructuring to follow a stricter pattern 2026-08-27 01:08:34 +03:00
arvanitakis ef356a6397 Feature: Products Restructuring to follow a strict rule 2026-08-27 01:05:04 +03:00
arvanitakis b3b5ca740d Feature: Resolving translated fields based on current locale, while also resturning a correct length aware paginator for the results 2026-08-27 00:48:38 +03:00
arvanitakis d01d27f7ea Hotfix: Correcting ProductResolver when importing Products 2026-08-27 00:46:43 +03:00
arvanitakis b86fe78852 Hotfix: Adding a correct media conversions to handle small review images 2026-08-27 00:34:39 +03:00
arvanitakis 0edf7b156d Feature: Updating Product Service to handle multiple locales, fetching the correct locale, or, the default locale as fallback 2026-08-26 23:52:33 +03:00
arvanitakis ef9e9daab0 Bump version to 0.5.4 2026-08-26 18:34:26 +03:00
arvanitakis 751aff5939 Feature: Adding Sorting To Product List 2026-08-26 18:05:15 +03:00
arvanitakis 09844ec2b5 Bump Version to 0.5.3 2026-08-26 14:29:29 +03:00
arvanitakis 885923380d Hotfix: Selcting correct table name for channel Id 2026-08-26 14:29:08 +03:00
arvanitakis 235fdda4a7 Bump Version to 0.5.2 2026-08-26 13:28:08 +03:00
arvanitakis c2eb9bd66a Hotfix: Updating LocaleMiddleware to handle more than 2 languages 2026-08-26 13:26:29 +03:00
arvanitakis 356fbd73c5 Bump Version to 0.5.1 2026-08-26 13:16:07 +03:00
arvanitakis fa137c9a79 Feature: Adding back ChannelIds on Product Indexer 2026-08-26 13:14:32 +03:00
65 changed files with 3083 additions and 292 deletions
+77
View File
@@ -4,6 +4,83 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.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
### Added
- `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
- **Breaking:** `Modules\Core\Catalog\ProductService::list()` now returns a real `Illuminate\Pagination\LengthAwarePaginator` (built from the localized Meilisearch hits) instead of a plain `array{data, meta}` — gives callers normal Laravel pagination behaviour (`$products->links()`, standard JSON serialization) without ever touching Scout's raw `paginateRaw()` response directly. `getById()`/`getBySlug()` are unaffected (still return `?array`).
- `ProductService::withLocalizedFields()` (used by `list()`, `getById()`, `getBySlug()`) no longer hardcodes `name`/`description` as the only translated fields — it now reads every `TranslatedText` attribute on `Product` from `Lunar\Base\AttributeManifest` (the same source Lunar's own indexer reads), so a store's own custom translated attributes (e.g. `seo_title`, `seo_description`) are resolved and locale-stripped automatically with no code change here. Raw `{handle}_{locale}` keys (e.g. `name_el`, `seo_title_en`) are now stripped from every returned product, not just `name_*`/`description_*`.
- Extracted `Modules\Core\Localization\Services\LanguageCache` (cached read layer over Lunar's `languages` table: `all()`, `defaultLocale()`, `availableLocales()`, `forget()`) out of `LocaleMiddleware`, which previously owned this as private/static methods despite not being middleware-specific behavior. `LocaleMiddleware` now takes `LanguageCache` via constructor injection. `LocaleMiddleware::defaultLocale()`/`forgetLanguagesCache()` (static) are removed — use `app(LanguageCache::class)` or inject `LanguageCache` directly.
### Fixed
- `Modules\Core\MigrateImport\JudgeMe\Resolvers\ProductResolver::resolve()` picked whichever `lunar_urls` row matched a slug first, which can be a soft-deleted product left behind by an earlier import batch rather than the current live one — a store can easily end up with more than one `Product` row sharing the same slug across re-imports, since a soft-deleted product's URL row isn't cleaned up. This silently broke every downstream lookup for that handle (e.g. `Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter` logging "no product found for handle, skipping review" and dropping the row, even though a live product with that exact handle existed). Rewrote as a join against `lunar_products` — via `Product::query()`, so Eloquent's `SoftDeletes` global scope excludes trashed rows — so only a URL pointing at a live product resolves.
- `Modules\Core\Review\Models\ProductReview` had no `registerMediaConversions()` at all, unlike `Product`/`ProductVariant` which get one automatically from Lunar's own `Lunar\Base\StandardMediaDefinitions`. `Modules\Core\Search\ProductIndexer::mapMedia()` is shared across product, variant, and review media and always requests the `small` conversion — the first time a review had an attached image, indexing it threw `Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidConversion`, silently failing the product's `MakeSearchable` queue job (and everything queued after it, since Scout batches). Added a matching `small` conversion (300×300, same fit/border/background as Lunar's standard one) directly on `ProductReview`.
### Breaking
- Merged `Modules\Core\Catalog` and `Modules\Core\Search` into a single `Modules\Core\Product` concern, since both existed purely to serve `Product` (browsing/filtering vs. indexing/full-text search — two services, one concern), following a stricter subfolder convention (`Contracts/`, `Enums/`, `Services/`, `DTOs/`, `Models/`, etc. per concern) going forward:
- `Modules\Core\Catalog\ProductService` → `Modules\Core\Product\Services\ProductService`
- `Modules\Core\Catalog\ProductFilters` → `Modules\Core\Product\DTOs\ProductFilters`
- `Modules\Core\Catalog\ProductSort` → `Modules\Core\Product\Enums\ProductSort`
- `Modules\Core\Search\ProductIndexer` → `Modules\Core\Product\Services\ProductIndexer`
- `Modules\Core\Search\ProductSearchService` → `Modules\Core\Product\Services\ProductSearchService`
Consuming apps must update any direct references — notably `config/lunar/search.php`'s `'indexers'` map, which points at `ProductIndexer` by FQCN. `Modules\Core\Catalog\ProductOptionTypeInterface` (in-progress, not yet wired to anything) was deliberately left in place rather than moved.
- Reorganized `Modules\Core\Localization` under the same stricter per-concern subfolder convention — `Events/`, `Filament/`, `Listeners/` were already correctly categorized; four loose root files moved into typed buckets by structural role:
- `Modules\Core\Localization\LocaleMiddleware` → `Modules\Core\Localization\Middleware\LocaleMiddleware`
- `Modules\Core\Localization\LanguageCacheObserver` → `Modules\Core\Localization\Observers\LanguageCacheObserver`
- `Modules\Core\Localization\TranslationReader` → `Modules\Core\Localization\Services\TranslationReader`
- `Modules\Core\Localization\TranslationService` → `Modules\Core\Localization\Services\TranslationService`
`Modules\Core\Localization\Services\LanguageCache` (added earlier in this same unreleased version) already lived at its correct final path — unaffected. The `'locale'` route-middleware alias (registered in `LocalizationServiceProvider`) is unaffected for consuming apps using it by string alias rather than FQCN.
## [0.5.4] - 2026-08-26
### Added
- `Modules\Core\Catalog\ProductService::list()` accepts a `sort` parameter (new `ProductSort` enum: `PriceAsc`, `PriceDesc`, `Newest`), translated into a Meilisearch `sort` clause — `list()` previously had no way to order results, since it always searches with an empty query string and so has no relevance score to fall back on. `Modules\Core\Search\ProductIndexer::getSortableFields()` now also marks `price` sortable (Lunar's base indexer only marks `created_at`/`updated_at`/`skus`/`status`). Requires re-syncing index settings (`php artisan lunar:meilisearch:setup`) on existing stores. Documented in `docs/product-listing.md` ("Sorting").
## [0.5.3] - 2026-08-26
### Fixed
- `Modules\Core\Search\ProductIndexer::toSearchableArray()` threw `column reference "id" is ambiguous` on Postgres when computing `channel_ids` — `$model->channels()->wherePivot('enabled', true)->pluck('id')` joins `lunar_channels` and `lunar_channelables`, both of which have an `id` column, and the unqualified `pluck('id')` left Postgres unable to resolve which table's column to select (SQLite/MySQL tolerated the ambiguity). Qualified as `pluck('lunar_channels.id')`.
## [0.5.2] - 2026-08-26
### Fixed
- `Modules\Core\Localization\LocaleMiddleware`'s shared view data only ever surfaced a single alternate locale (`altLocale`/`altLocaleUrl`, found via `firstWhere('code', '!=', $current)`) — correct by coincidence for a 2-language store, but silently dropped every locale past the first "other" one found for a 3+ language store, with no error. Replaced with `altLocales`, a collection of every other configured language (`code`, `name`, `url` for the current route each), so a language switcher or `hreflang` tags scale to any number of locales. Documented in `docs/localization.md` ("Shared view data — language switcher and `hreflang` tags").
## [0.5.1] - 2026-08-25
### Added
- `Modules\Core\Search\ProductIndexer` now indexes `channel_ids` (filterable) — Lunar's base indexer only marks `status` as filterable, not channel assignment, so storefront search couldn't otherwise scope results to products actually assigned and enabled on the current sales channel. Computed from `$product->channels()->wherePivot('enabled', true)`. Ported from an older `Products` branch whose remote had been deleted; the branch's other, now-superseded `ProductIndexer` changes were dropped in favor of the richer indexer already on `master` (collections, price, variants, reviews — see `0.5.0`).
## [0.5.0] - 2026-08-24
### Added
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.5.0",
"version": "0.7.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -36,6 +36,7 @@
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
]
}
+16
View File
@@ -16,4 +16,20 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
|--------------------------------------------------------------------------
|
| How long a cart (that hasn't converted to a placed order) can go without
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
*/
'cart' => [
'abandoned_after' => '1 hour',
],
];
+272
View File
@@ -0,0 +1,272 @@
# Cart Admin Visibility
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
mechanics this resource reads from.
---
## Scope: only carts with a known customer or user
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
— an anonymous guest's session cart is excluded entirely.
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
member could act on — no name, no email, nothing to follow up with — so listing every guest
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
checkout step required — so scoping to "identifiable" here is broader than Shopify's
equivalent, not a copy of it.
---
## Four states, not two — and not `Cart::completed_at`
`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but
**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it;
the only hits are the property declaration and the cast. It is not a real signal. `Cart` has
no `status` column at all — every state below is derived from relations/timestamps, not a
single field.
`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two
distinct states together: no order ever started, vs. a draft order exists
(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
different reachability (checkout usually captures an email even for a guest), so
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
split:
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
active tab on page load.
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
- **Completed** — has an order with `placed_at IS NOT NULL`.
```php
// Ongoing
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
// Abandoned Cart
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
// Abandoned Checkout
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff());
// Completed
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
```
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
(potentially large) table.
### Abandoned Cart vs Abandoned Checkout — why they're not one bucket
Different purchase intent, different reachability, and different recovery strategy — see
`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short:
- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not
a near-purchase. Frequently unreachable (no email/identity at all for a true guest).
Recovery leans on on-site retargeting and ad remarketing rather than email.
- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper
committed to buying and something blocked completion. Checkout typically captures contact
info even for a guest, so this state is usually reachable. This is the state the
researched 1h/24h/72h recovery-email cadence targets specifically.
`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned`
mirror this same split (see "Events" below) rather than one combined event.
---
## Why this scales fine at a large cart count
Two things keep this cheap regardless of how many carts exist (10,000+):
- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's
query is active — a page only ever fetches one page's worth of rows, never the whole
table, "All" tab or not (and there is no "All" tab — see above).
- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in
`->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the
rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no
per-record `getStateUsing()` closure anywhere in this table doing its own query — that's
the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\
Services\ProductIndexer` for the general "compute once at index time / one aggregate
query, never per-row" principle this project follows elsewhere).
The one thing that **does** scan more rows as the cart count grows is
`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and
runs once per admin page load, not once per cart row.
---
## Navigation badge — abandoned cart count
```php
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->count();
}
```
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to
a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to
render the badge.
---
## The view page runs the cart's full calculate pipeline — once
`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s
computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`,
...) are plain public properties populated as a side effect of that pipeline — never
persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md`
Gotchas). This only runs on the single-record view page, not per row in the list table —
running the full 5-step pipeline for every row of a paginated list would be needless cost for
data the list doesn't display.
---
## Not built: staff editing a cart
The resource is deliberately read-only (`canCreate()` returns `false`, no edit page
registered). A cart is owned by the storefront's own add/update/remove flow
(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a
supported use case here.
---
## `CartService` — the storefront-facing API
`Modules\Core\Cart\Services\CartService` mirrors `Modules\Core\Catalog\Services\
ProductService`/`CollectionService`'s shape — one boboko-owned API a storefront calls, so
Lunar's own `CartSession`/`Cart` stay an implementation detail rather than something a
consuming app depends on directly.
- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`),
the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see
`docs/lunar.md`'s Cart gotchas).
- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over
`Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap
Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`,
etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.
- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated
Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via
`Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\
InvalidCouponException` on a bad code — `CouponString`'s cast only normalizes casing, it
doesn't validate anything, so setting `coupon_code` directly would silently accept a bogus
code and just not discount anything once calculated.
- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later"
below.
Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()`
etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a
matching domain event.
### Events — Lunar dispatches none of its own
`Lunar` dispatches zero cart events — no "item added," no "cart created" (see
`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after
the underlying Lunar operation completes:
`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`,
`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under
`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under
`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection"
below for why.
**None of these currently have a listener.** They're dispatched-but-unconsumed by design —
built so something downstream (reindexing, notifications, a future read-side reporting
service) has a hook to attach to, not because a concrete consumer exists today. This was a
deliberate decision, not an oversight — see the "don't build speculative infrastructure"
calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own
`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
model saves/deletes automatically, independent of actor. `Modules\Core\Logging\
ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is
hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin
actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to
`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity`
write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in
separation beyond reading `causer_type` per row — a real limitation worth knowing about, but
not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar
model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The
intended fix, if this becomes a real need, is a read-side service that queries `activity_log`
and classifies by `causer_type`/`log_name` — not touching every write site.
### Save for later
A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via
`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used
elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\
ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the
stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before**
Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every
`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price
upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or
callers needing a different totals accessor.
`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain
`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's
existing meta and merge in the flag change before calling `Cart::updateLine()`, or an
unrelated meta key set by something else would be silently wiped.
### Coupons
See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString`
just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the
`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or
`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted.
---
## Abandonment detection
"Abandoned" is a **derived** state (`Cart::updated_at` older than
`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
for anything currently stale.
### Cart/Checkout have zero abandonment-related writes — by design
`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An
earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid
refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an
Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed
from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a
cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart,"
on the very next tab-count check.
The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from
having any way to write abandonment state at all. Deduplication ("has this cart already been
notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet
built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping
`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys.
**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
all right now. That's fine today only because nothing consumes these events yet (see
"Events" above); it would need addressing before anything real listens for them.
---
## Recovery Sequences — design only, not built
See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
label) per abandonment type. Explicitly parked as an open design question, not scoped for
implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern,
and how far the touch model needs to flex (channel choice, value-based branching, segment
targeting) are all still undecided.
+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.
+63 -7
View File
@@ -50,7 +50,7 @@ fine — just keep admin/Livewire/webhook routes registered outside of it (as th
## Behavior
`Modules\Core\Localization\LocaleMiddleware`:
`Modules\Core\Localization\Middleware\LocaleMiddleware`:
1. Reads the first path segment (`request()->segment(1)`).
2. Matches it against `Lunar\Models\Language::code`.
@@ -68,7 +68,7 @@ and invalidated automatically. Adding, editing, or removing a language via the F
### How invalidation is wired (event-driven, not the observer itself)
`Modules\Core\Localization\LanguageCacheObserver` observes `Lunar\Models\Language`'s
`Modules\Core\Localization\Observers\LanguageCacheObserver` observes `Lunar\Models\Language`'s
`created`/`updated`/`deleted` Eloquent events, but it's a thin trigger only — it doesn't do any
invalidation work itself. It dispatches one of three events from
`Modules\Core\Localization\Events` (`LanguageCreated`, `LanguageUpdated` — carrying the old
@@ -105,6 +105,33 @@ $language = $request->attributes->get('language'); // Lunar\Models\Language in
Use `$language->id` when querying Lunar's translatable content (e.g. `Url::where('language_id', ...)`).
### Shared view data — language switcher and `hreflang` tags
The middleware also shares two variables with every view, via `View::share()`, so a layout's
language switcher or `hreflang` tags don't have to recompute the language list themselves:
```blade
{{-- current locale --}}
{{ $currentLocale }} {{-- e.g. "el" --}}
{{-- every OTHER configured language, each with its own URL for the current page --}}
@foreach ($altLocales as $altLocale)
<a href="{{ $altLocale['url'] }}" hreflang="{{ $altLocale['code'] }}">{{ $altLocale['name'] }}</a>
@endforeach
```
`$altLocales` is a **collection**, not a single value — deliberately, so it scales to any number
of configured languages rather than assuming exactly two. Each entry is a plain array:
| Key | Description |
|---|---|
| `code` | The language's `Lunar\Models\Language::code` (e.g. `en`) |
| `name` | The language's display name |
| `url` | The **current route**, re-generated with that language's code — via `route($routeName, [...])` when the current request matched a named route, or a bare `/{code}` fallback otherwise |
A 3+ language store gets one `$altLocales` entry per additional language automatically — nothing
about this shape assumes or special-cases a two-language store.
---
## Single-language shops
@@ -151,12 +178,41 @@ namespaced groups so nothing collides. `__()` resolves the translation for whate
`App::getLocale()` currently is, which `LocaleMiddleware` already sets per-request (see
"Behavior" above) — no extra wiring needed between the two systems.
### Fallback locale follows the store's default language, not `config('app.fallback_locale')`
`spatie/laravel-translation-loader`'s stock `LanguageLine::getTranslation()` falls back to
`config('app.fallback_locale')` — a static `.env` value — when a key has no text for the current
locale. That's a second, disconnected "default language" concept: an admin changing the default
language via the Filament **Languages** resource has no effect on it, so an untranslated label
could silently fall back to the wrong language.
`Modules\Core\Localization\Models\LanguageLine` overrides `getTranslation()` to fall back to
`LanguageCache::defaultLocale()` instead — the same `languages.default` flag `LocaleMiddleware`
already treats as the single source of truth. It's swapped in via
`config('translation-loader.model')` (the package's own documented extension point for
"any model that extends `LanguageLine`"), set in `LocalizationServiceProvider::register()` so it
wins regardless of provider boot order (Laravel's `mergeConfigFrom()` only fills in config keys
not already set, so an explicit `register()`-time set always beats the package's own default).
No consuming app configuration needed — this is automatic once `LocalizationServiceProvider` is
registered.
### Seeding
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
`lunar:install`), guarded by `LanguageLine::where('group', 'storefront')->exists()` — same
idempotent pattern as the rest of that command, safe to run unattended on every boot.
`review.*`, `shop.*`, `pagination.*`, English + Greek) lives in
`Modules\Core\Localization\Services\StorefrontLabels::all()` — kept as its own class, separate
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
@@ -168,13 +224,13 @@ a third language automatically adds a third input, no resource changes needed.
### `TranslationService` — writes go through here, not the model directly
`Modules\Core\Localization\TranslationService` wraps create/update/delete on `LanguageLine` and
`Modules\Core\Localization\Services\TranslationService` wraps create/update/delete on `LanguageLine` and
dispatches a domain event after each write, following this project's standard event-driven
pattern (see `modules.md`'s "Splitting Service Providers" / event-listener convention —
the same shape as `Modules\Core\Auth\Events\UserCreated`):
```php
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
app(TranslationService::class)->create('storefront', 'nav.wishlist', [
'en' => 'Wishlist',
+60 -3
View File
@@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`.
```php
use Lunar\Facades\CartSession;
$cart = CartSession::current(); // calculates totals; returns null if no cart
$cart = CartSession::current(); // returns null unless a cart already exists in
// session — does NOT auto-create one (see Gotchas)
$cart = CartSession::manager(); // force-creates a cart if none exists yet — use
// this (or __call forwarding, see Gotchas) for
// "give me a cart to add to" flows
$cart->recalculate(); // force recalculation
CartSession::createOrder(); // creates order, removes cart from session
@@ -563,6 +567,39 @@ CartSession::forget(); // clear session (soft deletes cart by def
CartSession::forget(delete: false); // clear session, keep cart in DB
```
Session/identity: the active cart's id is stored under session key `lunar.cart_session.session_key`
(default `lunar_cart`). `CartSession`'s underlying manager (`Lunar\Managers\CartSessionManager`) —
not `Lunar\Base\CartSessionInterface`, which is stale/incomplete, see Gotchas — resolves the current
cart from that session key, falling back to the authenticated user's active cart
(`$user->carts()->active()->first()`) if the session has none.
### `config/lunar/cart_session.php`
| Key | Default | Meaning |
|---|---|---|
| `session_key` | `'lunar_cart'` | Laravel session key storing the active cart id. |
| `auto_create` | `false` | Whether `CartSession::current()` auto-creates a cart when none exists — it does **not**, by default (see Gotchas). |
| `allow_multiple_orders_per_cart` | `false` | If false, a cart with a completed order is abandoned in favor of a fresh cart on next fetch. |
| `delete_on_forget` | `true` | Whether `forget()` (called on logout) soft-deletes the cart — see the auth-policy note above. |
### `config/lunar/cart.php` (cart-line-relevant keys)
| Key | Default | Meaning |
|---|---|---|
| `auth_policy` | `'merge'` | Guest→user cart reconciliation on login: `merge` or `override`. |
| `pipelines.cart` | `CalculateLines, ApplyShipping, ApplyDiscounts, CalculateTax, Calculate` | Steps run on `$cart->calculate()`. |
| `pipelines.cart_lines` | `[GetUnitPrice::class]` | Steps run per-line before cart-level calc. |
| `actions.add_to_cart` | `AddOrUpdatePurchasable::class` | Swappable action behind `Cart::add()`. |
| `actions.get_existing_cart_line` | `GetExistingCartLine::class` | Line-matching logic for add-or-merge (see "Adding items" above). |
| `actions.update_cart_line` | `UpdateCartLine::class` | Behind `Cart::updateLine()`. |
| `actions.remove_from_cart` | `RemovePurchasable::class` | Behind `Cart::remove()`. |
| `validators.add_to_cart` | `[CartLineQuantity, CartLineStock]` | Run before add. |
| `validators.update_cart_line` | `[CartLineQuantity, CartLineStock]` | Run before update. |
| `validators.remove_from_cart` | `[]` | None by default. |
| `eager_load` | 7 relation paths (currency, `lines.purchasable.*`, `lines.cart.currency`) | Auto-eager-loaded whenever the session manager fetches a cart by id. Does **not** include `addresses`/`shippingAddress`/`billingAddress`, `discounts`, or `customer` — add these yourself if needed, to avoid N+1s. |
| `prune_tables.enabled` | `false` | Whether scheduled cart pruning runs. |
| `prune_tables.prune_interval` | `90` (days) | Age threshold for pruning. |
### Adding items
```php
@@ -573,6 +610,11 @@ $cart->addLines([
]);
```
`add()` matches an existing line by purchasable **and exact `meta` equality** (config
`lunar.cart.actions.get_existing_cart_line`, default `GetExistingCartLine`) — if it matches, the
existing line's quantity is incremented instead of a new line being created; any difference in
`meta` (e.g. a different chosen option) makes it a separate line for the same purchasable.
### Updating and removing
```php
@@ -664,6 +706,14 @@ class MyPipeline
`merge` — guest cart items combine with user's existing cart on login.
`override` — guest cart replaces user's cart.
This is wired via `Lunar\Listeners\CartSessionAuthListener`, listening on Laravel's own
`Illuminate\Auth\Events\Login`/`Logout`. On login, if the session already has a cart with no
`user_id` yet, it associates that cart to the user (running the policy above); if the session has
no cart at all, it looks up and resumes the user's own active cart instead. **On logout, it calls
`CartSession::forget()`** — which, per `cart_session.delete_on_forget` (default `true`), **soft-
deletes the cart**. A logged-in customer's cart is gone on logout unless that config is set to
`false`.
### Shipping options
```php
@@ -1206,6 +1256,13 @@ 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.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Search\ProductIndexer::translatedName()`.
- **`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\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.
- **`CartSession::current()` returns `null` for a fresh visitor by default.** `cart_session.auto_create` defaults to `false`, so nothing auto-creates a cart just from checking `current()`. Use `CartSession::manager()` (force-creates) for an "add to cart" flow, or rely on the fact that `add()`/`remove()`/etc. auto-create via `__call` forwarding (next entry) — don't gate an add-to-cart button on `current() !== null`, it will be null for every guest who hasn't added anything yet.
- **`CartSession`'s facade/interface don't declare `add()`, `remove()`, `updateLine()`, `clear()`, etc. at all — they work anyway, via `__call` magic.** `CartSessionManager::__call()` forwards any undeclared method call straight to the underlying `Cart` model (auto-creating one first if needed). So `CartSession::add($variant, 2)` genuinely works, but neither the facade's `@method` docblock nor `Lunar\Base\CartSessionInterface` mention it — reading either in isolation makes it look unsupported. Trust the manager's source (`Lunar\Managers\CartSessionManager`), not the interface, which is also missing several real methods (`manager()`, `createOrder()`, the shipping-estimate methods) and has a stale signature for `current()`.
- **`Cart::calculate()` is a no-op if totals already look populated — even right after you mutated lines with raw Eloquent.** It's memoized via `isCalculated()` (true when `total` and every line's `total` are non-blank). Every built-in mutator (`add`, `remove`, `updateLine`, `clear`, `associate`, …) already calls `$this->refresh()->recalculate()` to force past this memo — but custom code that touches `CartLine` rows directly (raw `update()`, a queued job, a migration) must call `$cart->recalculate()` itself, or `total`/`subTotal`/etc. silently stay stale.
- **`CartLine`'s computed properties (`unitPrice`, `subTotal`, `total`, `taxAmount`, …) are plain public properties, not DB columns or Eloquent attributes.** A raw `CartLine::find($id)` (no `calculate()` having run on its owning cart) has all of these as `null`/unset — they only populate as a side effect of the owning `Cart`'s pipeline running. Don't read them off a line fetched outside of `CartSession`/`Cart::add()` etc. without calling `$cart->calculate()` first.
- **Logging out deletes the cart by default.** `CartSessionAuthListener::logout()` calls `CartSession::forget()`, and `cart_session.delete_on_forget` defaults to `true` — so a logged-in customer's cart is soft-deleted the moment they log out, guest or not. Set `delete_on_forget` to `false` in `config/lunar/cart_session.php` if carts should survive a logout.
- **Lunar dispatches no cart events at all** — no "item added," "cart created," "line removed," nothing under `Lunar\Events\Cart*`/`CartLine*` exists (unlike products/collections, which have their own Scout indexing hooks). The only reactive surface is `CartLineObserver` (`creating`/`updating`, and it only validates the purchasable type — doesn't dispatch anything). If a feature needs to react to cart changes (reindexing, abandoned-cart notifications, analytics), it has to be built from scratch on plain Eloquent model events (`CartLine::created`, etc.) — there's no Lunar-native pattern to hook into.
- **No Filament admin resource exists for `Cart`/`CartLine`.** Carts aren't visible anywhere in the admin panel except indirectly through an order's `cart` relationship once that cart has become an order. Don't assume there's an admin cart-viewer to check against when debugging — there isn't one.
+111 -29
View File
@@ -1,10 +1,10 @@
# Product Listing
`Modules\Core\Catalog\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
Meilisearch index rather than the database. One data source for everything this service does.
This is separate from `Modules\Core\Search\ProductSearchService` (see `product-search.md`), which
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
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
`->get()`, which would re-hydrate Eloquent models from the database. This means the index has to
carry everything a detail page needs (variants, prices, options, media, reviews — see below), not
just the trimmed fields a listing page needs. `Modules\Core\Search\ProductIndexer` is built to
just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to
carry that full shape.
---
@@ -22,61 +22,100 @@ carry that full shape.
## Usage
```php
use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class);
// List everything, paginated
$result = $service->list(perPage: 24, page: 1);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator,
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator.
$products = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, and/or price range
$result = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0),
// Filter by collection, brand, price range, and/or stock
$products = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24,
page: 1,
);
$result['data']; // array of Meilisearch documents (plain arrays, not models)
$result['meta']['total'];
$result['meta']['per_page'];
$result['meta']['current_page'];
$result['meta']['last_page'];
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total();
$products->perPage();
$products->currentPage();
$products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual
// Single product, by primary key
$product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
// 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.
`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\Search\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,
status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as
filterable. `Modules\Core\Search\ProductIndexer` extends it to add everything `ProductService`
filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService`
needs, listing and detail alike:
| Field | Source | Notes |
|---|---|---|
| `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
| `collections` | `$product->collections->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. |
| `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. |
| `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. |
| `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. |
| `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. |
| `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. |
| `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. |
| `tags` | `$product->tags->pluck('value')` | Display only. |
| `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. |
| `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). |
| `reviews`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. |
| `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. |
`description` and other translated attributes are indexed as-is, including any HTML markup
(e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description
sourced from `ProductService`'s results must treat it as trusted HTML.
`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.
**`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike
`Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not
@@ -85,13 +124,41 @@ indexer's `translatedName()` reads the array directly instead. See `docs/lunar.m
---
## Locale resolution: `name`, `description`, and any other translated attribute
Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}`
field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same
for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw
document in Meilisearch carries all of them side by side, since a document is written once but
read across many different-locale requests.
`ProductService` resolves these back down to a single value per request. For every result it
returns (`list()`'s items, `getById()`, `getBySlug()`), it:
1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the
same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list,
so a store's own custom translated attributes are picked up automatically with no change here.
2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}`
(`LanguageCache::defaultLocale()`) if the current locale has no translation — e.g. a product with
no English copy yet still shows its Greek name on `/en/` rather than rendering blank.
3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** —
callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale
fields the index actually stores.
`description` and other translated attributes are otherwise indexed as-is, including any HTML
markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a
description sourced from `ProductService`'s results must treat it as trusted HTML.
---
## Reviews
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as
`reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the
product has no reviews). Only public-safe fields are included — **`reviewer_email` is deliberately
excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are
included, since they're meant to be shown alongside the review.
`Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under
a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews,
`average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe
fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no
storefront use. `reply`/`replied_at` (the staff response) are included, since they're meant to be
shown alongside the review.
A review is created/edited independently of its product (a customer submission, a staff reply)
— its own save doesn't touch the `Product` row, so the product's own model events never fire.
@@ -113,13 +180,28 @@ variants don't.
---
## Sorting
`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
`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
`ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see
below) — sortable attributes are index settings, not computed per-query, same as filterable ones.
Omitting `sort` leaves Meilisearch's default ordering, which is meaningless here since `list()`
always searches with an empty query string (`Product::search('')`) — there's no relevance score to
rank by, so results come back in whatever order the index returns them absent an explicit sort.
---
## Registering the indexer
Not automatic — an app opts in via its own `config/lunar/search.php`:
```php
'indexers' => [
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class,
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
// ...other model indexers unchanged
],
```
+113
View File
@@ -0,0 +1,113 @@
# Product Option Types
Lunar's `ProductOption`/`ProductOptionValue` are generic by design — a "Color" option
and a "Size" option are both just a handle, a translated name, and a list of values.
Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing in
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.
`Modules\Core\Catalog\Contracts\ProductOptionTypeInterface` describes how a category
of option behaves — what structured data its values carry in `meta`, and how an
admin edits that data — without introducing a new model. `ProductOption`/
`ProductOptionValue` stay exactly as Lunar defines them.
---
## Registering a type
A shop registers a type class from its own service provider's `boot()`, the same
shape as `Modules\Core\Notification\NotificationRegistry`:
```php
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
ProductOptionTypeManager::get()->register([
\App\ProductOptions\ColorOptionType::class,
]);
```
Not a published config array — the mapping isn't per-`ProductOption`, so there's
nothing for a shop to *key* by. Instead, an admin picks a type per-option from a
dropdown on the `ProductOption` edit form itself (see below); the choice is stored
in `ProductOption::meta['option_type']`, deliberately **not** tied to the option's
`handle` (a shop's own handle naming — transliterated Greek, legacy import slugs —
shouldn't have to match a type's key).
A `ProductOption` with no type selected behaves exactly as stock Lunar does — plain
name/position, no extra meta form.
---
## Writing a type
```php
namespace App\ProductOptions;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
```
`getMetaForm()` returns Filament form components, keyed under `meta.*` dot notation
— the path they save to on `ProductOptionValue::meta` (cast as `AsArrayObject`, a
plain jsonb column). `getKey()` is the identifier used in the admin's "Option Type"
dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the
`ProductOption::handle`.
A reference implementation ships at `Modules\Core\Catalog\OptionTypes\ColorOptionType`,
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
`Modules\Core\Catalog\Services\ProductOptionTypeManager` is a singleton registry:
- `get(): static` — the shared instance.
- `register(array $types): void` — registers one or more type classes, keyed
internally by `getKey()`.
- `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
(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's
classes needed:
- `Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension` extends
`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
type is enabled.
- `Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension` extends
the "Values" tab's form. Its `extendForm()` reads
`$option->meta['option_type']` off the owning `ProductOption`, resolves it via
`ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name
field. A `ProductOption` with no type selected gets the stock form unchanged.
---
## Reading the value back
Storefront code reads `ProductOptionValue::meta` like any other jsonb column — e.g.
`$value->meta['hex']` for a color swatch. `ProductOptionTypeManager` is an admin-side
concern only (describing *how to edit* the meta); nothing requires the storefront to
go through it to *read* the meta.
+3 -3
View File
@@ -1,6 +1,6 @@
# Product Search
`Modules\Core\Search\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.
---
@@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request).
## Usage
```php
use Modules\Core\Search\ProductSearchService;
use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
@@ -36,7 +36,7 @@ Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product`
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
`Modules\Core\Localization\LocaleMiddleware` (see `localization.md`), so callers in controllers
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers
don't need to pass it explicitly.
---
+128
View File
@@ -0,0 +1,128 @@
# Cart/Checkout Recovery Strategies — Design Notes
**Status: open design discussion, not scoped or built.** This is a record of the
reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't
have to be re-derived from scratch later. Nothing in this document is implemented.
See `docs/cart.md` for what's actually built today (the four-state cart classification,
`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`).
---
## Why Abandoned Cart and Abandoned Checkout need different strategies
Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent
signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order
exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout
typically captures an email/address even for a guest.
That difference in intent and reachability drives genuinely different marketing strategy, not
just a different admin filter:
### Abandoned Cart strategy — re-engagement, not completion
- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on
return visits) — often the only viable channel, since email may not exist yet.
- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here
specifically because it works off a browser/device signal, not an email address — the one
thing reliably available for an anonymous cart.
- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is
weak, so aggressive discounting is often poor ROI: it trains browsers who were never close
to buying to expect a coupon.
- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out,
sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule.
### Abandoned Checkout strategy — completion, not re-engagement
- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives —
conversion drops sharply with delay, since the shopper is often still in a "was about to
buy" mental state within the first hour.
- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart
contents/total, occasionally a countdown or limited-time incentive on later touches.
- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the
2nd/3rd touch is standard, because it's nudging someone who already decided to buy past
whatever blocked them (price shock, a broken payment step, indecision on shipping cost) —
not manufacturing demand from nothing.
- **SMS is more viable** — checkout often captures a phone number, and the higher intent
justifies a more direct channel than for cart-stage.
---
## The broader strategy space (beyond cadence + discount)
Raised as context for how far a "Recovery Sequence" feature might eventually need to flex,
without committing to building any of it yet:
**Message-content strategies**
- Social proof ("X people have this in their cart," reviews shown in the reminder)
- Scarcity/urgency framing (low-stock count, countdown timer on an offer)
- Personalized alternatives — a cheaper or complementary item instead of just re-showing the
abandoned one, useful when the likely blocker was price
**Channel strategies**
- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section)
- SMS — checkout-stage specifically, opt-in required
- Push notifications — not relevant yet given this project's storefront maturity, noted for
completeness
- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at
all, arguably the highest-value channel for Abandoned Cart specifically
- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting)
**Escalation/segmentation strategies**
- Value-based branching — a high-value abandoned checkout might skip straight to a bigger
incentive rather than waiting through a full ladder
- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing
either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what
stopped you?" survey) instead of another discount
- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant
"welcome discount" framing instead of a generic recovery email, since the blocker was
likely trust/unfamiliarity rather than price
**Timing refinement**
- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay
technically elapsed)
- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more
urgent first touch than a cart of always-in-stock staples
---
## First-pass feature shape (discussed, not finalized)
An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an
ordered sequence of **touches**. Each touch is three ideas:
1. **How long to wait** since the abandonment began
2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the
system rather than inventing a new pricing concept
3. **A label**, so staff can see what a touch represents in the admin UI
The system continuously re-evaluates every abandoned cart/checkout against its sequence, and
when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this
feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is
explicitly out of scope for this feature; something else, not yet designed, would consume that
signal.
### What this requires that isn't built yet
- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a
sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`,
which keeps moving every time the cart (or its own bookkeeping) is written to. This is the
same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section —
fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for
this feature, not a separate concern.
- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart
abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to
check "which touch, if any, is now due," for as long as it stays unrecovered.
### Still undecided
- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) —
candidates raised: a new `Recovery` concern, or `Marketing`. Not decided.
- **How far the touch model needs to flex.** The three-idea shape above (delay, discount,
label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel
choice, value-based branching, or segment targeting from the broader strategy list above.
Whether those get folded into the touch model, layered on top some other way, or deliberately
left out of v1 is unresolved.
- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled
customer touch based on a triggering condition" mechanism that cart/checkout abandonment
happens to be the first use case for.
@@ -0,0 +1,84 @@
<?php
namespace Modules\Core\Cart\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Recovery\Events\CartAbandoned;
use Modules\Core\Recovery\Events\CheckoutAbandoned;
/**
* "Abandoned" is a derived state (Cart::updated_at older than
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
* via a normal Eloquent write, so there's no model-event hook to dispatch
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
* place that moment gets detected; run it on a schedule (see docs/cart.md).
*
* Splits Cart::scopeActive()'s two branches into their own events —
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
* not one combined "abandoned" state: a cart with no order at all is a much
* weaker purchase-intent signal than one with a draft order that was never
* placed.
*
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
* earlier version recorded an "already notified" marker on Cart::meta/
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
* which un-staled the very cart being marked abandoned (the same field
* abandonment staleness is computed from) — see docs/cart.md's former
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
* state at all; every cart still matching the query below refires its event
* on every run until Recovery (not yet built — see
* docs/recovery-strategies.md) owns its own dedup/tracking table.
*/
class DetectAbandonedCarts extends Command
{
protected $signature = 'boboko:cart:detect-abandoned';
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
public function handle(): void
{
$cutoff = CartResource::abandonedCutoff();
$cartsAbandoned = 0;
$checkoutsAbandoned = 0;
Cart::query()
->whereDoesntHave('orders')
->where('updated_at', '<=', $cutoff)
->with('lines')
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
foreach ($carts as $cart) {
if ($cart->lines->isEmpty()) {
continue;
}
Event::dispatch(new CartAbandoned($cart));
$cartsAbandoned++;
}
});
Cart::query()
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', $cutoff)
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
foreach ($carts as $cart) {
$order = $cart->orders->first();
if ($order === null) {
continue;
}
Event::dispatch(new CheckoutAbandoned($cart, $order));
$checkoutsAbandoned++;
}
});
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCleared
{
/**
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
* Snapshot of every line that was in the cart before clearing — Cart::clear()
* deletes all rows directly, so nothing here can be fresh CartLine instances.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $lines,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponApplied
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartCouponRemoved
{
public function __construct(
public readonly Cart $cart,
public readonly string $code,
) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineAdded
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* The reverse of CartLineSaved — a previously saved-for-later line moved back
* into the purchasable cart (now counted in totals again).
*/
class CartLineMovedToCart
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
class CartLineRemoved
{
/**
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
* Snapshot of the removed line — the row is already deleted by the time this
* event dispatches, so nothing here can be a fresh CartLine model instance.
*/
public function __construct(
public readonly Cart $cart,
public readonly array $line,
) {}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
/**
* A line was moved OUT of the purchasable cart and into "saved for later" —
* not a removal (the row still exists), but distinct from CartLineUpdated
* since it's a state transition worth its own hook (e.g. abandoned-cart
* recovery treating a saved line very differently from a deleted one).
*/
class CartLineSaved
{
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
) {}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Cart\Events;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
class CartLineUpdated
{
/**
* @param array{quantity: int, meta: array} $old Snapshot before the update.
*/
public function __construct(
public readonly Cart $cart,
public readonly CartLine $line,
public readonly array $old,
) {}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Cart\Exceptions;
use RuntimeException;
/**
* Thrown by CartService::applyCoupon() when the given code doesn't match any
* currently-active, non-exhausted Discount — Lunar's own
* Discounts::validateCoupon() only returns a bool, it has no matching
* exception type of its own to reuse here.
*/
class InvalidCouponException extends RuntimeException
{
public function __construct(public readonly string $code)
{
parent::__construct("The coupon code \"{$code}\" is not valid.");
}
}
@@ -0,0 +1,120 @@
<?php
namespace Modules\Core\Cart\Filament\Resources;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
/**
* Read-only — a cart is managed entirely through the storefront (add/update/remove
* line, checkout), never hand-edited by staff. Scoped to carts with a known
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
* identity a staff member could act on (no name, no email, nothing to follow up
* with), so listing every such row would be noise, not a real admin capability —
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
* to follow a precedent from).
*/
class CartResource extends Resource
{
protected static ?string $model = Cart::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart';
protected static ?string $pluralModelLabel = 'Carts';
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
}
/**
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
* states (`active()` already covers "no order at all" and "draft order,
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
* — the badge is meant to answer "how many carts might need following up
* on," not the total including ones someone is actively shopping in right
* now.
*/
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
}
/**
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
* different things together: a cart someone is actively shopping in right now,
* and one that's genuinely been left behind. Lunar tracks no time-based
* staleness signal of its own — `Cart::updated_at` plus a configurable
* threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what
* this resource uses to tell them apart. A cart with no recent activity is
* "Abandoned"; anything more recent is "Ongoing".
*/
public static function abandonedCutoff(): Carbon
{
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('Cart')
->sortable(),
Tables\Columns\TextColumn::make('customer.full_name')
->label('Customer')
->placeholder('—')
->searchable()
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
Tables\Columns\TextColumn::make('user.email')
->label('User')
->placeholder('—')
->searchable(),
Tables\Columns\TextColumn::make('lines_count')
->label('Lines')
->counts('lines')
->sortable(),
Tables\Columns\TextColumn::make('lines_sum_quantity')
->label('Items')
->sum('lines', 'quantity')
->sortable(),
Tables\Columns\TextColumn::make('currency.code')
->label('Currency'),
Tables\Columns\TextColumn::make('updated_at')
->label('Last activity')
->dateTime()
->sortable(),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->defaultSort('updated_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListCarts::route('/'),
'view' => Pages\ViewCart::route('/{record}'),
];
}
public static function canCreate(): bool
{
return false;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ListCarts extends ListRecords
{
protected static string $resource = CartResource::class;
/**
* `Cart::completed_at` is declared/cast on the model but never actually written
* anywhere in Lunar core — it's dead, not a real "did this convert" signal.
* "Completed" instead means the cart has an order with `placed_at` set (a
* placed, not just drafted, order).
*
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
* distinct states: no order started at all, vs. a draft order exists
* (`placed_at IS NULL`) but was never placed — checkout was started, not
* finished. That's a real difference in purchase intent (a cart with a
* draft order is a much stronger signal than one with no order at all) and
* in reachability (checkout usually captures an email even for a guest),
* so they get separate tabs rather than one combined "no order yet"
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
*
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
* signal of its own, so recent activity is the only thing distinguishing a
* cart someone is shopping in right now from one genuinely left behind.
*/
public function getTabs(): array
{
return [
'ongoing' => Tab::make('Ongoing')
->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
'abandoned_cart' => Tab::make('Abandoned Cart')
->modifyQueryUsing(fn (Builder $query) => $query
->whereDoesntHave('orders')
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'abandoned_checkout' => Tab::make('Abandoned Checkout')
->modifyQueryUsing(fn (Builder $query) => $query
->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
->where('updated_at', '<=', CartResource::abandonedCutoff())),
'completed' => Tab::make('Completed')
->modifyQueryUsing(fn (Builder $query) => $query->whereHas(
'orders',
fn (Builder $query) => $query->whereNotNull('placed_at'),
)),
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Filament\Resources\CartResource;
class ViewCart extends ViewRecord
{
protected static string $resource = CartResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('viewCustomer')
->label('View Customer')
->icon('heroicon-o-user')
->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id]))
->visible(fn (Cart $record) => $record->customer_id !== null),
];
}
/**
* Cart's computed properties (subTotal/total/etc.) are plain public properties
* populated as a side effect of the pipeline calculate() runs — never persisted,
* so they don't exist on a plain Eloquent-fetched record. Calculated once here
* (a single view page load), not per-row in the list table, since running the
* full pipeline for every row of a paginated table would be expensive for no
* real benefit — see docs/lunar.md's Cart gotchas.
*/
protected function resolveRecord(int|string $key): Cart
{
/** @var Cart $cart */
$cart = parent::resolveRecord($key);
return $cart->calculate();
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('Cart')
->columns(3)
->schema([
TextEntry::make('id'),
TextEntry::make('customer.full_name')
->label('Customer')
->placeholder('—')
->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null),
TextEntry::make('user.email')
->label('User')
->placeholder('—'),
TextEntry::make('currency.code')
->label('Currency'),
TextEntry::make('completedOrderPlacedAt')
->label('Ordered at')
->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at'))
->dateTime()
->placeholder('Not ordered'),
TextEntry::make('updated_at')
->label('Last activity')
->dateTime(),
]),
Section::make('Lines')
->schema([
RepeatableEntry::make('lines')
->hiddenLabel()
->schema([
TextEntry::make('purchasable.sku')
->label('SKU')
->placeholder('—'),
TextEntry::make('quantity'),
TextEntry::make('unitPrice')
->label('Unit price')
->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'),
TextEntry::make('total')
->label('Line total')
->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'),
])
->columns(4),
]),
Section::make('Totals')
->columns(3)
->schema([
TextEntry::make('subTotal')
->label('Subtotal')
->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'),
TextEntry::make('discountTotal')
->label('Discount')
->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'),
TextEntry::make('taxTotal')
->label('Tax')
->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'),
TextEntry::make('total')
->label('Total')
->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—')
->weight('bold'),
]),
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Cart\Pipelines;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\CartLine as CartLineContract;
/**
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
* zeroes out unitPrice/unitPriceInclTax for any line flagged
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
* since CalculateLines sums every CartLine unconditionally with no meta-based
* exclusion of its own, zeroing the price here (rather than patching subTotal
* after the fact) is what makes every downstream total naturally correct
* without a second pass.
*/
class ZeroSavedForLaterPrice
{
public function handle(CartLineContract $cartLine, Closure $next): mixed
{
if ($cartLine->meta['saved_for_later'] ?? false) {
$currency = $cartLine->cart->currency;
$cartLine->unitPrice = new Price(0, $currency, 1);
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
}
return $next($cartLine);
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace Modules\Core\Cart\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Lunar\Actions\Carts\GetExistingCartLine;
use Lunar\Base\Purchasable;
use Lunar\Facades\CartSession;
use Lunar\Facades\Discounts;
use Lunar\Models\Cart;
use Lunar\Models\CartLine;
use Modules\Core\Cart\Events\CartCleared;
use Modules\Core\Cart\Events\CartCouponApplied;
use Modules\Core\Cart\Events\CartCouponRemoved;
use Modules\Core\Cart\Events\CartLineAdded;
use Modules\Core\Cart\Events\CartLineMovedToCart;
use Modules\Core\Cart\Events\CartLineRemoved;
use Modules\Core\Cart\Events\CartLineSaved;
use Modules\Core\Cart\Events\CartLineUpdated;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
/**
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
* ProductService/CollectionService's shape — one boboko-owned API a storefront
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
* than something a consuming app depends on directly.
*
* Every mutating method dispatches a matching domain event
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
* so without this, nothing in a consuming app has anything to react to when a
* cart actually changes (reindexing, notifications, analytics, etc.).
*
* All mutating methods return the recalculated Cart — matching Lunar's own
* Cart::add()/updateLine()/etc., which already return $this after
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
* no second fetch needed.
*/
class CartService
{
/**
* The current session's cart, or null if none exists yet. Does NOT
* auto-create one — see currentOrCreate() for that.
*/
public function current(): ?Cart
{
return CartSession::current();
}
/**
* The current session's cart, creating one if none exists yet — the right
* call for "add to cart" style flows where a cart must exist by the time
* the method returns.
*/
public function currentOrCreate(): Cart
{
return CartSession::manager();
}
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
{
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
->execute($cart, $purchasable, $meta);
if ($line !== null) {
Event::dispatch(new CartLineAdded($cart, $line));
}
return $cart;
}
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
{
$before = CartLine::findOrFail($cartLineId);
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineUpdated($cart, $line, $old));
}
return $cart;
}
public function removeLine(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$snapshot = $this->snapshotLine($line);
$cart = $this->currentOrCreate()->remove($cartLineId);
Event::dispatch(new CartLineRemoved($cart, $snapshot));
return $cart;
}
public function clear(): Cart
{
$cart = $this->currentOrCreate();
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
$cart = $cart->clear();
Event::dispatch(new CartCleared($cart, $snapshots));
return $cart;
}
/**
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
* up on the next calculate() — there's no dedicated Lunar action for this
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
* this is the closest thing to one for a consuming app to call.
*
* Validated via Discounts::validateCoupon() (does a matching, currently
* active, non-exhausted Discount exist?) before it's set — CouponString's
* cast only normalizes casing, it doesn't validate anything, so setting
* coupon_code directly would silently accept a bogus code and just not
* discount anything once calculated.
*
* @throws InvalidCouponException if the code doesn't match a valid, active,
* non-exhausted Discount
*/
public function applyCoupon(string $code): Cart
{
if (! Discounts::validateCoupon($code)) {
throw new InvalidCouponException($code);
}
$cart = $this->currentOrCreate();
$cart->coupon_code = $code;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
return $cart;
}
public function removeCoupon(): Cart
{
$cart = $this->currentOrCreate();
$code = $cart->coupon_code;
if ($code === null) {
return $cart;
}
$cart->coupon_code = null;
$cart->save();
$cart = $cart->recalculate();
Event::dispatch(new CartCouponRemoved($cart, $code));
return $cart;
}
/**
* Lines currently counted toward the cart's totals — everything except
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
* cart page's main list / checkout would iterate, since a saved line
* isn't pending purchase.
*
* @return Collection<int, CartLine>
*/
public function activeLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Lines a shopper has deliberately parked rather than deleted — excluded
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
* and from activeLines(). A cart page's "Saved for later" section iterates
* this set.
*
* @return Collection<int, CartLine>
*/
public function savedLines(?Cart $cart = null): Collection
{
$cart ??= $this->currentOrCreate();
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
}
/**
* Moves a line OUT of the purchasable cart without deleting it — it stays
* on the cart (still visible, still re-addable) but is excluded from
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
* Lunar's own CalculateLines sums the cart (which has no meta-based
* exclusion of its own).
*/
public function saveForLater(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineSaved($cart, $line));
}
return $cart;
}
/**
* The reverse of saveForLater() — moves a line back into the purchasable
* cart, counted in totals again.
*/
public function moveToCart(int $cartLineId): Cart
{
$line = CartLine::findOrFail($cartLineId);
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
$line = $cart->lines->firstWhere('id', $cartLineId);
if ($line !== null) {
Event::dispatch(new CartLineMovedToCart($cart, $line));
}
return $cart;
}
/**
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
*/
private function snapshotLine(CartLine $line): array
{
return [
'id' => $line->id,
'purchasable_type' => $line->purchasable_type,
'purchasable_id' => $line->purchasable_id,
'quantity' => $line->quantity,
'meta' => $line->meta->toArray(),
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component;
/**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
* "Color", "Size", "Material") behaves — namely, what structured data its values
* carry in their free-form `meta` jsonb column, and how an admin edits that data.
*
* `ProductOption`/`ProductOptionValue` themselves stay exactly as Lunar defines
* them — this is not a new model. `ProductOptionTypeManager` maps a
* `ProductOption::handle` to the type describing it (via `config('core.product_option_types')`,
* typed explicitly by the admin), so adding a new kind of option is a single new
* class, not scattered per-option special-casing across the admin UI or storefront.
*/
interface ProductOptionTypeInterface
{
/**
* Matches the ProductOption::handle this type describes (e.g. 'color', 'size').
*/
public static function getKey(): string;
/**
* Filament form components for editing a ProductOptionValue's `meta` under this
* option type — e.g. Color returns a color picker for `meta.hex`, Size returns a
* numeric input for `meta.sort_value`. Field names should be dot-notation under
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManagerExtension saves them.
*
* @return array<Component>
*/
public function getMetaForm(): array;
}
+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,
) {}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Catalog\Services\ProductIndexer'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 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 readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = 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',
};
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Catalog\Enums;
/**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Catalog\Services\
* ProductIndexer (see its getSortableFields()). Adding a case here requires the
* matching field to also be sortable in the index, re-synced via
* `php artisan lunar:meilisearch:setup`.
*/
enum ProductSort: string
{
case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc';
case Newest = 'newest';
public function toMeilisearchSort(): string
{
return match ($this) {
self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc',
};
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/**
* Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting
* an admin pick which registered `ProductOptionTypeInterface` (if any) describes this
* option's values — e.g. "Color" — independent of the option's own `handle`. The
* selection is saved to `ProductOption::meta['option_type']`.
*/
class ProductOptionResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
$options = collect(ProductOptionTypeManager::get()->all())
->keys()
->mapWithKeys(fn (string $key) => [$key => Str::headline($key)])
->all();
if ($options === []) {
return $form;
}
return $form->schema([
...$form->getComponents(),
Select::make('meta.option_type')
->label('Option Type')
->options($options)
->helperText('Controls which meta fields appear when editing this option\'s values.')
->native(false),
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form;
use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/**
* Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta
* form (if any) to Lunar's own ValuesRelationManager form, so e.g. a "color" option
* gets a hex-color picker for each value alongside the stock name field — without
* forking Lunar's relation manager.
*/
class ValuesRelationManagerExtension extends RelationManagerExtension
{
public function extendForm(Form $form): Form
{
/** @var ProductOption $option */
$option = $this->caller->getOwnerRecord();
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) {
return $form;
}
return $form->schema([
...$form->getComponents(),
...$type->getMetaForm(),
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Catalog\Observers;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Lunar\Models\ProductVariant;
/**
* Keeps every product using a ProductOption/ProductOptionValue in sync with
* Meilisearch. ProductIndexer::mapVariant() embeds each option value's `meta`
* (e.g. a color's hex) directly into the product's indexed document — but saving
* the option or one of its values never fires the *product's* own save/update
* events, so without this, a changed option_type or a changed hex would only
* reach the index on that product's next unrelated reindex.
*/
class ProductOptionReindexObserver
{
public function optionSaved(ProductOption $option): void
{
$this->reindexProductsForOption($option->id);
}
public function optionDeleted(ProductOption $option): void
{
$this->reindexProductsForOption($option->id);
}
public function valueSaved(ProductOptionValue $value): void
{
$this->reindexProductsForValues([$value->id]);
}
public function valueDeleted(ProductOptionValue $value): void
{
$this->reindexProductsForValues([$value->id]);
}
private function reindexProductsForOption(int $optionId): void
{
$valueIds = ProductOptionValue::where('product_option_id', $optionId)->pluck('id');
$this->reindexProductsForValues($valueIds->all());
}
private function reindexProductsForValues(array $valueIds): void
{
if ($valueIds === []) {
return;
}
$prefix = config('lunar.database.table_prefix');
$variantIds = DB::table("{$prefix}product_option_value_product_variant")
->whereIn('value_id', $valueIds)
->pluck('variant_id');
if ($variantIds->isEmpty()) {
return;
}
$productIds = ProductVariant::whereIn('id', $variantIds)->pluck('product_id')->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -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(),
];
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
namespace Modules\Core\Catalog;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer'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 ProductFilters
{
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
) {}
}
-126
View File
@@ -1,126 +0,0 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Models\Product;
use Modules\Core\Localization\LocaleMiddleware;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Search\ProductIndexer) - one data source, no
* ->get() model hydration anywhere in this service. Callers get plain arrays of the
* indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
return [
'data' => collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Search\ProductIndexer). Returns the full indexed
* product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves the current-locale `name`/`description` from the indexer's
* per-locale `name_{locale}`/`description_{locale}` fields, falling back to
* the store's default language (Language::default, see
* LocaleMiddleware::defaultLocale()) when the current locale has no
* translation - e.g. a product with no English copy yet still shows its
* Greek name/description on /en/ rather than rendering blank.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = LocaleMiddleware::defaultLocale();
$product['name'] = $product['name_'.$locale] ?? $product['name_'.$fallbackLocale] ?? null;
$product['description'] = $product['description_'.$locale] ?? $product['description_'.$fallbackLocale] ?? null;
return $product;
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginator $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
@@ -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
namespace Modules\Core\Search;
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -13,10 +13,17 @@ use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/**
* Extends Lunar's own indexer so Modules\Core\Catalog\ProductService can serve both
* listing/filtering AND single-product lookups from Meilisearch alone — one data
* source, no separate database read path for a product detail page. Adds:
* - collections (ids, filterable) and collection_names (display)
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can
* serve both listing/filtering AND single-product lookups from Meilisearch alone —
* one data source, no separate database read path for a product detail page. Adds:
* - collections: [{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
* ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all
@@ -24,9 +31,18 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* - variants: sku, stock, purchasable, option values, prices, media
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags
* - reviews: public-safe fields only (see mapReview() — reviewer_email is deliberately
* excluded, it's PII with no storefront use), including staff replies, plus an
* average rating
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
* only (see mapReview() — reviewer_email is deliberately excluded, it's PII with
* no storefront use), including staff replies
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be
* scoped to products actually assigned+enabled on the current sales channel
* - 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\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
@@ -46,9 +62,19 @@ class ProductIndexer extends BaseProductIndexer
...parent::getFilterableFields(),
'id',
'brand',
'collections',
'collection_ids',
'price',
'slugs',
'channel_ids',
'in_stock',
];
}
public function getSortableFields(): array
{
return [
...parent::getSortableFields(),
'price',
];
}
@@ -56,6 +82,7 @@ class ProductIndexer extends BaseProductIndexer
{
return parent::makeAllSearchableUsing($query)->with([
'collections',
'collections.ancestors',
'media',
'tags',
'urls',
@@ -73,16 +100,32 @@ class ProductIndexer extends BaseProductIndexer
$currency = Currency::getDefault();
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
$data['collection_names'] = $model->collections->map(fn ($collection) => $collection->translateAttribute('name'))->all();
$data['collections'] = $model->collections->map(fn ($collection) => [
'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['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
$data['price'] = $this->cheapestPrice($model, $currency);
$data['reviews'] = $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all();
$data['review_count'] = $reviews->count();
$data['average_rating'] = $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1);
$data['reviews'] = [
'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(),
'count' => $reviews->count(),
'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1),
];
$data['channel_ids'] = $model->channels()
->wherePivot('enabled', true)
->pluck('lunar_channels.id')
->toArray();
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
return $data;
}
@@ -96,6 +139,7 @@ class ProductIndexer extends BaseProductIndexer
'purchasable' => $variant->purchasable,
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle,
'value' => $this->translatedName($value->name),
'meta' => $value->meta,
])->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
namespace Modules\Core\Search;
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
+231
View File
@@ -0,0 +1,231 @@
<?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\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Catalog\Services\ProductIndexer) - one data
* source, no ->get() model hydration anywhere in this service. Callers get plain arrays
* of the indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Catalog\Services\
* ProductSearchService; this service is for browsing/filtering without a search term.
*/
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
* see "Meilisearch driver quirk" below) so a controller/view gets normal
* pagination behaviour ($products->links(), JSON serialization, etc.)
* without ever touching the raw Meilisearch response directly.
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
{
$options = ['filter' => $this->buildFilter($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search('')
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all();
return new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
}
/**
* 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
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
* indexed product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
/**
* @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) {
return null;
}
$clauses = Collection::make([
'collectionId' => $filters->collectionId !== null ? "collection_ids = \"{$filters->collectionId}\"" : null,
'brand' => $filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
'price' => Collection::make([
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter()->join(' AND ') ?: null,
'inStockOnly' => $filters->inStockOnly ? 'in_stock = true' : null,
])->except($exclude)->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
+26 -29
View File
@@ -18,7 +18,9 @@ use Lunar\Models\Product;
use Lunar\Models\ProductType;
use Lunar\Models\TaxClass;
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
@@ -32,7 +34,7 @@ class InstallLunarCommand extends Command
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...');
@@ -242,10 +244,8 @@ class InstallLunarCommand extends Command
}
});
if (! LanguageLine::where('group', 'storefront')->exists()) {
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels();
}
$this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations);
$this->components->info('Publishing Filament assets');
$this->call('filament:assets');
@@ -253,32 +253,29 @@ class InstallLunarCommand extends Command
$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 = [
'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' => 'Πίσω'],
'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' => 'Αναζήτηση προϊόντων…'],
];
$labels = StorefrontLabels::all();
$existingKeys = LanguageLine::where('group', 'storefront')
->whereIn('key', array_keys($labels))
->pluck('key');
foreach ($labels as $key => $text) {
LanguageLine::create([
'group' => 'storefront',
'key' => $key,
'text' => $text,
]);
if ($existingKeys->contains($key)) {
continue;
}
$translations->create('storefront', $key, $text);
}
}
}
+9 -1
View File
@@ -6,6 +6,8 @@ use Filament\Contracts\Plugin;
use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
use Lunar\Admin\Filament\Resources\ProductOptionResource;
use Lunar\Admin\Filament\Resources\ProductOptionResource\RelationManagers\ValuesRelationManager;
use Lunar\Admin\Filament\Resources\ProductResource;
use Lunar\Admin\Filament\Resources\StaffResource;
use Lunar\Admin\Models\Staff as LunarStaff;
@@ -15,8 +17,11 @@ use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview;
class CorePlugin implements Plugin
@@ -35,12 +40,15 @@ class CorePlugin implements Plugin
->login(Login::class)
->resources([
LanguageLineResource::class,
CartResource::class,
])
->plugin(ShippingPlugin::make());
LunarPanel::extensions([
StaffResource::class => StaffResourceExtension::class,
ProductResource::class => ProductResourceExtension::class,
ProductOptionResource::class => ProductOptionResourceExtension::class,
ValuesRelationManager::class => ValuesRelationManagerExtension::class,
]);
Product::macro('reviews', function (): HasMany {
@@ -5,7 +5,7 @@ namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Page
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
class CreateLanguageLine extends CreateRecord
{
@@ -7,7 +7,7 @@ use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Localization\TranslationService;
use Modules\Core\Localization\Services\TranslationService;
use Spatie\TranslationLoader\LanguageLine;
class EditLanguageLine extends EditRecord
@@ -5,12 +5,14 @@ namespace Modules\Core\Localization\Listeners;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\LocaleMiddleware;
use Modules\Core\Localization\Services\LanguageCache;
class FlushLanguageCache
{
public function __construct(private readonly LanguageCache $languages) {}
public function handle(LanguageCreated|LanguageUpdated|LanguageDeleted $event): void
{
LocaleMiddleware::forgetLanguagesCache();
$this->languages->forget();
}
}
@@ -1,24 +1,24 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Lunar\Models\Language;
use Modules\Core\Localization\Services\LanguageCache;
use Symfony\Component\HttpFoundation\Response;
class LocaleMiddleware
{
private const CACHE_KEY = 'core.localization.languages';
public function __construct(private readonly LanguageCache $languages) {}
public function handle(Request $request, Closure $next): Response
{
$languages = $this->availableLanguages();
$languages = $this->languages->all();
if ($languages->isEmpty()) {
return $next($request);
@@ -45,41 +45,37 @@ class LocaleMiddleware
return $next($request);
}
public static function forgetLanguagesCache(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code (Modules\Core\Catalog\ProductService) should use, as
* opposed to config('app.locale') which App::setLocale() mutates per
* request and so can't serve as a stable fallback.
*/
public static function defaultLocale(): ?string
{
return (new self)->availableLanguages()->firstWhere('default', true)?->code;
}
/**
* Shares the current/alternate locale (and the alternate's URL) with all
* views, so the header language switcher and layout hreflang tags don't
* have to recompute it.
* Shares the current locale and every OTHER available locale (each with its
* own URL for the current page) with all views, so the header language
* switcher and layout hreflang tags don't have to recompute it.
*
* `altLocales` is a collection, not a single value — firstWhere('code', '!=',
* ...) would only ever surface one alternate, which happens to look correct
* with exactly 2 configured languages (there's only one "other" to find) but
* silently drops every locale past the first for a 3+ language store, with no
* error, just fewer switcher options than actually configured. A view iterates
* `$altLocales` to render as many links/dropdown entries as there are
* alternates, whether that's 1 or 10.
*/
private function shareLocaleViewData(Request $request, Language $language, Collection $languages): void
{
$altLanguage = $languages->firstWhere('code', '!=', $language->code);
$route = $request->route();
$routeName = $route?->getName();
$altLocales = $languages
->reject(fn (Language $other) => $other->code === $language->code)
->map(fn (Language $other) => [
'code' => $other->code,
'name' => $other->name,
'url' => $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $other->code]))
: url('/'.$other->code),
])
->values();
View::share('currentLocale', $language->code);
View::share('altLocale', $altLanguage?->code);
View::share(
'altLocaleUrl',
$altLanguage && $routeName
? route($routeName, array_merge($route->parameters(), ['locale' => $altLanguage->code]))
: ($altLanguage ? url('/'.$altLanguage->code) : null),
);
View::share('altLocales', $altLocales);
}
private function redirectToLocalizedUrl(Request $request, Collection $languages): Response
@@ -108,12 +104,4 @@ class LocaleMiddleware
return $languages->firstWhere('default', true)?->code
?? $languages->first()->code;
}
private function availableLanguages(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Localization\Models;
use Modules\Core\Localization\Services\LanguageCache;
use Spatie\TranslationLoader\LanguageLine as BaseLanguageLine;
/**
* Overrides the base package's locale fallback (config('app.fallback_locale'), a
* static .env value) with the store's actual default language — Lunar's
* `languages.default` flag, the same source LocaleMiddleware/LanguageCache already
* treat as the single source of truth for "this store's default language".
*
* Without this, changing the default language via the Filament Languages resource
* has no effect on which locale an untranslated storefront label falls back to —
* two disconnected "default locale" concepts silently drifting apart. Swapped in
* via config('translation-loader.model') (see LocalizationServiceProvider), the
* package's own documented extension point for this.
*/
class LanguageLine extends BaseLanguageLine
{
public function getTranslation(string $locale): ?string
{
if (isset($this->text[$locale])) {
return $this->text[$locale];
}
$fallback = app(LanguageCache::class)->defaultLocale();
return $fallback !== null ? ($this->text[$fallback] ?? null) : null;
}
}
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Observers;
use Illuminate\Support\Facades\Event;
use Lunar\Models\Language;
@@ -0,0 +1,58 @@
<?php
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Language;
/**
* Cached read layer over Lunar's `languages` table — the single source both
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Catalog\Services\ProductService) read
* from, so the language list is fetched once per cache lifetime rather than once
* per caller. Cached forever, invalidated via forget() by
* Modules\Core\Localization\Listeners\FlushLanguageCache on
* LanguageCreated/LanguageUpdated/LanguageDeleted.
*/
class LanguageCache
{
private const CACHE_KEY = 'core.localization.languages';
public function all(): Collection
{
return Cache::rememberForever(
self::CACHE_KEY,
fn () => Language::query()->get(['id', 'code', 'name', 'default']),
);
}
/**
* The store's default language code (e.g. 'el') - the fixed fallback other
* locale-aware code should use, as opposed to config('app.locale') which
* App::setLocale() mutates per request and so can't serve as a stable
* fallback.
*/
public function defaultLocale(): ?string
{
return $this->all()->firstWhere('default', true)?->code;
}
/**
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs
* to enumerate all locales a TranslatedText attribute was indexed under (see
* Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes.
*
* @return array<int, string>
*/
public function availableLocales(): array
{
return $this->all()->pluck('code')->all();
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
}
@@ -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,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\App;
use Spatie\TranslationLoader\LanguageLine;
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Localization;
namespace Modules\Core\Localization\Services;
use Illuminate\Support\Facades\Event;
use Modules\Core\Localization\Events\TranslationCreated;
@@ -7,13 +7,23 @@ use Lunar\Models\Url;
class ProductResolver
{
/**
* A slug can have more than one `lunar_urls` row pointing at it across import
* batches — e.g. a product soft-deleted and re-imported leaves its old URL row
* behind, still matching the same slug. Picking "whichever Url row matches
* first" (as a plain Url::where('slug', ...)->first() would) can resolve to a
* soft-deleted product, silently failing every downstream write for that
* product (e.g. JudgeMeExportImporter logging "no product found" for a handle
* that, in isolation, clearly exists). Join against `lunar_products` directly
* so only a URL pointing at a live (non-deleted) product resolves.
*/
public function resolve(string $handle): ?Product
{
$url = Url::query()
->where('slug', $handle)
->where('element_type', (new Product)->getMorphClass())
return Product::query()
->join('lunar_urls', 'lunar_urls.element_id', '=', 'lunar_products.id')
->where('lunar_urls.slug', $handle)
->where('lunar_urls.element_type', (new Product)->getMorphClass())
->select('lunar_products.*')
->first();
return $url?->element;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
class CartServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([DetectAbandonedCarts::class]);
}
$this->app->booted(function () {
$this->app->make(Schedule::class)
->command(DetectAbandonedCarts::class)
->hourly();
});
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
class CatalogServiceProvider extends ServiceProvider
{
public function boot(): void
{
ProductOptionTypeManager::get()->register([
ColorOptionType::class,
]);
$observer = new ProductOptionReindexObserver;
ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option));
ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option));
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
}
}
+13 -2
View File
@@ -11,15 +11,26 @@ use Modules\Core\Localization\Events\LanguageUpdated;
use Modules\Core\Localization\Events\TranslationCreated;
use Modules\Core\Localization\Events\TranslationDeleted;
use Modules\Core\Localization\Events\TranslationUpdated;
use Modules\Core\Localization\LanguageCacheObserver;
use Modules\Core\Localization\Listeners\FlushLanguageCache;
use Modules\Core\Localization\Listeners\FlushTranslationCache;
use Modules\Core\Localization\Listeners\LogTranslationActivity;
use Modules\Core\Localization\Listeners\MigrateTranslationsForRenamedLanguage;
use Modules\Core\Localization\LocaleMiddleware;
use Modules\Core\Localization\Middleware\LocaleMiddleware;
use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Observers\LanguageCacheObserver;
class LocalizationServiceProvider extends ServiceProvider
{
public function register(): void
{
// Must run before Spatie\TranslationLoader\TranslationServiceProvider's
// register() merges its own config defaults - mergeConfigFrom() only fills
// in keys not already set, so setting this here (regardless of provider
// boot order) makes it win over the package's default
// Spatie\TranslationLoader\LanguageLine::class.
config(['translation-loader.model' => LanguageLine::class]);
}
public function boot(): void
{
$this->app['router']->aliasMiddleware('locale', LocaleMiddleware::class);
+2 -2
View File
@@ -9,8 +9,8 @@ use Modules\Core\Review\Models\ProductReview;
* Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply),
* so the product's own save/update events never fire for it — without this listener,
* Modules\Core\Search\ProductIndexer's review data would only refresh on the next
* full product reindex.
* Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on
* the next full product reindex.
*/
class ReviewServiceProvider extends ServiceProvider
{
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
/**
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
* with NO order ever started — the shopper added items and never began
* checkout. Weak purchase-intent signal: usually a browsing/price-check
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
* for a cart that DID reach checkout (a draft Order exists) but never placed
* it — a much stronger intent signal, and reachable via the email/address
* checkout itself usually captures even for a guest.
*
* Lives under Recovery, not Cart — abandonment detection/tracking is
* deliberately kept out of the Cart module entirely, including its event
* definitions, so Cart has no abandonment-related code at all. See
* docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at), not something that
* transitions via a normal Eloquent write, so there's no natural model-event
* hook to dispatch this from directly — detection is Recovery's own concern
* (not yet built; design notes in docs/recovery-strategies.md).
*/
class CartAbandoned
{
public function __construct(
public readonly Cart $cart,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Recovery\Events;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A cart's checkout has gone stale (no activity for
* config('core.cart.abandoned_after')) with a draft Order already created
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
* purchase-intent signal — the shopper committed to checking out, something
* blocked completion. Distinct from CartAbandoned, which fires for a cart
* with no order at all (weak intent, usually unreachable). Checkout
* typically captures an email/address even for a guest, so this state is
* normally reachable regardless of login status.
*
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
* is deliberately kept out of both modules entirely, including its event
* definitions. See docs/cart.md and docs/recovery-strategies.md.
*
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
* something that transitions via a normal Eloquent write — detection is
* Recovery's own concern (not yet built; design notes in
* docs/recovery-strategies.md).
*/
class CheckoutAbandoned
{
public function __construct(
public readonly Cart $cart,
public readonly Order $order,
) {}
}
@@ -1,9 +1,9 @@
<?php
namespace Modules\Core\Review\Extensions;
namespace Modules\Core\Review\Filament\Extensions;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Review\Pages\ManageProductReviews;
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
class ProductResourceExtension extends ResourceExtension
{
@@ -1,6 +1,6 @@
<?php
namespace Modules\Core\Review\Pages;
namespace Modules\Core\Review\Filament\Pages;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Placeholder;
+22
View File
@@ -5,8 +5,11 @@ namespace Modules\Core\Review\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Lunar\Models\Product;
use Spatie\Image\Enums\BorderType;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductReview extends Model implements HasMedia
{
@@ -30,4 +33,23 @@ class ProductReview extends Model implements HasMedia
{
$this->addMediaCollection(self::IMAGES_COLLECTION);
}
/**
* Unlike Product/ProductVariant, this model sits outside Lunar's own
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
* what registers the 'small' conversion those models get automatically. Without
* this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
* Exceptions\InvalidConversion the first time a review has an image, since
* $media->getUrl('small') has no matching conversion to resolve.
*/
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('small')
->fit(Fit::Fill, 300, 300)
->border(0, BorderType::Overlay, color: '#FFF')
->background('#FFF')
->sharpen(10)
->keepOriginalImageFormat();
}
}