111 lines
5.0 KiB
Markdown
111 lines
5.0 KiB
Markdown
# 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.
|