Skip to contentVibraUI

Other orders

The customer's other orders, newest first, each with its status and total and a link to its own page; reads orderRecord(id).

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-order-order-history

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

Source

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]/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 }))
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Order record page