Compare commits

..
7 Commits
32 changed files with 1462 additions and 90 deletions
+21
View File
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.12.0] - 2026-09-03
### Changed
- **Breaking:** `Modules\Core\Catalog\Services\ProductService::list()` now returns `Modules\Core\Catalog\DTOs\ProductListingResult` (`->products`: the same `Illuminate\Pagination\LengthAwarePaginator` as before, `->priceBounds`: a new `Modules\Core\Catalog\DTOs\PriceSliderBounds`) instead of returning the paginator directly. A caller doing `$service->list(...)->items()`/`->through(...)` must update to `$service->list(...)->products->items()`/`->through(...)`. This collapses what used to be two separate calls a controller had to orchestrate itself (`list()` for products, `priceRange()` + manual floor/ceil/"is this actually filtered" math for the slider) into one.
- **Breaking:** `Modules\Core\Catalog\Services\ProductSearchService::search()`'s signature changed from `search(string $query, ?string $locale = null)` to `search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null)` — the `$locale` parameter is gone (see "every configured language, always" below); `$filters`/`$sort` apply the same `Modules\Core\Catalog\Support\ProductFilterBuilder`/`ProductSort::toMeilisearchSort()` semantics `ProductService::list()` already used, so a text search can now be narrowed by price/brand/stock and sorted the same way a category listing can.
- `ProductSearchService` now targets every configured store language's fields on every search (`Lunar\Models\Language::all()`), not just the current request locale plus the store's default language. The old `{current, default}` pairing silently stopped catching anything outside those two locales whenever they were equal (a single-language store, or a shopper browsing in the default language) — always searching every configured language closes that gap in both directions. See `docs/product-search.md`.
- Extracted `Modules\Core\Catalog\Services\ProductService`'s private `buildFilter()` into a new standalone `Modules\Core\Catalog\Support\ProductFilterBuilder`, so `ProductSearchService` can apply the exact same Meilisearch filter-clause semantics to a text query, instead of reimplementing filter-building a second time.
### Added
- `Modules\Core\Catalog\Services\ProductService::priceSliderBounds()` — `priceRange()` rounded to whole currency units (floor/ceil) plus whether the given selected min/max actually narrows it, returned as a `PriceSliderBounds` DTO. Used internally by `list()` now; also callable directly for a caller (e.g. a text-search results page) that needs slider bounds without a full `list()` call.
- `Modules\Core\Catalog\Services\ProductService::priceRange()` gained an optional `string $query = ''` parameter, so a caller can scope the price range to a text search's own matches (pass the shopper's search text) instead of always spanning the whole catalog.
- `Modules\Core\Catalog\Services\ProductService::random(int $limit)` — random products still scoped to the Meilisearch index's own channel/status visibility, unlike a raw `Product::inRandomOrder()` (which has no notion of that filtering). Meilisearch has no `ORDER BY RANDOM()` equivalent, so this fetches every matching id only (`attributesToRetrieve: ['id']`), shuffles in PHP, then fetches the full localized documents for just the ids picked, restoring the shuffled order afterward (Meilisearch's `id IN [...]` filter doesn't preserve list order on its own).
- `Modules\Core\Catalog\Services\ProductService::variantSummaries(array $product)` — the id/price/image of every variant on a product document, for a variant picker/swatch list, without a caller reaching into `$product['variants'][n]['prices'][0]`/`['media'][0]` itself.
- `Modules\Core\Catalog\Services\ProductSearchService::search()` now also targets `variants.options.value` — a variant's own option value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is matchable by search even when that text never appears in the product's own name or description.
- `php artisan lunar:meilisearch:tune-product-search` (`Modules\Core\Command\TuneProductSearchCommand`) — tightens `minWordSizeForTypos` (1 typo only at 8+ characters, 2 typos only at 12+) and disables Meilisearch's `prefixSearch` on the product index. Meilisearch's defaults for both were loose enough to produce bad matches on short Greek words (confirmed the specific case was `prefixSearch`'s default `indexingTime` behavior on a shared word-start, not typo tolerance, via `showMatchesPosition`). Consuming apps should run this after `lunar:meilisearch:setup` whenever the product index needs (re)provisioning — **requires Meilisearch v1.12+** (`prefixSearch` didn't exist as a configurable setting before then).
## [0.11.1] - 2026-09-01
### Fixed
- `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start.
## [0.11.0] - 2026-09-01 ## [0.11.0] - 2026-09-01
### Added ### Added
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.11.0", "version": "0.12.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -42,7 +42,8 @@
"Modules\\Core\\Providers\\CatalogServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider",
"Modules\\Core\\Providers\\CartServiceProvider", "Modules\\Core\\Providers\\CartServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider", "Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\ShippingServiceProvider" "Modules\\Core\\Providers\\ShippingServiceProvider",
"Modules\\Core\\Providers\\OrderServiceProvider"
] ]
} }
}, },
+44 -15
View File
@@ -1,8 +1,9 @@
# Product Listing # Product Listing
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` —
Meilisearch index rather than the database. One data source for everything this service does. 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\Catalog\Services\ProductSearchService` (see `product-search.md`), which This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term. handles free-text query search. `ProductService` is for browsing/lookup without a search term.
@@ -28,13 +29,14 @@ use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
// List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, // One call for everything a listing page needs — products AND the price slider's
// built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see // bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller
// "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. // used to have to call list() and priceSliderBounds() (or the older priceRange())
$products = $service->list(perPage: 24, page: 1); // separately and glue the results together itself; that's now list()'s own job.
$listing = $service->list(perPage: 24, page: 1);
// Filter by collection, brand, price range, and/or stock // Filter by collection, brand, price range, and/or stock
$products = $service->list( $listing = $service->list(
filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true),
perPage: 24, perPage: 24,
page: 1, page: 1,
@@ -42,8 +44,13 @@ $products = $service->list(
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty). // relevance ordering (irrelevant here since the query is always empty).
$products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc);
$products = $listing->products; // 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->items(); // array of Meilisearch documents (plain arrays, not models) $products->items(); // array of Meilisearch documents (plain arrays, not models)
$products->total(); $products->total();
$products->perPage(); $products->perPage();
@@ -51,12 +58,32 @@ $products->currentPage();
$products->lastPage(); $products->lastPage();
$products->links(); // in a Blade view — renders pagination links as usual $products->links(); // in a Blade view — renders pagination links as usual
$bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds
$bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units
$bounds->ceil; // ?int — ceil() of the matching range's maximum
$bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually
// narrow the slider below/above these bounds (drives whether a
// "clear filter" control should show)
// Single product, by primary key // Single product, by primary key
$product = $service->getById(367); // array, or null if not found $product = $service->getById(367); // array, or null if not found
// Single product, by URL slug (any locale — slugs are indexed across all languages) // Single product, by URL slug (any locale — slugs are indexed across all languages)
$product = $service->getBySlug('erotika-mprelok'); // array, or null if not found $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found
// $limit random products — still scoped to the index's own default channel/status
// visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of
// that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this
// pulls every matching id only, shuffles in PHP, then fetches the full localized
// documents for just the ids picked — see random()'s own docblock.
$randomProducts = $service->random(13); // array of documents, same shape as list()'s items
// The id/price/image of every variant on a product document — the base price and
// thumbnail a variant picker/swatch list needs, without reaching into
// $product['variants'][n]['prices'][0]/['media'][0] yourself.
$variants = $service->variantSummaries($product);
// [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...]
// Facet counts for a sidebar — value => matching product count, scoped to whatever // 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 // $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, // facets()'s docblock for why, and how to build a standard "every option's count,
@@ -64,11 +91,13 @@ $product = $service->getBySlug('erotika-mprelok'); // array, or null if not fo
$brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); $brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17));
// ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] // ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135]
// Min/max price across matching products, for sizing a price-range slider. // Min/max price across matching products — the raw, unrounded values list() itself
// minPrice/maxPrice are ALWAYS excluded from the filter driving this (unlike // uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the
// facets(), which doesn't auto-exclude) — the slider's own bounds shouldn't shrink // filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's
// to whatever range is currently selected on it. Other filters (collectionId, // own bounds shouldn't shrink to whatever range is currently selected on it. Other
// brand, inStockOnly) still apply normally. // filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too
// to scope the range to a text search's own matches (see product-search.md) rather
// than the whole catalog.
$range = $service->priceRange(new ProductFilters(collectionId: 17)); $range = $service->priceRange(new ProductFilters(collectionId: 17));
// ['min' => 0.0, 'max' => 120.0] // ['min' => 0.0, 'max' => 120.0]
``` ```
@@ -77,8 +106,8 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
`facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric `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 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 `priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's
feature from `facetDistribution`. `facetStats` (min/max), a different feature from `facetDistribution`.
--- ---
+39 -13
View File
@@ -24,36 +24,62 @@ merges `$builder->options` directly into the search request).
## Usage ## Usage
```php ```php
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Services\ProductSearchService; use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes'); $results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el'); // Filters/sort apply the exact same semantics ProductService::list() uses for
// collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper
// narrowing a text search by price/brand/stock gets identical filter behavior
// to narrowing a category listing.
$results = app(ProductSearchService::class)->search(
'running shoes',
filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true),
sort: ProductSort::PriceAsc,
);
``` ```
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
`->get()` hydrates real models from the database after the Meilisearch query, so relations `->get()` hydrates real models from the database after the Meilisearch query, so relations
(`variants`, `brand`, `media`, etc.) are available on the results as normal. (`variants`, `brand`, `media`, etc.) are available on the results as normal.
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why
`Modules\Core\Localization\Middleware\LocaleMiddleware` (see `localization.md`), so callers in controllers every configured store language is always searched, regardless of the current request locale.
don't need to pass it explicitly.
--- ---
## Missing-translation fallback ## Missing-translation fallback, in both directions
If a product was only ever given an English name, `name_el` doesn't exist on that document at If a product was only ever given an English name, `name_el` doesn't exist on that document at
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
against `name_el` would make that product invisible to Greek-locale search, even though it's a against the current request's locale field would make that product invisible whenever a shopper's
real catalog item. locale doesn't match the language it happens to be translated into.
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both** `ProductSearchService` avoids this by targeting **every configured store language's fields**
the resolved locale's fields **and** the default language's fields (`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`, store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`,
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language). `description_el`, `description_en` together, regardless of which locale the shopper is browsing
A product missing an `el` translation still matches via its `en` fields. in. This is deliberately not scoped to "current locale + default locale": if the current locale
already equals the default (a single-language store, or a shopper browsing in the default
language), that pairing collapses to one locale and stops catching anything else — always
searching every configured language avoids that gap in both directions, at the cost of a larger
`attributesToSearchOn` list as the store's language count grows.
---
## Variant option values are searched too
Alongside the locale-suffixed attribute fields, every search also targets
`variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option
matches a search for that text, even though it never appears in the product's own name or
description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it),
so it can't be discovered the way `name`/`description` are — it's a structural field of
`Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`),
added here directly. Not locale-suffixed — each option value is stored as one already-resolved
string per variant.
--- ---
+450
View File
@@ -0,0 +1,450 @@
<title>Order Feature Survey</title>
<style>
:root {
--paper: #FAFAF7;
--ink: #1C1C1A;
--muted: #6B6B63;
--accent: #2F5D50;
--accent-soft: #E4EDE9;
--good: #3F7A5C;
--good-soft: #E6F0EA;
--warn: #B8863B;
--warn-soft: #F5ECDC;
--miss: #A14B3B;
--miss-soft: #F5E5E0;
--hairline: #E4E2DB;
--card: #FFFFFF;
}
:root:not([data-theme="light"]) {
@media (prefers-color-scheme: dark) {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
}
:root[data-theme="dark"] {
--paper: #17181A;
--ink: #EDEBE4;
--muted: #9B9A90;
--accent: #7FBFA8;
--accent-soft: #1E2C27;
--good: #6FBF97;
--good-soft: #1B2A22;
--warn: #D9A85C;
--warn-soft: #2C2418;
--miss: #D97C68;
--miss-soft: #2E1E1A;
--hairline: #2C2D2E;
--card: #1E1F21;
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 15.5px;
line-height: 1.55;
margin: 0;
padding: 4.5rem 1.5rem 6rem;
}
.wrap {
max-width: 780px;
margin: 0 auto;
}
header.page {
margin-bottom: 3.25rem;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.9rem;
}
h1 {
font-family: "Fraunces", Georgia, serif;
font-weight: 560;
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
line-height: 1.08;
letter-spacing: -0.01em;
margin: 0 0 0.9rem;
text-wrap: balance;
}
.dek {
color: var(--muted);
max-width: 60ch;
font-size: 1.02rem;
}
.dek strong {
color: var(--ink);
font-weight: 600;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1.6rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border-radius: 3px;
}
.chip.have { background: var(--good-soft); color: var(--good); }
.chip.partial { background: var(--warn-soft); color: var(--warn); }
.chip.missing { background: var(--miss-soft); color: var(--miss); }
section.category {
margin-top: 3rem;
}
.cat-head {
display: flex;
align-items: baseline;
gap: 0.85rem;
border-bottom: 1px solid var(--hairline);
padding-bottom: 0.7rem;
margin-bottom: 1.1rem;
}
.cat-num {
font-family: "Fraunces", Georgia, serif;
font-size: 1.05rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 1.6rem;
}
.cat-head h2 {
font-family: "Fraunces", Georgia, serif;
font-weight: 500;
font-size: 1.28rem;
margin: 0;
letter-spacing: -0.005em;
}
.cat-note {
color: var(--muted);
font-size: 0.86rem;
margin: 0 0 1.2rem;
max-width: 62ch;
}
.feature {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid var(--hairline);
align-items: start;
}
.feature:last-child { border-bottom: none; }
.f-name {
font-weight: 600;
font-size: 0.98rem;
}
.f-status {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
height: fit-content;
}
.f-status.have { background: var(--good-soft); color: var(--good); }
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
.f-note {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.87rem;
margin-top: 0.15rem;
max-width: 66ch;
}
.f-note code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 0.82em;
background: var(--accent-soft);
color: var(--accent);
padding: 0.08em 0.35em;
border-radius: 3px;
}
footer.page {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--hairline);
color: var(--muted);
font-size: 0.82rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
footer.page a { color: var(--accent); }
@media (max-width: 560px) {
body { padding: 3rem 1.1rem 4rem; }
.feature { grid-template-columns: 1fr; }
.f-status { justify-self: start; }
}
</style>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<div class="wrap">
<header class="page">
<div class="eyebrow">boboko / order · competitive survey</div>
<h1>What order management elsewhere can do that boboko can't yet</h1>
<p class="dek">
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
checked against what <strong>Lunar's <code>Order</code> model and the
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
today. For deciding what the new <code>Order</code> module needs to own, not a
build order.
</p>
<div class="legend">
<span class="chip have">● have</span>
<span class="chip partial">◐ partial</span>
<span class="chip missing">○ missing</span>
</div>
</header>
<section class="category">
<div class="cat-head">
<span class="cat-num">01</span>
<h2>Status model</h2>
</div>
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
<div class="feature">
<div class="f-name">Payment status independent of a single overall status</div>
<span class="f-status partial">partial</span>
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
</div>
<div class="feature">
<div class="f-name">Fulfillment status independent of overall status</div>
<span class="f-status missing">missing</span>
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
</div>
<div class="feature">
<div class="f-name">Staff-editable order status with a picker/action</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
</div>
<div class="feature">
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
<span class="f-status partial">partial</span>
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
</div>
<div class="feature">
<div class="f-name">Order-status-changed event other code can react to</div>
<span class="f-status missing">missing</span>
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">02</span>
<h2>Fulfillment &amp; shipment tracking</h2>
</div>
<p class="cat-note">Turning a placed order into a package that moves.</p>
<div class="feature">
<div class="f-name">Shipment as its own record, separate from the order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
</div>
<div class="feature">
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
<span class="f-status partial">partial</span>
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
</div>
<div class="feature">
<div class="f-name">Create-shipment action from the order admin screen</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
</div>
<div class="feature">
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
</div>
<div class="feature">
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
<span class="f-status missing">missing</span>
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
</div>
<div class="feature">
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
<span class="f-status missing">missing</span>
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">03</span>
<h2>Payments: capture, refund, cancellation</h2>
</div>
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
<div class="feature">
<div class="f-name">Refund action from the order screen, amount-scoped</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
</div>
<div class="feature">
<div class="f-name">Capture action for auth-then-capture payment flows</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
</div>
<div class="feature">
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
<span class="f-status missing">missing</span>
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
</div>
<div class="feature">
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
</div>
<div class="feature">
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
<span class="f-status partial">partial</span>
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">04</span>
<h2>Returns (RMA)</h2>
</div>
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
<div class="feature">
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
<span class="f-status missing">missing</span>
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
</div>
<div class="feature">
<div class="f-name">Return shipping label generation</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">05</span>
<h2>Order editing</h2>
</div>
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
<div class="feature">
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
<span class="f-status missing">missing</span>
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
</div>
<div class="feature">
<div class="f-name">Editable shipping/billing address after placement</div>
<span class="f-status missing">missing</span>
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
</div>
<div class="feature">
<div class="f-name">Tag editing on a placed order</div>
<span class="f-status have">have</span>
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
</div>
</section>
<section class="category">
<div class="cat-head">
<span class="cat-num">06</span>
<h2>Notes &amp; audit trail</h2>
</div>
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
<div class="feature">
<div class="f-name">Append-only change history (who changed what, when)</div>
<span class="f-status have">have</span>
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
</div>
<div class="feature">
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
<span class="f-status missing">missing</span>
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
</div>
<div class="feature">
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
<span class="f-status missing">missing</span>
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
</div>
</section>
<footer class="page">
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
<span>boboko-core / docs</span>
</footer>
</div>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* A price range slider's bounds and whether it's currently narrowed —
* built by ProductService::priceSliderBounds(), which owns the floor/ceil
* rounding and "is this actually a meaningful filter" comparison, so a
* controller (CategoryController, SearchController, ...) doesn't have to
* reimplement that rule itself.
*/
class PriceSliderBounds
{
public function __construct(
public readonly ?int $floor,
public readonly ?int $ceil,
public readonly bool $filtered,
) {}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Catalog\DTOs;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Everything a listing page needs from one ProductService::list() call —
* the product page itself plus the price slider's bounds, so a controller
* makes one service call instead of orchestrating list() and
* priceSliderBounds() separately. list() still issues two Meilisearch
* requests internally (the product search and the price facet stats — see
* priceSliderBounds()'s own docblock for why they can't be merged into one
* without changing the slider's UX), but that's this DTO's job to hide,
* not the controller's to know about.
*/
class ProductListingResult
{
public function __construct(
public readonly LengthAwarePaginator $products,
public readonly PriceSliderBounds $priceBounds,
) {}
}
+47 -15
View File
@@ -3,10 +3,12 @@
namespace Modules\Core\Catalog\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Facades\AttributeManifest; use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language; use Lunar\Models\Language;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/** /**
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed * Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
@@ -17,39 +19,69 @@ use Lunar\Models\Product;
*/ */
class ProductSearchService class ProductSearchService
{ {
public function __construct(
private readonly ProductFilterBuilder $filterBuilder,
) {}
/** /**
* $filters/$sort apply the exact same semantics ProductService::list()
* uses for collection browsing (same ProductFilterBuilder, same
* ProductSort::toMeilisearchSort()) — a shopper narrowing a text search
* by price/brand/stock gets identical filter behavior to narrowing a
* category listing, since both go through the same Meilisearch `filter`
* clause underneath.
*
* @return Collection<int, Product> * @return Collection<int, Product>
*/ */
public function search(string $query, ?string $locale = null): Collection public function search(string $query, ?ProductFilters $filters = null, ?ProductSort $sort = null): Collection
{ {
$locale ??= App::getLocale(); $options = [
$defaultLocale = Language::getDefault()->code; 'attributesToSearchOn' => $this->searchableFields(),
'filter' => $this->filterBuilder->build($filters),
];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
return Product::search($query) return Product::search($query)
->options([ ->options($options)
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get(); ->get();
} }
/** /**
* Target the resolved locale's fields plus the default locale's fields, so a * Targets every configured store language's fields, not just the current
* product that's only ever been translated into the default language still * request locale plus the store default — a shopper browsing in Greek
* surfaces when searched in another locale, instead of becoming invisible * typing an English word (or vice versa) should still match a product
* until every product is fully translated. * whose only translation for that text happens to be in a third
* language. There's no per-request "current locale" concept in this
* method any more: which fields exist to search on is a property of the
* store's configured languages, not of who's asking.
*
* Also targets variants.options.value directly — a variant's option
* value (e.g. "Κάπτεν Γαμέρικα" on a "Name" option) is how ProductIndexer
* already indexes it (see mapVariant()), but it isn't one of Lunar's own
* attributes, so it can't come from AttributeManifest the way name/
* description do; it's a structural field of the document, added here
* directly instead. Not locale-suffixed like the attribute-manifest
* fields — option values are stored as one already-resolved string per
* variant (see ProductIndexer::translatedName()), not per-locale.
* *
* @return array<int, string> * @return array<int, string>
*/ */
private function searchableFields(string $locale, string $defaultLocale): array private function searchableFields(): array
{ {
$handles = AttributeManifest::getSearchableAttributes(Product::morphName()) $handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle'); ->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]); $locales = Language::all()->pluck('code');
return $handles $attributeFields = $handles
->crossJoin($locales) ->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}") ->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}");
return $attributeFields
->push('variants.options.value')
->values() ->values()
->all(); ->all();
} }
+144 -42
View File
@@ -4,14 +4,16 @@ namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest; use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\PriceSliderBounds;
use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\DTOs\ProductListingResult;
use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
use Modules\Core\Catalog\Support\ProductFilterBuilder;
/** /**
* Storefront product listing/filtering AND single-product lookup, all reading directly * Storefront product listing/filtering AND single-product lookup, all reading directly
@@ -27,17 +29,33 @@ class ProductService
public function __construct( public function __construct(
private readonly LanguageCache $languages, private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes, private readonly AttributeManifest $attributes,
private readonly ProductFilterBuilder $filterBuilder,
) {} ) {}
/** /**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result - * One call for everything a listing page needs: the product page AND
* see "Meilisearch driver quirk" below) so a controller/view gets normal * the price slider's bounds — a controller used to have to call this
* pagination behaviour ($products->links(), JSON serialization, etc.) * plus priceSliderBounds() separately and glue the results together
* without ever touching the raw Meilisearch response directly. * itself; that orchestration now happens in here instead. Still issues
* two Meilisearch requests under the hood (the product search, and a
* separate price-facet-stats query — see priceSliderBounds()'s
* docblock for why they can't be merged into one without changing the
* slider's own UX), but the caller only ever makes one call.
*
* $filters->minPrice/$filters->maxPrice double as both the applied
* product filter AND the "is the slider actually narrowed" comparison
* in priceSliderBounds() — the same values, used two ways, so nothing
* new needs to be threaded through separately.
*
* The paginator itself is 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 public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): ProductListingResult
{ {
$options = ['filter' => $this->buildFilter($filters)]; $options = ['filter' => $this->filterBuilder->build($filters)];
if ($sort !== null) { if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()]; $options['sort'] = [$sort->toMeilisearchSort()];
@@ -51,13 +69,17 @@ class ProductService
->map(fn (array $product) => $this->withLocalizedFields($product)) ->map(fn (array $product) => $this->withLocalizedFields($product))
->all(); ->all();
return new LengthAwarePaginator( $products = new LengthAwarePaginator(
items: $data, items: $data,
total: $paginator->total(), total: $paginator->total(),
perPage: $paginator->perPage(), perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(), currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()], options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
); );
$priceBounds = $this->priceSliderBounds($filters, $filters?->minPrice, $filters?->maxPrice);
return new ProductListingResult($products, $priceBounds);
} }
/** /**
@@ -79,7 +101,7 @@ class ProductService
*/ */
public function facets(string $field, ?ProductFilters $filters = null): array public function facets(string $field, ?ProductFilters $filters = null): array
{ {
return $this->rawFacets($field, $this->buildFilter($filters))['facetDistribution'][$field] ?? []; return $this->rawFacets($field, $this->filterBuilder->build($filters))['facetDistribution'][$field] ?? [];
} }
/** /**
@@ -90,12 +112,17 @@ class ProductService
* not `facetDistribution` — the right feature for a numeric field's range, * not `facetDistribution` — the right feature for a numeric field's range,
* where `facets('price')` would otherwise return one entry per exact price. * where `facets('price')` would otherwise return one entry per exact price.
* *
* $query defaults to '' (every product, same as list()'s own default text
* query) — pass the shopper's search text here too so a search page's own
* price slider spans only the products that search actually matched,
* rather than the whole catalog's price range.
*
* @return array{min: ?float, max: ?float} null/null if no product matches * @return array{min: ?float, max: ?float} null/null if no product matches
*/ */
public function priceRange(?ProductFilters $filters = null): array public function priceRange(?ProductFilters $filters = null, string $query = ''): array
{ {
$filter = $this->buildFilter($filters, exclude: ['price']); $filter = $this->filterBuilder->build($filters, exclude: ['price']);
$stats = $this->rawFacets('price', $filter)['facetStats']['price'] ?? null; $stats = $this->rawFacets('price', $filter, $query)['facetStats']['price'] ?? null;
return [ return [
'min' => $stats['min'] ?? null, 'min' => $stats['min'] ?? null,
@@ -103,9 +130,36 @@ class ProductService
]; ];
} }
private function rawFacets(string $field, ?string $filter): array /**
* priceRange() rounded to whole euros (floor/ceil, so the slider's ends
* are never tighter than what's actually in range) plus whether
* $selectedMinPrice/$selectedMaxPrice actually narrow it — the same
* "floor/ceil + is this a real filter" rule CategoryController and
* SearchController each used to duplicate inline. $selectedMinPrice/
* $selectedMaxPrice are the currently-applied filter values (e.g.
* CategoryListing::$minPrice), not part of $filters itself, since
* $filters here must already exclude price the way priceRange() expects.
*/
public function priceSliderBounds(
?ProductFilters $filters,
?float $selectedMinPrice,
?float $selectedMaxPrice,
string $query = '',
): PriceSliderBounds {
$priceRange = $this->priceRange($filters, $query);
$floor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$ceil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
$filtered = ($selectedMinPrice !== null && $selectedMinPrice > ($floor ?? PHP_INT_MIN))
|| ($selectedMaxPrice !== null && $selectedMaxPrice < ($ceil ?? PHP_INT_MAX));
return new PriceSliderBounds($floor, $ceil, $filtered);
}
private function rawFacets(string $field, ?string $filter, string $query = ''): array
{ {
return Product::search('') return Product::search($query)
->options([ ->options([
'filter' => $filter, 'filter' => $filter,
'facets' => [$field], 'facets' => [$field],
@@ -133,15 +187,87 @@ class ProductService
return $this->findOneWhere("id = \"{$id}\""); return $this->findOneWhere("id = \"{$id}\"");
} }
/**
* The id/price/image of every variant on a product document (from
* getById()/getBySlug()'s own 'variants' array) — the base price and
* thumbnail a variant picker/swatch list needs, without a caller
* reaching into $product['variants'][n]['prices'][0]/['media'][0]
* itself. Domain shaping (which price/image represents a variant),
* not presentation — a card's href/layout stays a storefront concern
* (e.g. App\Catalog\ProductCard in 3dealer), but "the variant's price
* is its first price row" is a rule about the data, true regardless of
* which app renders it.
*
* @param array $product A document from getById()/getBySlug().
* @return array<int, array{id: int, price: ?float, image: ?string}>
*/
public function variantSummaries(array $product): array
{
return collect($product['variants'] ?? [])
->map(fn (array $variant) => [
'id' => $variant['id'],
'price' => $variant['prices'][0]['price'] ?? null,
'image' => $variant['media'][0]['url'] ?? null,
])
->values()
->all();
}
/**
* $limit random products, still scoped to the index's own default
* visibility (channel/status), unlike Eloquent's Product::inRandomOrder()
* which has no notion of that filtering at all — a random pick can never
* surface a hidden/unpublished product this way. Meilisearch itself has
* no ORDER BY RANDOM() equivalent, so this pulls every matching id only
* (attributesToRetrieve: ['id'], the lightest possible request — no
* name/media/variants/etc. for documents that will mostly be discarded),
* shuffles in PHP, then fetches the full localized documents for just
* the $limit ids actually picked.
*
* @return array<int, array>
*/
public function random(int $limit): array
{
$raw = Product::search('')
->options(['attributesToRetrieve' => ['id']])
->raw();
$ids = collect($raw['hits'] ?? [])->pluck('id')->shuffle()->take($limit)->values();
if ($ids->isEmpty()) {
return [];
}
// Meilisearch's `id IN [...]` doesn't preserve the given order — it's
// an unordered set filter, not a list to iterate — so the shuffle
// above would otherwise be silently undone by whatever order the
// re-fetch comes back in. Re-sort the fetched documents back into
// $ids's already-shuffled order instead of trusting the response's.
$products = collect($this->findAllWhere('id IN ['.$ids->implode(', ').']'))
->keyBy('id');
return $ids->map(fn ($id) => $products->get($id))->filter()->values()->all();
}
private function findOneWhere(string $filter): ?array private function findOneWhere(string $filter): ?array
{
$products = $this->findAllWhere($filter, limit: 1);
return $products[0] ?? null;
}
/**
* @return array<int, array>
*/
private function findAllWhere(string $filter, int $limit = 1000): array
{ {
$paginator = Product::search('') $paginator = Product::search('')
->options(['filter' => $filter]) ->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1); ->paginateRaw(perPage: $limit, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null; return collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
return $product !== null ? $this->withLocalizedFields($product) : null; ->all();
} }
/** /**
@@ -204,28 +330,4 @@ class ProductService
return collect($rawResponse['hits'] ?? [])->values()->all(); 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 ');
}
} }
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Services; namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Collection;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule; use Modules\Core\Catalog\Contracts\RecommendationRule;
@@ -25,7 +25,7 @@ class RecommendationService
*/ */
public function recommend(Product $product, int $limit = 4): Collection public function recommend(Product $product, int $limit = 4): Collection
{ {
$recommendations = collect(); $recommendations = new Collection();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) { foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) { if ($recommendations->count() >= $limit) {
@@ -0,0 +1,40 @@
<?php
namespace Modules\Core\Catalog\Support;
use Illuminate\Support\Collection;
use Modules\Core\Catalog\DTOs\ProductFilters;
/**
* Builds a Meilisearch `filter` clause from a ProductFilters DTO — extracted
* out of ProductService (where it originated, scoped to browsing/filtering
* without a search term) so ProductSearchService can apply the exact same
* filter semantics to a text query too, rather than reimplementing it.
*/
class ProductFilterBuilder
{
/**
* @param array<int, 'collectionId'|'brand'|'price'|'inStockOnly'> $exclude
* filter fields to leave out even if set on $filters — e.g.
* ProductService::priceRange() excludes 'price' so a price slider's own
* bounds don't shrink to whatever range is already selected on it.
*/
public function build(?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 ');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Laravel\Scout\EngineManager;
use Laravel\Scout\Engines\MeilisearchEngine;
use Lunar\Models\Product;
/**
* lunarphp/meilisearch's own `lunar:meilisearch:setup` only pushes
* filterableAttributes/sortableAttributes (see MeilisearchSetup::handle())
* — it has no notion of typo tolerance or prefix search, and Meilisearch's
* defaults for both are loose enough to produce bad matches on short Greek
* words. Confirmed via showMatchesPosition that a query for "Κάπτεν" was
* matching "κανένας" purely through prefixSearch's default 'indexingTime'
* behavior (their edit distance is far past anything typo tolerance would
* bridge) — fixed by disabling prefix search below, verified afterward with
* "Super"/"Superheroes"-style prefix probes returning no results for a
* partial word. minWordSizeForTypos is tightened defensively alongside it
* so short words in general get less typo-tolerant fuzzing, even though a
* separate short-word collision case ("Κάπτεν" vs "κάποτε", high letter
* overlap despite real edit distance) persisted after both settings were
* confirmed live and wasn't fully root-caused — treated as a known,
* narrow edge case rather than a blocker. Run this after
* `lunar:meilisearch:setup`, whenever Product's index needs
* (re)provisioning.
*
* Disabling prefix search here is a deliberate tradeoff: it also turns off
* legitimate partial-word matching (typing "car" matching "cart" before
* you finish the word) — useful for a future autocomplete/search-as-you-
* type UI. If that's built later, re-enable prefixSearch deliberately then,
* informed by real UX needs, rather than leaving it on by accident today.
*/
class TuneProductSearchCommand extends Command
{
protected $signature = 'lunar:meilisearch:tune-product-search';
protected $description = 'Tighten typo-tolerance and disable prefix search on the product search index';
public function handle(EngineManager $engineManager): void
{
/** @var MeilisearchEngine $engine */
$engine = $engineManager->createMeilisearchDriver();
$index = $engine->getIndex((new Product)->searchableAs());
$this->components->info('Updating typo tolerance for product search...');
$task = $index->updateTypoTolerance([
'minWordSizeForTypos' => [
'oneTypo' => 8,
'twoTypos' => 12,
],
]);
$engine->waitForTask($task['taskUid']);
$this->components->info('Disabling prefix search for product search...');
$task = $index->updatePrefixSearch('disabled');
$engine->waitForTask($task['taskUid']);
$this->components->info('Product search index tuned.');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Modules\Core\Order\Enums;
/**
* Derived from Shipment/ShipmentInfo — no equivalent existed anywhere in
* Lunar or this codebase before OrderStatus::fulfillment().
*/
enum FulfillmentStatus: string
{
case Unfulfilled = 'unfulfilled';
case Shipped = 'shipped';
case PartiallyShipped = 'partially-shipped';
case Delivered = 'delivered';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Order\Enums;
/**
* Same states/logic as Lunar's own ManageOrder::paymentStatus(), which
* only exists as a Filament-page Livewire #[Computed] method — this is
* that same derivation, reusable from anywhere via Order::paymentStatus().
*/
enum PaymentStatus: string
{
case Offline = 'offline';
case Uncaptured = 'uncaptured';
case Captured = 'captured';
case PartialRefund = 'partial-refund';
case Refunded = 'refunded';
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
/**
* Dispatched by TransactionObserver::saved() when a Transaction's type
* changes to 'capture' (from 'intent') and succeeds. Unlike refunds,
* Lunar's Stripe driver (StoreCharges) reuses the same Transaction row
* across intent -> capture rather than creating a new one, so this can't
* key off `wasRecentlyCreated` the way OrderRefunded does.
*/
class OrderCaptured
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly Transaction $transaction,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by Order's DeriveOrderDeliveredFromShipment listener, which
* reacts to Shipping's ShipmentStatusUpdatedByCarrier — delivery is a
* tracking checkpoint, not a manual status write, so it never goes through
* OrderStatusUpdated. Order.status itself is left untouched here; this is
* only the signal for delivery notifications and similar reactions.
*/
class OrderDelivered
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ShipmentInfo $shipmentInfo,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
/**
* Dispatched by TransactionObserver::saved() whenever a successful
* type=refund Transaction row is written — every payment driver (Lunar's
* own StripePaymentType::refund(), or a future boboko-owned driver for a
* provider Lunar doesn't ship) creates a new row for each refund, so
* `created` alone (filtered to type+success) is enough here, unlike
* captures which can reuse an existing row.
*/
class OrderRefunded
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly Transaction $transaction,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Order\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Lunar\Models\Order;
/**
* Dispatched by OrderObserver::updated() whenever an Order's status column
* changes, regardless of what wrote it — Filament's UpdateStatusAction,
* artisan tinker, a future API. Lunar's own UpdatesOrderStatus trait fires
* mailers inline, but only for that one admin action; this event is the
* general-purpose hook everything else (our own mailers, automations,
* derived payment/fulfillment status) should listen to instead.
*/
class OrderStatusUpdated
{
use Dispatchable;
public function __construct(
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
) {}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
/**
* Translates a carrier tracking checkpoint into OrderDelivered — the event
* OrderDeliveredNotification (via NotificationRegistry) actually listens
* to. Kept separate from the notification itself so the "is this checkpoint
* a delivery" filtering doesn't leak into notification code.
*/
class DeriveOrderDeliveredFromShipment
{
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
if ($event->shipmentInfo->status !== TrackingStatus::Delivered) {
return;
}
$order = $event->shipmentInfo->shipment->order;
if (! $order) {
return;
}
OrderDelivered::dispatch($order, $event->shipmentInfo);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderCaptured;
class OrderCapturedNotification extends BaseNotification
{
public function __construct(private readonly OrderCaptured $event) {}
public static function getKey(): string
{
return 'order.captured.customer.mail';
}
public static function listensTo(): string
{
return OrderCaptured::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Payment captured for your order :reference', ['reference' => $order->reference]))
->view('core::order.notifications.captured', [
'reference' => $order->reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDelivered;
class OrderDeliveredNotification extends BaseNotification
{
public function __construct(private readonly OrderDelivered $event) {}
public static function getKey(): string
{
return 'order.delivered.customer.mail';
}
public static function listensTo(): string
{
return OrderDelivered::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference has been delivered', ['reference' => $order->reference]))
->view('core::order.notifications.delivered', [
'reference' => $order->reference,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderRefunded;
class OrderRefundedNotification extends BaseNotification
{
public function __construct(private readonly OrderRefunded $event) {}
public static function getKey(): string
{
return 'order.refunded.customer.mail';
}
public static function listensTo(): string
{
return OrderRefunded::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference]))
->view('core::order.notifications.refunded', [
'reference' => $order->reference,
'amount' => $this->event->transaction->amount->formatted,
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Order\Notifications;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderStatusUpdated;
class OrderStatusUpdatedNotification extends BaseNotification
{
public function __construct(private readonly OrderStatusUpdated $event) {}
public static function getKey(): string
{
return 'order.status_updated.customer.mail';
}
public static function listensTo(): string
{
return OrderStatusUpdated::class;
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
->view('core::order.notifications.status-updated', [
'reference' => $order->reference,
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Order\Observers;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated;
class OrderObserver
{
public function updated(Order $order): void
{
if (! $order->wasChanged('status')) {
return;
}
OrderStatusUpdated::dispatch(
$order,
$order->getOriginal('status'),
$order->status,
);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Order\Observers;
use Lunar\Models\Transaction;
use Modules\Core\Order\Events\OrderCaptured;
use Modules\Core\Order\Events\OrderRefunded;
class TransactionObserver
{
public function saved(Transaction $transaction): void
{
if (! $transaction->success) {
return;
}
if ($transaction->type === 'refund' && $transaction->wasRecentlyCreated) {
OrderRefunded::dispatch($transaction->order, $transaction);
return;
}
if ($transaction->type === 'capture' && $transaction->wasChanged('type')) {
OrderCaptured::dispatch($transaction->order, $transaction);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Modules\Core\Order\Support;
use Lunar\Models\Order;
use Modules\Core\Order\Enums\FulfillmentStatus;
use Modules\Core\Order\Enums\PaymentStatus;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* Payment/fulfillment state, derived on read from transactions and
* shipments rather than stored — mirrors the logic Lunar's own
* ManageOrder::paymentStatus() computes as a Livewire #[Computed] method
* (Filament-page-only, not reusable), reimplemented here as a plain,
* queryable value any code can call via Order::macro() in
* OrderServiceProvider.
*/
class OrderStatus
{
public static function payment(Order $order): PaymentStatus
{
$transactions = $order->transactions;
$intentTotal = $transactions
->filter(fn ($t) => $t->type === 'intent' && $t->success)
->sum('amount.value');
$captureTotal = $transactions
->filter(fn ($t) => $t->type === 'capture' && $t->success)
->sum('amount.value');
$refundTotal = $transactions
->filter(fn ($t) => $t->type === 'refund' && $t->success)
->sum('amount.value');
$total = $intentTotal ?: $captureTotal;
if (! $total) {
return PaymentStatus::Offline;
}
if (
($refundTotal && $refundTotal < $total) ||
($captureTotal && $captureTotal < $intentTotal)
) {
return PaymentStatus::PartialRefund;
}
if ($refundTotal >= $total) {
return PaymentStatus::Refunded;
}
if ($captureTotal >= $intentTotal) {
return PaymentStatus::Captured;
}
return PaymentStatus::Uncaptured;
}
/**
* Reads shipments.shipmentInfo if already eager-loaded (the caller's
* job — e.g. Order::with('shipments.shipmentInfo')) and picks the
* latest checkpoint in PHP, instead of Shipment::latestShipmentInfo()'s
* per-shipment query — calling this across a list of orders would
* otherwise be an extra query per shipment.
*/
public static function fulfillment(Order $order): FulfillmentStatus
{
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled;
}
$latestStatuses = $shipments->map(function ($shipment) {
$latest = $shipment->relationLoaded('shipmentInfo')
? $shipment->shipmentInfo->sortByDesc('occurred_at')->first()
: $shipment->latestShipmentInfo();
return $latest?->status ?? TrackingStatus::Pending;
});
if ($latestStatuses->every(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
return FulfillmentStatus::Delivered;
}
if ($latestStatuses->contains(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
return FulfillmentStatus::PartiallyShipped;
}
return FulfillmentStatus::Shipped;
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand; use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand; use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand; use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\TuneProductSearchCommand;
class CoreServiceProvider extends ServiceProvider class CoreServiceProvider extends ServiceProvider
{ {
@@ -35,7 +36,7 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets'); ], 'core-assets');
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]); $this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, TuneProductSearchCommand::class]);
//Overriding lunar:install //Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class])); $this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Order;
use Lunar\Models\Transaction;
use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Notifications\OrderCapturedNotification;
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
use Modules\Core\Order\Notifications\OrderRefundedNotification;
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
use Modules\Core\Order\Observers\OrderObserver;
use Modules\Core\Order\Observers\TransactionObserver;
use Modules\Core\Order\Support\OrderStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
class OrderServiceProvider extends ServiceProvider
{
public function boot(): void
{
Order::observe(OrderObserver::class);
Transaction::observe(TransactionObserver::class);
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
NotificationRegistry::get()->register([
OrderDeliveredNotification::class,
OrderStatusUpdatedNotification::class,
OrderRefundedNotification::class,
OrderCapturedNotification::class,
]);
// Lets the consuming app override copy/markup without forking core
// — published into resources/views/vendor/core/order/notifications,
// which loadViewsFrom() (CoreServiceProvider) already resolves
// ahead of the package's own views for the `core::` namespace.
$this->publishes([
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
], 'core-views');
}
}