Customer
Who placed the order, where it ships, how many orders they have placed and what they have spent on the ones they kept; reads orderRecord(id).
Preview
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-order-order-customerNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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 }))
}"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 },
}
}"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
}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