/ecommerce/customers/[id]Customer record
One account's page: a record header with its status and owner, and five sections — its facts and revenue, its orders, its invoices, its history, and an editable note.
A real dynamic route: the page installs at app/ecommerce/customers/[id]/page.tsx and ships generateStaticParams over db.customers.all(). Its params are optional and fall back to the first account, so the docs preview — which renders the default export with no props — still has a record to show. Every panel is read on the server through a repository asked for that one account (orders by search key, invoices, cards and the subscription by filter key) and handed to the tab strip as children; NavTabs' renderLink turns each tab into a button so the sections switch in place instead of navigating out of a preview frame. The MRR series has no entity of its own, so it is walked back from what the account bills today with a generator seeded from its id, and an account that has never billed gets a sentence instead of a flat line. The note saves through a server action returning Result, and a refusal is printed on the field by InlineEdit. Composes AppShell, PageHeader, StatusBadge, UserCell, NavTabs, DescriptionList, Sparkline, SimpleTable, ActivityFeed, InlineEdit, SectionHeader and Card.
Preview
import { notFound } from "next/navigation"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { signOut } from "./actions"
import { CustomerActivity } from "./components/customer-activity"
import { CustomerInvoices } from "./components/customer-invoices"
import { CustomerNotes } from "./components/customer-notes"
import { CustomerOrders } from "./components/customer-orders"
import { CustomerOverview } from "./components/customer-overview"
import {
RecordTabPanel,
RecordTabsProvider,
RecordTabsStrip,
type RecordTab,
} from "./components/record-tabs"
import {
currentUser,
customerActivity,
customerIds,
customerRecord,
defaultNote,
fallbackId,
lastUpdated,
NOTE_LIMIT,
shellNotifications,
STATUS_MAP,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/** One static page per account, which is what makes this a real detail route. */
export function generateStaticParams() {
return customerIds().map((id) => ({ id }))
}
/**
* One customer's record. The route is `/ecommerce/customers/[id]`, and `params` is
* optional so the docs preview — which renders the default export with no
* props at all — still has a record to show: without one it falls back to the
* first account in `db.customers`.
*/
export default async function CustomerDetailPage({
params,
}: {
params?: Promise<{ id: string }>
}) {
const { id } = (await params) ?? { id: fallbackId() }
const record = await customerRecord(id)
if (!record) notFound()
const activity = await customerActivity(record)
const { customer, owner } = record
const tabs: RecordTab[] = [
{ id: "overview", label: "Overview" },
{ id: "orders", label: "Orders", count: record.orders.length },
{ id: "invoices", label: "Invoices", count: record.invoices.length },
{ id: "activity", label: "Activity", count: activity.length },
{ id: "notes", label: "Notes" },
]
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customer}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
breadcrumbs={[
{ title: NAV.brand.name, href: NAV.brand.href },
{ title: "Customers", href: ROUTES.customers },
{ title: customer.company, href: `${ROUTES.customers}/${customer.id}` },
]}
>
<RecordTabsProvider tabs={tabs}>
<PageHeader
backHref={ROUTES.customers}
title={customer.company}
badge={<StatusBadge status={customer.status} map={STATUS_MAP} />}
description={`${customer.name} · ${customer.email}`}
meta={lastUpdated()}
actions={
owner ? (
<UserCell
size="sm"
name={owner.name}
description={`Account owner · ${owner.role}`}
src={owner.avatarUrl}
/>
) : null
}
tabs={<RecordTabsStrip tabs={tabs} />}
/>
<RecordTabPanel id="overview">
<CustomerOverview record={record} />
</RecordTabPanel>
<RecordTabPanel id="orders">
<CustomerOrders rows={record.orders} />
</RecordTabPanel>
<RecordTabPanel id="invoices">
<CustomerInvoices rows={record.invoices} />
</RecordTabPanel>
<RecordTabPanel id="activity">
<CustomerActivity items={activity} />
</RecordTabPanel>
<RecordTabPanel id="notes">
<CustomerNotes id={customer.id} note={defaultNote(record)} limit={NOTE_LIMIT} />
</RecordTabPanel>
</RecordTabsProvider>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/ecommerce-customerNeeds the @vibra registry in your components.json — set it up once.
Source
import { notFound } from "next/navigation"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { signOut } from "./actions"
import { CustomerActivity } from "./components/customer-activity"
import { CustomerInvoices } from "./components/customer-invoices"
import { CustomerNotes } from "./components/customer-notes"
import { CustomerOrders } from "./components/customer-orders"
import { CustomerOverview } from "./components/customer-overview"
import {
RecordTabPanel,
RecordTabsProvider,
RecordTabsStrip,
type RecordTab,
} from "./components/record-tabs"
import {
currentUser,
customerActivity,
customerIds,
customerRecord,
defaultNote,
fallbackId,
lastUpdated,
NOTE_LIMIT,
shellNotifications,
STATUS_MAP,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/** One static page per account, which is what makes this a real detail route. */
export function generateStaticParams() {
return customerIds().map((id) => ({ id }))
}
/**
* One customer's record. The route is `/ecommerce/customers/[id]`, and `params` is
* optional so the docs preview — which renders the default export with no
* props at all — still has a record to show: without one it falls back to the
* first account in `db.customers`.
*/
export default async function CustomerDetailPage({
params,
}: {
params?: Promise<{ id: string }>
}) {
const { id } = (await params) ?? { id: fallbackId() }
const record = await customerRecord(id)
if (!record) notFound()
const activity = await customerActivity(record)
const { customer, owner } = record
const tabs: RecordTab[] = [
{ id: "overview", label: "Overview" },
{ id: "orders", label: "Orders", count: record.orders.length },
{ id: "invoices", label: "Invoices", count: record.invoices.length },
{ id: "activity", label: "Activity", count: activity.length },
{ id: "notes", label: "Notes" },
]
return (
<AppShell
nav={NAV}
activeHref={ROUTES.customer}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
breadcrumbs={[
{ title: NAV.brand.name, href: NAV.brand.href },
{ title: "Customers", href: ROUTES.customers },
{ title: customer.company, href: `${ROUTES.customers}/${customer.id}` },
]}
>
<RecordTabsProvider tabs={tabs}>
<PageHeader
backHref={ROUTES.customers}
title={customer.company}
badge={<StatusBadge status={customer.status} map={STATUS_MAP} />}
description={`${customer.name} · ${customer.email}`}
meta={lastUpdated()}
actions={
owner ? (
<UserCell
size="sm"
name={owner.name}
description={`Account owner · ${owner.role}`}
src={owner.avatarUrl}
/>
) : null
}
tabs={<RecordTabsStrip tabs={tabs} />}
/>
<RecordTabPanel id="overview">
<CustomerOverview record={record} />
</RecordTabPanel>
<RecordTabPanel id="orders">
<CustomerOrders rows={record.orders} />
</RecordTabPanel>
<RecordTabPanel id="invoices">
<CustomerInvoices rows={record.invoices} />
</RecordTabPanel>
<RecordTabPanel id="activity">
<CustomerActivity items={activity} />
</RecordTabPanel>
<RecordTabPanel id="notes">
<CustomerNotes id={customer.id} note={defaultNote(record)} limit={NOTE_LIMIT} />
</RecordTabPanel>
</RecordTabsProvider>
</AppShell>
)
}/**
* What this page reads. The record is a `db.customers` row; everything around
* it is another repository asked for that account — its subscription and plan,
* its cards, its orders, its invoices, and the audit lines that name it. The
* monthly revenue series has no entity of its own, so it is derived from the
* account's own life: what it bills now, walked back through the months it has
* existed with a generator seeded from its id, so the same account always
* draws the same line. "Now" is `REFERENCE_DATE`.
*/
import { formatDate, getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
seeded,
type Customer,
type Invoice,
type Member,
type Order,
type PaymentMethod,
type Plan,
type Subscription,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
const MONTH_MS = DAY_MS * 30
/** The account and everything the page says about it, gathered in one read. */
export type CustomerRecord = {
customer: Customer
owner: Member | undefined
subscription: Subscription | undefined
plan: Plan | undefined
cards: PaymentMethod[]
orders: Order[]
invoices: Invoice[]
/** Monthly recurring revenue in whole dollars. */
mrr: number
/** Twelve months of MRR, oldest first; empty for an account that has never billed. */
series: number[]
/** Total ever invoiced and paid, in whole dollars. */
paid: number
}
/**
* The account the page falls back to when it is rendered without a route param
* — which is exactly what the docs preview does.
*
* The first *paying* account rather than the first row: a record page is worth
* looking at when it has a revenue line, invoices and orders on it, and the
* first row of `db.customers` is a day-old trial with none of the three. Read
* per request, like every row here.
*/
export function fallbackId(): string {
const customers = db.customers.all()
return (customers.find((row) => row.status === "active" && row.mrrCents > 0) ?? customers[0]).id
}
/** Every account id, for `generateStaticParams`. */
export function customerIds(): string[] {
return db.customers.all().map((row) => row.id)
}
/**
* A plausible run of monthly revenue for an account that bills today: the
* current rate, walked backwards with small seeded steps and clipped to the
* months the account has actually existed. An account that has never billed
* gets no series at all, because a flat row of zeroes is not a chart.
*/
function mrrSeries(customer: Customer): number[] {
if (customer.mrrCents <= 0) return []
const rand = seeded(customer.id)
const age = Math.floor((REFERENCE_DATE.getTime() - customer.createdAt.getTime()) / MONTH_MS)
const months = Math.max(1, Math.min(12, age + 1))
const values: number[] = [customer.mrrCents / 100]
for (let i = 1; i < months; i++) {
// Backwards: each earlier month is the same or a little smaller, so the
// line reads as an account that has been growing into its plan.
const step = 1 - rand() * 0.12
values.unshift(Math.round(values[0] * step))
}
return values
}
/**
* The whole record, or undefined when the id names no account.
*
* The sub-reads go through `all()` rather than `list()`. A detail page is not a
* table: the orders and invoices here are the account's whole history, and
* `paid` sums every invoice it has ever settled — a capped page would quietly
* under-count it and nothing on screen would say so. `all()` is the accessor
* the repository documents for exactly this ("synchronous, for charts and
* aggregates"), and one account's rows are a handful either way.
*/
export async function customerRecord(id: string): Promise<CustomerRecord | undefined> {
const customer = await db.customers.get(id)
if (!customer) return undefined
const orders = db.orders
.all()
.filter((row) => row.customerId === customer.id)
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
const invoices = db.invoices
.all()
.filter((row) => row.customerId === customer.id)
.sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime())
const cards = db.paymentMethods.all().filter((row) => row.customerId === customer.id)
const subscription = db.subscriptions.all().find((row) => row.customerId === customer.id)
return {
customer,
owner: db.members.all().find((member) => member.id === customer.owner),
subscription,
plan: subscription
? db.plans.all().find((row) => row.id === subscription.planId)
: undefined,
cards,
orders,
invoices,
mrr: customer.mrrCents / 100,
series: mrrSeries(customer),
paid:
invoices
.filter((invoice) => invoice.status === "paid")
.reduce((sum, invoice) => sum + invoice.amountCents, 0) / 100,
}
}
export type RecordActivity = {
id: string
actorName: string
actorAvatar?: string
action: string
target?: string
time: Date
detail?: string
}
/**
* The account's history, newest first: the audit lines that name it, the orders
* it placed, the invoices it was sent, and the day it signed up. Nothing here
* is invented — every entry is a row some repository holds.
*/
export async function customerActivity(record: CustomerRecord): Promise<RecordActivity[]> {
const { customer, owner } = record
const audits = await db.auditEvents.list({
filters: { resource: "customer" },
search: customer.id,
pageSize: 50,
})
const items: RecordActivity[] = [
...audits.rows.map((event) => ({
id: event.id,
actorName: db.members.all().find((m) => m.id === event.actor)?.name ?? "Someone",
actorAvatar: db.members.all().find((m) => m.id === event.actor)?.avatarUrl,
action: `${event.action} the account`,
time: event.at,
detail: event.diff?.map((d) => `${d.field}: ${d.from} → ${d.to}`).join(" · "),
})),
...record.orders.map((order) => ({
id: order.id,
actorName: customer.name,
actorAvatar: customer.avatarUrl,
action: "placed order",
target: order.number,
time: order.placedAt,
detail: `${order.items.length} item${order.items.length === 1 ? "" : "s"} · ${order.paymentMethod}`,
})),
...record.invoices.map((invoice) => ({
id: invoice.id,
actorName: owner?.name ?? "Billing",
actorAvatar: owner?.avatarUrl,
action: `${invoice.status === "paid" ? "collected" : "issued"} invoice`,
target: invoice.number,
time: invoice.paidAt ?? invoice.issuedAt,
})),
{
id: `${customer.id}-signup`,
actorName: customer.name,
action: customer.status === "trial" ? "started a trial" : "created the workspace",
time: customer.createdAt,
},
]
return items.sort((a, b) => b.time.getTime() - a.time.getTime()).slice(0, 20)
}
/** The note the page opens with, written from what the row already knows. */
export function defaultNote(record: CustomerRecord): string {
const { customer, subscription } = record
const renewal = subscription ? formatDate(subscription.renewsAt, "medium", { timeZone: "UTC" }) : "no renewal on file"
const seats = `${customer.seats} seat${customer.seats === 1 ? "" : "s"}`
return `${customer.company} is on ${customer.plan} with ${seats}, out of ${customer.country}. Renewal: ${renewal}.`
}
/** How long the note may be before the action refuses it. */
export const NOTE_LIMIT = 280
/** Statuses `StatusBadge` has no default for. */
export const STATUS_MAP = { trial: "info", churned: "danger", suspended: "warning" } as const
/** 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 { asString, db, type Result } from "@/lib/sample-data"
import { NOTE_LIMIT } from "./data"
/**
* The two things this page changes. Both return `Result`, so the page prints
* the reason a save was refused instead of losing it.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/**
* Saves the account note.
*
* `Customer` has no note field, so there is nowhere in the seeded store to put
* one: the action validates the text and confirms the write, and a consumer
* swapping `db.customers` for a real repository adds the column and the
* `update` call here. The validation is the part worth keeping — it is what
* the field's error message is derived from.
*/
export async function saveNote(id: string, note: string): Promise<Result<{ note: string }>> {
const customer = await db.customers.get(id)
if (!customer) {
return { ok: false, error: { code: "not_found", message: `No account with id ${id}.` } }
}
const trimmed = asString(note).trim()
if (trimmed.length === 0) {
return {
ok: false,
error: { code: "empty_note", message: "A note cannot be empty.", field: "note" },
}
}
if (trimmed.length > NOTE_LIMIT) {
return {
ok: false,
error: {
code: "note_too_long",
message: `Notes are capped at ${NOTE_LIMIT} characters; that one is ${trimmed.length}.`,
field: "note",
},
}
}
return { ok: true, data: { note: trimmed } }
}import { getInitials } from "@/lib/format"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { ActivityFeed } from "@/components/ui/activity-feed"
import { SectionHeader } from "@/components/ui/section-header"
import { type RecordActivity } from "../data"
/**
* The account's history. `now` is passed so the day headings are measured
* against REFERENCE_DATE rather than the reader's clock — the same moment the
* rest of the page is dated from.
*/
export function CustomerActivity({ items }: { items: RecordActivity[] }) {
return (
<>
<SectionHeader
as="h2"
size="sm"
title="Activity"
description="Everything this account's rows say has happened to it, newest first."
/>
<ActivityFeed
groupByDay
now={REFERENCE_DATE}
emptyMessage="Nothing has happened on this account yet."
items={items.map((item) => ({
id: item.id,
actor: { name: item.actorName, src: item.actorAvatar, fallback: getInitials(item.actorName) },
action: item.action,
target: item.target,
time: item.time,
meta: item.detail,
}))}
/>
</>
)
}import { formatCurrency } from "@/lib/format"
import { type Invoice } from "@/lib/sample-data"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, DateCell } from "@/components/ui/table-cells"
/** Every invoice sent to this account, newest first, with what is still owed. */
export function CustomerInvoices({ rows }: { rows: Invoice[] }) {
const outstanding = rows
.filter((invoice) => invoice.status === "open" || invoice.status === "overdue")
.reduce((sum, invoice) => sum + invoice.amountCents, 0)
return (
<>
<SectionHeader
as="h2"
size="sm"
title="Invoices"
description="What this account has been billed, and what is still owed."
/>
<SimpleTable
size="sm"
rows={rows}
rowKey="id"
caption={
outstanding > 0
? `${formatCurrency(outstanding / 100)} outstanding across ${rows.length} invoices.`
: `${rows.length} invoice${rows.length === 1 ? "" : "s"}, nothing outstanding.`
}
emptyMessage="This account has never been invoiced."
columns={[
{
key: "number",
header: "Invoice",
cell: (invoice) => <span className="font-mono text-xs">{invoice.number}</span>,
},
{ key: "issuedAt", header: "Issued", cell: (invoice) => <DateCell date={invoice.issuedAt} /> },
{ key: "dueAt", header: "Due", cell: (invoice) => <DateCell date={invoice.dueAt} /> },
{
key: "status",
header: "Status",
cell: (invoice) => (
<StatusBadge status={invoice.status} map={{ open: "info", draft: "neutral", void: "neutral" }} />
),
},
{
key: "amountCents",
header: "Amount",
align: "right",
cell: (invoice) => <CurrencyCell value={invoice.amountCents / 100} />,
},
]}
/>
</>
)
}"use client"
import * as React from "react"
import { Card, CardContent } from "@/components/ui/card"
import { InlineEdit } from "@/components/ui/inline-edit"
import { SectionHeader } from "@/components/ui/section-header"
import { saveNote } from "../actions"
/**
* The account note, edited in place. The save is a server action returning
* `Result`; a refusal is thrown so `InlineEdit` prints it on the field it
* belongs to, with `aria-invalid` and `aria-describedby` pointing at it, rather
* than dropping the edit and saying nothing. The cap the action holds it to
* comes from the page, so this island never imports the module that reads `db`.
*/
export function CustomerNotes({ id, note, limit }: { id: string; note: string; limit: number }) {
const [value, setValue] = React.useState(note)
const [saved, setSaved] = React.useState<string | null>(null)
async function commit(next: string) {
const result = await saveNote(id, next)
if (!result.ok) {
setSaved(null)
// InlineEdit catches this and renders it under the field.
throw new Error(result.error.message)
}
setValue(result.data.note)
setSaved(`Saved ${result.data.note.length} of ${limit} characters.`)
}
return (
<Card>
<CardContent className="flex flex-col gap-3">
<SectionHeader
as="h2"
size="sm"
title="Notes"
description="What the team should know before they call. Click the text to edit it; Enter saves, Escape puts it back."
/>
<InlineEdit
multiline
aria-label="Account note"
value={value}
onSave={commit}
placeholder="No note yet."
/>
<p role="status" className="min-h-4 text-xs text-muted-foreground">
{saved}
</p>
</CardContent>
</Card>
)
}import Link from "next/link"
import { ORDER_STATUS_MAP, orderHref, PAYMENT_LABEL } from "@/lib/dashboards/ecommerce/vocabulary"
import { type Order } from "@/lib/sample-data"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, DateCell, NumberCell } from "@/components/ui/table-cells"
/** Every order this account has placed, newest first, each number opening the order's own page. */
export function CustomerOrders({ rows }: { rows: Order[] }) {
return (
<>
<SectionHeader
as="h2"
size="sm"
title="Orders"
description="Everything this account has bought, newest first."
/>
<SimpleTable
size="sm"
rows={rows}
rowKey="id"
caption={`${rows.length} order${rows.length === 1 ? "" : "s"} placed by this account.`}
emptyMessage="This account has not placed an order yet."
columns={[
{
key: "number",
header: "Order",
cell: (order) => (
<Link
href={orderHref(order.id)}
className="rounded-sm font-mono text-xs underline-offset-4 focus-ring hover:underline"
>
{order.number}
</Link>
),
},
{
key: "placedAt",
header: "Placed",
cell: (order) => <DateCell date={order.placedAt} />,
},
{
key: "status",
header: "Status",
cell: (order) => (
<StatusBadge status={order.status} map={ORDER_STATUS_MAP} />
),
},
{
key: "paymentMethod",
header: "Payment",
cell: (order) => <span className="text-muted-foreground">{PAYMENT_LABEL[order.paymentMethod]}</span>,
},
{
key: "items",
header: "Items",
align: "right",
cell: (order) => <NumberCell value={order.items.reduce((sum, item) => sum + item.qty, 0)} />,
},
{
key: "totalCents",
header: "Total",
align: "right",
cell: (order) => <CurrencyCell value={order.totalCents / 100} />,
},
]}
/>
</>
)
}import { formatCurrency, formatDate, formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { DescriptionList } from "@/components/ui/description-list"
import { SectionHeader } from "@/components/ui/section-header"
import { Sparkline } from "@/components/ui/sparkline"
import { StatusBadge } from "@/components/ui/status-badge"
import { STATUS_MAP, type CustomerRecord } from "../data"
/**
* The facts about the account, and what it has billed. Each card's title is a
* real `h2` from `SectionHeader` rather than `CardTitle`, which is a div — the
* page's outline has to survive being read without the styles.
*
* The revenue card only draws a line for an account that bills: a row of
* zeroes across a trial's first week would be a chart of nothing, so it says
* what is actually true instead.
*/
export function CustomerOverview({ record }: { record: CustomerRecord }) {
const { customer, subscription, plan, cards, series, mrr, paid } = record
const card = cards.find((method) => method.default) ?? cards[0]
return (
<div data-widget="widget-ecommerce-customer-customer-overview" className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardContent className="flex flex-col gap-4">
<SectionHeader
as="h2"
size="sm"
title="Account"
description="What this workspace is signed up to."
/>
<DescriptionList
columns={2}
items={[
{
term: "Account ID",
description: <span className="font-mono text-xs">{customer.id}</span>,
},
{
term: "Status",
description: <StatusBadge status={customer.status} map={STATUS_MAP} />,
},
{ term: "Plan", description: plan?.name ?? customer.plan },
{
term: "Seats",
description: <span className="tabular-nums">{formatNumber(customer.seats)}</span>,
},
{ term: "Primary contact", description: customer.name },
{ term: "Country", description: customer.country },
{ term: "Customer since", description: formatDate(customer.createdAt, "medium", { timeZone: "UTC" }) },
{ term: "Last seen", description: formatDate(customer.lastSeenAt, "medium", { timeZone: "UTC" }) },
{
term: "Renews",
description: subscription ? formatDate(subscription.renewsAt, "medium", { timeZone: "UTC" }) : "—",
},
{
term: "Payment method",
description: card ? (
<span className="flex items-center gap-2">
<Badge variant="secondary" className="capitalize">
{card.brand}
</Badge>
<span className="font-mono text-xs">•••• {card.last4}</span>
</span>
) : (
"None on file"
),
},
{
term: "Orders",
description: (
<span className="tabular-nums">{formatNumber(record.orders.length)}</span>
),
},
{
term: "Subscription",
description: subscription ? (
<StatusBadge
status={subscription.status}
map={{ trialing: "info", past_due: "warning", cancelled: "danger" }}
/>
) : (
"—"
),
},
]}
/>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-4">
<SectionHeader
as="h2"
size="sm"
title="Monthly revenue"
description={
series.length > 0
? `MRR over the last ${series.length} month${series.length === 1 ? "" : "s"}.`
: "This account has not billed yet."
}
/>
<p data-slot="customer-mrr" className="text-2xl font-semibold tabular-nums">
{formatCurrency(mrr)}
</p>
{series.length > 0 ? (
<Sparkline data={series} type="area" height={56} />
) : (
<p className="text-sm text-muted-foreground">
{subscription
? `On ${subscription.status === "trialing" ? "trial" : subscription.status} since ${formatDate(subscription.startedAt, "medium", { timeZone: "UTC" })}.`
: "No subscription on file."}
</p>
)}
<p className="text-sm text-muted-foreground">
<span className="tabular-nums text-foreground">{formatCurrency(paid)}</span> collected
across {formatNumber(record.invoices.length)} invoice
{record.invoices.length === 1 ? "" : "s"}.
</p>
</CardContent>
</Card>
</div>
)
}"use client"
import * as React from "react"
import { NavTabs } from "@/components/ui/nav-tabs"
/**
* The record's five sections.
*
* `NavTabs` is a row of links — one page per tab, the URL as the state — and
* that is not what a preview frame can do: a click would leave the frame for a
* route the gallery does not serve. So this uses the item for its look and
* gives it real tab semantics instead: the row is a `tablist`, each item is a
* `tab` that owns its panel through `aria-controls`, and each panel names its
* tab back through `aria-labelledby`. One tab is in the page's tab order at a
* time and the arrow keys move between them, the way `quick-filters.tsx` does.
*
* Every panel is rendered on the server and handed in as children; this only
* decides which one is shown.
*/
export type RecordTab = { id: string; label: string; count?: number }
type RecordTabsContextValue = {
active: string
select: (id: string) => void
tabId: (id: string) => string
panelId: (id: string) => string
}
const TabsContext = React.createContext<RecordTabsContextValue | null>(null)
export function RecordTabsProvider({
tabs,
children,
}: {
tabs: RecordTab[]
children: React.ReactNode
}) {
const [active, setActive] = React.useState(tabs[0].id)
// One prefix for the whole record, so a page with two of these never
// cross-wires a tab to the other one's panel.
const prefix = React.useId()
const value = React.useMemo<RecordTabsContextValue>(
() => ({
active,
select: setActive,
tabId: (id) => `${prefix}-tab-${id}`,
panelId: (id) => `${prefix}-panel-${id}`,
}),
[active, prefix]
)
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
}
function useRecordTabs() {
const context = React.useContext(TabsContext)
if (!context) throw new Error("Record tabs must be used inside a <RecordTabsProvider>.")
return context
}
/** The strip itself, for the page header's `tabs` slot. */
export function RecordTabsStrip({ tabs }: { tabs: RecordTab[] }) {
const { active, select, tabId, panelId } = useRecordTabs()
const buttons = React.useRef(new Map<string, HTMLButtonElement | null>())
// The first tab stands in for the tab stop when `active` matches nothing, so
// the row is always reachable by keyboard.
const tabStop = tabs.some((tab) => tab.id === active) ? active : tabs[0]?.id
function move(from: number, direction: 1 | -1) {
const next = tabs[(((from + direction) % tabs.length) + tabs.length) % tabs.length]
select(next.id)
buttons.current.get(next.id)?.focus()
}
return (
<NavTabs
role="tablist"
aria-label="Customer record sections"
// Five tabs do not fit on a phone; the strip scrolls rather than
// pushing the page sideways.
className="overflow-x-auto"
activeHref={active}
items={tabs.map((tab) => ({ label: tab.label, href: tab.id, count: tab.count }))}
renderLink={(item, children, className) => {
const index = tabs.findIndex((tab) => tab.id === item.href)
const selected = item.href === active
return (
<button
ref={(node) => {
buttons.current.set(item.href, node)
}}
type="button"
role="tab"
id={tabId(item.href)}
aria-selected={selected}
aria-controls={panelId(item.href)}
tabIndex={item.href === tabStop ? 0 : -1}
data-slot="nav-tabs-item"
data-active={selected || undefined}
className={className}
onClick={() => select(item.href)}
onKeyDown={(event) => {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault()
move(index, 1)
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault()
move(index, -1)
}
}}
>
{children}
</button>
)
}}
/>
)
}
/** One section's content. Rendered on the server, revealed by the strip above. */
export function RecordTabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { active, tabId, panelId } = useRecordTabs()
if (active !== id) return null
return (
<section
role="tabpanel"
id={panelId(id)}
aria-labelledby={tabId(id)}
// The panel takes focus after its tab, so a keyboard reader lands in the
// content the arrow keys just revealed rather than skipping past it.
tabIndex={0}
data-slot="record-tab-panel"
data-tab={id}
className="flex flex-col gap-4 outline-none"
>
{children}
</section>
)
}