Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views
This commit is contained in:
@@ -30,6 +30,22 @@ return [
|
|||||||
|
|
||||||
'cart' => [
|
'cart' => [
|
||||||
'abandoned_after' => '1 hour',
|
'abandoned_after' => '1 hour',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
| Unrecoverable Cap
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Beyond this age, a stale cart stops being treated as an active
|
||||||
|
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||||
|
| CartLifecycleService) — too old to be a realistic recovery target
|
||||||
|
| (pricing/stock/tax likely stale by then). This is about the
|
||||||
|
| abandoned-cart pipeline only, not data retention — no rows are
|
||||||
|
| deleted or pruned based on this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'unrecoverable_after' => '90 days',
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+48
-40
@@ -1,28 +1,34 @@
|
|||||||
# Cart Admin Visibility
|
# Cart Admin Visibility
|
||||||
|
|
||||||
`Modules\Core\Cart\Filament\Resources\CartResource` gives staff read-only visibility into
|
`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
|
every cart in the Filament admin panel, guest carts included. Lunar itself ships no cart
|
||||||
all — no Filament resource for `Cart`/`CartLine` exists anywhere in `lunarphp/lunar` or
|
admin view at all — no Filament resource for `Cart`/`CartLine` exists anywhere in
|
||||||
`lunarphp/core` — this is a from-scratch addition, not an extension of something Lunar
|
`lunarphp/lunar` or `lunarphp/core` — this is a from-scratch addition, not an extension of
|
||||||
half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the underlying Lunar cart
|
something Lunar half-built. See `docs/lunar.md`'s "Cart and Checkout" section for the
|
||||||
mechanics this resource reads from.
|
underlying Lunar cart mechanics this resource reads from.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scope: only carts with a known customer or user
|
## Scope: every cart, identified or not
|
||||||
|
|
||||||
`CartResource::getEloquentQuery()` filters to `Cart::whereNotNull('user_id')->orWhereNotNull('customer_id')`
|
`CartResource` lists every cart the four lifecycle states (below) cover, with no
|
||||||
— an anonymous guest's session cart is excluded entirely.
|
`user_id`/`customer_id` filter — an anonymous guest's session cart is included.
|
||||||
|
|
||||||
This was a deliberate call, not an oversight: an anonymous cart carries no identity a staff
|
This was a reversal of an earlier, deliberate call to exclude guest carts entirely (on the
|
||||||
member could act on — no name, no email, nothing to follow up with — so listing every guest
|
reasoning that an anonymous cart carries no identity a staff member could act on — no name, no
|
||||||
session cart would be noise, not a real admin capability. This does **not** mirror Shopify's
|
email, nothing to follow up with — so listing every guest session cart would be noise, not a
|
||||||
admin (Shopify has no "all carts" view at all — only "Abandoned checkouts," gated on a
|
real admin capability). That reasoning holds for "can I click through to a Customer record,"
|
||||||
shopper reaching checkout and entering contact info, a later/narrower stage than Lunar's
|
but not for the resource's other real use — seeing how many carts are ongoing/abandoned right
|
||||||
`Cart`). Lunar's own `Cart` model already gets `user_id`/`customer_id` set the moment a
|
now regardless of who's shopping. Most real storefront traffic never reaches an identified
|
||||||
shopper is authenticated (via `Lunar\Listeners\CartSessionAuthListener` on login), with no
|
user/customer, so excluding it silently undercounts exactly the thing `ListCarts`'s tabs (and
|
||||||
checkout step required — so scoping to "identifiable" here is broader than Shopify's
|
`CartLifecycleService`, which they and `DetectAbandonedCarts` both build on) exist to report
|
||||||
equivalent, not a copy of it.
|
on. The `Customer`/`User` columns on a guest row just render "—" (Filament's `placeholder()`)
|
||||||
|
instead of a link — nothing to click into, but the row and its contents are still visible via
|
||||||
|
`ViewCart`.
|
||||||
|
|
||||||
|
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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,28 +46,29 @@ distinct states together: no order ever started, vs. a draft order exists
|
|||||||
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
|
different purchase-intent signals (see "Abandoned Cart vs Abandoned Checkout" below) and
|
||||||
different reachability (checkout usually captures an email even for a guest), so
|
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
|
`ListCarts::getTabs()` splits them into four tabs instead of `scopeActive()`'s two-state
|
||||||
split:
|
split.
|
||||||
|
|
||||||
- **Ongoing** — `scopeActive()` and recent `updated_at` (within `abandonedCutoff()`). Default
|
`Modules\Core\Cart\Services\CartLifecycleService` is the single source of truth for these four
|
||||||
active tab on page load.
|
query shapes — both `ListCarts::getTabs()` (staff browsing) and `DetectAbandonedCarts`
|
||||||
- **Abandoned Cart** — `whereDoesntHave('orders')` and stale `updated_at`.
|
(abandonment-event dispatch) build on it, rather than each reimplementing the same split
|
||||||
- **Abandoned Checkout** — has an order with `placed_at IS NULL`, and stale `updated_at`.
|
independently (which is what happened before this service existed, and is exactly the kind of
|
||||||
- **Completed** — has an order with `placed_at IS NOT NULL`.
|
drift that lets the admin panel and the recovery-email pipeline quietly disagree about what
|
||||||
|
"abandoned" means):
|
||||||
|
|
||||||
```php
|
- **Ongoing** (`ongoing()`) — `scopeActive()` and recent `updated_at` (within
|
||||||
// Ongoing
|
`abandonedCutoff()`). Default active tab on page load.
|
||||||
$query->active()->where('updated_at', '>', CartResource::abandonedCutoff());
|
- **Abandoned Cart** (`abandonedCarts()`) — `whereDoesntHave('orders')` and stale
|
||||||
|
`updated_at`.
|
||||||
|
- **Abandoned Checkout** (`abandonedCheckouts()`) — has an order with `placed_at IS NULL`,
|
||||||
|
and stale `updated_at`.
|
||||||
|
- **Completed** (`completed()`) — has an order with `placed_at IS NOT NULL`.
|
||||||
|
|
||||||
// Abandoned Cart
|
Each method takes a `Builder` and returns it further scoped, so callers compose it onto
|
||||||
$query->whereDoesntHave('orders')->where('updated_at', '<=', CartResource::abandonedCutoff());
|
whatever base query they already have (`CartResource::getEloquentQuery()` for the Filament
|
||||||
|
tabs, a bare `Cart::query()` for the command). Deliberately query-shape-only: consent
|
||||||
// Abandoned Checkout
|
(`meta->recovery_consent`) and non-empty-lines filtering stay in `DetectAbandonedCarts`, not on
|
||||||
$query->whereHas('orders', fn ($q) => $q->whereNull('placed_at'))
|
the service — those gate whether a recovery *event* should fire, not what "abandoned" means to
|
||||||
->where('updated_at', '<=', CartResource::abandonedCutoff());
|
a staff member browsing the list.
|
||||||
|
|
||||||
// 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
|
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
|
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
|
||||||
@@ -111,7 +118,7 @@ runs once per admin page load, not once per cart row.
|
|||||||
```php
|
```php
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
return (string) static::getEloquentQuery()->active()->count();
|
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -235,9 +242,10 @@ just upper-cases the code; `Lunar\Managers\DiscountManager::validateCoupon()` (v
|
|||||||
via a normal Eloquent write, so there's no model-event hook to dispatch from directly.
|
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\Cart\Commands\DetectAbandonedCarts` (registered on an hourly schedule by
|
||||||
`Modules\Core\Providers\CartServiceProvider`) is the only place that moment gets detected: it
|
`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
|
builds on the same `CartLifecycleService::abandonedCarts()`/`abandonedCheckouts()` queries
|
||||||
never placed) and dispatches `Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned`
|
`ListCarts::getTabs()` uses (no order at all vs. draft order never placed) and dispatches
|
||||||
for anything currently stale.
|
`Modules\Core\Recovery\Events\CartAbandoned`/`CheckoutAbandoned` for anything currently stale
|
||||||
|
that also has `meta->recovery_consent = true`.
|
||||||
|
|
||||||
### Cart/Checkout have zero abandonment-related writes — by design
|
### Cart/Checkout have zero abandonment-related writes — by design
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Modules\Core\Cart\Commands;
|
|||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Lunar\Models\Cart;
|
use Lunar\Models\Cart;
|
||||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||||
use Modules\Core\Recovery\Events\CartAbandoned;
|
use Modules\Core\Recovery\Events\CartAbandoned;
|
||||||
use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
||||||
|
|
||||||
@@ -37,7 +37,14 @@ use Modules\Core\Recovery\Events\CheckoutAbandoned;
|
|||||||
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
|
* sends (Checkout\Services\CheckoutService::setRecoveryConsent() is where
|
||||||
* that consent is actually recorded), and a non-consenting cart's
|
* that consent is actually recorded), and a non-consenting cart's
|
||||||
* abandonment must never be dispatched at all, not merely filtered later
|
* abandonment must never be dispatched at all, not merely filtered later
|
||||||
* at send time — see docs referenced above for the legal reasoning.
|
* at send time — see docs referenced above for the legal reasoning. This
|
||||||
|
* consent filter stays here rather than on Modules\Core\Cart\Services\
|
||||||
|
* CartLifecycleService, whose two "abandoned" queries this command builds
|
||||||
|
* on — dispatch eligibility is this command's own concern, not part of
|
||||||
|
* what "abandoned" means to a staff member browsing the admin panel. (The
|
||||||
|
* non-empty-lines requirement, by contrast, IS part of what "abandoned"
|
||||||
|
* means either way, so it lives on CartLifecycleService::abandonedCarts()
|
||||||
|
* itself, not here.)
|
||||||
*/
|
*/
|
||||||
class DetectAbandonedCarts extends Command
|
class DetectAbandonedCarts extends Command
|
||||||
{
|
{
|
||||||
@@ -45,33 +52,22 @@ class DetectAbandonedCarts extends Command
|
|||||||
|
|
||||||
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
|
protected $description = 'Dispatch CartAbandoned/CheckoutAbandoned for carts that just crossed the abandonment threshold.';
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(CartLifecycleService $lifecycle): void
|
||||||
{
|
{
|
||||||
$cutoff = CartResource::abandonedCutoff();
|
|
||||||
|
|
||||||
$cartsAbandoned = 0;
|
$cartsAbandoned = 0;
|
||||||
$checkoutsAbandoned = 0;
|
$checkoutsAbandoned = 0;
|
||||||
|
|
||||||
Cart::query()
|
$lifecycle->abandonedCarts(Cart::query())
|
||||||
->whereDoesntHave('orders')
|
|
||||||
->where('updated_at', '<=', $cutoff)
|
|
||||||
->where('meta->recovery_consent', true)
|
->where('meta->recovery_consent', true)
|
||||||
->with('lines')
|
|
||||||
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
->chunkById(200, function ($carts) use (&$cartsAbandoned) {
|
||||||
foreach ($carts as $cart) {
|
foreach ($carts as $cart) {
|
||||||
if ($cart->lines->isEmpty()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Event::dispatch(new CartAbandoned($cart));
|
Event::dispatch(new CartAbandoned($cart));
|
||||||
|
|
||||||
$cartsAbandoned++;
|
$cartsAbandoned++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Cart::query()
|
$lifecycle->abandonedCheckouts(Cart::query())
|
||||||
->whereHas('orders', fn ($query) => $query->whereNull('placed_at'))
|
|
||||||
->where('updated_at', '<=', $cutoff)
|
|
||||||
->where('meta->recovery_consent', true)
|
->where('meta->recovery_consent', true)
|
||||||
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
->with(['orders' => fn ($query) => $query->whereNull('placed_at')])
|
||||||
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
->chunkById(200, function ($carts) use (&$checkoutsAbandoned) {
|
||||||
|
|||||||
@@ -9,20 +9,22 @@ 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;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||||
use Lunar\Models\Cart;
|
use Lunar\Models\Cart;
|
||||||
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
use Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||||
|
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only — a cart is managed entirely through the storefront (add/update/remove
|
* 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
|
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
|
||||||
* `user_id`/`customer_id` only: an anonymous guest's session cart carries no
|
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
|
||||||
* identity a staff member could act on (no name, no email, nothing to follow up
|
* anonymous cart's Customer/User columns just render "—" (see table() below)
|
||||||
* with), so listing every such row would be noise, not a real admin capability —
|
* rather than the row being hidden outright: most real traffic never reaches
|
||||||
* see docs/cart.md for the reasoning (Lunar itself ships no cart admin view at all
|
* an identified user/customer, and "how many carts are ongoing/abandoned
|
||||||
* to follow a precedent from).
|
* right now" is a real reporting need regardless of identity — excluding
|
||||||
|
* anonymous carts would silently undercount it. Lunar itself ships no cart
|
||||||
|
* admin view at all to follow a precedent from.
|
||||||
*/
|
*/
|
||||||
class CartResource extends Resource
|
class CartResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -36,12 +38,6 @@ class CartResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $pluralModelLabel = 'Carts';
|
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
|
* Count only, not a fetch — no rows are loaded. Combines BOTH abandoned
|
||||||
* states (`active()` already covers "no order at all" and "draft order,
|
* states (`active()` already covers "no order at all" and "draft order,
|
||||||
@@ -56,6 +52,11 @@ class CartResource extends Resource
|
|||||||
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
return (string) static::getEloquentQuery()->active()->where('updated_at', '<=', static::abandonedCutoff())->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function lifecycle(): CartLifecycleService
|
||||||
|
{
|
||||||
|
return app(CartLifecycleService::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
|
* `Cart::scopeActive()` (not-yet-converted-to-an-order carts) mixes two very
|
||||||
* different things together: a cart someone is actively shopping in right now,
|
* different things together: a cart someone is actively shopping in right now,
|
||||||
@@ -67,7 +68,7 @@ class CartResource extends Resource
|
|||||||
*/
|
*/
|
||||||
public static function abandonedCutoff(): Carbon
|
public static function abandonedCutoff(): Carbon
|
||||||
{
|
{
|
||||||
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
return static::lifecycle()->abandonedCutoff();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
|
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||||
|
|
||||||
class ListCarts extends ListRecords
|
class ListCarts extends ListRecords
|
||||||
{
|
{
|
||||||
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
|
|||||||
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
* bucket — same distinction Modules\Core\Recovery\Events\CartAbandoned /
|
||||||
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
* Modules\Core\Recovery\Events\CheckoutAbandoned draw.
|
||||||
*
|
*
|
||||||
* "Ongoing" vs the two abandoned tabs all split on `updated_at` against
|
* The four query shapes below live on Modules\Core\Cart\Services\
|
||||||
* `CartResource::abandonedCutoff()` — Lunar has no time-based staleness
|
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
|
||||||
* signal of its own, so recent activity is the only thing distinguishing a
|
* DetectAbandonedCarts — see that service's docblock for why duplicating
|
||||||
* cart someone is shopping in right now from one genuinely left behind.
|
* them independently in both places was worth centralizing.
|
||||||
*/
|
*/
|
||||||
public function getTabs(): array
|
public function getTabs(): array
|
||||||
{
|
{
|
||||||
|
$lifecycle = app(CartLifecycleService::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||||
->modifyQueryUsing(fn(Builder $query) => $query
|
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
|
||||||
->whereDoesntHave('orders')
|
|
||||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
|
||||||
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
'abandoned_checkout' => Tab::make('Abandoned Checkout')
|
||||||
->modifyQueryUsing(fn(Builder $query) => $query
|
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
|
||||||
->whereHas('orders', fn(Builder $query) => $query->whereNull('placed_at'))
|
|
||||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
|
||||||
|
|
||||||
'ongoing' => Tab::make('Ongoing')
|
'ongoing' => Tab::make('Ongoing')
|
||||||
->modifyQueryUsing(fn(Builder $query) => $query->active()->where('updated_at', '>', CartResource::abandonedCutoff())),
|
->modifyQueryUsing(fn (Builder $query) => $lifecycle->ongoing($query)),
|
||||||
'completed' => Tab::make('Completed')
|
'completed' => Tab::make('Completed')
|
||||||
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
|
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
|
||||||
'orders',
|
|
||||||
fn(Builder $query) => $query->whereNotNull('placed_at'),
|
|
||||||
)),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
|||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Infolists\Components\ImageEntry;
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Filament\Support\Colors\Color;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Lunar\Admin\Filament\Resources\CustomerResource;
|
use Lunar\Admin\Filament\Resources\CustomerResource;
|
||||||
|
use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct;
|
||||||
use Lunar\Models\Cart;
|
use Lunar\Models\Cart;
|
||||||
use Lunar\Models\CartLine;
|
use Lunar\Models\CartLine;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||||
|
|
||||||
class ViewCart extends ViewRecord
|
class ViewCart extends ViewRecord
|
||||||
@@ -35,12 +41,23 @@ class ViewCart extends ViewRecord
|
|||||||
* (a single view page load), not per-row in the list table, since running the
|
* (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
|
* full pipeline for every row of a paginated table would be expensive for no
|
||||||
* real benefit — see docs/lunar.md's Cart gotchas.
|
* real benefit — see docs/lunar.md's Cart gotchas.
|
||||||
|
*
|
||||||
|
* Eager-loads what the Lines section (below) reads off each line's
|
||||||
|
* purchasable — name, thumbnail, options — the same relations Lunar's
|
||||||
|
* own OrderItemsTable loads for an order's line items (`with(['purchasable'])`,
|
||||||
|
* see vendor/lunarphp/lunar/.../OrderItemsTable::getDefaultTable()) — so
|
||||||
|
* rendering the product grid doesn't N+1 per line.
|
||||||
*/
|
*/
|
||||||
protected function resolveRecord(int|string $key): Cart
|
protected function resolveRecord(int|string $key): Cart
|
||||||
{
|
{
|
||||||
/** @var Cart $cart */
|
/** @var Cart $cart */
|
||||||
$cart = parent::resolveRecord($key);
|
$cart = parent::resolveRecord($key);
|
||||||
|
|
||||||
|
$cart->load('lines.purchasable', 'shippingAddress.country');
|
||||||
|
|
||||||
|
EloquentCollection::make($cart->lines->pluck('purchasable')->filter(fn ($p) => $p instanceof ProductVariant))
|
||||||
|
->loadMissing(['product.thumbnail', 'images', 'values']);
|
||||||
|
|
||||||
return $cart->calculate();
|
return $cart->calculate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +94,37 @@ class ViewCart extends ViewRecord
|
|||||||
RepeatableEntry::make('lines')
|
RepeatableEntry::make('lines')
|
||||||
->hiddenLabel()
|
->hiddenLabel()
|
||||||
->schema([
|
->schema([
|
||||||
|
ImageEntry::make('image')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
|
||||||
|
? $record->purchasable->getThumbnail()?->getUrl('small')
|
||||||
|
: null)
|
||||||
|
->defaultImageUrl(fn () => 'data:image/svg+xml;base64,'.base64_encode(
|
||||||
|
Blade::render('<x-filament::icon icon="heroicon-o-photo" style="color:rgb('.Color::Gray[400].');"/>')
|
||||||
|
))
|
||||||
|
->imageSize(48),
|
||||||
|
TextEntry::make('description')
|
||||||
|
->label('Product')
|
||||||
|
// ProductVariant::getDescription()/getOption() are typed
|
||||||
|
// string but internally read translateAttribute()/
|
||||||
|
// translate(), which return null for a product/option
|
||||||
|
// with no attribute data set for the active locale —
|
||||||
|
// reading the underlying relations directly here avoids
|
||||||
|
// that TypeError rather than calling through them.
|
||||||
|
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
|
||||||
|
? ($record->purchasable->product?->translateAttribute('name') ?? '—')
|
||||||
|
: '—')
|
||||||
|
->url(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
|
||||||
|
? EditProduct::getUrl(['record' => $record->purchasable->product_id])
|
||||||
|
: null)
|
||||||
|
->weight('bold'),
|
||||||
|
TextEntry::make('options')
|
||||||
|
->label('Options')
|
||||||
|
->state(fn (CartLine $record) => $record->purchasable instanceof ProductVariant
|
||||||
|
? ($record->purchasable->values->map(fn ($value) => $value->translate('name'))->filter()->join(', ') ?: null)
|
||||||
|
: null)
|
||||||
|
->placeholder('—')
|
||||||
|
->badge(),
|
||||||
TextEntry::make('purchasable.sku')
|
TextEntry::make('purchasable.sku')
|
||||||
->label('SKU')
|
->label('SKU')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@@ -90,6 +138,53 @@ class ViewCart extends ViewRecord
|
|||||||
])
|
])
|
||||||
->columns(4),
|
->columns(4),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Shipping')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('shippingAddress.shipping_option')
|
||||||
|
->label('Shipping method')
|
||||||
|
// The raw identifier (e.g. "acs") is all a
|
||||||
|
// CartAddress row stores — the human-readable
|
||||||
|
// name only exists on the resolved
|
||||||
|
// Lunar\DataTypes\ShippingOption, which is what
|
||||||
|
// shippingBreakdown's items are keyed/named
|
||||||
|
// from below, so fall back to that name rather
|
||||||
|
// than showing the bare identifier.
|
||||||
|
->formatStateUsing(fn (Cart $record, ?string $state) => $state
|
||||||
|
? ($record->shippingBreakdown?->items->get($state)?->name ?? $state)
|
||||||
|
: null)
|
||||||
|
->placeholder('Not selected'),
|
||||||
|
TextEntry::make('shippingAddress.country.name')
|
||||||
|
->label('Shipping to')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextEntry::make('shippingTotal')
|
||||||
|
->label('Shipping total')
|
||||||
|
->formatStateUsing(fn (Cart $record) => $record->shippingTotal?->formatted() ?? '—')
|
||||||
|
->weight('bold'),
|
||||||
|
RepeatableEntry::make('shippingBreakdownItems')
|
||||||
|
->label('Breakdown')
|
||||||
|
->columnSpanFull()
|
||||||
|
// shippingBreakdown->items is a plain (non-Eloquent)
|
||||||
|
// Collection of Lunar\Base\ValueObjects\Cart\
|
||||||
|
// ShippingBreakdownItem — e.g. the carrier rate and,
|
||||||
|
// separately, Modules\Core\Payment\Pipelines\Cart\
|
||||||
|
// ApplyPaymentMethodFee's own line item when the
|
||||||
|
// selected payment method carries a fee (see
|
||||||
|
// CHANGELOG 0.16.3) — both show up here individually
|
||||||
|
// rather than only as the summed shippingTotal above.
|
||||||
|
->state(fn (Cart $record) => $record->shippingBreakdown?->items->values() ?? [])
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('name')
|
||||||
|
->hiddenLabel(),
|
||||||
|
TextEntry::make('price')
|
||||||
|
->hiddenLabel()
|
||||||
|
->formatStateUsing(fn ($state) => $state?->formatted() ?? '—')
|
||||||
|
->alignEnd(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->visible(fn (Cart $record) => (bool) $record->shippingBreakdown?->items->isNotEmpty()),
|
||||||
|
])
|
||||||
|
->visible(fn (Cart $record) => $record->shippingAddress !== null),
|
||||||
Section::make('Totals')
|
Section::make('Totals')
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Cart\Services;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single source of truth for the four cart lifecycle states documented in
|
||||||
|
* docs/cart.md ("Four states, not two — and not Cart::completed_at"). Both
|
||||||
|
* Modules\Core\Cart\Filament\Resources\CartResource/ListCarts (staff-facing
|
||||||
|
* browsing/tabs) and Modules\Core\Cart\Commands\DetectAbandonedCarts
|
||||||
|
* (abandonment-event dispatch) build on these same four query shapes — before
|
||||||
|
* this existed, each reimplemented them independently, which is exactly the
|
||||||
|
* kind of drift that lets the admin panel and the recovery-email pipeline
|
||||||
|
* quietly disagree about what "abandoned" means.
|
||||||
|
*
|
||||||
|
* `Cart::completed_at` is declared/cast on the model but never actually
|
||||||
|
* written anywhere in Lunar core — not a real signal, not used here.
|
||||||
|
* `Cart::scopeActive()` (Lunar's own "not yet converted to an order" scope)
|
||||||
|
* mixes two distinct states together (no order at all vs. a draft order that
|
||||||
|
* was never placed) — see docs/cart.md for why they're kept apart as
|
||||||
|
* different purchase-intent/reachability signals rather than folded into one
|
||||||
|
* "not converted" bucket.
|
||||||
|
*
|
||||||
|
* Query shape only: consent (`meta->recovery_consent`) and non-empty-lines
|
||||||
|
* filtering stay in DetectAbandonedCarts, not here — those are specific to
|
||||||
|
* whether a recovery event should fire, not to what "abandoned" means. Staff
|
||||||
|
* browsing the admin panel should see every abandoned cart, consenting or
|
||||||
|
* not.
|
||||||
|
*
|
||||||
|
* `unrecoverableCutoff()` is a second, older threshold
|
||||||
|
* (`core.cart.unrecoverable_after`, default 90 days) applied as a lower
|
||||||
|
* bound on both abandoned*() methods below: a cart past it is too old to be
|
||||||
|
* a realistic recovery target (pricing/stock/tax have likely moved on), so
|
||||||
|
* it drops out of "Abandoned Cart"/"Abandoned Checkout" entirely rather than
|
||||||
|
* staying flagged as an actionable abandonment forever. It does not appear
|
||||||
|
* in `ongoing()`/`completed()` either — this is about the abandoned-cart
|
||||||
|
* pipeline specifically, not a retention/deletion policy (no rows are
|
||||||
|
* touched here).
|
||||||
|
*/
|
||||||
|
class CartLifecycleService
|
||||||
|
{
|
||||||
|
public function abandonedCutoff(): Carbon
|
||||||
|
{
|
||||||
|
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unrecoverableCutoff(): Carbon
|
||||||
|
{
|
||||||
|
return now()->sub(config('core.cart.unrecoverable_after', '90 days'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not yet converted to an order (scopeActive()), with recent activity —
|
||||||
|
* someone plausibly shopping right now, not (yet) left behind.
|
||||||
|
*/
|
||||||
|
public function ongoing(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->active()->where('updated_at', '>', $this->abandonedCutoff());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No order started at all, stale, not yet past the unrecoverable cap, and
|
||||||
|
* actually has something in it — the weaker of the two abandoned states
|
||||||
|
* (see docs/cart.md's "Abandoned Cart vs Abandoned Checkout"). An empty
|
||||||
|
* cart (created but nothing ever added — e.g. a bot, or a session that
|
||||||
|
* never shopped) was never really "abandoned"; there's nothing to
|
||||||
|
* recover, so it's excluded rather than counted as a false positive.
|
||||||
|
*/
|
||||||
|
public function abandonedCarts(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereDoesntHave('orders')
|
||||||
|
->whereHas('lines')
|
||||||
|
->where('updated_at', '<=', $this->abandonedCutoff())
|
||||||
|
->where('updated_at', '>', $this->unrecoverableCutoff());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draft order exists (checkout was started) but was never placed,
|
||||||
|
* stale, and not yet past the unrecoverable cap — the stronger of the
|
||||||
|
* two abandoned states.
|
||||||
|
*/
|
||||||
|
public function abandonedCheckouts(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereHas('orders', fn (Builder $query) => $query->whereNull('placed_at'))
|
||||||
|
->where('updated_at', '<=', $this->abandonedCutoff())
|
||||||
|
->where('updated_at', '>', $this->unrecoverableCutoff());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has an order that was actually placed, not just drafted.
|
||||||
|
*/
|
||||||
|
public function completed(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereHas('orders', fn (Builder $query) => $query->whereNotNull('placed_at'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user