Files
the-pet-loft/apps/admin/docs/07-orders-ui-design-plan.md
ianshaloom 83a5172397 feat(admin): update configuration and documentation for order processing
- 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.
2026-03-07 19:31:10 +03:00

22 KiB
Raw Blame History

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:

  1. 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.
  2. Status-driven UI. The order's status field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
  3. Consistency over novelty. Use the exact same patterns already in the codebase: rounded-lg border for table wrappers, text-xl font-semibold headings, usePaginatedQuery + "Load more", AlertDialog for 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 shows toast.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: 34 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.