Compare commits

..
18 Commits
Author SHA1 Message Date
arvanitakis 448da869a9 Bump version to 0.12.0 2026-09-03 11:44:36 +03:00
arvanitakis 1287b513cd Feat: Updating Product Service with new Methods, DTOs for ListingResult And Slider Bounds 2026-09-03 11:44:23 +03:00
arvanitakis b2919f1f4b Feature: Product Search Service Updates 2026-09-03 11:04:19 +03:00
arvanitakis 3497553b41 Feature: Order Service Provider, Order Events Observers 2026-09-01 14:04:12 +03:00
arvanitakis 29e77a973b Feat: Orders Feature Survey 2026-09-01 13:34:37 +03:00
arvanitakis 026ab5bc2d BUmp version to 0.11.1 2026-09-01 13:04:54 +03:00
arvanitakis 483c0ce00f Fix: Recommendations are an eloquent collection, not an Generic Collection 2026-09-01 13:03:39 +03:00
arvanitakis ce405d20e6 Bump Version to 0.11.0 2026-09-01 12:08:47 +03:00
arvanitakis 0f07751559 Feature: Recommendation Service
This commit introduces a RecommendationService that is used when indexing products.

The service is utilizing a recommendation rules interface, so many rules can be created and applied during indexing the products
2026-09-01 12:06:29 +03:00
arvanitakis 53d8a5aefe Bump Version to 0.10.1 2026-09-01 12:04:06 +03:00
arvanitakis 380e860386 Feat: Adding Translations to the Seeder 2026-09-01 10:52:56 +03:00
arvanitakis d680200ab1 Bump Version to 0.10.0 2026-08-31 14:18:51 +03:00
arvanitakis 9529036585 Feat: Updating PaymentDrivers and CheckoutService to handle payment methods 2026-08-31 14:12:21 +03:00
arvanitakis 2db1e1331f Feat: Creating PaymentMethods, Setting Fees, Availabilities 2026-08-31 13:54:20 +03:00
arvanitakis d873cb4931 Feat: Upgrading Lunar to 1.5 2026-08-31 13:16:13 +03:00
arvanitakis 661e8b9a96 Feat: Adding Payment Drivers, configuring stripe 2026-08-31 12:50:29 +03:00
arvanitakis 637da37b9b Merge branch 'master' into Payment-Methods 2026-08-29 14:17:48 +03:00
arvanitakis 808c769595 Add cash-on-delivery payment type with cart fee pipeline 2026-07-19 03:35:16 +03:00
96 changed files with 2966 additions and 258 deletions
+51
View File
@@ -4,6 +4,57 @@ 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
### Added
- `Modules\Core\Catalog\Services\RecommendationService` — computes "related products" for a given product as a configurable, ordered chain of strategies (`config('catalog.recommendation_rules')`), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships with `Modules\Core\Catalog\Recommendations\SameCategoryRule` (other products sharing the source product's first collection) and `RandomRule` (the universal fallback, placed last in the default chain). A new rule is just a class implementing `Modules\Core\Catalog\Contracts\RecommendationRule`. Documented in `docs/product-recommendations.md`.
- `Modules\Core\Catalog\Services\ProductIndexer` embeds the result directly into each product's own Meilisearch document as `recommendations: [{id, name, price, image}, ...]` (`recommendations.id` filterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existing `collections` field. Deliberately embeds an `id` for the view to build a locale-correct URL from, not a resolved `href` — `product.show` is locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run.
- `Modules\Core\Catalog\Events\ProductSaved`/`ProductDeleted`, dispatched from `Product::saved()`/`Product::deleted()` in `CatalogServiceProvider` (the latter fires for both a soft delete and a force delete, matching Scout's own `unsearchable()` trigger point) — feed `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct`, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (`recommendations.id = "..."` — there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own `->searchable()`. Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented for `in_stock`/`price` — see `docs/product-recommendations.md`.
- `CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product" --refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, covering a newly-created product not yet appearing as a recommendation and any other drift already accepted between reindexes. `--refresh` also re-syncs filterable/sortable index settings, not just documents.
## [0.10.1] - 2026-09-01
### Added
- `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched.
## [0.10.0] - 2026-08-31
### Changed
- **Breaking:** Upgraded `lunarphp/lunar`, `lunarphp/core`, `lunarphp/stripe`, `lunarphp/table-rate-shipping`, and `lunarphp/search` to `1.5.0`, and `filament/filament` to `v4.12.6` — the first Filament v4 admin panel on this codebase. `lunarphp/filament3-2fa` and `kalnoy/nestedset` are gone, replaced by Filament v4's native two-factor auth and `lunarphp/nestedset`. Ran Filament's automated `filament-v4` migration tool across `src/`, then hand-fixed three bugs it introduced or left behind: a stale `$infolist` variable reference in `CartResource`'s `ViewCart` page (the parameter had been renamed to `$schema` but the body wasn't updated), `ShippingMethodResourceExtension` rewritten to call `getDefaultChildComponents()` (returns `array|Schema`) instead of the type-safe `getChildComponents()` (always `array<Component>`), and — unrelated to the tool, but surfaced by the same PHP version bump — `InvalidCouponException`'s `readonly $code` property illegally shadowing the built-in `Exception::$code`, renamed to `$couponCode`. `LunarStaff::addActivitylogExcept()` updated for the renamed `two_factor_secret`/`two_factor_recovery_codes` staff columns (now `app_authentication_secret`/`app_authentication_recovery_codes`; `two_factor_confirmed_at` removed). Consuming apps must run `composer update boboko/core --with-all-dependencies` and `php artisan migrate`.
### Added
- `Modules\Core\Checkout\Contracts\PaymentDriver` — the abstraction every payment provider implements: `confirm(Cart $cart, string $type, string $fingerprint, array $data): Order` and `isConfigured(): bool`. A driver only ever calls `CheckoutService::placeOrder()` once it has, by whatever mechanism is native to that gateway, independently confirmed payment — never Lunar's raw `Cart::createOrder()`. This is what lets the storefront checkout sequence stay uniform regardless of which provider is active: set addresses, select shipping, hand off to whichever driver is configured, and the driver decides when (or whether) the order gets created.
- `Modules\Core\Payment\Drivers\OfflinePaymentDriver` — shared by every payment type with no real gateway to confirm against (`cash-in-hand`, `cash-on-delivery`): places the order immediately via `CheckoutService::placeOrder()`, then sets the order status from `config("lunar.payments.types.{$type}.authorized")` using the type actually confirmed, not a hardcoded key, since one driver instance serves multiple types.
- `Modules\Core\Payment\Drivers\StripePaymentDriver` — a fork, not a decoration, of `lunarphp/stripe`'s `StripePaymentType::authorize()`: that method is `final` and calls `Cart::createOrder()` directly with no seam to redirect into our fingerprint-checked `placeOrder()`, so this class reimplements its logic (intent retrieval, capture-on-policy, status mapping via `UpdateOrderFromIntent`) with that one substitution. Throws the new `Modules\Core\Payment\Exceptions\PaymentNotConfirmedException` on anything short of a genuinely confirmed payment intent — never falls through to placing an order on ambiguity.
- `CheckoutService::getPaymentMethods(): array` — every payment type currently offered to the storefront: every key in `config('lunar.payments.types')` that is both administratively enabled (`Modules\Core\Payment\Models\PaymentMethod::enabled`) and whose driver reports `isConfigured()` (e.g. Stripe with no API key set is never offered, regardless of the enabled toggle). `selectPaymentMethod(string $type)` and `confirmPayment(string $type, array $data)` both validate against this list, throwing the new `UnknownPaymentTypeException` for a type that isn't currently offered — re-checked in `confirmPayment()` too, since a type could be disabled between selection and confirmation.
- `CheckoutService::selectPaymentMethod()` snapshots `Cart::fingerprint()` into `cart->meta['checkout_fingerprint']` *after* saving the chosen type and recalculating — the fingerprint has to reflect the final total including any payment-type-specific adjustment (e.g. a COD surcharge), which only exists once `payment_method` is set. `confirmPayment()` reads this stored fingerprint internally rather than taking one as a parameter: a storefront should never need to know `Cart::fingerprint()` exists or capture it at exactly the right moment itself.
- `Modules\Core\Payment\Models\PaymentMethod` — one DB row per payment type key (matching `config('lunar.payments.types')`), `enabled` boolean plus a `data` jsonb column (starting with `fee`, the flat cash-on-delivery surcharge) — mirrors Lunar's own `Discount` model (a single jsonb column of keyed settings, not a fixed column per setting or a separate conditions table). Seeded idempotently by `InstallLunarCommand` (skip-if-exists per type, safe to re-run after installing a new payment-provider package), always `enabled: false` — a newly-seeded type shouldn't go live for shoppers before staff have configured and reviewed it. Admin-editable via the new `PaymentMethodResource` (inline enabled toggle, modal fee editor) under Settings.
- `ApplyCashOnDeliveryFee` now reads its surcharge from `PaymentMethod` instead of static config, so it's admin-editable without a deploy.
### Fixed
- `CashOnDeliveryPaymentDriver` renamed to `OfflinePaymentDriver` and generalized to work for any offline-style type — it previously hardcoded `'cash-on-delivery'` when reading the post-placement order status from config, which would have silently read the wrong type's status the moment a second offline type (`cash-in-hand`) used it.
## [0.9.0] - 2026-08-29 ## [0.9.0] - 2026-08-29
### Added ### Added
+10 -6
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.9.0", "version": "0.12.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -10,14 +10,15 @@
}, },
"require": { "require": {
"php": "^8.5", "php": "^8.5",
"lunarphp/lunar": "1.3.0", "lunarphp/lunar": "1.5.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"symfony/yaml": "^7.0", "symfony/yaml": "^7.0",
"lunarphp/table-rate-shipping": "^1.3", "lunarphp/table-rate-shipping": "1.5.0",
"lunarphp/search": "*", "lunarphp/search": "*",
"lunarphp/meilisearch": "*", "lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8" "spatie/laravel-translation-loader": "^2.8",
"lunarphp/stripe": "^1.5"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -27,7 +28,8 @@
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.6", "pestphp/pest": "^4.6",
"pestphp/pest-plugin-laravel": "^4.1" "pestphp/pest-plugin-laravel": "^4.1",
"filament/upgrade": "^4.0"
}, },
"extra": { "extra": {
"laravel": { "laravel": {
@@ -35,11 +37,13 @@
"Modules\\Core\\Providers\\CoreServiceProvider", "Modules\\Core\\Providers\\CoreServiceProvider",
"Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\PaymentServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider",
"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"
] ]
} }
}, },
+25
View File
@@ -0,0 +1,25 @@
<?php
use Modules\Core\Catalog\Recommendations\RandomRule;
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
return [
/*
|--------------------------------------------------------------------------
| Product recommendation rules
|--------------------------------------------------------------------------
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
| the first rule that returns at least one product wins. The order here IS
| the fallback chain: SameCategoryRule first, then RandomRule as a
| last-resort so a product page is never left with zero recommendations
| (as long as the store has more than one product). A consuming app can
| reorder, add, or remove rules freely — nothing about the chain shape is
| hardcoded in the service itself.
|
*/
'recommendation_rules' => [
SameCategoryRule::class,
RandomRule::class,
],
];
+46
View File
@@ -0,0 +1,46 @@
<?php
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
use Modules\Core\Payment\Pipelines\Cart\ApplyCashOnDeliveryFee;
return [
/*
|--------------------------------------------------------------------------
| Lunar payment types merged in by Boboko Core
|--------------------------------------------------------------------------
|
| These are merged into config('lunar.payments.types') so every app using
| boboko-core gets cash-on-delivery out of the box, without publishing
| Lunar's own config.
|
| 'payment_driver' is boboko-owned, alongside Lunar's own 'driver' key —
| it's the Modules\Core\Checkout\Contracts\PaymentDriver class
| CheckoutService::confirmPayment() resolves via the container and calls
| confirm() on. Kept on the same row as 'driver' rather than a second,
| separately-keyed map, so a type's full definition — Lunar's driver,
| its config, and its PaymentDriver — lives in one place.
|
*/
'types' => [
'cash-on-delivery' => [
'driver' => 'offline',
'payment_driver' => OfflinePaymentDriver::class,
'authorized' => 'awaiting-payment',
'fee' => 0,
],
],
/*
|--------------------------------------------------------------------------
| Lunar cart pipeline additions
|--------------------------------------------------------------------------
|
| Appended to config('lunar.cart.pipelines.cart') after ApplyShipping so
| the cash-on-delivery fee is added to the shipping total before the
| final Calculate step sums everything up.
|
*/
'cart_pipeline' => [
ApplyCashOnDeliveryFee::class,
],
];
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->string('type')->unique();
$table->boolean('enabled')->default(true);
$table->json('data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
+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`.
--- ---
+155
View File
@@ -0,0 +1,155 @@
# Product Recommendations
`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given
product — a same-category pick today, with a random fallback, but built as a configurable chain of
strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds
the result directly into each product's own Meilisearch document, so a product detail page renders
its recommendations with zero extra queries — same reasoning as `collections` (see
`docs/product-listing.md`).
---
## The rule chain
```php
use Modules\Core\Catalog\Services\RecommendationService;
$recommendations = app(RecommendationService::class)->recommend($product, limit: 4);
// Illuminate\Support\Collection<int, Lunar\Models\Product>
```
`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each
successive rule until `$limit` distinct products are collected or every rule is exhausted — it does
not stop at the first rule that returns *something*. If a product's category only has 3 other
products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is
handed the ids already collected (`$exclude`, always including the source product's own id) so it
never wastes its own `$limit` budget re-suggesting something already picked, and the same product
is never returned twice even if two rules would both suggest it.
Default chain (`config/catalog.php`):
```php
'recommendation_rules' => [
SameCategoryRule::class, // other products sharing $product's first collection
RandomRule::class, // universal fallback — always returns something as
// long as the store has more than one product
],
```
A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about
the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers,
"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\
RecommendationRule`, added to the array:
```php
interface RecommendationRule
{
/**
* @param array<int> $exclude ids to never return — the source product's own
* id, plus every id an earlier rule in the chain already picked
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
```
Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`),
not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below.
---
## Why not `ProductService`
Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads
Meilisearch and resolves translated fields to whatever locale the *current request* is in (see
`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it:
they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no
meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very
product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer`
resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own
`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded
`collections` field — including that field's same accepted index-time-locale tradeoff (a
recommendation's embedded `name` reflects whatever locale was active when *that* product was last
indexed, not the viewer's current locale).
---
## What's embedded, and why not just an id
`ProductIndexer` embeds full card data per recommendation, not just an id:
```php
$data['recommendations'] = [
['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'],
// ...
];
```
This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront
components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via
`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show`
is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only
for whichever locale happened to be active during that index run — wrong for every other locale.
Building the URL is left to the view, which knows the current request's locale.
`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup
reindexing below.
---
## Keeping it fresh: `ProductSaved` / `ProductDeleted`
A recommendation is computed once, at index time, and embedded — it does not update itself when the
recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\
Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by),
there is no Postgres relation for "which products currently recommend product X" — a recommendation
only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join,
wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`):
- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`.
- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a
soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event
Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`.
- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the
product index for `recommendations.id = "{id}"`, finds every referencing product, and calls
`->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the
changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to
the configured limit via the rule chain, same as any other reindex.
`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this
listener does no synchronous Meilisearch writing itself.
**Product creation is deliberately not hooked into this.** A brand-new product has no
`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct
— nothing to add). What's *not* immediate is other products picking the new one up as a fresh
recommendation candidate — that happens on their own next natural reindex (a save, or the nightly
full reindex below), the same accepted staleness window `docs/product-listing.md` already documents
for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was
considered and rejected as unnecessary cost for a cosmetic delay.
---
## Nightly full reindex
`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product"
--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a
replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created
product not yet appearing as a recommendation elsewhere, and any other drift already accepted
between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh`
also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed
`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run
manually right after that deploy.
---
## Re-syncing after this change
Same as any other `ProductIndexer` field change (see `docs/product-listing.md`):
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after
this change" for why a running worker won't otherwise pick up the new indexer code.
+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>
@@ -2,18 +2,18 @@
namespace Modules\Core\Auth\Extensions; namespace Modules\Core\Auth\Extensions;
use Filament\Forms\Form; use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
class StaffResourceExtension extends ResourceExtension class StaffResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $form): Schema
{ {
$schema = collect($form->getComponents()) $schema = collect($form->getComponents())
->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password') ->reject(fn ($component) => method_exists($component, 'getName') && $component->getName() == 'password')
->values() ->values()
->all(); ->all();
return $form->schema($schema); return $form->components($schema);
} }
} }
+2 -2
View File
@@ -15,7 +15,7 @@ class Login extends SimplePage
{ {
use WithRateLimiting; use WithRateLimiting;
protected static string $view = 'core::auth.filament.pages.login'; protected string $view = 'core::auth.filament.pages.login';
public ?string $email = ''; public ?string $email = '';
public ?string $otp = ''; public ?string $otp = '';
@@ -78,7 +78,7 @@ class Login extends SimplePage
]); ]);
} }
if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentPanel())) { if ($staff instanceof FilamentUser && !$staff->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => 'You do not have access to this panel.', 'email' => 'You do not have access to this panel.',
]); ]);
@@ -12,8 +12,8 @@ use RuntimeException;
*/ */
class InvalidCouponException extends RuntimeException class InvalidCouponException extends RuntimeException
{ {
public function __construct(public readonly string $code) public function __construct(public readonly string $couponCode)
{ {
parent::__construct("The coupon code \"{$code}\" is not valid."); parent::__construct("The coupon code \"{$couponCode}\" is not valid.");
} }
} }
+17 -13
View File
@@ -2,6 +2,10 @@
namespace Modules\Core\Cart\Filament\Resources; namespace Modules\Core\Cart\Filament\Resources;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\ViewAction;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ListCarts;
use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -24,9 +28,9 @@ class CartResource extends Resource
{ {
protected static ?string $model = Cart::class; protected static ?string $model = Cart::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-cart';
protected static ?string $navigationGroup = 'Sales'; protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $modelLabel = 'Cart'; protected static ?string $modelLabel = 'Cart';
@@ -70,37 +74,37 @@ class CartResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('id') TextColumn::make('id')
->label('Cart') ->label('Cart')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('customer.full_name') TextColumn::make('customer.full_name')
->label('Customer') ->label('Customer')
->placeholder('—') ->placeholder('—')
->searchable() ->searchable()
->url(fn (Cart $record) => $record->customer_id !== null ->url(fn (Cart $record) => $record->customer_id !== null
? CustomerResource::getUrl('view', ['record' => $record->customer_id]) ? CustomerResource::getUrl('view', ['record' => $record->customer_id])
: null), : null),
Tables\Columns\TextColumn::make('user.email') TextColumn::make('user.email')
->label('User') ->label('User')
->placeholder('—') ->placeholder('—')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('lines_count') TextColumn::make('lines_count')
->label('Lines') ->label('Lines')
->counts('lines') ->counts('lines')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('lines_sum_quantity') TextColumn::make('lines_sum_quantity')
->label('Items') ->label('Items')
->sum('lines', 'quantity') ->sum('lines', 'quantity')
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency.code') TextColumn::make('currency.code')
->label('Currency'), ->label('Currency'),
Tables\Columns\TextColumn::make('updated_at') TextColumn::make('updated_at')
->label('Last activity') ->label('Last activity')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
]) ])
->actions([ ->recordActions([
Tables\Actions\ViewAction::make(), ViewAction::make(),
]) ])
->defaultSort('updated_at', 'desc'); ->defaultSort('updated_at', 'desc');
} }
@@ -108,8 +112,8 @@ class CartResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListCarts::route('/'), 'index' => ListCarts::route('/'),
'view' => Pages\ViewCart::route('/{record}'), 'view' => ViewCart::route('/{record}'),
]; ];
} }
@@ -2,7 +2,7 @@
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Resources\Components\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Cart\Filament\Resources\CartResource; use Modules\Core\Cart\Filament\Resources\CartResource;
@@ -2,11 +2,11 @@
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages; namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Infolist;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\CustomerResource;
use Lunar\Models\Cart; use Lunar\Models\Cart;
@@ -44,10 +44,10 @@ class ViewCart extends ViewRecord
return $cart->calculate(); return $cart->calculate();
} }
public function infolist(Infolist $infolist): Infolist public function infolist(Schema $schema): Schema
{ {
return $infolist return $schema
->schema([ ->components([
Section::make('Cart') Section::make('Cart')
->columns(3) ->columns(3)
->schema([ ->schema([
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Contracts; namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component; use Filament\Schemas\Components\Component;
/** /**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g. * A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* One strategy for producing recommended products for a given product —
* e.g. same category, same tag, best sellers, random. Modules\Core\Catalog\
* Services\RecommendationService runs rules registered in
* config('catalog.recommendation_rules') in order, topping up from each
* successive rule until $limit distinct products are collected or every
* rule is exhausted (e.g. 3 from SameCategoryRule + 1 from RandomRule) —
* nothing here decides that accumulation itself; a store composes its own
* chain by ordering rules in config (e.g. [SameCategoryRule::class,
* RandomRule::class]).
*
* Returns raw Product models, not ProductService::list()'s locale-resolved
* array output — this runs at index time (ProductIndexer::toSearchableArray()),
* where "the current locale" isn't a meaningful concept the way it is for a
* storefront request. ProductIndexer resolves translated fields itself via
* translateAttribute(), same as it already does for the embedded `collections`
* field — same known index-time-locale tradeoff, not a new one.
*/
interface RecommendationRule
{
/**
* $exclude carries $product's own id plus every id already picked by an
* earlier rule this call — RecommendationService never shows the same
* product twice even when two rules would both suggest it, and a rule
* shouldn't spend its $limit budget re-returning something already
* collected.
*
* @param array<int> $exclude
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
+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,
) {}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Catalog\Events;
/**
* Dispatched whenever a Product is deleted (see CatalogServiceProvider,
* which wires this to the model's own deleted() hook — fires for both a
* soft delete and a force delete, same as Laravel Scout's own
* ModelObserver::deleted() that triggers unsearchable() for the product
* itself). Same purpose as Modules\Core\Catalog\Events\ProductSaved: lets
* Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct find
* and re-index every OTHER product that currently embeds this one in its
* `recommendations` field, so a deleted product doesn't linger as a dead
* reference elsewhere. Carries only the id, not the Product model — by the
* time this fires the model may already be gone (force delete), and the
* reverse lookup only ever needs the id to filter on.
*/
class ProductDeleted
{
public function __construct(
public readonly int $productId,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Catalog\Events;
use Lunar\Models\Product;
/**
* Dispatched whenever a Product is saved (see CatalogServiceProvider,
* which wires this to the model's own saved() hook) — exists specifically
* so Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct can
* find and re-index every OTHER product that currently embeds this one in
* its own `recommendations` field (see ProductIndexer). Those products
* have no direct database relationship to this one — a recommendation is
* computed and stored only inside Meilisearch (Modules\Core\Catalog\
* Services\RecommendationService) — so nothing about their own save
* lifecycle would otherwise pick up this product's changed name/price/
* image.
*/
class ProductSaved
{
public function __construct(
public readonly Product $product,
) {}
}
@@ -2,8 +2,8 @@
namespace Modules\Core\Catalog\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Schemas\Schema;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -16,7 +16,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/ */
class ProductOptionResourceExtension extends ResourceExtension class ProductOptionResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
$options = collect(ProductOptionTypeManager::get()->all()) $options = collect(ProductOptionTypeManager::get()->all())
->keys() ->keys()
@@ -24,11 +24,11 @@ class ProductOptionResourceExtension extends ResourceExtension
->all(); ->all();
if ($options === []) { if ($options === []) {
return $form; return $schema;
} }
return $form->schema([ return $schema->components([
...$form->getComponents(), ...$schema->getComponents(),
Select::make('meta.option_type') Select::make('meta.option_type')
->label('Option Type') ->label('Option Type')
->options($options) ->options($options)
@@ -2,7 +2,7 @@
namespace Modules\Core\Catalog\Filament\Extensions; namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form; use Filament\Schemas\Schema;
use Lunar\Admin\Support\Extending\RelationManagerExtension; use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
@@ -15,7 +15,7 @@ use Modules\Core\Catalog\Services\ProductOptionTypeManager;
*/ */
class ValuesRelationManagerExtension extends RelationManagerExtension class ValuesRelationManagerExtension extends RelationManagerExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
/** @var ProductOption $option */ /** @var ProductOption $option */
$option = $this->caller->getOwnerRecord(); $option = $this->caller->getOwnerRecord();
@@ -23,11 +23,11 @@ class ValuesRelationManagerExtension extends RelationManagerExtension
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null); $type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) { if ($type === null) {
return $form; return $schema;
} }
return $form->schema([ return $schema->components([
...$form->getComponents(), ...$schema->getComponents(),
...$type->getMetaForm(), ...$type->getMetaForm(),
]); ]);
} }
@@ -0,0 +1,64 @@
<?php
namespace Modules\Core\Catalog\Listeners;
use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
/**
* Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
* equivalent ("who references this option value"), there is no Postgres
* table to query here — a recommendation only exists inside Meilisearch,
* computed by RecommendationService at index time — so the reverse lookup
* is a Meilisearch filter query against `recommendations.id`, not a
* database join.
*
* Handles both ProductSaved (name/price/image changed — referencing
* products' embedded copy is stale) and ProductDeleted (the recommended
* product no longer exists at all — referencing products need to drop it
* and, since RecommendationService tops up to its limit, naturally pick up
* a replacement on reindex). Same reverse lookup either way, just a
* different source for the id being searched for.
*
* Re-indexing via ->searchable() dispatches Scout's own (queued, if
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct
{
public function handleSaved(ProductSaved $event): void
{
$this->reindexReferencingProducts($event->product->id);
}
public function handleDeleted(ProductDeleted $event): void
{
$this->reindexReferencingProducts($event->productId);
}
private function reindexReferencingProducts(int $productId): void
{
$hits = Product::search('')
->options([
'filter' => "recommendations.id = \"{$productId}\"",
'attributesToRetrieve' => ['id'],
// Meilisearch's own hitsPerPage default (20) would silently
// drop referencing products past that count — this is a
// reverse lookup, not a paginated storefront result, so it
// needs every match, up to Meilisearch's hard limit.
'hitsPerPage' => 1000,
])
->raw()['hits'] ?? [];
$ids = collect($hits)->pluck('id')->unique()->values();
if ($ids->isEmpty()) {
return;
}
Product::whereIn('id', $ids)->get()->each->searchable();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* The universal fallback — always returns something as long as the store
* has more than one product, since it has no eligibility condition of its
* own to come up empty on. Meant to be placed last in
* config('catalog.recommendation_rules'), not first: every store using
* the default chain gets a real fallback, but one that only kicks in once
* more specific rules (same category, same tag, ...) have had a chance.
*/
class RandomRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
return Product::query()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Recommends other products sharing at least one of $product's directly-
* assigned collections — takes $product's first collection (a product
* usually has one primary category; if it has several, the first is as
* good a choice as any without a "primary collection" concept to prefer).
* Returns nothing if $product has no collection at all, letting the next
* rule in the chain (see RecommendationRule's docblock) take over.
*
* Queries Eloquent directly rather than going through Modules\Core\Catalog\
* Services\ProductService — this runs at index time (see
* RecommendationRule's docblock), where Meilisearch may be mid-reindex for
* this very product and ProductService::list()'s locale-resolution has no
* meaningful "current locale" to resolve against anyway.
*/
class SameCategoryRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
$collection = $product->collections->first();
if ($collection === null) {
return collect();
}
return $collection->products()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
+26
View File
@@ -44,6 +44,21 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* Reflects stock as of the last reindex only — nothing currently reindexes a * Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md). * product when an order decrements its stock (see docs/product-listing.md).
* *
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
* configured rule chain (config('catalog.recommendation_rules')). Embedded
* card data, not just ids, same reasoning as `collections`: renders
* directly with zero extra Meilisearch calls. `name` is resolved via
* translateAttribute() at index time (not through ProductService's
* per-request locale resolution, since indexing has no "current locale"
* the way a storefront request does) — same known index-time-locale
* tradeoff `collections` already has. `recommendations.id` is filterable
* specifically so Modules\Core\Catalog\Listeners\
* ReindexProductsRecommendingProduct can find every product currently
* recommending a given one, when that one changes — there's no Postgres
* relation for this, a recommendation only exists inside the index.
*
* A review is created/edited independently of its product (Modules\Core\Providers\ * A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so * ReviewServiceProvider re-indexes the product on review create/update/delete), so
* this data doesn't go stale between full reindexes. * this data doesn't go stale between full reindexes.
@@ -67,6 +82,7 @@ class ProductIndexer extends BaseProductIndexer
'slugs', 'slugs',
'channel_ids', 'channel_ids',
'in_stock', 'in_stock',
'recommendations.id',
]; ];
} }
@@ -126,6 +142,16 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains( $data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1) fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
); );
$data['recommendations'] = app(RecommendationService::class)
->recommend($model)
->load(['media', 'variants.prices'])
->map(fn (Product $recommendation) => [
'id' => $recommendation->id,
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
])
->all();
return $data; return $data;
} }
+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 ');
}
} }
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Runs each rule in config('catalog.recommendation_rules'), in order,
* topping up from each successive rule until $limit distinct products are
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
* (the product's category only has 3 other products) + 1 from RandomRule.
* No rule is special-cased as "the fallback" here; a store gets fallback
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
* before RandomRule). Never returns the same product twice even if two
* rules would both suggest it (see RecommendationRule's $exclude), and
* never returns fewer than $limit unless the store genuinely doesn't have
* that many other products at all.
*/
class RecommendationService
{
/**
* @return Collection<int, Product>
*/
public function recommend(Product $product, int $limit = 4): Collection
{
$recommendations = new Collection();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) {
break;
}
$exclude = [$product->id, ...$recommendations->pluck('id')];
$remaining = $limit - $recommendations->count();
/** @var RecommendationRule $rule */
$rule = app($ruleClass);
$recommendations = $recommendations->merge(
$rule->recommend($product, $remaining, $exclude)
);
}
return $recommendations->take($limit)->values();
}
}
@@ -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 ');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Checkout\Contracts;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
/**
* A boboko-owned payment driver — wraps a payment gateway's own confirmation
* mechanics (Stripe's synchronous authorize() call, a redirect-based
* provider's async callback/webhook, anything else) behind one uniform
* moment: "payment is confirmed, place the order."
*
* confirm() is the only thing a driver is required to do: once it has,
* by whatever mechanism is native to that gateway, independently decided
* the payment succeeded, it calls Modules\Core\Checkout\Services\
* CheckoutService::placeOrder($fingerprint) itself — no driver ever calls
* Lunar\Models\Cart::createOrder() directly. This is what lets the
* storefront checkout sequence stay uniform regardless of which provider is
* active: set addresses, select shipping, hand off to whichever driver is
* configured, and the driver decides when (or whether) the order actually
* gets created. See docs/checkout.md / docs/payments.md.
*/
interface PaymentDriver
{
/**
* Whether this driver can actually be used right now — e.g. Stripe
* checking its own API key is present, an offline-style driver always
* returning true since it has no external dependency. Independent of
* Modules\Core\Payment\Models\PaymentMethod::enabled (the admin
* on/off toggle) — CheckoutService::getPaymentMethods() combines both:
* a type is only offered to the storefront if it's administratively
* enabled AND its driver reports itself configured.
*/
public function isConfigured(): bool;
/**
* $type is the payment type key being confirmed (e.g. 'cash-in-hand',
* 'cash-on-delivery', 'stripe') — passed through even though most
* drivers only ever serve one type, because a driver shared across
* several types (e.g. one "no real confirmation" offline driver behind
* both cash-in-hand and cash-on-delivery) needs it to look up that
* type's own config (e.g. its 'authorized' status) rather than another
* type's.
*
* $data carries whatever the gateway needs to confirm this specific
* payment (Stripe: ['payment_intent' => $id], a redirect-based
* provider: its callback payload) — passed explicitly by the caller
* (a controller, a webhook job) rather than a driver reaching into the
* global request(), so confirm() works the same whether it's called
* from a synchronous HTTP request or an async webhook/job with no
* active request at all.
*
* @param array<string, mixed> $data
*
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order;
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Checkout\Events;
use Lunar\Models\Cart;
/**
* Dispatched by CheckoutService::selectPaymentMethod() — carries the plain
* type key (e.g. 'cash-on-delivery', 'stripe'), same convention as
* CartService's events (a plain reference the listener resolves further
* itself, rather than an already-resolved object) since a payment type key
* has nothing further to eagerly resolve the way a ShippingOption does.
*/
class PaymentMethodSelected
{
public function __construct(
public readonly Cart $cart,
public readonly string $type,
) {}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Checkout\Exceptions;
use RuntimeException;
/**
* Thrown by CheckoutService::selectPaymentMethod()/confirmPayment() when
* $type doesn't resolve to a registered Modules\Core\Checkout\Contracts\
* PaymentDriver (config('payment.drivers')) — same reasoning as
* InvalidShippingOptionException: nothing here has a matching Lunar
* exception type to reuse, so this is the boboko-owned signal instead of a
* silent no-op or an opaque container-resolution error.
*/
class UnknownPaymentTypeException extends RuntimeException
{
public function __construct(public readonly string $type)
{
parent::__construct("The payment type \"{$type}\" is not registered.");
}
}
+126 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Checkout\Services; namespace Modules\Core\Checkout\Services;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Lunar\Base\Addressable; use Lunar\Base\Addressable;
@@ -10,11 +12,15 @@ use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart; use Lunar\Models\Cart;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Events\BillingAddressSet; use Modules\Core\Checkout\Events\BillingAddressSet;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Checkout\Events\PaymentMethodSelected;
use Modules\Core\Checkout\Events\ShippingAddressSet; use Modules\Core\Checkout\Events\ShippingAddressSet;
use Modules\Core\Checkout\Events\ShippingOptionSelected; use Modules\Core\Checkout\Events\ShippingOptionSelected;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Storefront-facing checkout operations, mirroring * Storefront-facing checkout operations, mirroring
@@ -99,6 +105,10 @@ class CheckoutService
* stock adjusted the total, another tab modified the cart) rather than * stock adjusted the total, another tab modified the cart) rather than
* silently placing an order at a different total than what was shown. * silently placing an order at a different total than what was shown.
* *
* Not called directly by a storefront — see confirmPayment(), which is
* the only caller and supplies the fingerprint captured in
* selectPaymentMethod(), not one the storefront has to obtain itself.
*
* No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation * No exception wrapping: Lunar\Validation\Cart\ValidateCartForOrderCreation
* (run inside Cart::createOrder()) already throws * (run inside Cart::createOrder()) already throws
* Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag * Lunar\Exceptions\Carts\CartException with a field-keyed MessageBag
@@ -107,8 +117,8 @@ class CheckoutService
* render as form errors directly. FingerprintMismatchException * render as form errors directly. FingerprintMismatchException
* propagates the same way, for the same reason. * propagates the same way, for the same reason.
* *
* @throws \Lunar\Exceptions\FingerprintMismatchException * @throws FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException * @throws CartException
*/ */
public function placeOrder(string $fingerprint): Order public function placeOrder(string $fingerprint): Order
{ {
@@ -121,4 +131,118 @@ class CheckoutService
return $order; return $order;
} }
/**
* Every payment type currently offered to the storefront — every key
* in config('lunar.payments.types') that is BOTH administratively
* enabled (Modules\Core\Payment\Models\PaymentMethod::enabled) AND
* whose registered PaymentDriver reports itself usable right now
* (PaymentDriver::isConfigured() — e.g. Stripe with no API key set is
* never offered, regardless of the enabled toggle). A type with no
* PaymentMethod row at all (never seeded) is treated as not offered,
* same as disabled — nothing here creates one; see
* InstallLunarCommand::seedPaymentMethods().
*
* @return array<string>
*/
public function getPaymentMethods(): array
{
return PaymentMethod::where('enabled', true)
->pluck('type')
->filter(fn (string $type) => $this->resolvePaymentDriver($type)?->isConfigured() ?? false)
->values()
->all();
}
/**
* Records which payment type the shopper picked (Cart::meta
* ['payment_method']) — read by e.g. Modules\Core\Payment\Pipelines\
* Cart\ApplyCashOnDeliveryFee to add that type's own cart-total
* adjustments before recalculation.
*
* Also snapshots Cart::fingerprint() into meta, *after* saving the
* chosen type — the fingerprint has to reflect the final total
* including any payment-type-specific adjustment (e.g. a COD
* surcharge), which only exists once payment_method is set and the
* cart recalculates. Captured here, server-side, rather than asked of
* the storefront: this is the last moment before confirmPayment() that
* the shopper's reviewed total is known, and confirmPayment() reads it
* back internally instead of taking a fingerprint parameter — a
* storefront should never need to know Cart::fingerprint() exists.
*
* Does not itself call a PaymentDriver — selecting a method and
* confirming payment against it are deliberately separate steps, same
* as selecting a shipping option happens before placing the order.
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* — see getPaymentMethods() for what that means (registered,
* administratively enabled, and its driver reports itself usable)
*/
public function selectPaymentMethod(string $type): Cart
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$cart->meta = [...$cart->meta->toArray(), 'payment_method' => $type];
$cart->save();
$cart = $cart->calculate();
$cart->meta = [...$cart->meta->toArray(), 'checkout_fingerprint' => $cart->fingerprint()];
$cart->save();
Event::dispatch(new PaymentMethodSelected($cart, $type));
return $cart;
}
/**
* Resolves $type's registered PaymentDriver and calls confirm() —
* the driver decides whether/when the order actually gets placed (see
* Modules\Core\Checkout\Contracts\PaymentDriver's docblock). $data
* carries whatever that driver needs (Stripe's payment_intent id, a
* future redirect-based provider's callback payload).
*
* The fingerprint passed to the driver is the one captured by
* selectPaymentMethod(), not supplied by the caller — see that
* method's docblock. Throws the same FingerprintMismatchException a
* caller-supplied one would if the cart's total has since changed;
* missing entirely (selectPaymentMethod() was never called for this
* cart) is treated the same as a mismatch, not a different error.
*
* @param array<string, mixed> $data
*
* @throws UnknownPaymentTypeException if $type isn't currently offered
* (see getPaymentMethods()) — re-checked here, not just in
* selectPaymentMethod(), since a type could be disabled between
* selection and confirmation
* @throws \Lunar\Exceptions\FingerprintMismatchException
* @throws \Lunar\Exceptions\Carts\CartException
*/
public function confirmPayment(string $type, array $data = []): Order
{
if (! in_array($type, $this->getPaymentMethods(), true)) {
throw new UnknownPaymentTypeException($type);
}
$cart = $this->cart->currentOrCreate();
$fingerprint = $cart->meta['checkout_fingerprint'] ?? '';
return $this->resolvePaymentDriver($type)->confirm($cart, $type, $fingerprint, $data);
}
/**
* Resolves $type's registered PaymentDriver, or null if $type has no
* 'payment_driver' registered in config('lunar.payments.types.<type>')
* at all — deliberately non-throwing so getPaymentMethods() can filter
* unresolvable types silently rather than treating "not registered"
* as an error condition when just checking availability.
*/
private function resolvePaymentDriver(string $type): ?PaymentDriver
{
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
return $driverClass ? app($driverClass) : null;
}
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use Lunar\Admin\Models\Staff;
use Lunar\Admin\Console\Commands\MakeLunarAdminCommand; use Lunar\Admin\Console\Commands\MakeLunarAdminCommand;
use function Laravel\Prompts\text; use function Laravel\Prompts\text;
@@ -31,7 +32,7 @@ class CreateAdminCommand extends MakeLunarAdminCommand
required: true, required: true,
validate: fn (string $email): ?string => match (true) { validate: fn (string $email): ?string => match (true) {
! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.', ! filter_var($email, FILTER_VALIDATE_EMAIL) => 'The email address must be valid.',
\Lunar\Admin\Models\Staff::where('email', $email)->exists() => 'A user with this email address already exists', Staff::where('email', $email)->exists() => 'A user with this email address already exists',
default => null, default => null,
}, },
), ),
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use FilesystemIterator;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Modules\Core\ResultType\Error; use Modules\Core\ResultType\Error;
@@ -88,10 +91,10 @@ class ExportCommand extends Command
$zip->addFile($sqlFile, basename($sqlFile)); $zip->addFile($sqlFile, basename($sqlFile));
if (is_dir($filesDir)) { if (is_dir($filesDir)) {
$iterator = new \RecursiveIteratorIterator( $iterator = new RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( new RecursiveDirectoryIterator(
$filesDir, $filesDir,
\FilesystemIterator::SKIP_DOTS, FilesystemIterator::SKIP_DOTS,
), ),
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
+37
View File
@@ -21,6 +21,7 @@ use Lunar\Models\TaxZone;
use Modules\Core\Localization\Models\LanguageLine; use Modules\Core\Localization\Models\LanguageLine;
use Modules\Core\Localization\Services\StorefrontLabels; use Modules\Core\Localization\Services\StorefrontLabels;
use Modules\Core\Localization\Services\TranslationService; use Modules\Core\Localization\Services\TranslationService;
use Modules\Core\Payment\Models\PaymentMethod;
/** /**
* Overrides Lunar's own lunar:install to skip the interactive prompts (migrate * Overrides Lunar's own lunar:install to skip the interactive prompts (migrate
@@ -247,6 +248,9 @@ class InstallLunarCommand extends Command
$this->components->info('Seeding storefront label translations'); $this->components->info('Seeding storefront label translations');
$this->seedStorefrontLabels($translations); $this->seedStorefrontLabels($translations);
$this->components->info('Seeding payment method settings');
$this->seedPaymentMethods();
$this->components->info('Publishing Filament assets'); $this->components->info('Publishing Filament assets');
$this->call('filament:assets'); $this->call('filament:assets');
@@ -278,4 +282,37 @@ class InstallLunarCommand extends Command
$translations->create('storefront', $key, $text); $translations->create('storefront', $key, $text);
} }
} }
/**
* Per-type skip-if-exists, same idempotent convention as
* seedStorefrontLabels() — a type already present (including one an
* admin has since edited via the Filament Payment Methods resource) is
* left untouched. Safe to re-run after a new payment type is added to
* config('lunar.payments.types') (e.g. installing a Stripe/Nexi
* package), which is the whole reason this isn't a one-time-only seed.
*
* Seeded disabled — a newly-seeded row (whether from this store's
* initial install, or a payment provider package installed later)
* shouldn't go live for shoppers before staff have actually reviewed
* it (real credentials configured, a fee set, etc.) and turned it on
* via the Payment Methods resource. See CheckoutService::
* getPaymentMethods(), which only offers a type once both 'enabled'
* here and its driver's own isConfigured() check pass.
*/
private function seedPaymentMethods(): void
{
$existingTypes = PaymentMethod::pluck('type');
foreach (array_keys(config('lunar.payments.types', [])) as $type) {
if ($existingTypes->contains($type)) {
continue;
}
PaymentMethod::create([
'type' => $type,
'enabled' => false,
'data' => [],
]);
}
}
} }
+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.');
}
}
+6 -4
View File
@@ -2,6 +2,7 @@
namespace Modules\Core; namespace Modules\Core;
use Lunar\Admin\Filament\Resources\OrderResource\Pages\ManageOrder;
use Filament\Contracts\Plugin; use Filament\Contracts\Plugin;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,7 @@ use Modules\Core\Cart\Filament\Resources\CartResource;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\OrderViewExtension;
@@ -48,6 +50,7 @@ class CorePlugin implements Plugin
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
CartResource::class, CartResource::class,
PaymentMethodResource::class,
]) ])
->plugin(ShippingPlugin::make()) ->plugin(ShippingPlugin::make())
->pages([ManagePickupManifests::class]); ->pages([ManagePickupManifests::class]);
@@ -59,7 +62,7 @@ class CorePlugin implements Plugin
ValuesRelationManager::class => ValuesRelationManagerExtension::class, ValuesRelationManager::class => ValuesRelationManagerExtension::class,
ShippingMethodResource::class => ShippingMethodResourceExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class,
ListShippingMethod::class => ShippingMethodListExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class,
OrderResource\Pages\ManageOrder::class => OrderViewExtension::class, ManageOrder::class => OrderViewExtension::class,
]); ]);
Product::macro('reviews', function (): HasMany { Product::macro('reviews', function (): HasMany {
@@ -73,9 +76,8 @@ class CorePlugin implements Plugin
'password', 'password',
'remember_token', 'remember_token',
'email_verified_at', 'email_verified_at',
'two_factor_secret', 'app_authentication_secret',
'two_factor_recovery_codes', 'app_authentication_recovery_codes',
'two_factor_confirmed_at',
]); ]);
LunarStaff::created(function (LunarStaff $staff) { LunarStaff::created(function (LunarStaff $staff) {
@@ -2,12 +2,12 @@
namespace Modules\Core\Customer\RelationManagers; namespace Modules\Core\Customer\RelationManagers;
use Filament\Forms\Components\Group; use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Schemas\Components\Group;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -38,9 +38,9 @@ class AddressRelationManager extends BaseAddressRelationManager
), ),
]) ])
->headerActions([ ->headerActions([
CreateAction::make()->form($this->addressForm()), CreateAction::make()->schema($this->addressForm()),
]) ])
->actions([ ->recordActions([
EditAction::make('editAddress') EditAction::make('editAddress')
->fillForm(fn (AddressContract $record): array => [ ->fillForm(fn (AddressContract $record): array => [
'line_one' => $record->line_one, 'line_one' => $record->line_one,
@@ -51,7 +51,7 @@ class AddressRelationManager extends BaseAddressRelationManager
'contact_email' => $record->contact_email, 'contact_email' => $record->contact_email,
'contact_phone' => $record->contact_phone, 'contact_phone' => $record->contact_phone,
]) ])
->form($this->addressForm()), ->schema($this->addressForm()),
DeleteAction::make('deleteAddress'), DeleteAction::make('deleteAddress'),
]); ]);
} }
@@ -2,6 +2,8 @@
namespace Modules\Core\Customer\RelationManagers; namespace Modules\Core\Customer\RelationManagers;
use Filament\Tables\Columns\TextColumn;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -14,16 +16,16 @@ class UserRelationManager extends BaseUserRelationManager
public function getDefaultTable(Table $table): Table public function getDefaultTable(Table $table): Table
{ {
return $table->columns([ return $table->columns([
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->label(__('lunarpanel::user.table.name.label')), ->label(__('lunarpanel::user.table.name.label')),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->label(__('lunarpanel::user.table.email.label')), ->label(__('lunarpanel::user.table.email.label')),
])->actions([ ])->recordActions([
Tables\Actions\EditAction::make('edit') EditAction::make('edit')
->after( ->after(
fn (Model $record) => CustomerUserEdited::dispatch($record) fn (Model $record) => CustomerUserEdited::dispatch($record)
) )
->form([ ->schema([
TextInput::make('email') TextInput::make('email')
->label(__('lunarpanel::user.form.email.label')) ->label(__('lunarpanel::user.form.email.label'))
->required() ->required()
@@ -2,8 +2,17 @@
namespace Modules\Core\Localization\Filament\Resources; namespace Modules\Core\Localization\Filament\Resources;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Fieldset;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\ListLanguageLines;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\CreateLanguageLine;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages\EditLanguageLine;
use Filament\Forms\Components\Textarea;
use Illuminate\Support\Collection;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -15,29 +24,29 @@ class LanguageLineResource extends Resource
{ {
protected static ?string $model = LanguageLine::class; protected static ?string $model = LanguageLine::class;
protected static ?string $navigationIcon = 'heroicon-o-language'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationGroup = 'Settings'; protected static string | \UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Translation'; protected static ?string $modelLabel = 'Translation';
protected static ?string $pluralModelLabel = 'Translations'; protected static ?string $pluralModelLabel = 'Translations';
public static function form(Form $form): Form public static function form(Schema $schema): Schema
{ {
return $form->schema([ return $schema->components([
Forms\Components\TextInput::make('group') TextInput::make('group')
->required() ->required()
->maxLength(255) ->maxLength(255)
->default('storefront') ->default('storefront')
->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'), ->helperText('Namespace for this label, e.g. "storefront" for e-shop UI text.'),
Forms\Components\TextInput::make('key') TextInput::make('key')
->required() ->required()
->maxLength(255) ->maxLength(255)
->helperText('Dot-notation key, e.g. "nav.cart".'), ->helperText('Dot-notation key, e.g. "nav.cart".'),
Forms\Components\Fieldset::make('Translations') Fieldset::make('Translations')
->schema(static::localeInputs()), ->schema(static::localeInputs()),
]); ]);
} }
@@ -46,16 +55,16 @@ class LanguageLineResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
Tables\Columns\TextColumn::make('group') TextColumn::make('group')
->badge() ->badge()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('key') TextColumn::make('key')
->searchable() ->searchable()
->sortable(), ->sortable(),
...static::localeColumns(), ...static::localeColumns(),
]) ])
->filters([ ->filters([
Tables\Filters\SelectFilter::make('group') SelectFilter::make('group')
->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')), ->options(fn () => LanguageLine::query()->distinct()->pluck('group', 'group')),
]) ])
->defaultSort('key'); ->defaultSort('key');
@@ -69,38 +78,38 @@ class LanguageLineResource extends Resource
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => Pages\ListLanguageLines::route('/'), 'index' => ListLanguageLines::route('/'),
'create' => Pages\CreateLanguageLine::route('/create'), 'create' => CreateLanguageLine::route('/create'),
'edit' => Pages\EditLanguageLine::route('/{record}/edit'), 'edit' => EditLanguageLine::route('/{record}/edit'),
]; ];
} }
/** /**
* @return array<Forms\Components\Textarea> * @return array<Textarea>
*/ */
private static function localeInputs(): array private static function localeInputs(): array
{ {
return static::localeCodes() return static::localeCodes()
->map(fn (string $code) => Forms\Components\Textarea::make("text.{$code}") ->map(fn (string $code) => Textarea::make("text.{$code}")
->label(strtoupper($code)) ->label(strtoupper($code))
->rows(2)) ->rows(2))
->all(); ->all();
} }
/** /**
* @return array<Tables\Columns\TextColumn> * @return array<TextColumn>
*/ */
private static function localeColumns(): array private static function localeColumns(): array
{ {
return static::localeCodes() return static::localeCodes()
->map(fn (string $code) => Tables\Columns\TextColumn::make("text.{$code}") ->map(fn (string $code) => TextColumn::make("text.{$code}")
->label(strtoupper($code)) ->label(strtoupper($code))
->limit(40) ->limit(40)
->toggleable()) ->toggleable())
->all(); ->all();
} }
private static function localeCodes(): \Illuminate\Support\Collection private static function localeCodes(): Collection
{ {
return Language::query()->pluck('code'); return Language::query()->pluck('code');
} }
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages; namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@@ -17,7 +18,7 @@ class EditLanguageLine extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->action(function (LanguageLine $record) { ->action(function (LanguageLine $record) {
app(TranslationService::class)->delete($record); app(TranslationService::class)->delete($record);
@@ -2,6 +2,7 @@
namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages; namespace Modules\Core\Localization\Filament\Resources\LanguageLineResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
@@ -13,7 +14,7 @@ class ListLanguageLines extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), CreateAction::make(),
]; ];
} }
} }
@@ -76,6 +76,9 @@ class StorefrontLabels
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'], 'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'], 'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'], 'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'], 'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'], 'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'], 'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport; namespace Modules\Core\MigrateImport;
use InvalidArgumentException;
use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter; use Modules\Core\MigrateImport\JudgeMe\JudgeMeExportImporter;
use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter; use Modules\Core\MigrateImport\Shopify\ShopifyExportImporter;
@@ -15,7 +16,7 @@ class ImporterFactory
['judgeme', 'export'] => new JudgeMeExportImporter, ['judgeme', 'export'] => new JudgeMeExportImporter,
// ["woocommerce", "export"] => new WooCommerceExportImporter(), // ["woocommerce", "export"] => new WooCommerceExportImporter(),
// ["woocommerce", "api"] => new WooCommerceApiImporter(), // ["woocommerce", "api"] => new WooCommerceApiImporter(),
default => throw new \InvalidArgumentException( default => throw new InvalidArgumentException(
"No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".", "No importer available for source \"{$spec->source}\" with type \"{$spec->type}\".",
), ),
}; };
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe;
use RuntimeException;
class JudgeMeCsvReader class JudgeMeCsvReader
{ {
/** /**
@@ -12,7 +14,7 @@ class JudgeMeCsvReader
$handle = fopen($csvPath, 'r'); $handle = fopen($csvPath, 'r');
if ($handle === false) { if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}"); throw new RuntimeException("Could not open CSV file: {$csvPath}");
} }
$headers = fgetcsv($handle); $headers = fgetcsv($handle);
@@ -2,6 +2,7 @@
namespace Modules\Core\MigrateImport\JudgeMe; namespace Modules\Core\MigrateImport\JudgeMe;
use Throwable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Importer;
@@ -75,7 +76,7 @@ class JudgeMeExportImporter implements Importer
foreach ($urls as $url) { foreach ($urls as $url) {
try { try {
$review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION); $review->addMediaFromUrl($url)->toMediaCollection(ProductReview::IMAGES_COLLECTION);
} catch (\Throwable $e) { } catch (Throwable $e) {
Log::warning('JudgeMe import: failed to download review image', [ Log::warning('JudgeMe import: failed to download review image', [
'review_id' => $review->id, 'review_id' => $review->id,
'url' => $url, 'url' => $url,
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify;
use RuntimeException;
class ShopifyCsvReader class ShopifyCsvReader
{ {
/** /**
@@ -12,7 +14,7 @@ class ShopifyCsvReader
$handle = fopen($csvPath, 'r'); $handle = fopen($csvPath, 'r');
if ($handle === false) { if ($handle === false) {
throw new \RuntimeException("Could not open CSV file: {$csvPath}"); throw new RuntimeException("Could not open CSV file: {$csvPath}");
} }
$headers = fgetcsv($handle); $headers = fgetcsv($handle);
@@ -2,6 +2,8 @@
namespace Modules\Core\MigrateImport\Shopify; namespace Modules\Core\MigrateImport\Shopify;
use Lunar\Models\TaxClass;
use Lunar\Models\ProductOption;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Lunar\Models\Collection; use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup; use Lunar\Models\CollectionGroup;
@@ -120,7 +122,7 @@ class ShopifyExportImporter implements Importer
} }
/** /**
* @return array<int, \Lunar\Models\ProductOption> * @return array<int, ProductOption>
*/ */
private function attachOptions(Product $product, array $row): array private function attachOptions(Product $product, array $row): array
{ {
@@ -146,7 +148,7 @@ class ShopifyExportImporter implements Importer
string $handle, string $handle,
int $index, int $index,
array $row, array $row,
\Lunar\Models\TaxClass $taxClass, TaxClass $taxClass,
Currency $currency, Currency $currency,
array $options, array $options,
): void { ): void {
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Notification; namespace Modules\Core\Notification;
use Throwable;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
class NotificationRegistry class NotificationRegistry
@@ -44,7 +45,7 @@ class NotificationRegistry
$notification->delay($event->delaySeconds); $notification->delay($event->delaySeconds);
} }
$notification->notifiable()->notify($notification); $notification->notifiable()->notify($notification);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
} }
}); });
+6 -3
View File
@@ -2,6 +2,9 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use InvalidArgumentException;
use Exception;
use RuntimeException;
use Traversable; use Traversable;
/** /**
@@ -39,7 +42,7 @@ final class LazyOption extends Option
public function __construct($callback, array $arguments = []) public function __construct($callback, array $arguments = [])
{ {
if (!is_callable($callback)) { if (!is_callable($callback)) {
throw new \InvalidArgumentException("Invalid callback given"); throw new InvalidArgumentException("Invalid callback given");
} }
$this->callback = $callback; $this->callback = $callback;
@@ -71,7 +74,7 @@ final class LazyOption extends Option
return $this->option()->getOrCall($callable); return $this->option()->getOrCall($callable);
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
return $this->option()->getOrThrow($ex); return $this->option()->getOrThrow($ex);
} }
@@ -146,7 +149,7 @@ final class LazyOption extends Option
if ($option instanceof Option) { if ($option instanceof Option) {
$this->option = $option; $this->option = $option;
} else { } else {
throw new \RuntimeException( throw new RuntimeException(
sprintf("Expected instance of %s", Option::class), sprintf("Expected instance of %s", Option::class),
); );
} }
+4 -2
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use RuntimeException;
use Exception;
use EmptyIterator; use EmptyIterator;
/** /**
@@ -24,7 +26,7 @@ final class None extends Option
public function get() public function get()
{ {
throw new \RuntimeException("None has no value."); throw new RuntimeException("None has no value.");
} }
public function getOrCall($callable) public function getOrCall($callable)
@@ -37,7 +39,7 @@ final class None extends Option
return $default; return $default;
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
throw $ex; throw $ex;
} }
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use Exception;
use ArrayAccess; use ArrayAccess;
use IteratorAggregate; use IteratorAggregate;
@@ -164,7 +165,7 @@ abstract class Option implements IteratorAggregate
abstract public function getOrCall($callable); abstract public function getOrCall($callable);
/** @return T */ /** @return T */
abstract public function getOrThrow(\Exception $ex); abstract public function getOrThrow(Exception $ex);
abstract public function isEmpty(): bool; abstract public function isEmpty(): bool;
+3 -2
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Option; namespace Modules\Core\Option;
use RuntimeException;
use ArrayIterator; use ArrayIterator;
use Exception; use Exception;
@@ -56,7 +57,7 @@ final class Some extends Option
return $this->value; return $this->value;
} }
public function getOrThrow(\Exception $ex) public function getOrThrow(Exception $ex)
{ {
return $this->value; return $this->value;
} }
@@ -88,7 +89,7 @@ final class Some extends Option
/** @var mixed */ /** @var mixed */
$rs = $callable($this->value); $rs = $callable($this->value);
if (!$rs instanceof Option) { if (!$rs instanceof Option) {
throw new \RuntimeException( throw new RuntimeException(
"Callables passed to flatMap() must return an Option. Maybe you should use map() instead?", "Callables passed to flatMap() must return an Option. Maybe you should use map() instead?",
); );
} }
+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;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
/**
* Shared by every payment type with no real gateway to confirm against —
* cash-in-hand, cash-on-delivery — where the shopper pays at pickup/on
* delivery, not at checkout. confirm() has nothing to wait on, so it places
* the order immediately, same as Lunar's own OfflinePayment would, but
* through CheckoutService::placeOrder() so it goes through the same
* fingerprint check every other driver does. $data is unused: nothing about
* this confirmation depends on gateway-specific payload.
*
* Sets the order status to config("lunar.payments.types.{$type}.authorized")
* afterward, using the type actually confirmed — not a hardcoded key —
* since this one driver is shared across multiple types.
* placeOrder() itself leaves the order at Lunar's configured draft_status,
* same as every driver is responsible for moving it on from.
*/
class OfflinePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Always true — no external dependency to be missing.
*/
public function isConfigured(): bool
{
return true;
}
/**
* @throws FingerprintMismatchException
* @throws CartException
* @throws DisallowMultipleCartOrdersException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$order = $this->checkout->placeOrder($fingerprint);
$order->update([
'status' => config("lunar.payments.types.{$type}.authorized", $order->status),
]);
return $order->refresh();
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Modules\Core\Payment\Drivers;
use Lunar\Exceptions\FingerprintMismatchException;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Exceptions\DisallowMultipleCartOrdersException;
use Lunar\Models\Cart;
use Lunar\Models\Order;
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
use Lunar\Stripe\Facades\Stripe;
use Lunar\Stripe\Models\StripePaymentIntent;
use Modules\Core\Checkout\Contracts\PaymentDriver;
use Modules\Core\Checkout\Services\CheckoutService;
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
use Stripe\PaymentIntent;
/**
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
* Modules\Core\Checkout\Contracts\PaymentDriver — calls
* CheckoutService::placeOrder($fingerprint) at the moment Stripe confirms
* payment, instead of the vendor's own Cart::createOrder() call.
*
* This is a fork, not a decoration: StripePaymentType::authorize() is
* `final` and calls Cart::createOrder() directly with no seam to redirect
* that one call — so this class reimplements authorize()'s logic (intent
* retrieval, capture-on-policy, status mapping via UpdateOrderFromIntent)
* rather than wrapping the vendor method. Kept deliberately close to the
* original so a lunarphp/stripe upgrade is easy to diff against. See
* docs/payments.md.
*/
class StripePaymentDriver implements PaymentDriver
{
public function __construct(
private readonly CheckoutService $checkout,
) {}
/**
* Same key lunarphp/stripe's own StripeManager reads its API key from
* (Stripe::setApiKey(config('services.stripe.key')) in
* StripeManager::__construct()) — no key, no usable driver.
*/
public function isConfigured(): bool
{
return filled(config('services.stripe.key'));
}
/**
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
* payment intent (wrong intent id, already processed, order already
* placed, or the gateway call itself fails) — nothing here should be
* treated as "place the order anyway."
* @throws FingerprintMismatchException
* @throws CartException
*/
public function confirm(Cart $cart, string $type, string $fingerprint, array $data): Order
{
$paymentIntentId = $data['payment_intent'];
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
throw new PaymentNotConfirmedException('Payment intent already processed.');
}
if (! $paymentIntentModel) {
$paymentIntentModel = StripePaymentIntent::create([
'intent_id' => $paymentIntentId,
'cart_id' => $cart->id,
]);
}
$paymentIntentModel->update(['processing_at' => now()]);
$stripe = Stripe::getClient();
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (! $paymentIntent) {
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
}
$policy = config('lunar.stripe.policy', 'automatic');
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
}
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
$paymentIntentModel->update(['status' => $paymentIntent->status]);
throw new PaymentNotConfirmedException(
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
);
}
try {
$order = $this->checkout->placeOrder($fingerprint);
} catch (DisallowMultipleCartOrdersException|CartException $e) {
throw new PaymentNotConfirmedException($e->getMessage(), previous: $e);
}
$paymentIntentModel->order_id = $order->id;
$paymentIntentModel->status = $paymentIntent->status;
$paymentIntentModel->processed_at = now();
$paymentIntentModel->save();
UpdateOrderFromIntent::execute($order, $paymentIntent);
return $order->refresh();
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Payment\Exceptions;
use RuntimeException;
use Throwable;
/**
* Thrown by a Modules\Core\Checkout\Contracts\PaymentDriver when the
* gateway has not confirmed payment — wrong/expired intent, already
* processed, or the gateway itself rejects the confirmation. A driver
* throws this instead of silently placing the order: CheckoutService::
* placeOrder() must only ever be called once a driver has positively
* confirmed payment, never as a fallback.
*/
class PaymentNotConfirmedException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Modules\Core\Payment\Filament\Resources;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages\ListPaymentMethods;
use Modules\Core\Payment\Models\PaymentMethod;
/**
* One row per payment type key (config('lunar.payments.types')), seeded by
* InstallLunarCommand — never created/deleted here, only edited. `enabled`
* toggles inline; `data.fee` (currently the only type-specific setting, for
* cash-on-delivery's flat surcharge — see ApplyCashOnDeliveryFee) is edited
* via a modal action rather than a dedicated form field, since not every
* type has the same data keys.
*/
class PaymentMethodResource extends Resource
{
protected static ?string $model = PaymentMethod::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static string|\UnitEnum|null $navigationGroup = 'Settings';
protected static ?string $modelLabel = 'Payment Method';
protected static ?string $pluralModelLabel = 'Payment Methods';
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('type')
->label('Type'),
ToggleColumn::make('enabled')
->label('Enabled'),
TextColumn::make('data.fee')
->label('Fee')
->formatStateUsing(fn (?int $state) => $state
? number_format($state / 100, 2)
: '—'),
TextColumn::make('updated_at')
->label('Last updated')
->dateTime(),
])
->recordActions([
static::editFeeAction(),
])
->defaultSort('type');
}
/**
* $data['fee'] is stored as an integer minor unit (cents), matching
* Lunar's own Price convention everywhere else in this codebase — the
* form collects/displays a decimal and converts at the boundary.
*/
private static function editFeeAction(): Action
{
return Action::make('edit_fee')
->label('Edit fee')
->icon('heroicon-o-pencil')
->schema([
TextInput::make('fee')
->label('Fee')
->numeric()
->minValue(0)
->step(0.01)
->helperText('Flat surcharge added when this payment method is selected.'),
])
->fillForm(fn (PaymentMethod $record) => [
'fee' => filled($record->data['fee'] ?? null) ? $record->data['fee'] / 100 : null,
])
->action(function (PaymentMethod $record, array $data) {
$record->update([
'data' => [
...$record->data->toArray(),
'fee' => filled($data['fee']) ? (int) round($data['fee'] * 100) : null,
],
]);
});
}
public static function getPages(): array
{
return [
'index' => ListPaymentMethods::route('/'),
];
}
public static function canCreate(): bool
{
return false;
}
public static function canDelete($record = null): bool
{
return false;
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Core\Payment\Filament\Resources\PaymentMethodResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Core\Payment\Filament\Resources\PaymentMethodResource;
class ListPaymentMethods extends ListRecords
{
protected static string $resource = PaymentMethodResource::class;
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Payment\Models;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-editable settings for one payment type key (matching a key in
* config('lunar.payments.types')) — enabled/disabled, and whatever type-
* specific data it needs (starts with 'fee' for cash-on-delivery's flat
* surcharge). Mirrors Lunar's own Discount model: a single jsonb 'data'
* column holding keyed settings, rather than a fixed column per setting or
* a separate conditions table — new settings are a code change (a new key
* read from data), not a migration.
*
* Seeded once per type by InstallLunarCommand (skip-if-exists, same
* idempotent convention as seedStorefrontLabels()) — never auto-created on
* read, so a read path stays a pure read.
*/
class PaymentMethod extends Model
{
protected $guarded = [];
protected $casts = [
'enabled' => 'boolean',
'data' => AsArrayObject::class,
];
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Payment\Pipelines\Cart;
use Closure;
use Lunar\DataTypes\Price;
use Lunar\Models\Contracts\Cart as CartContract;
use Modules\Core\Payment\Models\PaymentMethod;
final class ApplyCashOnDeliveryFee
{
/**
* Called just before cart totals are calculated.
*
* @param Closure(CartContract): mixed $next
*/
public function handle(CartContract $cart, Closure $next): mixed
{
if (($cart->meta['payment_method'] ?? null) === 'cash-on-delivery') {
$fee = (int) (PaymentMethod::where('type', 'cash-on-delivery')->value('data->fee') ?? 0);
$cart->shippingTotal = new Price(
($cart->shippingTotal?->value ?? 0) + $fee,
$cart->currency,
1
);
}
return $next($cart);
}
}
+38
View File
@@ -2,17 +2,32 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Product;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver; use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType; use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
class CatalogServiceProvider extends ServiceProvider class CatalogServiceProvider extends ServiceProvider
{ {
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/catalog.php', 'catalog');
}
public function boot(): void public function boot(): void
{ {
$this->publishes([
__DIR__ . '/../../config/catalog.php' => config_path('catalog.php'),
], 'core-config');
ProductOptionTypeManager::get()->register([ ProductOptionTypeManager::get()->register([
ColorOptionType::class, ColorOptionType::class,
]); ]);
@@ -24,5 +39,28 @@ class CatalogServiceProvider extends ServiceProvider
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value)); ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value)); ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
$this->app->booted(function () {
// A full nightly reindex, on top of the per-event reindexing
// above — catches everything event-driven reindexing
// deliberately doesn't cover: a newly-created product not yet
// appearing as a recommendation elsewhere, in_stock/price
// drifting from an order decrementing stock outside a product
// save, and any other staleness ProductIndexer's own docblock
// already documents as accepted between reindexes. --refresh
// re-syncs filterable/sortable field settings too, not just
// documents, so a deploy that changed ProductIndexer's field
// list self-heals here even if `lunar:meilisearch:setup`
// wasn't run manually after that deploy.
$this->app->make(Schedule::class)
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
->dailyAt('03:00');
});
} }
} }
+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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Pipelines\Cart\ApplyShipping;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/payment.php', 'payment');
}
public function boot(): void
{
config([
'lunar.payments.types' => array_merge(
config('lunar.payments.types', []),
config('payment.types', [])
),
]);
$cartPipeline = config('lunar.cart.pipelines.cart', []);
$insertAfter = array_search(ApplyShipping::class, $cartPipeline, true);
foreach (config('payment.cart_pipeline', []) as $pipe) {
if (in_array($pipe, $cartPipeline, true)) {
continue;
}
if ($insertAfter === false) {
$cartPipeline[] = $pipe;
} else {
array_splice($cartPipeline, $insertAfter + 1, 0, [$pipe]);
$insertAfter++;
}
}
config(['lunar.cart.pipelines.cart' => $cartPipeline]);
}
}
+10 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None; use Modules\Core\Option\None;
use Modules\Core\Option\Some; use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T * @template T
* @template E * @template E
* *
* @extends \Modules\Core\ResultType\Result<T,E> * @extends Result<T, E>
*/ */
final class Error extends Result final class Error extends Result
{ {
@@ -39,7 +40,7 @@ final class Error extends Result
* *
* @param F $value * @param F $value
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public static function create($value): Error public static function create($value): Error
{ {
@@ -49,7 +50,7 @@ final class Error extends Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
public function success() public function success()
{ {
@@ -63,7 +64,7 @@ final class Error extends Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public function map(callable $f): Result public function map(callable $f): Result
{ {
@@ -76,20 +77,20 @@ final class Error extends Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
public function flatMap(callable $f): Result public function flatMap(callable $f): Result
{ {
/** @var \Modules\Core\ResultType\Result<S,F> */ /** @var Result<S, F> */
return self::create($this->value); return self::create($this->value);
} }
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
public function error(): Some public function error(): Some
{ {
@@ -103,7 +104,7 @@ final class Error extends Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public function mapError(callable $f): Result public function mapError(callable $f): Result
{ {
+8 -6
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
/** /**
* @template T * @template T
* @template E * @template E
@@ -13,7 +15,7 @@ abstract class Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
abstract public function success(); abstract public function success();
@@ -24,7 +26,7 @@ abstract class Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
abstract public function map(callable $f); abstract public function map(callable $f);
@@ -34,16 +36,16 @@ abstract class Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
abstract public function flatMap(callable $f); abstract public function flatMap(callable $f);
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
abstract public function error(); abstract public function error();
@@ -54,7 +56,7 @@ abstract class Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
abstract public function mapError(callable $f); abstract public function mapError(callable $f);
} }
+9 -8
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Core\ResultType; namespace Modules\Core\ResultType;
use Modules\Core\Option\Option;
use Modules\Core\Option\None; use Modules\Core\Option\None;
use Modules\Core\Option\Some; use Modules\Core\Option\Some;
@@ -11,7 +12,7 @@ use Modules\Core\Option\Some;
* @template T * @template T
* @template E * @template E
* *
* @extends \Modules\Core\ResultType\Result<T,E> * @extends Result<T, E>
*/ */
final class Success extends Result final class Success extends Result
{ {
@@ -39,7 +40,7 @@ final class Success extends Result
* *
* @param S $value * @param S $value
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public static function create($value): Success public static function create($value): Success
{ {
@@ -49,7 +50,7 @@ final class Success extends Result
/** /**
* Get the success option value. * Get the success option value.
* *
* @return \Modules\Core\Option\Option<T> * @return Option<T>
*/ */
public function success(): Some public function success(): Some
{ {
@@ -63,7 +64,7 @@ final class Success extends Result
* *
* @param callable(T):S $f * @param callable(T):S $f
* *
* @return \Modules\Core\ResultType\Result<S,E> * @return Result<S, E>
*/ */
public function map(callable $f): Result public function map(callable $f): Result
{ {
@@ -76,9 +77,9 @@ final class Success extends Result
* @template S * @template S
* @template F * @template F
* *
* @param callable(T):\Modules\Core\ResultType\Result<S,F> $f * @param callable(T):Result<S, F> $f
* *
* @return \Modules\Core\ResultType\Result<S,F> * @return Result<S, F>
*/ */
public function flatMap(callable $f) public function flatMap(callable $f)
{ {
@@ -88,7 +89,7 @@ final class Success extends Result
/** /**
* Get the error option value. * Get the error option value.
* *
* @return \Modules\Core\Option\Option<E> * @return Option<E>
*/ */
public function error() public function error()
{ {
@@ -102,7 +103,7 @@ final class Success extends Result
* *
* @param callable(E):F $f * @param callable(E):F $f
* *
* @return \Modules\Core\ResultType\Result<T,F> * @return Result<T, F>
*/ */
public function mapError(callable $f): Result public function mapError(callable $f): Result
{ {
@@ -2,15 +2,15 @@
namespace Modules\Core\Review\Filament\Pages; namespace Modules\Core\Review\Filament\Pages;
use Filament\Forms\Components\Group; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Group;
use Filament\Actions\ViewAction;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -40,10 +40,10 @@ class ManageProductReviews extends BaseManageRelatedRecords
return 'Reviews'; return 'Reviews';
} }
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
return $form return $schema
->schema([ ->components([
TextInput::make('rating') TextInput::make('rating')
->label('Rating') ->label('Rating')
->disabled(), ->disabled(),
@@ -127,12 +127,12 @@ class ManageProductReviews extends BaseManageRelatedRecords
->filters([ ->filters([
// //
]) ])
->actions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
Action::make('reply') Action::make('reply')
->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply') ->label(fn (ProductReview $record) => $record->reply ? 'Edit reply' : 'Reply')
->icon('heroicon-o-chat-bubble-left-right') ->icon('heroicon-o-chat-bubble-left-right')
->form([ ->schema([
Textarea::make('reply') Textarea::make('reply')
->label('Reply') ->label('Reply')
->required(), ->required(),
@@ -146,7 +146,7 @@ class ManageProductReviews extends BaseManageRelatedRecords
}), }),
DeleteAction::make(), DeleteAction::make(),
]) ])
->bulkActions([ ->toolbarActions([
DeleteBulkAction::make(), DeleteBulkAction::make(),
]); ]);
} }
@@ -2,6 +2,7 @@
namespace Modules\Core\Shipping\Carriers\Acs; namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
@@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
public function cancelShipment(Shipment $shipment): void public function cancelShipment(Shipment $shipment): void
{ {
if ($shipment->manifest_reference) { if ($shipment->manifest_reference) {
throw new \RuntimeException('Cannot cancel a shipment already included in an issued manifest.'); throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.');
} }
$this->client->call('ACS_Delete_Voucher', [ $this->client->call('ACS_Delete_Voucher', [
+2 -1
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Shipping\Concerns; namespace Modules\Core\Shipping\Concerns;
use Closure;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Models\Cart; use Lunar\Models\Cart;
@@ -27,7 +28,7 @@ use Lunar\Shipping\Models\ShippingRate;
*/ */
trait CachesLivePricing trait CachesLivePricing
{ {
private function cached(ShippingRate $shippingRate, Cart $cart, \Closure $resolve): ?ShippingOption private function cached(ShippingRate $shippingRate, Cart $cart, Closure $resolve): ?ShippingOption
{ {
return Cache::remember( return Cache::remember(
"shipping.live_price.{$shippingRate->id}.{$cart->id}", "shipping.live_price.{$shippingRate->id}.{$cart->id}",
+14 -9
View File
@@ -2,6 +2,11 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Closure;
use Throwable;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@@ -20,28 +25,28 @@ class OrderViewExtension extends ViewPageExtension
return $actions; return $actions;
} }
private function createShipmentAction(): Actions\Action private function createShipmentAction(): Action
{ {
return Actions\Action::make('create_shipment') return Action::make('create_shipment')
->label('Create Shipment') ->label('Create Shipment')
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->form([ ->schema([
Forms\Components\TextInput::make('weight') TextInput::make('weight')
->label('Package weight (kg)') ->label('Package weight (kg)')
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->helperText('Leave blank to use the carrier\'s default.'), ->helperText('Leave blank to use the carrier\'s default.'),
Forms\Components\TextInput::make('destination_location_id') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->helperText('Only required for Box Now shipments.') ->helperText('Only required for Box Now shipments.')
->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null), ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null),
Forms\Components\Toggle::make('confirm') Toggle::make('confirm')
->label('Confirm') ->label('Confirm')
->helperText('This will create a real shipment with the carrier.') ->helperText('This will create a real shipment with the carrier.')
->rules([ ->rules([
function () { function () {
return function (string $attribute, $value, \Closure $fail) { return function (string $attribute, $value, Closure $fail) {
if ($value !== true) { if ($value !== true) {
$fail('Please confirm before creating the shipment.'); $fail('Please confirm before creating the shipment.');
} }
@@ -49,7 +54,7 @@ class OrderViewExtension extends ViewPageExtension
}, },
]), ]),
]) ])
->action(function (Order $record, array $data, Actions\Action $action) { ->action(function (Order $record, array $data, Action $action) {
$service = $this->resolveFulfillmentService($record); $service = $this->resolveFulfillmentService($record);
if (! $service) { if (! $service) {
@@ -70,7 +75,7 @@ class OrderViewExtension extends ViewPageExtension
try { try {
$service->createShipment($record, $request); $service->createShipment($record, $request);
} catch (\Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
Notification::make() Notification::make()
@@ -2,8 +2,9 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\CreateAction;
use Filament\Schemas\Components\Group;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Lunar\Admin\Support\Extending\BaseExtension; use Lunar\Admin\Support\Extending\BaseExtension;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
@@ -22,8 +23,8 @@ class ShippingMethodListExtension extends BaseExtension
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
foreach ($actions as $action) { foreach ($actions as $action) {
if ($action instanceof Actions\CreateAction) { if ($action instanceof CreateAction) {
$action->form([ $action->schema([
ShippingMethodResource::getNameFormComponent(), ShippingMethodResource::getNameFormComponent(),
Group::make([ Group::make([
ShippingMethodResource::getCodeFormComponent(), ShippingMethodResource::getCodeFormComponent(),
@@ -2,11 +2,12 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Forms\Components\Component; use Filament\Schemas\Schema;
use Filament\Forms\Components\Concerns\HasChildComponents; use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -15,11 +16,11 @@ use Modules\Core\Shipping\Contracts\SupportsLivePricing;
class ShippingMethodResourceExtension extends ResourceExtension class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Form $form): Form public function extendForm(Schema $schema): Schema
{ {
return $form->schema( return $schema->components(
$this->replaceChargeByField( $this->replaceChargeByField(
$this->replaceDriverField($form->getComponents()) $this->replaceDriverField($schema->getComponents())
) )
); );
} }
@@ -83,7 +84,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
try { try {
return Shipping::driver($driver) instanceof SupportsLivePricing; return Shipping::driver($driver) instanceof SupportsLivePricing;
} catch (\InvalidArgumentException) { } catch (InvalidArgumentException) {
return false; return false;
} }
} }
@@ -120,7 +121,7 @@ class ShippingMethodResourceExtension extends ResourceExtension
* Select (nested inside Section > Group) with one listing every * Select (nested inside Section > Group) with one listing every
* registered driver, built-in or custom. * registered driver, built-in or custom.
* *
* @param array<Component> $components * @param array<Component> $components
* @return array<Component> * @return array<Component>
*/ */
private function replaceDriverField(array $components): array private function replaceDriverField(array $components): array
@@ -2,10 +2,10 @@
namespace Modules\Core\Shipping\Filament\Pages; namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
@@ -21,7 +21,7 @@ class ManagePickupManifests extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
protected static ?string $navigationIcon = 'heroicon-o-truck'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Pickup Manifests'; protected static ?string $navigationLabel = 'Pickup Manifests';
@@ -38,11 +38,11 @@ class ManagePickupManifests extends Page implements HasTable
* OrderResource uses 1) so this page never competes to be first even as * OrderResource uses 1) so this page never competes to be first even as
* more Sales-group items are added later. * more Sales-group items are added later.
*/ */
protected static ?string $navigationGroup = 'Sales'; protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?int $navigationSort = 100; protected static ?int $navigationSort = 100;
protected static string $view = 'core::shipping.filament.pages.manage-pickup-manifests'; protected string $view = 'core::shipping.filament.pages.manage-pickup-manifests';
public function table(Table $table): Table public function table(Table $table): Table
{ {
@@ -54,13 +54,13 @@ class ManagePickupManifests extends Page implements HasTable
TextColumn::make('order.reference')->label('Order'), TextColumn::make('order.reference')->label('Order'),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
]) ])
->actions([ ->recordActions([
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Shipment $record) => $this->printShipment($record)), ->action(fn (Shipment $record) => $this->printShipment($record)),
]) ])
->bulkActions([ ->toolbarActions([
BulkAction::make('print_selected') BulkAction::make('print_selected')
->label('Print selected') ->label('Print selected')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
@@ -2,9 +2,9 @@
namespace Modules\Core\Shipping\Filament\Pages; namespace Modules\Core\Shipping\Filament\Pages;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -36,12 +36,12 @@ use Lunar\Shipping\Models\ShippingRate;
*/ */
class ManageShippingRates extends BaseManageShippingRates class ManageShippingRates extends BaseManageShippingRates
{ {
public function form(Form $form): Form public function form(Schema $schema): Schema
{ {
$form = parent::form($form); $schema = parent::form($schema);
return $form->schema( return $schema->components(
$this->labelPriceFieldsAsFallbackWhenLive($form->getComponents()) $this->labelPriceFieldsAsFallbackWhenLive($schema->getComponents())
); );
} }