Feature: Creating Cart Views, rules for abandonment
This commit is contained in:
@@ -16,4 +16,20 @@ return [
|
|||||||
|
|
||||||
'auto_create_customer_for_user' => true,
|
'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',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "Abandoned" vs "Completed" — 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.
|
||||||
|
|
||||||
|
The list page's tabs (`ListCarts::getTabs()`) instead key off whether the cart has a
|
||||||
|
**placed** order:
|
||||||
|
|
||||||
|
- **Abandoned** — mirrors `Cart::scopeActive()` exactly: no orders at all, or only orders
|
||||||
|
still in draft (`placed_at IS NULL`). Default active tab on page load.
|
||||||
|
- **Completed** — has at least one order with `placed_at IS NOT NULL`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Abandoned
|
||||||
|
$query->active();
|
||||||
|
|
||||||
|
// Completed
|
||||||
|
$query->whereHas('orders', fn ($query) => $query->whereNotNull('placed_at'));
|
||||||
|
```
|
||||||
|
|
||||||
|
There is deliberately **no "All" tab.** Every row shown is always scoped to one of the two
|
||||||
|
states above — the list never runs an unfiltered `Cart::query()->get()` over the whole
|
||||||
|
(potentially large) table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
+58
-1
@@ -554,7 +554,11 @@ Customer resolution order: session → `$user->latestCustomer()`.
|
|||||||
```php
|
```php
|
||||||
use Lunar\Facades\CartSession;
|
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
|
$cart->recalculate(); // force recalculation
|
||||||
|
|
||||||
CartSession::createOrder(); // creates order, removes cart from session
|
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
|
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
|
### Adding items
|
||||||
|
|
||||||
```php
|
```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
|
### Updating and removing
|
||||||
|
|
||||||
```php
|
```php
|
||||||
@@ -664,6 +706,14 @@ class MyPipeline
|
|||||||
`merge` — guest cart items combine with user's existing cart on login.
|
`merge` — guest cart items combine with user's existing cart on login.
|
||||||
`override` — guest cart replaces user's cart.
|
`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
|
### Shipping options
|
||||||
|
|
||||||
```php
|
```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`.
|
- **`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()`.
|
- **`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.
|
- **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,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'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use Lunar\Shipping\ShippingPlugin;
|
|||||||
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
use Modules\Core\Auth\Extensions\StaffResourceExtension;
|
||||||
use Modules\Core\Auth\Filament\Pages\Login;
|
use Modules\Core\Auth\Filament\Pages\Login;
|
||||||
use Modules\Core\Auth\Mail\InviteMail;
|
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\ProductOptionResourceExtension;
|
||||||
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
|
||||||
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
|
||||||
@@ -39,6 +40,7 @@ class CorePlugin implements Plugin
|
|||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
->resources([
|
->resources([
|
||||||
LanguageLineResource::class,
|
LanguageLineResource::class,
|
||||||
|
CartResource::class,
|
||||||
])
|
])
|
||||||
->plugin(ShippingPlugin::make());
|
->plugin(ShippingPlugin::make());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user