Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-ecommerce-customer-customer-overview

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

Source

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]/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 }))
}

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