Skip to contentVibraUI

Order items

What was bought — each line's product, quantity, unit price and total at what the order charged — with the order's total under them; reads orderRecord(id).

Preview

Install

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

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

Source

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