Skip to contentVibraUI
Part of the E-commerce dashboardinstalls at /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.

Open the live page

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

Install

npx shadcn@latest add @vibra/ecommerce-customer

Needs the @vibra registry in your components.json — set it up once.

Source

app/ecommerce/customers/[id]/page.tsx
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>
  )
}
app/ecommerce/customers/[id]/data.ts
/**
 * 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 }))
}
app/ecommerce/customers/[id]/actions.ts
"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 } }
}
app/ecommerce/customers/[id]/components/customer-activity.tsx
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,
        }))}
      />
    </>
  )
}
app/ecommerce/customers/[id]/components/customer-invoices.tsx
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} />,
          },
        ]}
      />
    </>
  )
}
app/ecommerce/customers/[id]/components/customer-notes.tsx
"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>
  )
}
app/ecommerce/customers/[id]/components/customer-orders.tsx
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} />,
          },
        ]}
      />
    </>
  )
}
app/ecommerce/customers/[id]/components/customer-overview.tsx
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>
  )
}
app/ecommerce/customers/[id]/components/record-tabs.tsx
"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>
  )
}