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
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-order-order-itemsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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