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