Feat: Updating Cart Lifecycle Service, and Capping Abandoned Cart Days. Also Updating Cart Views
This commit is contained in:
@@ -9,20 +9,22 @@ use Modules\Core\Cart\Filament\Resources\CartResource\Pages\ViewCart;
|
||||
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;
|
||||
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* line, checkout), never hand-edited by staff. Lists every cart, guest carts
|
||||
* included — see docs/cart.md ("Scope: every cart, identified or not"). An
|
||||
* anonymous cart's Customer/User columns just render "—" (see table() below)
|
||||
* rather than the row being hidden outright: most real traffic never reaches
|
||||
* an identified user/customer, and "how many carts are ongoing/abandoned
|
||||
* 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
|
||||
{
|
||||
@@ -36,12 +38,6 @@ class CartResource extends Resource
|
||||
|
||||
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,
|
||||
@@ -56,6 +52,11 @@ class CartResource extends Resource
|
||||
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
|
||||
* 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
|
||||
{
|
||||
return now()->sub(config('core.cart.abandoned_after', '1 hour'));
|
||||
return static::lifecycle()->abandonedCutoff();
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
|
||||
@@ -6,6 +6,7 @@ use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
use Modules\Core\Cart\Services\CartLifecycleService;
|
||||
|
||||
class ListCarts extends ListRecords
|
||||
{
|
||||
@@ -27,30 +28,24 @@ class ListCarts extends ListRecords
|
||||
* 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.
|
||||
* The four query shapes below live on Modules\Core\Cart\Services\
|
||||
* CartLifecycleService, shared with Modules\Core\Cart\Commands\
|
||||
* DetectAbandonedCarts — see that service's docblock for why duplicating
|
||||
* them independently in both places was worth centralizing.
|
||||
*/
|
||||
public function getTabs(): array
|
||||
{
|
||||
$lifecycle = app(CartLifecycleService::class);
|
||||
|
||||
return [
|
||||
'abandoned_cart' => Tab::make('Abandoned Cart')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query
|
||||
->whereDoesntHave('orders')
|
||||
->where('updated_at', '<=', CartResource::abandonedCutoff())),
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCarts($query)),
|
||||
'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())),
|
||||
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->abandonedCheckouts($query)),
|
||||
'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')
|
||||
->modifyQueryUsing(fn(Builder $query) => $query->whereHas(
|
||||
'orders',
|
||||
fn(Builder $query) => $query->whereNotNull('placed_at'),
|
||||
)),
|
||||
->modifyQueryUsing(fn (Builder $query) => $lifecycle->completed($query)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace Modules\Core\Cart\Filament\Resources\CartResource\Pages;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\ImageEntry;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
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\ProductResource\Pages\EditProduct;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Cart\Filament\Resources\CartResource;
|
||||
|
||||
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
|
||||
* full pipeline for every row of a paginated table would be expensive for no
|
||||
* 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
|
||||
{
|
||||
/** @var Cart $cart */
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -77,6 +94,37 @@ class ViewCart extends ViewRecord
|
||||
RepeatableEntry::make('lines')
|
||||
->hiddenLabel()
|
||||
->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')
|
||||
->label('SKU')
|
||||
->placeholder('—'),
|
||||
@@ -90,6 +138,53 @@ class ViewCart extends ViewRecord
|
||||
])
|
||||
->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')
|
||||
->columns(3)
|
||||
->schema([
|
||||
|
||||
Reference in New Issue
Block a user