Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /ecommerce/orders/[id]

Order record

One order's page: its lines from the catalogue at the prices it charged, the parcel's stages, the customer with their face and their other orders, how it was paid — and a refund and a fulfilment that each say why when they cannot run.

Open the live page

A real dynamic route: the page installs at app/ecommerce/orders/[id]/page.tsx and ships generateStaticParams over db.orders.all(); an id the book does not hold is notFound(), and with no params — the docs preview — it falls back to the newest paid order whose parcel is still moving and not held. Each line reads db.products by productId when the page is read and is drawn with ProductArt, so a product that has left the catalogue prints as "Discontinued item", on a plain box rather than another product's likeness, at the unitCents the order stored; every line is priced at what the order charged, never today's list price, so the lines always add up to the subtotal. A web order's subtotal is its total, and the footer says so in one row; a till sale took a code off and charged tax on what was left, so its footer shows Subtotal, Discount and Tax above the Order total, and the total explains itself. The parcel is the order's db.shipments row drawn as a StageProgress with the stamp of each stage reached, a held parcel's stage blocked and its exception in a warning callout; the customer is their db.customers row with their face, every order they have placed, what they spent on the ones they kept (paid or fulfilled — the others read with the page, this one by its live status, so a refund here leaves Spent at once) and their five newest other orders, and the card is their default db.paymentMethods row. Two server actions return Result: refundOrder refuses a refunded, cancelled or unpaid order, and markFulfilled refuses anything but a paid order and a parcel that is held, and on success dates the fulfilment REFERENCE_DATE and stamps the parcel through to shipped — a parcel that had already gone keeps its stamps, and the status line says so rather than claiming to have moved it. A small provider reads each Result back, so the badge, the payment card and the stage bar move together; the refusal lands in a danger callout under the buttons and the success in an always-mounted status line, and a button that does not apply is dimmed with its reason but still presses, so the server's rule is heard. Composes AppShell, PageHeader, StatusBadge, AsyncButton, Callout, DashboardGrid, Widget, SimpleTable, TableCell, ProductArt, StageProgress, DescriptionList, UserCell, OrderProvider (block-local) and OrderHeader (block-local).

Preview

Install

npx shadcn@latest add @vibra/ecommerce-order

Needs the @vibra registry in your components.json — set it up once.

Source

app/ecommerce/orders/[id]/page.tsx
import { notFound } from "next/navigation"

import { orderHref } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate } from "@/lib/format"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"

import { signOut } from "./actions"
import { OrderProvider } from "./components/order-context"
import { OrderCustomer } from "./components/order-customer"
import { OrderHeader } from "./components/order-header"
import { OrderHistory } from "./components/order-history"
import { OrderItems } from "./components/order-items"
import { OrderPayment } from "./components/order-payment"
import { OrderShipment } from "./components/order-shipment"
import { currentUser, fallbackId, lastUpdated, orderIds, orderRecord, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"

/** One static page per order, which is what makes this a real detail route. */
export function generateStaticParams() {
  return orderIds().map((id) => ({ id }))
}

/**
 * One order. The route is `/ecommerce/orders/[id]`, and `params` is optional
 * so the docs preview — which renders the default export with no props at
 * all — still has an order to show: without one it falls back to the newest
 * paid order whose parcel is still on its way.
 */
export default async function OrderPage({ params }: { params?: Promise<{ id: string }> }) {
  const { id } = (await params) ?? { id: fallbackId() }
  const record = await orderRecord(id)
  if (!record) notFound()

  const { order, customer, parcel } = record
  const placed = formatDate(order.placedAt, "medium", { timeZone: "UTC" })
  const by = customer ? ` by ${customer.name} · ${customer.company}` : ""

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.order}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
      breadcrumbs={[
        { title: NAV.brand.name, href: NAV.brand.href },
        { title: "Orders", href: ROUTES.orders },
        { title: order.number, href: orderHref(order.id) },
      ]}
    >
      <OrderProvider
        id={order.id}
        number={order.number}
        initial={{ status: order.status, fulfilledAt: order.fulfilledAt, stage: parcel?.stage, stages: parcel?.stages }}
      >
        <OrderHeader
          number={order.number}
          backHref={ROUTES.orders}
          description={`Placed ${placed}${by}`}
          meta={lastUpdated()}
        />

        {/* The side column joins the order at xl: at lg, with the sidebar open,
            a third of the page is too narrow for an order number and its date.
            Stacked, the side cards pair up once their row is wide enough. */}
        <DashboardGrid>
          <DashboardGridItem colSpan={{ base: 12, xl: 8 }}>
            <div className="flex flex-col gap-[var(--density-gap,1rem)]">
              <OrderItems lines={record.lines} totals={order} />
              <OrderShipment parcel={parcel} />
            </div>
          </DashboardGridItem>
          <DashboardGridItem colSpan={{ base: 12, xl: 4 }} className="@container/side">
            <div className="grid gap-[var(--density-gap,1rem)] @2xl/side:grid-cols-2">
              <OrderCustomer customer={customer} lifetime={record.lifetime} total={order.total} />
              <OrderPayment method={order.paymentMethod} card={record.card} total={order.total} placedAt={order.placedAt} />
              <div className="@2xl/side:col-span-2">
                <OrderHistory others={record.others} count={record.lifetime.orders - 1} />
              </div>
            </div>
          </DashboardGridItem>
        </DashboardGrid>
      </OrderProvider>
    </AppShell>
  )
}
app/ecommerce/orders/[id]/data.ts
/**
 * What one order's page reads. The order is a `db.orders` row; its lines are
 * resolved against `db.products` by id when the page is read — a product the
 * catalogue no longer holds prints as a discontinued item at the price the
 * order stored — the parcel is its `db.shipments` row, the customer their
 * `db.customers` row, and the card on file their default `db.paymentMethods`
 * row. Every line is priced at what the order charged, never today's list
 * price, so the lines always add up to the order's total. "Now" is
 * `REFERENCE_DATE`; nothing here reads a clock.
 */
import { isSold } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  type Member,
  type Order,
  type ShipmentException,
  type ShipmentStageName,
} from "@/lib/sample-data"

/** What the page prints for a line whose product has left the catalogue. */
export const DISCONTINUED = "Discontinued item"

/** One line of the order, as the items table prints it. */
export type OrderLine = {
  productId: string
  name: string
  /** Absent for a discontinued item: the catalogue no longer knows it. */
  sku?: string
  category?: string
  qty: number
  /** What one unit cost on this order, in whole dollars. */
  unit: number
  total: number
}

export type OrderParcel = {
  id: string
  carrier: string
  service: "standard" | "express" | "overnight"
  tracking?: string
  destination: string
  weightGrams: number
  stages: { name: ShipmentStageName; at?: Date }[]
  stage: ShipmentStageName
  exception?: ShipmentException
}

/** The order and everything the page says about it, gathered in one read. */
export type OrderRecord = {
  order: {
    id: string
    number: string
    status: Order["status"]
    placedAt: Date
    fulfilledAt?: Date
    paymentMethod: Order["paymentMethod"]
    country: string
    /** In whole dollars. */
    total: number
    /** What the lines came to before any discount or tax, in whole dollars. */
    subtotal: number
    /** Taken off by a code at the till, in whole dollars; absent when nothing was. */
    discount?: number
    /** Sales tax charged at the till, in whole dollars; absent on a web order. */
    tax?: number
  }
  customer?: { id: string; name: string; company: string; email: string; avatarUrl?: string; country: string }
  /** The default card on file, when the order was paid by card and one is on file. */
  card?: { brand: string; last4: string }
  lines: OrderLine[]
  parcel?: OrderParcel
  /** The customer's other orders, newest first — five at most. */
  others: { id: string; number: string; status: Order["status"]; placedAt: Date; total: number }[]
  /**
   * The customer's account with the store, read when the page is: `orders`
   * is every order they have placed, this one included, whatever became of
   * it; `spentOnOthers` is what the others they kept — paid or fulfilled —
   * came to, in whole dollars. This order's own total is left out, because
   * the page adds it from the order's live status: a refund made there takes
   * it out of what they spent in the same render.
   */
  lifetime: { orders: number; spentOnOthers: number }
}

/** The customer's other orders the card lists. */
const OTHERS_SHOWN = 5

/**
 * The order the page falls back to when it is rendered with no route param —
 * which is what the docs preview does: the newest paid order whose parcel is
 * still on its way and not held, because that is an order both buttons have
 * something to do to. Read per request, like every row here, so an order
 * refunded or fulfilled since hands the preview on to the next one.
 */
export function fallbackId(): string {
  const parcels = new Map(db.shipments.all().map((parcel) => [parcel.orderId, parcel]))
  const orders = db.orders.all()
  const paid = orders
    .filter((order) => order.status === "paid")
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
  const moving = paid.find((order) => {
    const parcel = parcels.get(order.id)
    return parcel && parcel.stage !== "delivered" && !parcel.exception
  })
  return (moving ?? paid[0] ?? orders[0]).id
}

/** Every order id, for `generateStaticParams`. */
export function orderIds(): string[] {
  return db.orders.all().map((order) => order.id)
}

/** The whole record, or undefined when the id names no order. */
export async function orderRecord(id: string): Promise<OrderRecord | undefined> {
  const order = await db.orders.get(id)
  if (!order) return undefined

  // Read when the page is, not when the module loads: a product archived or
  // removed since then has to show up on the very next render.
  const products = new Map(db.products.all().map((product) => [product.id, product]))
  const lines = order.items.map((item): OrderLine => {
    const product = products.get(item.productId)
    return {
      productId: item.productId,
      name: product?.name ?? DISCONTINUED,
      sku: product?.sku,
      category: product?.category,
      qty: item.qty,
      unit: item.unitCents / 100,
      total: (item.qty * item.unitCents) / 100,
    }
  })

  const customer = db.customers.all().find((row) => row.id === order.customerId)
  const card =
    order.paymentMethod === "card"
      ? db.paymentMethods
          .all()
          .filter((method) => method.customerId === order.customerId)
          .sort((a, b) => Number(b.default) - Number(a.default))[0]
      : undefined
  const parcel = db.shipments.all().find((row) => row.orderId === order.id)
  // A walk-in sale at the till has no customer, and walk-ins are not one person.
  const theirs = db.orders
    .all()
    .filter((row) => (order.customerId ? row.customerId === order.customerId : row.id === order.id))
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
  const keptElsewhere = theirs.filter((row) => row.id !== order.id && isSold(row))

  return {
    order: {
      id: order.id,
      number: order.number,
      status: order.status,
      placedAt: order.placedAt,
      fulfilledAt: order.fulfilledAt,
      paymentMethod: order.paymentMethod,
      country: order.country,
      total: order.totalCents / 100,
      subtotal: order.items.reduce((sum, item) => sum + item.qty * item.unitCents, 0) / 100,
      discount: order.discountCents ? order.discountCents / 100 : undefined,
      tax: order.taxCents ? order.taxCents / 100 : undefined,
    },
    customer: customer
      ? {
          id: customer.id,
          name: customer.name,
          company: customer.company,
          email: customer.email,
          avatarUrl: customer.avatarUrl,
          country: customer.country,
        }
      : undefined,
    card: card ? { brand: card.brand, last4: card.last4 } : undefined,
    lines,
    parcel: parcel
      ? {
          id: parcel.id,
          carrier: parcel.carrier,
          service: parcel.service,
          tracking: parcel.tracking,
          destination: parcel.destination,
          weightGrams: parcel.weightGrams,
          stages: parcel.stages,
          stage: parcel.stage,
          exception: parcel.exception,
        }
      : undefined,
    others: theirs
      .filter((row) => row.id !== order.id)
      .slice(0, OTHERS_SHOWN)
      .map((row) => ({
        id: row.id,
        number: row.number,
        status: row.status,
        placedAt: row.placedAt,
        total: row.totalCents / 100,
      })),
    lifetime: {
      orders: theirs.length,
      spentOnOthers: keptElsewhere.reduce((sum, row) => sum + row.totalCents, 0) / 100,
    },
  }
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/ecommerce/orders/[id]/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import {
  db,
  REFERENCE_DATE,
  SHIPMENT_STAGES,
  type Order,
  type Result,
  type ShipmentStage,
  type ShipmentStageName,
} from "@/lib/sample-data"

/**
 * The two things one order's page does to it, and the sign-out. Every answer
 * is a `Result`, and every name in it — the order number, the amount — is the
 * store's own, read off the row this action just looked up.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

const notFound = (id: string) => ({ ok: false as const, error: { code: "not_found", message: `No order with id "${id}".` } })

/**
 * Sends the money back. Only an order that took a payment can be refunded: a
 * pending one never took it, a cancelled one gave it up, and a refunded one
 * has already had it returned.
 */
export async function refundOrder(id: string): Promise<Result<{ id: string; status: Order["status"]; refundedCents: number }>> {
  const order = await db.orders.get(id)
  if (!order) return notFound(id)

  if (order.status === "refunded") {
    return { ok: false, error: { code: "already_refunded", message: `${order.number} has already been refunded.` } }
  }
  if (order.status === "cancelled") {
    return {
      ok: false,
      error: { code: "cancelled", message: `${order.number} was cancelled, so it never took a payment to send back.` },
    }
  }
  if (order.status === "pending") {
    return {
      ok: false,
      error: { code: "unpaid", message: `${order.number} has not been paid for, so there is nothing to send back.` },
    }
  }

  const updated = await db.orders.update(id, { status: "refunded" })
  if (!updated.ok) return updated
  return { ok: true, data: { id, status: updated.data.status, refundedCents: updated.data.totalCents } }
}

const FULFIL_REFUSALS: Record<Exclude<Order["status"], "paid">, (number: string) => string> = {
  pending: (number) => `${number} has not been paid for yet.`,
  fulfilled: (number) => `${number} is already fulfilled.`,
  refunded: (number) => `${number} was refunded, so there is nothing left to send.`,
  cancelled: (number) => `${number} was cancelled, so there is nothing to send.`,
}

/** What a fulfilment answers: the order, and its parcel's stage when it has one. */
export type Fulfilment = {
  id: string
  status: Order["status"]
  fulfilledAt: Date
  stage?: ShipmentStageName
  stages?: ShipmentStage[]
  /** Whether this fulfilment moved the parcel on; false for a parcel that had already shipped. */
  moved?: boolean
}

/**
 * Says the goods have gone. Only a paid order can be fulfilled, and not while
 * its parcel is held — an address that will not resolve, a label that never
 * printed — because a held parcel has not gone anywhere. The order takes
 * today's date as its fulfilment; its parcel, if there is one, is stamped
 * through to "shipped" at the same instant, so the stage bar and the order
 * never disagree about where the goods are. A parcel already at or past that
 * stage keeps the stamps it has, and the answer says it was not moved.
 */
export async function markFulfilled(id: string): Promise<Result<Fulfilment>> {
  const order = await db.orders.get(id)
  if (!order) return notFound(id)
  if (order.status !== "paid") {
    return { ok: false, error: { code: "not_paid", message: FULFIL_REFUSALS[order.status](order.number) } }
  }

  const parcel = db.shipments.all().find((row) => row.orderId === id)
  if (parcel?.exception) {
    return {
      ok: false,
      error: { code: "held", message: `${order.number}'s parcel is held: ${parcel.exception.message}` },
    }
  }

  const updated = await db.orders.update(id, { status: "fulfilled", fulfilledAt: REFERENCE_DATE })
  if (!updated.ok) return updated
  if (!parcel) return { ok: true, data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE } }

  const shipped = SHIPMENT_STAGES.indexOf("shipped")
  const reached = SHIPMENT_STAGES.indexOf(parcel.stage)
  if (reached >= shipped) {
    return {
      ok: true,
      data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE, stage: parcel.stage, stages: parcel.stages, moved: false },
    }
  }

  const stages = parcel.stages.map((stage, index) =>
    index <= shipped && !stage.at ? { name: stage.name, at: REFERENCE_DATE } : stage
  )
  const stamped = await db.shipments.update(parcel.id, { stages, stage: "shipped" })
  if (!stamped.ok) return stamped
  return {
    ok: true,
    data: { id, status: updated.data.status, fulfilledAt: REFERENCE_DATE, stage: stamped.data.stage, stages: stamped.data.stages, moved: true },
  }
}
app/ecommerce/orders/[id]/components/order-context.tsx
"use client"

import * as React from "react"

import { formatCurrency } from "@/lib/format"
import type { Order, ShipmentStage, ShipmentStageName } from "@/lib/sample-data"

import { markFulfilled, refundOrder } from "../actions"

/** The part of the order its two actions can change, held once for every card that prints it. */
export type OrderState = {
  status: Order["status"]
  fulfilledAt?: Date
  stage?: ShipmentStageName
  stages?: ShipmentStage[]
}

type OrderContextValue = {
  state: OrderState
  /** What the last action said: a success for the status line, a refusal for the alert. Never both. */
  notice: string
  refusal: string | null
  refund: () => Promise<void>
  fulfil: () => Promise<void>
}

const OrderContext = React.createContext<OrderContextValue | null>(null)

export type OrderProviderProps = {
  id: string
  number: string
  initial: OrderState
  children: React.ReactNode
}

/**
 * Holds what the server last said about the order, and runs its two actions.
 * Each action's `Result` is read back into this state — the badge, the payment
 * card and the parcel's stage bar all print from here — so a refund or a
 * fulfilment shows everywhere at once, and a refusal leaves every card as it was.
 */
export function OrderProvider({ id, number, initial, children }: OrderProviderProps) {
  const [state, setState] = React.useState(initial)
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState<string | null>(null)

  const refund = React.useCallback(async () => {
    setNotice("")
    setRefusal(null)
    const result = await refundOrder(id)
    if (!result.ok) return setRefusal(result.error.message)
    setState((current) => ({ ...current, status: result.data.status }))
    setNotice(`Refunded ${formatCurrency(result.data.refundedCents / 100)} on ${number}. It is on its way back to the customer.`)
  }, [id, number])

  const fulfil = React.useCallback(async () => {
    setNotice("")
    setRefusal(null)
    const result = await markFulfilled(id)
    if (!result.ok) return setRefusal(result.error.message)
    const { status, fulfilledAt, stage, stages, moved } = result.data
    setState((current) => ({ ...current, status, fulfilledAt, ...(stage ? { stage, stages } : {}) }))
    // Only a parcel this fulfilment moved is "marked"; one that had already
    // gone is said to have, so the line never claims a change it did not make.
    setNotice(
      !stage
        ? `${number} is fulfilled.`
        : moved
          ? `${number} is fulfilled, and its parcel is marked ${stage}.`
          : `${number} is fulfilled. Its parcel was already ${stage}.`
    )
  }, [id, number])

  const value = React.useMemo(
    () => ({ state, notice, refusal, refund, fulfil }),
    [state, notice, refusal, refund, fulfil]
  )

  return <OrderContext.Provider value={value}>{children}</OrderContext.Provider>
}

export function useOrder(): OrderContextValue {
  const context = React.useContext(OrderContext)
  if (!context) throw new Error("useOrder must be used inside <OrderProvider>.")
  return context
}
app/ecommerce/orders/[id]/components/order-customer.tsx
"use client"

import Link from "next/link"
import { ArrowUpRightIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { customerHref, isSold } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { DescriptionList } from "@/components/ui/description-list"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type OrderRecord } from "../data"
import { useOrder } from "./order-context"

export type OrderCustomerProps = Pick<OrderRecord, "customer" | "lifetime"> & {
  /** This order's total in whole dollars, which Spent counts while the order is kept. */
  total: number
}

/**
 * Who placed the order: their face, their account, where it ships, and what
 * they have done with the store — every order they have placed, and what
 * they spent on the ones they kept, paid or fulfilled. The other orders are
 * read when the page is; this one counts by its live status, so a refund
 * made on this page takes it out of Spent in the same render as the badge.
 */
export function OrderCustomer({ customer, lifetime, total }: OrderCustomerProps) {
  const { state } = useOrder()

  if (!customer) {
    return (
      <Widget data-widget="widget-ecommerce-order-order-customer" title="Customer" description="Not on the books">
        <p className="text-sm text-muted-foreground">The account that placed this order has been removed.</p>
      </Widget>
    )
  }

  const spent = lifetime.spentOnOthers + (isSold(state) ? total : 0)

  return (
    <Widget
      data-widget="widget-ecommerce-order-order-customer"
      title="Customer"
      description={customer.company}
      footer={
        <Link
          href={customerHref(customer.id)}
          className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
        >
          Open customer record
          <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" />
        </Link>
      }
    >
      <div className="flex flex-col gap-4">
        <UserCell name={customer.name} description={customer.email} src={customer.avatarUrl} />
        <DescriptionList
          size="sm"
          items={[
            { term: "Ships to", description: customer.country },
            { term: "Orders", description: <span className="tabular-nums">{formatNumber(lifetime.orders)}</span> },
            {
              term: "Spent",
              description: <span className="tabular-nums">{formatCurrency(spent, "USD", { maximumFractionDigits: 0 })}</span>,
            },
          ]}
        />
      </div>
    </Widget>
  )
}
app/ecommerce/orders/[id]/components/order-header.tsx
"use client"

import * as React from "react"
import { PackageCheckIcon, UndoIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { ORDER_STATUS_MAP } from "@/lib/dashboards/ecommerce/vocabulary"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { PageHeader } from "@/components/ui/page-header"
import { StatusBadge } from "@/components/ui/status-badge"

import { useOrder } from "./order-context"

export type OrderHeaderProps = {
  number: string
  description: string
  meta: string
  backHref: string
}

/** Why a button does not apply to the order as it stands, or nothing when it does. */
function fulfilReason(status: string): string | null {
  if (status === "paid") return null
  if (status === "pending") return "Not paid for yet."
  if (status === "fulfilled") return "Already fulfilled."
  return `The order was ${status}.`
}

function refundReason(status: string): string | null {
  if (status === "paid" || status === "fulfilled") return null
  if (status === "refunded") return "Already refunded."
  return status === "pending" ? "No payment taken yet." : "The order was cancelled."
}

/**
 * The order's title, its live status, and the two things that can be done to
 * it. A button that does not apply to the order as it stands is dimmed and
 * says why, but still presses: the server owns the rule, and pressing it
 * hears the rule out loud rather than nothing at all. What came back lands
 * right under the buttons — a refusal in an alert, a success in the status
 * line — never in the same slot.
 */
export function OrderHeader({ number, description, meta, backHref }: OrderHeaderProps) {
  const { state, notice, refusal, refund, fulfil } = useOrder()
  const noFulfil = fulfilReason(state.status)
  const noRefund = refundReason(state.status)

  return (
    <div className="flex flex-col gap-3">
      <PageHeader
        backHref={backHref}
        title={number}
        // An order number is an id, so it is set in the mono face like every other.
        className="[&_[data-slot=page-header-title]]:font-mono"
        badge={<StatusBadge status={state.status} map={ORDER_STATUS_MAP} />}
        description={description}
        meta={meta}
        actions={
          <>
            <AsyncButton
              variant={noFulfil ? "outline" : "default"}
              aria-disabled={noFulfil ? true : undefined}
              aria-describedby={noFulfil ? "fulfil-reason" : undefined}
              className={cn(noFulfil && "opacity-60")}
              onClick={fulfil}
            >
              <PackageCheckIcon data-icon="inline-start" aria-hidden="true" />
              Mark fulfilled
            </AsyncButton>
            <AsyncButton
              variant="outline"
              aria-disabled={noRefund ? true : undefined}
              aria-describedby={noRefund ? "refund-reason" : undefined}
              className={cn(noRefund && "opacity-60")}
              onClick={refund}
            >
              <UndoIcon data-icon="inline-start" aria-hidden="true" />
              Refund order
            </AsyncButton>
            <span id="fulfil-reason" className="sr-only">
              {noFulfil}
            </span>
            <span id="refund-reason" className="sr-only">
              {noRefund}
            </span>
          </>
        }
      />

      {refusal ? (
        <Callout variant="danger" role="alert" title="Not done">
          {refusal}
        </Callout>
      ) : null}
      <p
        data-slot="order-status"
        role="status"
        aria-live="polite"
        className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
      >
        {notice}
      </p>
    </div>
  )
}
app/ecommerce/orders/[id]/components/order-history.tsx
import Link from "next/link"

import { ORDER_STATUS_MAP, orderHref } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatDate } from "@/lib/format"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

import { type OrderRecord } from "../data"

/**
 * The customer's other orders, newest first, each one a link to its own page.
 * `count` is how many other orders they have in all; the card lists the
 * newest few and says so when there are more.
 */
export function OrderHistory({ others, count }: { others: OrderRecord["others"]; count: number }) {
  const description =
    count === 0
      ? "This is their first order"
      : others.length < count
        ? `The ${others.length} newest of ${count}`
        : `${count} more from this customer`

  return (
    <Widget data-widget="widget-ecommerce-order-order-history" title="Other orders" description={description}>
      {others.length === 0 ? (
        <p className="text-sm text-muted-foreground">Nothing else on the books from this account yet.</p>
      ) : (
        <ul className="flex flex-col">
          {others.map((order) => (
            <li
              key={order.id}
              className="flex items-center justify-between gap-3 border-b py-2.5 first:pt-0 last:border-b-0 last:pb-0"
            >
              <div className="flex min-w-0 flex-col">
                <Link
                  href={orderHref(order.id)}
                  className="self-start rounded-sm font-mono text-xs font-medium underline-offset-4 focus-ring hover:underline"
                >
                  {order.number}
                </Link>
                <span className="text-xs text-muted-foreground">
                  {formatDate(order.placedAt, "medium", { timeZone: "UTC" })}
                </span>
              </div>
              <div className="flex shrink-0 items-center gap-3">
                <StatusBadge status={order.status} map={ORDER_STATUS_MAP} />
                <span className="w-20 text-right text-sm tabular-nums">{formatCurrency(order.total)}</span>
              </div>
            </li>
          ))}
        </ul>
      )}
    </Widget>
  )
}
app/ecommerce/orders/[id]/components/order-items.tsx
import type * as React from "react"
import Link from "next/link"
import { PackageIcon } from "lucide-react"

import { productHref } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatCurrency, formatNumber } from "@/lib/format"
import { ProductArt } from "@/components/ui/product-art"
import { SimpleTable } from "@/components/ui/simple-table"
import { TableCell } from "@/components/ui/table"
import { Widget } from "@/components/ui/widget"

import { type OrderLine, type OrderRecord } from "../data"

/** What the footer states: the total, and the working a till sale kept. */
export type OrderTotals = Pick<OrderRecord["order"], "subtotal" | "discount" | "tax" | "total">

/**
 * One footer row: its words across the product and quantity columns, the
 * unit column left empty (it steps aside on a phone, as the lines' does), and
 * the amount under the lines' totals. The total is the row that carries the
 * weight; the working above it reads as the quieter ledger it is.
 */
function footerRow(label: string, amount: string, total = false): React.ReactNode {
  return (
    <>
      <TableCell colSpan={2} className={total ? "px-3 font-medium" : "px-3 font-normal text-muted-foreground"}>
        {label}
      </TableCell>
      <TableCell className="hidden sm:table-cell" />
      <TableCell className={total ? "px-3 text-right font-semibold tabular-nums" : "px-3 text-right font-normal tabular-nums"}>
        {amount}
      </TableCell>
    </>
  )
}

/**
 * The footer's rows. A web order's lines are what was paid, so one row says
 * so. A till sale took a code off the goods and charged tax on what was left,
 * so its lines add up to the subtotal and not to the total: the working —
 * subtotal, the discount, the tax — goes above the total, and the total
 * explains itself.
 */
function totalRows({ subtotal, discount, tax, total }: OrderTotals): React.ReactNode[] {
  const rows: React.ReactNode[] = []
  if (discount || tax) {
    rows.push(footerRow("Subtotal", formatCurrency(subtotal)))
    // A minus sign, not a hyphen: it is read as one, and it is the width of the digits.
    if (discount) rows.push(footerRow("Discount", `\u2212${formatCurrency(discount)}`))
    if (tax) rows.push(footerRow("Tax", formatCurrency(tax)))
  }
  rows.push(footerRow("Order total", formatCurrency(total), true))
  return rows
}

/**
 * A line's picture: the product's drawing, or — for an item the catalogue no
 * longer knows, so there is nothing to draw — a plain box on a quiet tile,
 * rather than some other product's likeness. Decorative either way.
 */
function LineArt({ line }: { line: OrderLine }) {
  if (line.sku) return <ProductArt name={line.name} category={line.category} className="size-9 shrink-0 rounded-md" />
  return (
    <span
      aria-hidden="true"
      className="grid size-9 shrink-0 place-items-center rounded-md bg-secondary text-muted-foreground [&_svg]:size-4"
    >
      <PackageIcon />
    </span>
  )
}

/**
 * What was bought: each line's product from the catalogue, its quantity, the
 * unit price the order charged and the line's total, with the order's total
 * under them — and, for a till sale, the subtotal, discount and tax that took
 * the lines to it. A product the catalogue no longer holds is still a line —
 * the money was taken for it — so it prints as a discontinued item at the
 * price the order stored.
 */
export function OrderItems({ lines, totals }: { lines: OrderLine[]; totals: OrderTotals }) {
  const units = lines.reduce((sum, line) => sum + line.qty, 0)

  return (
    <Widget
      data-widget="widget-ecommerce-order-order-items"
      title="Items"
      description={`${formatNumber(units)} unit${units === 1 ? "" : "s"} across ${lines.length} line${lines.length === 1 ? "" : "s"}`}
      contentClassName="p-0"
    >
      <SimpleTable
        rows={lines}
        rowKey="productId"
        className="rounded-none border-0"
        columns={[
          {
            key: "name",
            header: "Product",
            cell: (line) => (
              <div className="flex min-w-0 items-center gap-2.5">
                <LineArt line={line} />
                <div className="flex min-w-0 flex-col">
                  {line.sku ? (
                    <Link
                      href={productHref(line.productId)}
                      className="truncate rounded-sm font-medium underline-offset-4 focus-ring hover:underline"
                    >
                      {line.name}
                    </Link>
                  ) : (
                    <span className="truncate font-medium text-muted-foreground">{line.name}</span>
                  )}
                  <span className="font-mono text-xs text-muted-foreground">{line.sku ?? line.productId}</span>
                </div>
              </div>
            ),
          },
          { key: "qty", header: "Qty", align: "right" },
          // The unit price steps aside on a phone: quantity and line total are the pair a reader checks there.
          { key: "unit", header: "Unit", align: "right", className: "hidden sm:table-cell", cell: (line) => formatCurrency(line.unit) },
          { key: "total", header: "Total", align: "right", cell: (line) => formatCurrency(line.total) },
        ]}
        footerRows={totalRows(totals)}
      />
    </Widget>
  )
}
app/ecommerce/orders/[id]/components/order-payment.tsx
"use client"

import { formatCurrency, formatDate } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { Widget } from "@/components/ui/widget"

import { useOrder } from "./order-context"
import { CARD_BRANDS, METHOD_LABELS } from "./order-vocabulary"

export type OrderPaymentProps = {
  method: string
  card?: { brand: string; last4: string }
  total: number
  placedAt: Date
}

/**
 * How the order was paid for and where the money stands. The headline reads
 * off the order's live status, so a refund here says what went back the
 * moment the server confirms it; the net is what the store keeps.
 */
export function OrderPayment({ method, card, total, placedAt }: OrderPaymentProps) {
  const { state } = useOrder()
  const taken = state.status === "paid" || state.status === "fulfilled" || state.status === "refunded"
  const refunded = state.status === "refunded"

  const headline =
    state.status === "pending"
      ? "Awaiting payment"
      : state.status === "cancelled"
        ? "No payment taken"
        : refunded
          ? `Refunded ${formatCurrency(total)}`
          : `Captured ${formatCurrency(total)}`

  return (
    <Widget data-widget="widget-ecommerce-order-order-payment" title="Payment" description={headline}>
      <DescriptionList
        size="sm"
        items={[
          {
            term: "Method",
            description:
              method === "card" && card ? (
                <span className="flex items-center gap-2">
                  {CARD_BRANDS[card.brand] ?? card.brand}
                  <span className="font-mono text-xs">•••• {card.last4}</span>
                </span>
              ) : (
                (METHOD_LABELS[method] ?? method)
              ),
          },
          { term: "Placed", description: formatDate(placedAt, "medium", { timeZone: "UTC" }) },
          { term: "Charged", description: <span className="tabular-nums">{taken ? formatCurrency(total) : "—"}</span> },
          ...(refunded
            ? [{ term: "Refunded", description: <span className="tabular-nums">−{formatCurrency(total)}</span> }]
            : []),
          {
            term: "Kept",
            description: (
              <span className="font-medium tabular-nums">
                {formatCurrency(taken && !refunded ? total : 0)}
              </span>
            ),
          },
        ]}
      />
    </Widget>
  )
}
app/ecommerce/orders/[id]/components/order-shipment.tsx
"use client"

import { formatDate, formatNumber } from "@/lib/format"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { StageProgress, type StageProgressStage } from "@/components/ui/stage-progress"
import { Widget } from "@/components/ui/widget"

import { type OrderParcel } from "../data"
import { useOrder } from "./order-context"
import { SERVICE_LABELS, STAGE_LABELS } from "./order-vocabulary"

// Fixed to UTC so a stamp reads the same wherever the page is rendered.
const STAMP = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})

/**
 * Where the parcel has got to, from its `db.shipments` row. Every stage before
 * the current one is done; the current one is in progress — done, if it is
 * the last — or blocked while the parcel is held; the rest are still to come.
 * The stamps under the bar are the times each stage was actually reached.
 */
export function OrderShipment({ parcel }: { parcel?: OrderParcel }) {
  const { state } = useOrder()

  if (!parcel) {
    return (
      <Widget data-widget="widget-ecommerce-order-order-shipment" title="Shipment" description="No parcel on record">
        <p className="text-sm text-muted-foreground">
          {state.status === "pending" || state.status === "cancelled"
            ? "Nothing leaves the warehouse for an order that was never paid for."
            : "This order left before the shipment log begins, so there is no parcel to follow."}
        </p>
      </Widget>
    )
  }

  const stages = state.stages ?? parcel.stages
  const current = stages.findIndex((stage) => stage.name === (state.stage ?? parcel.stage))
  const last = stages.length - 1
  const bar: StageProgressStage[] = stages.map((stage, index) => ({
    id: stage.name,
    label: STAGE_LABELS[stage.name],
    state:
      index < current || (index === current && index === last)
        ? "done"
        : index === current
          ? parcel.exception
            ? "blocked"
            : "active"
          : "pending",
  }))

  return (
    <Widget
      data-widget="widget-ecommerce-order-order-shipment"
      title="Shipment"
      description={`${parcel.carrier} · ${SERVICE_LABELS[parcel.service]} · to ${parcel.destination}`}
    >
      {/* A container, so the stamps pair up only when the card itself is wide enough for two columns. */}
      <div className="@container/shipment flex flex-col gap-4">
        <StageProgress stages={bar} />

        {parcel.exception ? (
          <Callout variant="warning" title="Held">
            {parcel.exception.message} Raised {formatDate(parcel.exception.raisedAt, "medium", { timeZone: "UTC" })}.
          </Callout>
        ) : null}

        <DescriptionList
          size="sm"
          className="@xl/shipment:grid-cols-2"
          items={[
            ...stages.map((stage) => ({
              term: STAGE_LABELS[stage.name],
              description: stage.at ? (
                <time dateTime={new Date(stage.at).toISOString()} className="tabular-nums">
                  {STAMP.format(new Date(stage.at))}
                </time>
              ) : (
                <span className="text-muted-foreground">Not yet</span>
              ),
            })),
            {
              term: "Tracking",
              description: parcel.tracking ? (
                <span className="font-mono text-xs">{parcel.tracking}</span>
              ) : (
                <span className="text-muted-foreground">Issued once the carrier has it</span>
              ),
            },
            {
              term: "Weight",
              description: <span className="tabular-nums">{formatNumber(parcel.weightGrams / 1000, { maximumFractionDigits: 2 })} kg</span>,
            },
          ]}
        />
      </div>
    </Widget>
  )
}
app/ecommerce/orders/[id]/components/order-vocabulary.ts
/**
 * The words this page alone puts on an order's payment and its parcel. The
 * words every E-commerce page shares — the status tones, the routes — come
 * from the dashboard's vocabulary. Vocabulary, not data: it lives beside the
 * islands that print it, because `data.ts` reads `db` and must never reach
 * the browser.
 */

export const METHOD_LABELS: Record<string, string> = {
  card: "Card",
  wallet: "Wallet",
  bank: "Bank transfer",
  cash: "Cash",
  voucher: "Voucher",
}

export const CARD_BRANDS: Record<string, string> = { visa: "Visa", mastercard: "Mastercard", amex: "Amex" }

export const STAGE_LABELS: Record<string, string> = {
  picking: "Picking",
  packing: "Packing",
  shipped: "Shipped",
  delivered: "Delivered",
}

export const SERVICE_LABELS: Record<string, string> = { standard: "Standard", express: "Express", overnight: "Overnight" }