Account overview
What the account is signed up to — status, plan, seats, contact, renewal and card on file — beside its monthly revenue line and what it has paid; reads customerRecord(id).
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-customer-customer-overviewNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}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 Customer record page