Directory
Every workspace, newest first — searchable, filterable by plan and status, sortable and paged; reads directory().
Preview
"use client"
import * as React from "react"
import { ArrowRightIcon, MailIcon, UserRoundXIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { DateCell, NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { type Customer } from "../data"
/** The three account states StatusBadge has no default variant for. */
const STATUS_MAP: Record<string, StatusVariant> = {
trial: "info",
churned: "danger",
suspended: "warning",
}
const STATUS_FACET: DataTableFacet<Customer> = {
columnId: "status",
title: "Status",
options: ["active", "trial", "churned", "suspended"].map((status) => ({
label: status.charAt(0).toUpperCase() + status.slice(1),
value: status,
})),
}
export type CustomerDirectoryProps = {
rows: Customer[]
/** Each plan as the pricing page writes it, in the pricing page's order — looked up on the server. */
planNames: Record<Customer["plan"], string>
}
export function CustomerDirectory({ rows, planNames }: CustomerDirectoryProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const facets = React.useMemo<DataTableFacet<Customer>[]>(
() => [
{
columnId: "plan",
title: "Plan",
options: Object.entries(planNames).map(([value, label]) => ({ label, value })),
},
STATUS_FACET,
],
[planNames]
)
const columns = React.useMemo<DataTableColumnDef<Customer>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Person" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />
),
meta: { label: "Person" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
cell: ({ row }) => <TruncateCell maxWidth={200}>{row.original.company}</TruncateCell>,
meta: { label: "Workspace" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{planNames[row.original.plan] ?? row.original.plan}</span>
),
meta: { label: "Plan" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "seats",
header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />,
cell: ({ row }) => <NumberCell value={row.original.seats} />,
meta: { align: "right", label: "Seats" },
},
{
accessorKey: "mrrCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
// Zero is not "no revenue recorded" — it is a free, trial or churned
// account, which reads better as a dash than as $0.00.
cell: ({ row }) => (
<span className="block text-right tabular-nums">
{row.original.mrrCents > 0 ? (
formatCurrency(row.original.mrrCents / 100, "USD", { maximumFractionDigits: 0 })
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
),
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Joined" />,
cell: ({ row }) => (
<DateCell date={row.original.createdAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Joined" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Open workspace", icon: <ArrowRightIcon />, onSelect: () => {} },
{ label: "Email the owner", icon: <MailIcon />, onSelect: () => {} },
{
label: "Suspend account",
icon: <UserRoundXIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
],
[planNames]
)
return (
<Widget titleId={titleId} data-widget="widget-saas-customers-customer-directory" title="Directory" description="Every workspace, newest first">
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={8}
searchKey="company"
searchPlaceholder="Search workspaces…"
facets={facets}
enableRowSelection={false}
getRowId={(customer) => customer.id}
initialSorting={[{ id: "createdAt", desc: true }]}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-customers-customer-directoryNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { ArrowRightIcon, MailIcon, UserRoundXIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { DateCell, NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { type Customer } from "../data"
/** The three account states StatusBadge has no default variant for. */
const STATUS_MAP: Record<string, StatusVariant> = {
trial: "info",
churned: "danger",
suspended: "warning",
}
const STATUS_FACET: DataTableFacet<Customer> = {
columnId: "status",
title: "Status",
options: ["active", "trial", "churned", "suspended"].map((status) => ({
label: status.charAt(0).toUpperCase() + status.slice(1),
value: status,
})),
}
export type CustomerDirectoryProps = {
rows: Customer[]
/** Each plan as the pricing page writes it, in the pricing page's order — looked up on the server. */
planNames: Record<Customer["plan"], string>
}
export function CustomerDirectory({ rows, planNames }: CustomerDirectoryProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const facets = React.useMemo<DataTableFacet<Customer>[]>(
() => [
{
columnId: "plan",
title: "Plan",
options: Object.entries(planNames).map(([value, label]) => ({ label, value })),
},
STATUS_FACET,
],
[planNames]
)
const columns = React.useMemo<DataTableColumnDef<Customer>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Person" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />
),
meta: { label: "Person" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
cell: ({ row }) => <TruncateCell maxWidth={200}>{row.original.company}</TruncateCell>,
meta: { label: "Workspace" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{planNames[row.original.plan] ?? row.original.plan}</span>
),
meta: { label: "Plan" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "seats",
header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />,
cell: ({ row }) => <NumberCell value={row.original.seats} />,
meta: { align: "right", label: "Seats" },
},
{
accessorKey: "mrrCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
// Zero is not "no revenue recorded" — it is a free, trial or churned
// account, which reads better as a dash than as $0.00.
cell: ({ row }) => (
<span className="block text-right tabular-nums">
{row.original.mrrCents > 0 ? (
formatCurrency(row.original.mrrCents / 100, "USD", { maximumFractionDigits: 0 })
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
),
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Joined" />,
cell: ({ row }) => (
<DateCell date={row.original.createdAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Joined" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Open workspace", icon: <ArrowRightIcon />, onSelect: () => {} },
{ label: "Email the owner", icon: <MailIcon />, onSelect: () => {} },
{
label: "Suspend account",
icon: <UserRoundXIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
],
[planNames]
)
return (
<Widget titleId={titleId} data-widget="widget-saas-customers-customer-directory" title="Directory" description="Every workspace, newest first">
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={8}
searchKey="company"
searchPlaceholder="Search workspaces…"
facets={facets}
enableRowSelection={false}
getRowId={(customer) => customer.id}
initialSorting={[{ id: "createdAt", desc: true }]}
/>
</Widget>
)
}/**
* What /customers/overview reads. The accounts are `db.customers`, the spend
* that brought them in is `db.campaigns`, and the plans they sit on are
* `db.plans`. Two things the rows do not carry are rules over them: a
* campaign's conversion target, set from the clicks it bought, and next
* month's projection, run on the trailing daily rate. "Now" is REFERENCE_DATE,
* so the month in progress is always the same one.
*/
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Campaign, type Customer, type Member } from "@/lib/sample-data"
export type { Customer }
// Read per call, never held at module scope: an account or a campaign written
// since the server started is what the next render counts.
const allCustomers = () => db.customers.all()
const allCampaigns = () => db.campaigns.all()
const DAY_MS = 86_400_000
/** Midnight UTC on the first of the month `k` months before the current one. */
function monthStart(k: number): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}
const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)
const PRIOR_MONTH = monthStart(2)
const createdIn = (from: Date, to: Date): number =>
allCustomers().filter((row) => row.createdAt >= from && row.createdAt < to).length
const paying = (row: Customer): boolean => row.mrrCents > 0
export type CustomerStat = {
key: string
label: string
value: number
delta?: number
description: string
format: "number" | "percent"
positiveIsGood: boolean
}
/** The four headline numbers: how many, how many new, how many pay, how many left. */
export function customerStats(): CustomerStat[] {
const customers = allCustomers()
const live = customers.filter((row) => row.status === "active" || row.status === "trial").length
const fresh = createdIn(LAST_MONTH, THIS_MONTH)
const before = createdIn(PRIOR_MONTH, LAST_MONTH)
const churned = customers.filter((row) => row.status === "churned").length
return [
{
key: "live",
label: "Live workspaces",
value: live,
description: `of ${customers.length} ever created`,
format: "number",
positiveIsGood: true,
},
{
key: "new",
label: "New last month",
value: fresh,
delta: before > 0 ? fresh / before - 1 : 0,
description: "vs the month before",
format: "number",
positiveIsGood: true,
},
{
key: "paying",
label: "Paying",
value: customers.filter(paying).length,
description: "on a plan that bills",
format: "number",
positiveIsGood: true,
},
{
key: "churned",
label: "Churned",
value: churned / customers.length,
description: "of every account ever opened",
format: "percent",
positiveIsGood: false,
},
]
}
const isLive = (row: Customer): boolean => row.status === "active" || row.status === "trial"
/**
* Every workspace ever opened, down to the ones on a big tier. Each step is a
* subset of the one above it, so the funnel narrows for a reason rather than
* comparing two unrelated counts — which is also why the clicks and leads that
* bought these accounts are a line of copy beside it rather than a first step:
* half a million clicks against 250 workspaces draws four invisible bars.
*/
export function acquisitionFunnel(): { label: string; value: number }[] {
const customers = allCustomers()
const live = customers.filter(isLive)
return [
{ label: "Workspaces created", value: customers.length },
{ label: "Still live", value: live.length },
{ label: "On a paid plan", value: live.filter((row) => row.plan !== "free").length },
// Not "Paying": that is the stat card above, which counts every account
// that bills anything. This step is narrower — a live account on a paid
// plan that is being invoiced now, which a trial on that plan is not.
{ label: "Billing now", value: live.filter(paying).length },
{
label: "Team or Enterprise",
value: live.filter((row) => paying(row) && (row.plan === "team" || row.plan === "enterprise"))
.length,
},
]
}
/** What the campaigns delivered, for the line under the funnel. */
export function acquisitionSpend(): { clicks: number; leads: number } {
const campaigns = allCampaigns()
return {
clicks: campaigns.reduce((total, campaign) => total + campaign.clicks, 0),
leads: campaigns.reduce((total, campaign) => total + campaign.conversions, 0),
}
}
/** How many complete months of signups the chart shows before the one in progress. */
export const GROWTH_MONTHS = 12
// The window the run rate is measured over: long enough to smooth a slow week,
// short enough that a projection still describes now.
const RATE_DAYS = 90
export type GrowthPoint = {
month: string
created: number
/** Only the month in progress carries one; every other month is done. */
projected: number | null
}
/**
* Signups by month, ending on the month in progress. That last bar is only as
* long as the month has run, so it gets a second, hatched bar beside it: where
* it lands if the trailing daily rate holds to the end of the month.
*/
export function growthByMonth(): GrowthPoint[] {
const rate = createdIn(new Date(REFERENCE_DATE.getTime() - RATE_DAYS * DAY_MS), REFERENCE_DATE) / RATE_DAYS
// Day 0 of the next month is the last day of this one.
const monthEnd = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0))
const daysLeft = Math.max(0, Math.round((monthEnd.getTime() - REFERENCE_DATE.getTime()) / DAY_MS))
return Array.from({ length: GROWTH_MONTHS + 1 }, (_, index) => {
const from = monthStart(GROWTH_MONTHS - index)
const to = monthStart(GROWTH_MONTHS - index - 1)
const created = createdIn(from, index === GROWTH_MONTHS ? REFERENCE_DATE : to)
return {
month: from.toISOString().slice(0, 7),
created,
projected: index === GROWTH_MONTHS ? created + Math.round(rate * daysLeft) : null,
}
})
}
// The plans as a reader sees them on the pricing page, keyed by the value a
// customer row carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))
/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
return PLAN_NAMES.get(plan) ?? plan
}
/** The plans a row can carry, in the order the pricing page lists them. */
const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]
/** Every plan as the pricing page writes it, for the directory to look up — it is an island, and never reads the store. */
export function planNames(): Record<Customer["plan"], string> {
return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}
/** Every account, newest first — the directory the table pages through. */
export function directory(): Customer[] {
const customers = allCustomers()
return [...customers].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
export type CampaignGoal = {
id: string
name: string
channel: Campaign["channel"]
conversions: number
/** What the clicks it bought should have converted, rounded to a round number. */
goal: number
status: Campaign["status"]
deadline: string
}
const ENDED = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/**
* The five campaigns that bought the most clicks, each against its own target:
* a twentieth of those clicks, rounded to the nearest 25, which is how a
* marketing target gets written down. Ranked by clicks rather than by
* conversions, so the list is not just the ones that already beat it.
*/
export function campaignGoals(): CampaignGoal[] {
const campaigns = allCampaigns()
return [...campaigns]
.sort((a, b) => b.clicks - a.clicks)
.slice(0, 5)
.map((campaign) => ({
id: campaign.id,
name: campaign.name,
channel: campaign.channel,
conversions: campaign.conversions,
goal: Math.max(25, Math.round((campaign.clicks * 0.05) / 25) * 25),
status: campaign.status,
deadline: campaign.endedAt
? `Ended ${ENDED.format(campaign.endedAt)}`
: `Running since ${ENDED.format(campaign.startedAt)}`,
}))
}
/** 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 }))
}
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 }
}
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
/** Which month the page is reading, and which one is still running. */
export function lastUpdated(): string {
return `Closed through ${MONTH_NAME.format(LAST_MONTH)} · ${MONTH_NAME.format(THIS_MONTH)} still running`
}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 Customers dashboard page