Feature: Creating Cart Views, rules for abandonment

This commit is contained in:
2026-08-28 00:27:46 +03:00
parent e1299fafee
commit a8ddbb8056
7 changed files with 456 additions and 1 deletions
@@ -0,0 +1,118 @@
<?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. Scoped to genuinely abandoned
* carts specifically (mirrors ListCarts::getTabs()'s "Abandoned" query, not
* "Ongoing"), since that's the number a staff member glancing at the sidebar
* actually wants: 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,40 @@
<?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).
*
* "Ongoing" vs "Abandoned" both start from `Cart::scopeActive()` (not yet
* converted to an order) and 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' => Tab::make('Abandoned')
->modifyQueryUsing(fn (Builder $query) => $query->active()->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'),
]),
]);
}
}