Compare commits
3
Commits
e1299fafee
..
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66068ef68a | ||
|
|
f6ef0761d6 | ||
|
|
a8ddbb8056 |
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.8.0] - 2026-08-27
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into carts in the Filament admin panel — Lunar ships no cart admin view at all. Scoped to carts with a known `user_id`/`customer_id` (an anonymous guest cart carries no identity staff could act on); list table shows customer/user, line/item counts (via Filament's built-in `->counts()`/`->sum()`, no per-row queries), currency, and last activity. List page has only two tabs, **Abandoned** (default active) and **Completed** — no "All" tab, so the list never runs an unfiltered fetch over the whole table. They key off whether the cart has a **placed** order (`orders.placed_at IS NOT NULL`), not `Cart::completed_at` — that column is declared/cast on the model but never actually written anywhere in Lunar core, so it's not a real signal; "Abandoned" mirrors Lunar's own `Cart::scopeActive()`. `getNavigationBadge()` shows the abandoned-cart count in the sidebar via a single `COUNT(*)` query, no rows loaded. View page runs `$cart->calculate()` once so line/cart totals (plain public properties Lunar never persists) are populated, without paying that cost per row in the list. Documented in `docs/cart.md`.
|
||||
|
||||
## [0.7.0] - 2026-08-27
|
||||
|
||||
### Added
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
@@ -37,6 +37,7 @@
|
||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||
"Modules\\Core\\Providers\\ReviewServiceProvider"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,4 +16,20 @@ return [
|
||||
|
||||
'auto_create_customer_for_user' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cart Abandonment Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How long a cart (that hasn't converted to a placed order) can go without
|
||||
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||
|
|
||||
*/
|
||||
|
||||
'cart' => [
|
||||
'abandoned_after' => '1 hour',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
# Cart Admin Visibility
|
||||
|
||||
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
|
||||
customer/user carts in the Filament admin panel. Lunar itself ships no cart admin view at
|
||||
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
|
||||
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
|
||||
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
|
||||
mechanics this resource reads from.
|
||||
|
||||
---
|
||||
|
||||
## Scope: only carts with a known customer or user
|
||||
|
||||
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
|
||||
— an anonymous guest's session cart is excluded entirely.
|
||||
|
||||
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
|
||||
member could act on — no name, no email, nothing to follow up with — so listing every guest
|
||||
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
|
||||
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
|
||||
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
|
||||
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
|
||||
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
|
||||
checkout step required — so scoping to "identifiable" here is broader than Shopify's
|
||||
equivalent, not a copy of it.
|
||||
|
||||
---
|
||||
|
||||
## Four states, not two — and not `Cart::completed_at`
|
||||
|
||||
`Lunar\Models\Cart::completed_at` is declared and cast (`'completed_at' => 'datetime'`) but
|
||||
**never actually written anywhere in Lunar core** — grep `vendor/lunarphp/core/src` for it;
|
||||
the only hits are the property declaration and the cast. It is not a real signal. `Cart` has
|
||||
no `status` column at all — every state below is derived from relations/timestamps, not a
|
||||
single field.
|
||||
|
||||
`Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope) actually mixes two
|
||||
distinct states together: no order ever started, vs. a draft order exists
|
||||
(`placed_at IS NULL`) but was never placed — checkout was started, not finished. Those are
|
||||
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
|
||||
different reachability (checkout usually captures an email even for a guest), so
|
||||
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
|
||||
split:
|
||||
|
||||
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
|
||||
active tab on page load.
|
||||
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
|
||||
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
|
||||
- **Completed** — has an order with `placed_at IS NOT NULL`.
|
||||
|
||||
```php
|
||||
// Ongoing
|
||||
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
|
||||
|
||||
// Abandoned Cart
|
||||
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
|
||||
|
||||
// Abandoned Checkout
|
||||
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff());
|
||||
|
||||
// Completed
|
||||
$query->whereHas('orders', fn ($q) => $q->whereNotNull('placed_at'));
|
||||
```
|
||||
|
||||
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the four
|
||||
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
|
||||
(potentially large) table.
|
||||
|
||||
### Abandoned Cart vs Abandoned Checkout — why they're not one bucket
|
||||
|
||||
Different purchase intent, different reachability, and different recovery strategy — see
|
||||
`docs/recovery-strategies.md` for the full marketing-strategy discussion. In short:
|
||||
|
||||
- **Abandoned Cart** (no order started) is a weak intent signal — often window-shopping, not
|
||||
a near-purchase. Frequently unreachable (no email/identity at all for a true guest).
|
||||
Recovery leans on on-site retargeting and ad remarketing rather than email.
|
||||
- **Abandoned Checkout** (draft order, never placed) is a strong intent signal — the shopper
|
||||
committed to buying and something blocked completion. Checkout typically captures contact
|
||||
info even for a guest, so this state is usually reachable. This is the state the
|
||||
researched 1h/24h/72h recovery-email cadence targets specifically.
|
||||
|
||||
`Modules\Core\Cart\Events\CartAbandoned` and `Modules\Core\Checkout\Events\CheckoutAbandoned`
|
||||
mirror this same split (see "Events" below) rather than one combined event.
|
||||
|
||||
---
|
||||
|
||||
## Why this scales fine at a large cart count
|
||||
|
||||
Two things keep this cheap regardless of how many carts exist (10,000+):
|
||||
|
||||
- **The list is always paginated.** Filament applies `LIMIT`/`OFFSET` to whichever tab's
|
||||
query is active — a page only ever fetches one page's worth of rows, never the whole
|
||||
table, "All" tab or not (and there is no "All" tab — see above).
|
||||
- **No per-row queries.** `lines_count`/`lines_sum_quantity` use Filament's built-in
|
||||
`->counts('lines')`/`->sum('lines', 'quantity')`, which fold into the same query as the
|
||||
rest of the list (one `LEFT JOIN`-based aggregate, not N separate lookups). There's no
|
||||
per-record `getStateUsing()` closure anywhere in this table doing its own query — that's
|
||||
the pattern to avoid if a future column needs derived data (see `Modules\Core\Catalog\
|
||||
Services\ProductIndexer` for the general "compute once at index time / one aggregate
|
||||
query, never per-row" principle this project follows elsewhere).
|
||||
|
||||
The one thing that **does** scan more rows as the cart count grows is
|
||||
`CartResource::getNavigationBadge()` (see below) — but it's a `COUNT(*)`, not a fetch, and
|
||||
runs once per admin page load, not once per cart row.
|
||||
|
||||
---
|
||||
|
||||
## Navigation badge — abandoned cart count
|
||||
|
||||
```php
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getEloquentQuery()->active()->count();
|
||||
}
|
||||
```
|
||||
|
||||
Shows the number of abandoned carts (not all carts — a converted cart isn't something a
|
||||
staff member needs to keep noticing) next to "Carts" in the sidebar. `->count()` compiles to
|
||||
a single `SELECT COUNT(*) ...` — confirmed via query log — no rows are ever loaded just to
|
||||
render the badge.
|
||||
|
||||
---
|
||||
|
||||
## The view page runs the cart's full calculate pipeline — once
|
||||
|
||||
`ViewCart::resolveRecord()` calls `$cart->calculate()` before rendering, since `CartLine`'s
|
||||
computed properties (`unitPrice`, `total`, etc.) and `Cart`'s own totals (`subTotal`, `total`,
|
||||
...) are plain public properties populated as a side effect of that pipeline — never
|
||||
persisted, so a plain Eloquent-fetched `Cart` has them all `null`/unset (see `docs/lunar.md`
|
||||
Gotchas). This only runs on the single-record view page, not per row in the list table —
|
||||
running the full 5-step pipeline for every row of a paginated list would be needless cost for
|
||||
data the list doesn't display.
|
||||
|
||||
---
|
||||
|
||||
## Not built: staff editing a cart
|
||||
|
||||
The resource is deliberately read-only (`canCreate()` returns `false`, no edit page
|
||||
registered). A cart is owned by the storefront's own add/update/remove flow
|
||||
(`CartSession`/`Cart::add()`/etc.) — hand-editing cart contents from the admin panel isn't a
|
||||
supported use case here.
|
||||
|
||||
---
|
||||
|
||||
## `CartService` — the storefront-facing API
|
||||
|
||||
`Modules\Core\Cart\Services\CartService` mirrors `Modules\Core\Catalog\Services\
|
||||
ProductService`/`CollectionService`'s shape — one boboko-owned API a storefront calls, so
|
||||
Lunar's own `CartSession`/`Cart` stay an implementation detail rather than something a
|
||||
consuming app depends on directly.
|
||||
|
||||
- `current()` / `currentOrCreate()` — the latter force-creates a cart (`CartSession::manager()`),
|
||||
the former doesn't (`CartSession::current()`, returns `null` for a fresh visitor — see
|
||||
`docs/lunar.md`'s Cart gotchas).
|
||||
- `addLine()` / `updateLine()` / `removeLine()` / `clear()` — thin wrappers over
|
||||
`Cart::add()`/`updateLine()`/`remove()`/`clear()`. No boboko-owned exception types wrap
|
||||
Lunar's own cart exceptions (`InvalidCartLineQuantityException`, `CartLineIdMismatchException`,
|
||||
etc.) — they propagate as-is; a wrapper would add indirection with identical semantics.
|
||||
- `applyCoupon()` / `removeCoupon()` — sets/clears `Cart::coupon_code` (there's no dedicated
|
||||
Lunar action for this, unlike add/update/remove). `applyCoupon()` validates via
|
||||
`Discounts::validateCoupon()` first and throws `Modules\Core\Cart\Exceptions\
|
||||
InvalidCouponException` on a bad code — `CouponString`'s cast only normalizes casing, it
|
||||
doesn't validate anything, so setting `coupon_code` directly would silently accept a bogus
|
||||
code and just not discount anything once calculated.
|
||||
- `saveForLater()` / `moveToCart()` / `activeLines()` / `savedLines()` — see "Save for later"
|
||||
below.
|
||||
|
||||
Every mutating method returns the recalculated `Cart` (matching Lunar's own `Cart::add()`
|
||||
etc., which already return `$this` after `refresh()->recalculate()`) and dispatches a
|
||||
matching domain event.
|
||||
|
||||
### Events — Lunar dispatches none of its own
|
||||
|
||||
`Lunar` dispatches zero cart events — no "item added," no "cart created" (see
|
||||
`docs/lunar.md`'s Cart gotchas). `CartService` fills that gap with its own, dispatched after
|
||||
the underlying Lunar operation completes:
|
||||
|
||||
`CartLineAdded`, `CartLineUpdated`, `CartLineRemoved`, `CartCleared`, `CartCouponApplied`,
|
||||
`CartCouponRemoved`, `CartLineSaved`, `CartLineMovedToCart` — all under
|
||||
`Modules\Core\Cart\Events`. `CartAbandoned`/`CheckoutAbandoned` live under
|
||||
`Modules\Core\Recovery\Events` instead, not `Cart`/`Checkout` — see "Abandonment detection"
|
||||
below for why.
|
||||
|
||||
**None of these currently have a listener.** They're dispatched-but-unconsumed by design —
|
||||
built so something downstream (reindexing, notifications, a future read-side reporting
|
||||
service) has a hook to attach to, not because a concrete consumer exists today. This was a
|
||||
deliberate decision, not an oversight — see the "don't build speculative infrastructure"
|
||||
calls made elsewhere in this project (e.g. not wrapping Lunar's cart exceptions).
|
||||
|
||||
**Why not wired to Spatie's Activity Log:** `Cart`/`CartLine` already use Lunar's own
|
||||
`LogsActivity` trait (Spatie's package, Lunar's defaults) — confirmed from source, this logs
|
||||
model saves/deletes automatically, independent of actor. `Modules\Core\Logging\
|
||||
ActivityLogService` (this project's own wrapper, used by e.g. `LogTranslationActivity`) is
|
||||
hardcoded to the `staff` guard — correctly scoped for staff-driven writes (Filament admin
|
||||
actions), but wrong for customer-driven cart activity, which would resolve `causedBy()` to
|
||||
`null` every time. Both `ActivityLogService` and `Cart`/`CartLine`'s native `LogsActivity`
|
||||
write to the **same** `log_name = 'lunar'` / `activity_log` table, with no built-in
|
||||
separation beyond reading `causer_type` per row — a real limitation worth knowing about, but
|
||||
not one this project is fixing by giving Cart a distinct `log_name`, since every other Lunar
|
||||
model logs to `'lunar'` too and a Cart-only carve-out would just be inconsistent. The
|
||||
intended fix, if this becomes a real need, is a read-side service that queries `activity_log`
|
||||
and classifies by `causer_type`/`log_name` — not touching every write site.
|
||||
|
||||
### Save for later
|
||||
|
||||
A `CartLine` can be moved out of the purchasable cart without being deleted — flagged via
|
||||
`meta.saved_for_later`, not a new column (matches the free-form-JSON pattern already used
|
||||
elsewhere, e.g. `ProductOptionValue::meta`). `Modules\Core\Cart\Pipelines\
|
||||
ZeroSavedForLaterPrice` (registered in `config('lunar.cart.pipelines.cart_lines')`, after the
|
||||
stock `GetUnitPrice`) zeroes `unitPrice`/`unitPriceInclTax` for flagged lines **before**
|
||||
Lunar's own `CalculateLines` pipeline step sums the cart — `CalculateLines` sums every
|
||||
`CartLine` unconditionally with no meta-based exclusion of its own, so zeroing the price
|
||||
upstream is what makes `Cart::subTotal`/`total` naturally correct without a second pass or
|
||||
callers needing a different totals accessor.
|
||||
|
||||
`Lunar\Actions\Carts\UpdateCartLine` **replaces** the whole `meta` column on write (plain
|
||||
`update(['meta' => $meta])`, not a merge) — `saveForLater()`/`moveToCart()` read the line's
|
||||
existing meta and merge in the flag change before calling `Cart::updateLine()`, or an
|
||||
unrelated meta key set by something else would be silently wiped.
|
||||
|
||||
### Coupons
|
||||
|
||||
See `CartService::applyCoupon()`/`removeCoupon()` above. `Lunar\Base\Casts\CouponString`
|
||||
just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (via the
|
||||
`Discounts` facade) is the actual check — does a matching `Discount` (type `AmountOff` or
|
||||
`BuyXGetY`) exist, `active()`, with `max_uses` not exhausted.
|
||||
|
||||
---
|
||||
|
||||
## Abandonment detection
|
||||
|
||||
"Abandoned" is a **derived** state (`Cart::updated_at` older than
|
||||
`config('core.cart.abandoned_after')`, default `1 hour`) — nothing transitions a cart into it
|
||||
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
|
||||
`Modules\Core\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
|
||||
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
|
||||
queries the same two branches `ListCarts::getTabs()` uses (no order at all vs. draft order
|
||||
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
|
||||
for anything currently stale.
|
||||
|
||||
### Cart/Checkout have zero abandonment-related writes — by design
|
||||
|
||||
`DetectAbandonedCarts` **only dispatches** — it never writes to `Cart`/`Order` at all. An
|
||||
earlier version recorded an "already notified" marker on `Cart::meta`/`Order::meta` to avoid
|
||||
refiring the same event every run, but that `->save()` call bumped `Cart::updated_at` as an
|
||||
Eloquent side effect — since `updated_at` is also the field abandonment staleness is computed
|
||||
from, the write **un-staled the very cart it had just marked abandoned**: confirmed live, a
|
||||
cart that correctly fired `CartAbandoned` showed back up as "Ongoing," not "Abandoned Cart,"
|
||||
on the very next tab-count check.
|
||||
|
||||
The fix wasn't to write the marker more carefully — it was to stop `Cart`/`Checkout` from
|
||||
having any way to write abandonment state at all. Deduplication ("has this cart already been
|
||||
notified") is deliberately **not** this command's job; it belongs to `Recovery` (not yet
|
||||
built — see `docs/recovery-strategies.md`), which will own its own tracking table, keeping
|
||||
`Cart`/`Order` permanently free of abandonment-related columns or `meta` keys.
|
||||
|
||||
**Current tradeoff, accepted deliberately**: until `Recovery` exists, every cart still
|
||||
matching the "abandoned" query refires its event on every hourly run — there is no dedup at
|
||||
all right now. That's fine today only because nothing consumes these events yet (see
|
||||
"Events" above); it would need addressing before anything real listens for them.
|
||||
|
||||
---
|
||||
|
||||
## Recovery Sequences — design only, not built
|
||||
|
||||
See `docs/recovery-strategies.md` — a full marketing-strategy discussion and a first-pass
|
||||
feature design for an admin-configurable sequence of "touches" (delay + optional discount +
|
||||
label) per abandonment type. Explicitly parked as an open design question, not scoped for
|
||||
implementation yet — whether this belongs under `Cart`, a new `Recovery`/`Marketing` concern,
|
||||
and how far the touch model needs to flex (channel choice, value-based branching, segment
|
||||
targeting) are all still undecided.
|
||||
+58
-1
@@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`.
|
||||
```php
|
||||
use Lunar\Facades\CartSession;
|
||||
|
||||
$cart = CartSession::current(); // calculates totals; returns null if no cart
|
||||
$cart = CartSession::current(); // returns null unless a cart already exists in
|
||||
// session — does NOT auto-create one (see Gotchas)
|
||||
$cart = CartSession::manager(); // force-creates a cart if none exists yet — use
|
||||
// this (or __call forwarding, see Gotchas) for
|
||||
// "give me a cart to add to" flows
|
||||
$cart->recalculate(); // force recalculation
|
||||
|
||||
CartSession::createOrder(); // creates order, removes cart from session
|
||||
@@ -563,6 +567,39 @@ CartSession::forget(); // clear session (soft deletes cart by def
|
||||
CartSession::forget(delete: false); // clear session, keep cart in DB
|
||||
```
|
||||
|
||||
Session/identity: the active cart's id is stored under session key `lunar.cart_session.session_key`
|
||||
(default `lunar_cart`). `CartSession`'s underlying manager (`Lunar\Managers\CartSessionManager`) —
|
||||
not `Lunar\Base\CartSessionInterface`, which is stale/incomplete, see Gotchas — resolves the current
|
||||
cart from that session key, falling back to the authenticated user's active cart
|
||||
(`$user->carts()->active()->first()`) if the session has none.
|
||||
|
||||
### `config/lunar/cart_session.php`
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `session_key` | `'lunar_cart'` | Laravel session key storing the active cart id. |
|
||||
| `auto_create` | `false` | Whether `CartSession::current()` auto-creates a cart when none exists — it does **not**, by default (see Gotchas). |
|
||||
| `allow_multiple_orders_per_cart` | `false` | If false, a cart with a completed order is abandoned in favor of a fresh cart on next fetch. |
|
||||
| `delete_on_forget` | `true` | Whether `forget()` (called on logout) soft-deletes the cart — see the auth-policy note above. |
|
||||
|
||||
### `config/lunar/cart.php` (cart-line-relevant keys)
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `auth_policy` | `'merge'` | Guest→user cart reconciliation on login: `merge` or `override`. |
|
||||
| `pipelines.cart` | `CalculateLines, ApplyShipping, ApplyDiscounts, CalculateTax, Calculate` | Steps run on `$cart->calculate()`. |
|
||||
| `pipelines.cart_lines` | `[GetUnitPrice::class]` | Steps run per-line before cart-level calc. |
|
||||
| `actions.add_to_cart` | `AddOrUpdatePurchasable::class` | Swappable action behind `Cart::add()`. |
|
||||
| `actions.get_existing_cart_line` | `GetExistingCartLine::class` | Line-matching logic for add-or-merge (see "Adding items" above). |
|
||||
| `actions.update_cart_line` | `UpdateCartLine::class` | Behind `Cart::updateLine()`. |
|
||||
| `actions.remove_from_cart` | `RemovePurchasable::class` | Behind `Cart::remove()`. |
|
||||
| `validators.add_to_cart` | `[CartLineQuantity, CartLineStock]` | Run before add. |
|
||||
| `validators.update_cart_line` | `[CartLineQuantity, CartLineStock]` | Run before update. |
|
||||
| `validators.remove_from_cart` | `[]` | None by default. |
|
||||
| `eager_load` | 7 relation paths (currency, `lines.purchasable.*`, `lines.cart.currency`) | Auto-eager-loaded whenever the session manager fetches a cart by id. Does **not** include `addresses`/`shippingAddress`/`billingAddress`, `discounts`, or `customer` — add these yourself if needed, to avoid N+1s. |
|
||||
| `prune_tables.enabled` | `false` | Whether scheduled cart pruning runs. |
|
||||
| `prune_tables.prune_interval` | `90` (days) | Age threshold for pruning. |
|
||||
|
||||
### Adding items
|
||||
|
||||
```php
|
||||
@@ -573,6 +610,11 @@ $cart->addLines([
|
||||
]);
|
||||
```
|
||||
|
||||
`add()` matches an existing line by purchasable **and exact `meta` equality** (config
|
||||
`lunar.cart.actions.get_existing_cart_line`, default `GetExistingCartLine`) — if it matches, the
|
||||
existing line's quantity is incremented instead of a new line being created; any difference in
|
||||
`meta` (e.g. a different chosen option) makes it a separate line for the same purchasable.
|
||||
|
||||
### Updating and removing
|
||||
|
||||
```php
|
||||
@@ -664,6 +706,14 @@ class MyPipeline
|
||||
`merge` — guest cart items combine with user's existing cart on login.
|
||||
`override` — guest cart replaces user's cart.
|
||||
|
||||
This is wired via `Lunar\Listeners\CartSessionAuthListener`, listening on Laravel's own
|
||||
`Illuminate\Auth\Events\Login`/`Logout`. On login, if the session already has a cart with no
|
||||
`user_id` yet, it associates that cart to the user (running the policy above); if the session has
|
||||
no cart at all, it looks up and resumes the user's own active cart instead. **On logout, it calls
|
||||
`CartSession::forget()`** — which, per `cart_session.delete_on_forget` (default `true`), **soft-
|
||||
deletes the cart**. A logged-in customer's cart is gone on logout unless that config is set to
|
||||
`false`.
|
||||
|
||||
### Shipping options
|
||||
|
||||
```php
|
||||
@@ -1209,3 +1259,10 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
|
||||
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`.
|
||||
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`.
|
||||
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
|
||||
- **`CartSession::current()` returns `null` for a fresh visitor by default.** `cart_session.auto_create` defaults to `false`, so nothing auto-creates a cart just from checking `current()`. Use `CartSession::manager()` (force-creates) for an "add to cart" flow, or rely on the fact that `add()`/`remove()`/etc. auto-create via `__call` forwarding (next entry) — don't gate an add-to-cart button on `current() !== null`, it will be null for every guest who hasn't added anything yet.
|
||||
- **`CartSession`'s facade/interface don't declare `add()`, `remove()`, `updateLine()`, `clear()`, etc. at all — they work anyway, via `__call` magic.** `CartSessionManager::__call()` forwards any undeclared method call straight to the underlying `Cart` model (auto-creating one first if needed). So `CartSession::add($variant, 2)` genuinely works, but neither the facade's `@method` docblock nor `Lunar\Base\CartSessionInterface` mention it — reading either in isolation makes it look unsupported. Trust the manager's source (`Lunar\Managers\CartSessionManager`), not the interface, which is also missing several real methods (`manager()`, `createOrder()`, the shipping-estimate methods) and has a stale signature for `current()`.
|
||||
- **`Cart::calculate()` is a no-op if totals already look populated — even right after you mutated lines with raw Eloquent.** It's memoized via `isCalculated()` (true when `total` and every line's `total` are non-blank). Every built-in mutator (`add`, `remove`, `updateLine`, `clear`, `associate`, …) already calls `$this->refresh()->recalculate()` to force past this memo — but custom code that touches `CartLine` rows directly (raw `update()`, a queued job, a migration) must call `$cart->recalculate()` itself, or `total`/`subTotal`/etc. silently stay stale.
|
||||
- **`CartLine`'s computed properties (`unitPrice`, `subTotal`, `total`, `taxAmount`, …) are plain public properties, not DB columns or Eloquent attributes.** A raw `CartLine::find($id)` (no `calculate()` having run on its owning cart) has all of these as `null`/unset — they only populate as a side effect of the owning `Cart`'s pipeline running. Don't read them off a line fetched outside of `CartSession`/`Cart::add()` etc. without calling `$cart->calculate()` first.
|
||||
- **Logging out deletes the cart by default.** `CartSessionAuthListener::logout()` calls `CartSession::forget()`, and `cart_session.delete_on_forget` defaults to `true` — so a logged-in customer's cart is soft-deleted the moment they log out, guest or not. Set `delete_on_forget` to `false` in `config/lunar/cart_session.php` if carts should survive a logout.
|
||||
- **Lunar dispatches no cart events at all** — no "item added," "cart created," "line removed," nothing under `Lunar\Events\Cart*`/`CartLine*` exists (unlike products/collections, which have their own Scout indexing hooks). The only reactive surface is `CartLineObserver` (`creating`/`updating`, and it only validates the purchasable type — doesn't dispatch anything). If a feature needs to react to cart changes (reindexing, abandoned-cart notifications, analytics), it has to be built from scratch on plain Eloquent model events (`CartLine::created`, etc.) — there's no Lunar-native pattern to hook into.
|
||||
- **No Filament admin resource exists for `Cart`/`CartLine`.** Carts aren't visible anywhere in the admin panel except indirectly through an order's `cart` relationship once that cart has become an order. Don't assume there's an admin cart-viewer to check against when debugging — there isn't one.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Cart/Checkout Recovery Strategies — Design Notes
|
||||
|
||||
**Status: open design discussion, not scoped or built.** This is a record of the
|
||||
reasoning behind an eventual "Recovery Sequences" feature, kept so the discussion doesn't
|
||||
have to be re-derived from scratch later. Nothing in this document is implemented.
|
||||
|
||||
See `docs/cart.md` for what's actually built today (the four-state cart classification,
|
||||
`CartAbandoned`/`CheckoutAbandoned` events, `DetectAbandonedCarts`).
|
||||
|
||||
---
|
||||
|
||||
## Why Abandoned Cart and Abandoned Checkout need different strategies
|
||||
|
||||
Established in `docs/cart.md`: Abandoned Cart (no order ever started) is a weak purchase-intent
|
||||
signal and often unreachable (no identity for a true guest). Abandoned Checkout (a draft order
|
||||
exists, `placed_at IS NULL`) is a strong intent signal and usually reachable, since checkout
|
||||
typically captures an email/address even for a guest.
|
||||
|
||||
That difference in intent and reachability drives genuinely different marketing strategy, not
|
||||
just a different admin filter:
|
||||
|
||||
### Abandoned Cart strategy — re-engagement, not completion
|
||||
|
||||
- **On-site retargeting first** (exit-intent popups, "still thinking it over?" banners on
|
||||
return visits) — often the only viable channel, since email may not exist yet.
|
||||
- **Ad platform retargeting** (Meta/Google dynamic remarketing) is the dominant channel here
|
||||
specifically because it works off a browser/device signal, not an email address — the one
|
||||
thing reliably available for an anonymous cart.
|
||||
- **Soft messaging** ("did you forget something?") rather than urgency-driven — intent is
|
||||
weak, so aggressive discounting is often poor ROI: it trains browsers who were never close
|
||||
to buying to expect a coupon.
|
||||
- **Longer, gentler cadence** — a single reminder around 24h, maybe a second a few days out,
|
||||
sometimes trigger-based (a price drop, back-in-stock) rather than a fixed schedule.
|
||||
|
||||
### Abandoned Checkout strategy — completion, not re-engagement
|
||||
|
||||
- **Speed matters most.** This is where the classic 1h/24h/72h recovery-email cadence lives —
|
||||
conversion drops sharply with delay, since the shopper is often still in a "was about to
|
||||
buy" mental state within the first hour.
|
||||
- **Direct, urgency-framed messaging** ("complete your order"), sometimes showing cart
|
||||
contents/total, occasionally a countdown or limited-time incentive on later touches.
|
||||
- **Discount escalation pays off here** — a small incentive (free shipping, 10% off) on the
|
||||
2nd/3rd touch is standard, because it's nudging someone who already decided to buy past
|
||||
whatever blocked them (price shock, a broken payment step, indecision on shipping cost) —
|
||||
not manufacturing demand from nothing.
|
||||
- **SMS is more viable** — checkout often captures a phone number, and the higher intent
|
||||
justifies a more direct channel than for cart-stage.
|
||||
|
||||
---
|
||||
|
||||
## The broader strategy space (beyond cadence + discount)
|
||||
|
||||
Raised as context for how far a "Recovery Sequence" feature might eventually need to flex,
|
||||
without committing to building any of it yet:
|
||||
|
||||
**Message-content strategies**
|
||||
- Social proof ("X people have this in their cart," reviews shown in the reminder)
|
||||
- Scarcity/urgency framing (low-stock count, countdown timer on an offer)
|
||||
- Personalized alternatives — a cheaper or complementary item instead of just re-showing the
|
||||
abandoned one, useful when the likely blocker was price
|
||||
|
||||
**Channel strategies**
|
||||
- Email (the baseline; nothing built yet — see `docs/cart.md`'s "Recovery Sequences" section)
|
||||
- SMS — checkout-stage specifically, opt-in required
|
||||
- Push notifications — not relevant yet given this project's storefront maturity, noted for
|
||||
completeness
|
||||
- On-site remarketing (banner/modal on the shopper's next visit) — doesn't require email at
|
||||
all, arguably the highest-value channel for Abandoned Cart specifically
|
||||
- Ad platform sync (pushing abandoned-cart product data to a custom audience for paid retargeting)
|
||||
|
||||
**Escalation/segmentation strategies**
|
||||
- Value-based branching — a high-value abandoned checkout might skip straight to a bigger
|
||||
incentive rather than waiting through a full ladder
|
||||
- Repeat-abandoner suppression — a customer who's abandoned 3+ times without ever completing
|
||||
either stops receiving emails (fatigue/spam risk) or gets a different tactic (e.g. a "what
|
||||
stopped you?" survey) instead of another discount
|
||||
- New vs. returning customer branching — a first-time visitor's abandoned cart might warrant
|
||||
"welcome discount" framing instead of a generic recovery email, since the blocker was
|
||||
likely trust/unfamiliarity rather than price
|
||||
|
||||
**Timing refinement**
|
||||
- Time-of-day/timezone-aware sending (don't fire a touch at 3am local time even if the delay
|
||||
technically elapsed)
|
||||
- Cart-content-triggered timing — a fast-moving/low-stock item might warrant an earlier, more
|
||||
urgent first touch than a cart of always-in-stock staples
|
||||
|
||||
---
|
||||
|
||||
## First-pass feature shape (discussed, not finalized)
|
||||
|
||||
An admin defines, independently per abandonment type (Abandoned Cart, Abandoned Checkout), an
|
||||
ordered sequence of **touches**. Each touch is three ideas:
|
||||
|
||||
1. **How long to wait** since the abandonment began
|
||||
2. **What offer to attach**, optional — reusing whatever `Discount` already exists in the
|
||||
system rather than inventing a new pricing concept
|
||||
3. **A label**, so staff can see what a touch represents in the admin UI
|
||||
|
||||
The system continuously re-evaluates every abandoned cart/checkout against its sequence, and
|
||||
when a cart becomes due for the next touch it hasn't had yet, that becomes a signal — this
|
||||
feature's responsibility ends there. Actually sending anything (email, SMS, on-site banner) is
|
||||
explicitly out of scope for this feature; something else, not yet designed, would consume that
|
||||
signal.
|
||||
|
||||
### What this requires that isn't built yet
|
||||
|
||||
- **A fixed "abandonment began at" timestamp**, captured once and never re-derived — a
|
||||
sequence needs to schedule touches from a stable starting point, not from `Cart::updated_at`,
|
||||
which keeps moving every time the cart (or its own bookkeeping) is written to. This is the
|
||||
same underlying issue as the known bug in `docs/cart.md`'s "Abandonment detection" section —
|
||||
fixing that bug properly (freezing the abandonment moment) is very likely a prerequisite for
|
||||
this feature, not a separate concern.
|
||||
- **Re-evaluation, not one-shot detection** — `DetectAbandonedCarts` today marks a cart
|
||||
abandoned once and stops; a sequence needs a cart to be revisited on every scheduler run to
|
||||
check "which touch, if any, is now due," for as long as it stays unrecovered.
|
||||
|
||||
### Still undecided
|
||||
|
||||
- **Which concern this belongs under.** Not `Cart` (it's not a cart-mechanics concern) —
|
||||
candidates raised: a new `Recovery` concern, or `Marketing`. Not decided.
|
||||
- **How far the touch model needs to flex.** The three-idea shape above (delay, discount,
|
||||
label) covers cadence + discount escalation cleanly, but doesn't yet accommodate channel
|
||||
choice, value-based branching, or segment targeting from the broader strategy list above.
|
||||
Whether those get folded into the touch model, layered on top some other way, or deliberately
|
||||
left out of v1 is unresolved.
|
||||
- **Whether "recovery" is cart/checkout-specific at all**, or a more general "scheduled
|
||||
customer touch based on a triggering condition" mechanism that cart/checkout abandonment
|
||||
happens to be the first use case for.
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Recovery\Events\CartAbandoned;
|
||||
use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||
|
||||
/**
|
||||
* "Abandoned" is a derived state (Cart::updated_at older than
|
||||
* config('core.cart.abandoned_after')) — nothing transitions a cart into it
|
||||
* via a normal Eloquent write, so there's no model-event hook to dispatch
|
||||
* CartAbandoned/CheckoutAbandoned from directly. This command is the only
|
||||
* place that moment gets detected; run it on a schedule (see docs/cart.md).
|
||||
*
|
||||
* Splits Cart::scopeActive()'s two branches into their own events —
|
||||
* see CartAbandoned/CheckoutAbandoned's docblocks for why they're distinct,
|
||||
* not one combined "abandoned" state: a cart with no order at all is a much
|
||||
* weaker purchase-intent signal than one with a draft order that was never
|
||||
* placed.
|
||||
*
|
||||
* Deliberately does NOT write anything to Cart/Order — dispatch only. An
|
||||
* earlier version recorded an "already notified" marker on Cart::meta/
|
||||
* Order::meta, but that write bumped updated_at as an Eloquent side effect,
|
||||
* which un-staled the very cart being marked abandoned (the same field
|
||||
* abandonment staleness is computed from) — see docs/cart.md's former
|
||||
* "Known bug" note. Cart/Checkout must have no way of writing abandonment
|
||||
* state at all; every cart still matching the query below refires its event
|
||||
* on every run until Recovery (not yet built — see
|
||||
* docs/recovery-strategies.md) owns its own dedup/tracking table.
|
||||
*/
|
||||
class DetectAbandonedCarts extends Command
|
||||
{
|
||||
protected $signature = 'boboko:cart:detect-abandoned';
|
||||
|
||||
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$cutoff = CartResource::abandonedCutoff();
|
||||
|
||||
$cartsAbandoned = 0;
|
||||
$checkoutsAbandoned = 0;
|
||||
|
||||
Cart::query()
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with('lines')
|
||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
if ($cart->lines->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CartAbandoned($cart));
|
||||
|
||||
$cartsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
Cart::query()
|
||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', $cutoff)
|
||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||
foreach ($carts as $cart) {
|
||||
$order = $cart->orders->first();
|
||||
|
||||
if ($order === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Event::dispatch(new CheckoutAbandoned($cart, $order));
|
||||
|
||||
$checkoutsAbandoned++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->components->info("Dispatched CartAbandoned for {$cartsAbandoned} cart(s), CheckoutAbandoned for {$checkoutsAbandoned} checkout(s).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCleared
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}> $lines
|
||||
* Snapshot of every line that was in the cart before clearing — Cart::clear()
|
||||
* deletes all rows directly, so nothing here can be fresh CartLine instances.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $lines,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponApplied
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartCouponRemoved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly string $code,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineAdded
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* The reverse of CartLineSaved — a previously saved-for-later line moved back
|
||||
* into the purchasable cart (now counted in totals again).
|
||||
*/
|
||||
class CartLineMovedToCart
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
class CartLineRemoved
|
||||
{
|
||||
/**
|
||||
* @param array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array} $line
|
||||
* Snapshot of the removed line — the row is already deleted by the time this
|
||||
* event dispatches, so nothing here can be a fresh CartLine model instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly array $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
/**
|
||||
* A line was moved OUT of the purchasable cart and into "saved for later" —
|
||||
* not a removal (the row still exists), but distinct from CartLineUpdated
|
||||
* since it's a state transition worth its own hook (e.g. abandoned-cart
|
||||
* recovery treating a saved line very differently from a deleted one).
|
||||
*/
|
||||
class CartLineSaved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
|
||||
class CartLineUpdated
|
||||
{
|
||||
/**
|
||||
* @param array{quantity: int, meta: array} $old Snapshot before the update.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly CartLine $line,
|
||||
public readonly array $old,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by CartService::applyCoupon() when the given code doesn't match any
|
||||
* currently-active, non-exhausted Discount — Lunar's own
|
||||
* Discounts::validateCoupon() only returns a bool, it has no matching
|
||||
* exception type of its own to reuse here.
|
||||
*/
|
||||
class InvalidCouponException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $code)
|
||||
{
|
||||
parent::__construct("The coupon code \"{$code}\" is not valid.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources;
|
||||
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||
use Lunar\Models\Cart;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
/**
|
||||
* Read-only — a cart is managed entirely through the storefront (add/update/remove
|
||||
* line, checkout), never hand-edited by staff. Scoped to carts with a known
|
||||
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
|
||||
* identity a staff member could act on (no name, no email, nothing to follow up
|
||||
* with), so listing every such row would be noise, not a real admin capability —
|
||||
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
|
||||
* to follow a precedent from).
|
||||
*/
|
||||
class CartResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Cart::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';
|
||||
|
||||
protected static ?string $navigationGroup = 'Sales';
|
||||
|
||||
protected static ?string $modelLabel = 'Cart';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Carts';
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->where(fn (Builder $query) => $query->whereNotNull('user_id')->orWhereNotNull('customer_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
|
||||
* states (`active()` already covers "no order at all" and "draft order,
|
||||
* never placed" together — see ListCarts::getTabs()'s "Abandoned Cart" /
|
||||
* "Abandoned Checkout" tabs for where they're split apart), not "Ongoing"
|
||||
* — the badge is meant to answer "how many carts might need following up
|
||||
* on," not the total including ones someone is actively shopping in right
|
||||
* now.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
|
||||
* different things together: a cart someone is actively shopping in right now,
|
||||
* and one that's genuinely been left behind. Lunar tracks no time-based
|
||||
* staleness signal of its own — `Cart::updated_at` plus a configurable
|
||||
* threshold (`config('core.cart.abandoned_after')`, default 1 hour) is what
|
||||
* this resource uses to tell them apart. A cart with no recent activity is
|
||||
* "Abandoned"; anything more recent is "Ongoing".
|
||||
*/
|
||||
public static function abandonedCutoff(): Carbon
|
||||
{
|
||||
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('Cart')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('customer.full_name')
|
||||
->label('Customer')
|
||||
->placeholder('—')
|
||||
->searchable()
|
||||
->url(fn (Cart $record) => $record->customer_id !== null
|
||||
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
|
||||
: null),
|
||||
Tables\Columns\TextColumn::make('user.email')
|
||||
->label('User')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('lines_count')
|
||||
->label('Lines')
|
||||
->counts('lines')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('lines_sum_quantity')
|
||||
->label('Items')
|
||||
->sum('lines', 'quantity')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency.code')
|
||||
->label('Currency'),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Last activity')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->defaultSort('updated_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListCarts::route('/'),
|
||||
'view' => Pages\ViewCart::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
use Filament\Resources\Components\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
class ListCarts extends ListRecords
|
||||
{
|
||||
protected static string $resource = CartResource::class;
|
||||
|
||||
/**
|
||||
* `Cart::completed_at` is declared/cast on the model but never actually written
|
||||
* anywhere in Lunar core — it's dead, not a real "did this convert" signal.
|
||||
* "Completed" instead means the cart has an order with `placed_at` set (a
|
||||
* placed, not just drafted, order).
|
||||
*
|
||||
* `Cart::scopeActive()` (not yet converted to an order) actually mixes two
|
||||
* distinct states: no order started at all, vs. a draft order exists
|
||||
* (`placed_at IS NULL`) but was never placed — checkout was started, not
|
||||
* finished. That's a real difference in purchase intent (a cart with a
|
||||
* draft order is a much stronger signal than one with no order at all) and
|
||||
* in reachability (checkout usually captures an email even for a guest),
|
||||
* so they get separate tabs rather than one combined "no order yet"
|
||||
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
||||
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
||||
*
|
||||
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
|
||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
|
||||
* signal of its own, so recent activity is the only thing distinguishing a
|
||||
* cart someone is shopping in right now from one genuinely left behind.
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
return [
|
||||
'ongoing' => Tab::make('Ongoing')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
|
||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query
|
||||
->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
'completed' => Tab::make('Completed')
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->whereHas(
|
||||
'orders',
|
||||
fn (Builder $query) => $query->whereNotNull('placed_at'),
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Infolist;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
class ViewCart extends ViewRecord
|
||||
{
|
||||
protected static string $resource = CartResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('viewCustomer')
|
||||
->label('View Customer')
|
||||
->icon('heroicon-o-user')
|
||||
->url(fn (Cart $record) => CustomerResource::getUrl('view', ['record' => $record->customer_id]))
|
||||
->visible(fn (Cart $record) => $record->customer_id !== null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart's computed properties (subTotal/total/etc.) are plain public properties
|
||||
* populated as a side effect of the pipeline calculate() runs — never persisted,
|
||||
* so they don't exist on a plain Eloquent-fetched record. Calculated once here
|
||||
* (a single view page load), not per-row in the list table, since running the
|
||||
* full pipeline for every row of a paginated table would be expensive for no
|
||||
* real benefit — see docs/lunar.md's Cart gotchas.
|
||||
*/
|
||||
protected function resolveRecord(int|string $key): Cart
|
||||
{
|
||||
/** @var Cart $cart */
|
||||
$cart = parent::resolveRecord($key);
|
||||
|
||||
return $cart->calculate();
|
||||
}
|
||||
|
||||
public function infolist(Infolist $infolist): Infolist
|
||||
{
|
||||
return $infolist
|
||||
->schema([
|
||||
Section::make('Cart')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('id'),
|
||||
TextEntry::make('customer.full_name')
|
||||
->label('Customer')
|
||||
->placeholder('—')
|
||||
->url(fn (Cart $record) => $record->customer_id !== null
|
||||
? CustomerResource::getUrl('view', ['record' => $record->customer_id])
|
||||
: null),
|
||||
TextEntry::make('user.email')
|
||||
->label('User')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('currency.code')
|
||||
->label('Currency'),
|
||||
TextEntry::make('completedOrderPlacedAt')
|
||||
->label('Ordered at')
|
||||
->state(fn (Cart $record) => $record->orders()->whereNotNull('placed_at')->value('placed_at'))
|
||||
->dateTime()
|
||||
->placeholder('Not ordered'),
|
||||
TextEntry::make('updated_at')
|
||||
->label('Last activity')
|
||||
->dateTime(),
|
||||
]),
|
||||
Section::make('Lines')
|
||||
->schema([
|
||||
RepeatableEntry::make('lines')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('purchasable.sku')
|
||||
->label('SKU')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('quantity'),
|
||||
TextEntry::make('unitPrice')
|
||||
->label('Unit price')
|
||||
->formatStateUsing(fn (CartLine $record) => $record->unitPrice?->formatted() ?? '—'),
|
||||
TextEntry::make('total')
|
||||
->label('Line total')
|
||||
->formatStateUsing(fn (CartLine $record) => $record->total?->formatted() ?? '—'),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
Section::make('Totals')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextEntry::make('subTotal')
|
||||
->label('Subtotal')
|
||||
->formatStateUsing(fn (Cart $record) => $record->subTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('discountTotal')
|
||||
->label('Discount')
|
||||
->formatStateUsing(fn (Cart $record) => $record->discountTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('taxTotal')
|
||||
->label('Tax')
|
||||
->formatStateUsing(fn (Cart $record) => $record->taxTotal?->formatted() ?? '—'),
|
||||
TextEntry::make('total')
|
||||
->label('Total')
|
||||
->formatStateUsing(fn (Cart $record) => $record->total?->formatted() ?? '—')
|
||||
->weight('bold'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Pipelines;
|
||||
|
||||
use Closure;
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\Models\Contracts\CartLine as CartLineContract;
|
||||
|
||||
/**
|
||||
* Runs in config('lunar.cart.pipelines.cart_lines'), after GetUnitPrice —
|
||||
* zeroes out unitPrice/unitPriceInclTax for any line flagged
|
||||
* meta.saved_for_later, BEFORE Lunar's own CalculateLines pipeline step reads
|
||||
* unitPrice to compute subTotal/total. A saved-for-later item is deliberately
|
||||
* parked, not pending purchase, so it shouldn't inflate Cart::total — and
|
||||
* since CalculateLines sums every CartLine unconditionally with no meta-based
|
||||
* exclusion of its own, zeroing the price here (rather than patching subTotal
|
||||
* after the fact) is what makes every downstream total naturally correct
|
||||
* without a second pass.
|
||||
*/
|
||||
class ZeroSavedForLaterPrice
|
||||
{
|
||||
public function handle(CartLineContract $cartLine, Closure $next): mixed
|
||||
{
|
||||
if ($cartLine->meta['saved_for_later'] ?? false) {
|
||||
$currency = $cartLine->cart->currency;
|
||||
|
||||
$cartLine->unitPrice = new Price(0, $currency, 1);
|
||||
$cartLine->unitPriceInclTax = new Price(0, $currency, 1);
|
||||
}
|
||||
|
||||
return $next($cartLine);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Cart\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Lunar\Actions\Carts\GetExistingCartLine;
|
||||
use Lunar\Base\Purchasable;
|
||||
use Lunar\Facades\CartSession;
|
||||
use Lunar\Facades\Discounts;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Modules\Core\Cart\Events\CartCleared;
|
||||
use Modules\Core\Cart\Events\CartCouponApplied;
|
||||
use Modules\Core\Cart\Events\CartCouponRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineAdded;
|
||||
use Modules\Core\Cart\Events\CartLineMovedToCart;
|
||||
use Modules\Core\Cart\Events\CartLineRemoved;
|
||||
use Modules\Core\Cart\Events\CartLineSaved;
|
||||
use Modules\Core\Cart\Events\CartLineUpdated;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
|
||||
/**
|
||||
* Storefront-facing cart operations, mirroring Modules\Core\Catalog\Services\
|
||||
* ProductService/CollectionService's shape — one boboko-owned API a storefront
|
||||
* calls, so Lunar's own CartSession/Cart stay an implementation detail rather
|
||||
* than something a consuming app depends on directly.
|
||||
*
|
||||
* Every mutating method dispatches a matching domain event
|
||||
* (Modules\Core\Cart\Events\*) after the underlying Lunar operation completes —
|
||||
* Lunar itself dispatches zero cart events (see docs/lunar.md's Cart gotchas),
|
||||
* so without this, nothing in a consuming app has anything to react to when a
|
||||
* cart actually changes (reindexing, notifications, analytics, etc.).
|
||||
*
|
||||
* All mutating methods return the recalculated Cart — matching Lunar's own
|
||||
* Cart::add()/updateLine()/etc., which already return $this after
|
||||
* refresh()->recalculate() — so a caller gets fresh totals in the same call,
|
||||
* no second fetch needed.
|
||||
*/
|
||||
class CartService
|
||||
{
|
||||
/**
|
||||
* The current session's cart, or null if none exists yet. Does NOT
|
||||
* auto-create one — see currentOrCreate() for that.
|
||||
*/
|
||||
public function current(): ?Cart
|
||||
{
|
||||
return CartSession::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current session's cart, creating one if none exists yet — the right
|
||||
* call for "add to cart" style flows where a cart must exist by the time
|
||||
* the method returns.
|
||||
*/
|
||||
public function currentOrCreate(): Cart
|
||||
{
|
||||
return CartSession::manager();
|
||||
}
|
||||
|
||||
public function addLine(Purchasable $purchasable, int $quantity = 1, array $meta = []): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate()->add($purchasable, $quantity, $meta);
|
||||
|
||||
$line = app(config('lunar.cart.actions.get_existing_cart_line', GetExistingCartLine::class))
|
||||
->execute($cart, $purchasable, $meta);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineAdded($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function updateLine(int $cartLineId, int $quantity, ?array $meta = null): Cart
|
||||
{
|
||||
$before = CartLine::findOrFail($cartLineId);
|
||||
$old = ['quantity' => $before->quantity, 'meta' => $before->meta->toArray()];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineUpdated($cart, $line, $old));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeLine(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$snapshot = $this->snapshotLine($line);
|
||||
|
||||
$cart = $this->currentOrCreate()->remove($cartLineId);
|
||||
|
||||
Event::dispatch(new CartLineRemoved($cart, $snapshot));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function clear(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$snapshots = $cart->lines->map($this->snapshotLine(...))->all();
|
||||
|
||||
$cart = $cart->clear();
|
||||
|
||||
Event::dispatch(new CartCleared($cart, $snapshots));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cart's coupon code, which the ApplyDiscounts pipeline step picks
|
||||
* up on the next calculate() — there's no dedicated Lunar action for this
|
||||
* (unlike add/update/remove, coupon_code is a plain cast attribute), so
|
||||
* this is the closest thing to one for a consuming app to call.
|
||||
*
|
||||
* Validated via Discounts::validateCoupon() (does a matching, currently
|
||||
* active, non-exhausted Discount exist?) before it's set — CouponString's
|
||||
* cast only normalizes casing, it doesn't validate anything, so setting
|
||||
* coupon_code directly would silently accept a bogus code and just not
|
||||
* discount anything once calculated.
|
||||
*
|
||||
* @throws InvalidCouponException if the code doesn't match a valid, active,
|
||||
* non-exhausted Discount
|
||||
*/
|
||||
public function applyCoupon(string $code): Cart
|
||||
{
|
||||
if (! Discounts::validateCoupon($code)) {
|
||||
throw new InvalidCouponException($code);
|
||||
}
|
||||
|
||||
$cart = $this->currentOrCreate();
|
||||
$cart->coupon_code = $code;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponApplied($cart, $cart->coupon_code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
public function removeCoupon(): Cart
|
||||
{
|
||||
$cart = $this->currentOrCreate();
|
||||
$code = $cart->coupon_code;
|
||||
|
||||
if ($code === null) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
$cart->coupon_code = null;
|
||||
$cart->save();
|
||||
$cart = $cart->recalculate();
|
||||
|
||||
Event::dispatch(new CartCouponRemoved($cart, $code));
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines currently counted toward the cart's totals — everything except
|
||||
* ones flagged meta.saved_for_later (see savedLines()). This is the set a
|
||||
* cart page's main list / checkout would iterate, since a saved line
|
||||
* isn't pending purchase.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function activeLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->reject(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lines a shopper has deliberately parked rather than deleted — excluded
|
||||
* from Cart totals (see Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice)
|
||||
* and from activeLines(). A cart page's "Saved for later" section iterates
|
||||
* this set.
|
||||
*
|
||||
* @return Collection<int, CartLine>
|
||||
*/
|
||||
public function savedLines(?Cart $cart = null): Collection
|
||||
{
|
||||
$cart ??= $this->currentOrCreate();
|
||||
|
||||
return $cart->lines->filter(fn (CartLine $line) => $line->meta['saved_for_later'] ?? false)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a line OUT of the purchasable cart without deleting it — it stays
|
||||
* on the cart (still visible, still re-addable) but is excluded from
|
||||
* totals via meta.saved_for_later, zeroed by ZeroSavedForLaterPrice before
|
||||
* Lunar's own CalculateLines sums the cart (which has no meta-based
|
||||
* exclusion of its own).
|
||||
*/
|
||||
public function saveForLater(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => true];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineSaved($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse of saveForLater() — moves a line back into the purchasable
|
||||
* cart, counted in totals again.
|
||||
*/
|
||||
public function moveToCart(int $cartLineId): Cart
|
||||
{
|
||||
$line = CartLine::findOrFail($cartLineId);
|
||||
$meta = [...$line->meta->toArray(), 'saved_for_later' => false];
|
||||
|
||||
$cart = $this->currentOrCreate()->updateLine($cartLineId, $line->quantity, $meta);
|
||||
|
||||
$line = $cart->lines->firstWhere('id', $cartLineId);
|
||||
|
||||
if ($line !== null) {
|
||||
Event::dispatch(new CartLineMovedToCart($cart, $line));
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, purchasable_type: string, purchasable_id: int, quantity: int, meta: array}
|
||||
*/
|
||||
private function snapshotLine(CartLine $line): array
|
||||
{
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'purchasable_type' => $line->purchasable_type,
|
||||
'purchasable_id' => $line->purchasable_id,
|
||||
'quantity' => $line->quantity,
|
||||
'meta' => $line->meta->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use Lunar\Shipping\ShippingPlugin;
|
||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||
use Modules\Core\Auth\Filament\Pages\Login;
|
||||
use Modules\Core\Auth\Mail\InviteMail;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
|
||||
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||
@@ -39,6 +40,7 @@ class CorePlugin implements Plugin
|
||||
->login(Login::class)
|
||||
->resources([
|
||||
LanguageLineResource::class,
|
||||
CartResource::class,
|
||||
])
|
||||
->plugin(ShippingPlugin::make());
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Core\Cart\Commands\DetectAbandonedCarts;
|
||||
|
||||
class CartServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([DetectAbandonedCarts::class]);
|
||||
}
|
||||
|
||||
$this->app->booted(function () {
|
||||
$this->app->make(Schedule::class)
|
||||
->command(DetectAbandonedCarts::class)
|
||||
->hourly();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Recovery\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
|
||||
/**
|
||||
* A cart has gone stale (no activity for config('core.cart.abandoned_after'))
|
||||
* with NO order ever started — the shopper added items and never began
|
||||
* checkout. Weak purchase-intent signal: usually a browsing/price-check
|
||||
* action, not a near-purchase. Distinct from CheckoutAbandoned, which fires
|
||||
* for a cart that DID reach checkout (a draft Order exists) but never placed
|
||||
* it — a much stronger intent signal, and reachable via the email/address
|
||||
* checkout itself usually captures even for a guest.
|
||||
*
|
||||
* Lives under Recovery, not Cart — abandonment detection/tracking is
|
||||
* deliberately kept out of the Cart module entirely, including its event
|
||||
* definitions, so Cart has no abandonment-related code at all. See
|
||||
* docs/cart.md and docs/recovery-strategies.md.
|
||||
*
|
||||
* "Abandoned" is a derived state (stale updated_at), not something that
|
||||
* transitions via a normal Eloquent write, so there's no natural model-event
|
||||
* hook to dispatch this from directly — detection is Recovery's own concern
|
||||
* (not yet built; design notes in docs/recovery-strategies.md).
|
||||
*/
|
||||
class CartAbandoned
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Recovery\Events;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* A cart's checkout has gone stale (no activity for
|
||||
* config('core.cart.abandoned_after')) with a draft Order already created
|
||||
* (Order::isDraft() — placed_at IS NULL) but never placed. Strong
|
||||
* purchase-intent signal — the shopper committed to checking out, something
|
||||
* blocked completion. Distinct from CartAbandoned, which fires for a cart
|
||||
* with no order at all (weak intent, usually unreachable). Checkout
|
||||
* typically captures an email/address even for a guest, so this state is
|
||||
* normally reachable regardless of login status.
|
||||
*
|
||||
* Lives under Recovery, not Checkout/Cart — abandonment detection/tracking
|
||||
* is deliberately kept out of both modules entirely, including its event
|
||||
* definitions. See docs/cart.md and docs/recovery-strategies.md.
|
||||
*
|
||||
* "Abandoned" is a derived state (stale updated_at, no placed_at), not
|
||||
* something that transitions via a normal Eloquent write — detection is
|
||||
* Recovery's own concern (not yet built; design notes in
|
||||
* docs/recovery-strategies.md).
|
||||
*/
|
||||
class CheckoutAbandoned
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Cart $cart,
|
||||
public readonly Order $order,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user