- Removed .env.example file as it is no longer needed. - Updated next.config.js to include turbopack configuration. - Added detailed implementation plans for order processing and UI design in new markdown files. - Introduced API usage guide for background removal service. This commit enhances the order processing backend and UI design documentation, ensuring clarity and improved configuration management.
22 KiB
Orders UI — Design Plan (Admin Dashboard)
Audience: Senior software engineers
Scope: Orders UI only — list page, detail page, all admin actions. No new backend work.
Backend reference: 06-order-processing-backend-implementation-plan.md
Design constraint: Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).
1. Design philosophy
Three principles guide every decision here:
- Single responsibility per component. Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
- Status-driven UI. The order's
statusfield is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing. - Consistency over novelty. Use the exact same patterns already in the codebase:
rounded-lg borderfor table wrappers,text-xl font-semiboldheadings,usePaginatedQuery+ "Load more",AlertDialogfor destructive actions,toast(Sonner) for feedback.
2. Route structure
/orders → OrdersPage (list)
/orders/[id] → OrderDetailPage (detail)
The detail page is a full Next.js route (app/(dashboard)/orders/[id]/page.tsx), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.
3. Status vocabulary
Both pages share the same two badge components. Define color intent once, reuse everywhere.
3.1 Order status badges
| Status | Badge variant | Tailwind override | Meaning |
|---|---|---|---|
pending |
secondary |
— | Payment not yet confirmed |
confirmed |
outline |
border-blue-500 text-blue-600 |
Paid, awaiting fulfilment |
processing |
outline |
border-violet-500 text-violet-600 |
Label created, packing |
shipped |
outline |
border-indigo-500 text-indigo-600 |
In transit |
delivered |
default |
bg-green-600 |
Delivered |
cancelled |
destructive |
— | Cancelled |
refunded |
secondary |
line-through |
Refunded |
3.2 Payment status badges
| Status | Badge variant | Tailwind override |
|---|---|---|
pending |
secondary |
— |
paid |
default |
bg-green-600 |
failed |
destructive |
— |
refunded |
secondary |
— |
Both mappings live in apps/admin/src/components/orders/shared/statusConfig.ts — a plain TypeScript constants file, no JSX.
4. Orders list page
File: apps/admin/src/app/(dashboard)/orders/page.tsx
4.1 Layout (top to bottom)
┌─────────────────────────────────────────────────┐
│ Orders │
├─────────────────────────────────────────────────┤
│ [🔍 Search by order # or email…] [Status ▾] │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ Order # │ Customer │ Date │ Status │ Total │ │
│ │──────────┼──────────┼──────┼────────┼───────│ │
│ │ ORD-AB12 │ Jane Doe │ … │ [Conf] │ £42 │ │
│ └─────────────────────────────────────────────┘ │
│ 12 loaded [Load more] │
└─────────────────────────────────────────────────┘
4.2 Data fetching
- Default (no filter, no search):
usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 }) - Status filter active:
usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 }) - Pagination: "Load more" button + count footer — identical to ProductsPage.
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.
4.3 Table columns
| Column | Notes |
|---|---|
| Order # | font-mono text-xs; clicking the row navigates to detail |
| Customer | order.email; text-muted-foreground |
| Date | createdAt formatted short; text-xs text-muted-foreground |
| Status | <OrderStatusBadge status={order.status} /> |
| Payment | <OrderPaymentBadge status={order.paymentStatus} /> |
| Total | formatPrice(order.total) right-aligned |
No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.
4.4 Extracted components
| Component | File | Responsibility |
|---|---|---|
OrderStatusBadge |
components/orders/shared/OrderStatusBadge.tsx |
Renders one <Badge> for order status |
OrderPaymentBadge |
components/orders/shared/OrderPaymentBadge.tsx |
Renders one <Badge> for payment status |
OrdersTableSkeleton |
inline in list page | 10 skeleton rows, same columns |
4.5 Empty and loading states
- Loading: 10 skeleton rows with matching column widths.
- Empty (no orders): Centered row: "No orders yet."
- Empty (filter applied): "No orders with status 'confirmed'."
5. Order detail page
File: apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
This page uses useQuery(api.orders.getById, { id }) and useQuery(api.orders.getTimeline, { orderId: id }) to load both the order and its timeline in parallel.
5.1 Layout
Two-column layout on md+, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.
┌──────────────────────┬───────────────────┐
│ │ │
│ ← Orders │ Customer │
│ ORD-AB12 [Conf] │ Jane Doe │
│ 14 Jan 2026 │ jane@example.com │
│ ├───────────────────┤
│ [Admin actions bar] │ Shipping address │
│ │ 123 Main St… │
├──────────────────────┤ │
│ Order items ├───────────────────┤
│ ────────────────── │ Fulfilment │
│ Product Qty Total │ Carrier: DPD UK │
│ … │ Service: Next Day│
├──────────────────────┤ [Create Label] │
│ Financials ├───────────────────┤
│ Subtotal £38.00 │ │
│ Shipping £4.99 │ (empty if no │
│ Total £42.99 │ tracking yet) │
├──────────────────────┤ │
│ Timeline │ │
│ ○ Order confirmed │ │
│ ○ Label created │ │
│ ○ Delivered │ │
└──────────────────────┴───────────────────┘
On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.
5.2 Loading state
All cards render as Skeleton blocks while data is undefined. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.
5.3 Component breakdown
Every component below has one job. None of them fetch data themselves — the page fetches and passes props down.
5.3.1 OrderPageHeader
File: components/orders/detail/OrderPageHeader.tsx
Job: Back link, order number, date, status badges.
← Orders ORD-AB12 [Confirmed] [Paid] 14 Jan 2026, 10:22
Props: orderNumber, createdAt, status, paymentStatus.
No actions here. Badges are read-only. Headings use text-xl font-semibold.
5.3.2 OrderActionsBar
File: components/orders/detail/OrderActionsBar.tsx
Job: Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads status, paymentStatus, trackingNumber, returnRequestedAt, returnReceivedAt and decides what to render.
Action matrix:
| Condition | Buttons shown |
|---|---|
status === "confirmed" and no trackingNumber |
Create Label (primary), Update Status (outline) |
status === "confirmed" and trackingNumber exists |
Update Status (outline) |
status === "confirmed" and returnRequestedAt and no returnReceivedAt |
Mark Return Received (outline), Update Status |
status === "confirmed" and returnReceivedAt and paymentStatus !== "refunded" |
Issue Refund (destructive), Update Status |
status === "processing" or "shipped" |
Update Status only |
status === "delivered" and paymentStatus !== "refunded" |
Issue Refund (destructive), Update Status |
status === "cancelled" or "refunded" |
Read-only — no buttons |
OrderActionsBar does not implement the action logic itself. It renders the individual action components below. It is purely a conditional layout shell.
5.3.3 CreateLabelButton
File: components/orders/actions/CreateLabelButton.tsx
Job: Call api.fulfillmentActions.createShippingLabel, show loading spinner, surface structured errors.
- Uses
useAction(api.fulfillmentActions.createShippingLabel). - On click: sets loading, calls action, on success shows
toast.success("Label created. Tracking: {trackingNumber}"), on structured error showstoast.error(result.message), on thrown error shows generic toast. - Button label: "Create Label" → "Creating…" while loading.
- Disabled while loading.
- No AlertDialog — creating a label is not destructive.
5.3.4 UpdateStatusDialog
File: components/orders/actions/UpdateStatusDialog.tsx
Job: A Dialog containing a Select of valid next statuses. On confirm, calls api.orders.updateStatus.
- Triggered by "Update Status" button in
OrderActionsBar. - Select shows all statuses except the current one.
- Submit button shows spinner while mutating.
- On success:
toast.success("Status updated to {status}").
5.3.5 MarkReturnReceivedButton
File: components/orders/actions/MarkReturnReceivedButton.tsx
Job: Call api.orders.markReturnReceived with an AlertDialog confirmation.
- AlertDialog title: "Mark return as received?"
- Description: "Confirm that the returned items have arrived. You can then issue a refund."
- Uses
useMutation(api.orders.markReturnReceived). - Shows spinner on the confirm button while loading.
5.3.6 IssueRefundButton
File: components/orders/actions/IssueRefundButton.tsx
Job: Call api.returnActions.issueRefund with an AlertDialog confirmation.
- AlertDialog title: "Issue full refund?"
- Description: "A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."
- Uses
useAction(api.returnActions.issueRefund). - Confirm button: destructive variant, "Issue Refund" → "Refunding…".
- On success:
toast.success("Refund issued"). - On error:
toast.error(err.message).
5.3.7 OrderItemsCard
File: components/orders/detail/OrderItemsCard.tsx
Job: Display the line items in a Card with a bordered table inside.
Columns: Product name (+ variant name below in text-xs text-muted-foreground), SKU (font-mono text-xs), Qty, Unit price, Total.
No actions. Read-only.
┌─ Order items ────────────────────────────────────┐
│ Product SKU Qty Unit Total │
│ Royal Canin… │
│ Adult 2kg SKU-001 2 £19.00 £38.00 │
└──────────────────────────────────────────────────┘
5.3.8 OrderFinancialsCard
File: components/orders/detail/OrderFinancialsCard.tsx
Job: Render the price breakdown.
Uses InfoRow-style layout (label left, value right) inside a Card. Subtotal, shipping, discount (if > 0), tax (if > 0), and a Separator before the bold total.
5.3.9 CustomerCard
File: components/orders/detail/CustomerCard.tsx
Job: Display the customer name and email.
Card with CardHeader (title "Customer") and CardContent. Email is a mailto: link styled as text-sm text-muted-foreground hover:underline.
5.3.10 ShippingAddressCard
File: components/orders/detail/ShippingAddressCard.tsx
Job: Render the shippingAddressSnapshot in a Card.
Displays each address line in text-sm, country in text-xs text-muted-foreground. No editing — it's a snapshot.
5.3.11 FulfilmentCard
File: components/orders/detail/FulfilmentCard.tsx
Job: Shipping service info + tracking state. No action buttons — those live in OrderActionsBar.
Three visual states:
State A — No label yet (status: confirmed, no trackingNumber):
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Method Express │
│ │
│ No label created yet. │
└───────────────────────────────────────────────┘
State B — Label created, in transit:
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status TRANSIT │
│ Est. delivery 18 Jan 2026 │
│ Label [↗ Download] │
└───────────────────────────────────────────────┘
State C — Delivered:
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status DELIVERED │
│ Delivered 17 Jan 2026, 14:35 │
└───────────────────────────────────────────────┘
The trackingStatus field drives the displayed status string. Tracking and label URLs open in a new tab.
5.3.12 OrderTimelineCard
File: components/orders/detail/OrderTimelineCard.tsx
Job: Render orderTimelineEvents as a vertical stepper/timeline.
Receives the events array as a prop (fetched by the page).
Each event row:
○ Status changed: confirmed → processing 14 Jan · 10:35
by admin
Event type → human label mapping:
eventType |
Display label |
|---|---|
status_change |
"Status changed: {fromStatus} → {toStatus}" |
label_created |
"Shipping label created" + tracking number from payload |
tracking_update |
"Tracking update: {status}" |
customer_cancel |
"Customer requested cancellation" |
return_requested |
"Customer requested return" |
return_received |
"Return marked as received" |
refund |
"Refund issued" |
The dot (○) color matches the status badge color for status_change events; neutral for all others.
Loading state: 3–4 skeleton rows. Empty state: "No activity yet."
6. Shared utilities
File: components/orders/shared/statusConfig.ts
// Order status → { label, badgeVariant, className }
// Payment status → { label, badgeVariant, className }
// Event type → human label
// Timeline dot color by toStatus
Keep all display constants here. Both pages and all badge components import from this single file. No duplication.
7. File tree
apps/admin/src/
├── app/(dashboard)/
│ └── orders/
│ ├── page.tsx ← Orders list page
│ └── [id]/
│ └── page.tsx ← Order detail page
└── components/
└── orders/
├── shared/
│ ├── statusConfig.ts ← Constants only, no JSX
│ ├── OrderStatusBadge.tsx
│ └── OrderPaymentBadge.tsx
├── detail/
│ ├── OrderPageHeader.tsx
│ ├── OrderActionsBar.tsx ← Layout shell, no action logic
│ ├── OrderItemsCard.tsx
│ ├── OrderFinancialsCard.tsx
│ ├── CustomerCard.tsx
│ ├── ShippingAddressCard.tsx
│ ├── FulfilmentCard.tsx
│ └── OrderTimelineCard.tsx
└── actions/
├── CreateLabelButton.tsx
├── UpdateStatusDialog.tsx
├── MarkReturnReceivedButton.tsx
└── IssueRefundButton.tsx
8. Data flow
OrderDetailPage (page.tsx)
│
│ useQuery(api.orders.getById, { id }) → order
│ useQuery(api.orders.getTimeline, { orderId }) → events
│
├── OrderPageHeader (order)
├── OrderActionsBar (order)
│ ├── CreateLabelButton (orderId, order.status, order.trackingNumber)
│ ├── UpdateStatusDialog (orderId, order.status)
│ ├── MarkReturnReceivedButton (orderId)
│ └── IssueRefundButton (orderId, order.total, order.currency)
│
├── [left column]
│ ├── OrderItemsCard (order.items)
│ ├── OrderFinancialsCard (order)
│ └── OrderTimelineCard (events)
│
└── [right column]
├── CustomerCard (order.email, order.userId)
├── ShippingAddressCard (order.shippingAddressSnapshot)
└── FulfilmentCard (order)
All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.
9. Implementation order
| Step | What to build | Why first |
|---|---|---|
| 1 | statusConfig.ts, OrderStatusBadge, OrderPaymentBadge |
Shared atoms used by both pages |
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
| 3 | OrderPageHeader, OrderItemsCard, OrderFinancialsCard |
Core detail page scaffold — read-only |
| 4 | CustomerCard, ShippingAddressCard, FulfilmentCard |
Right column — all read-only |
| 5 | OrderTimelineCard |
Needs timeline query wired |
| 6 | UpdateStatusDialog |
Lowest-risk action — any status |
| 7 | CreateLabelButton |
Most impactful Phase 1 action |
| 8 | MarkReturnReceivedButton, IssueRefundButton |
Phase 3/4 actions |
| 9 | OrderActionsBar |
Wire all actions together with condition logic |
Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.
10. Design decisions and rationale
Why a full page for detail, not a dialog? Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.
Why does OrderActionsBar not implement actions?
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.
Why no search on the list page?
The orders table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a by_order_number or by_email query when the need is clear.
Why useAction for createShippingLabel and issueRefund?
Both are Convex actions (they call external APIs). useAction from convex/react is the correct hook — not useMutation.
Why show trackingStatus in FulfilmentCard rather than a separate card?
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.
Document version: 1.0. UI only — no new backend work required.