Subscriptions
Every subscription ever opened, newest first — searchable, filterable, sortable and paged; reads subscriptionRows().
Preview
"use client"
import * as React from "react"
import { ArrowRightIcon, BanIcon, ReceiptTextIcon } 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 { Widget } from "@/components/ui/widget"
import { type SubscriptionRow } from "../data"
// StatusBadge resolves none of these on its own: "trialing" and "past_due" are
// not in its defaults, and "active"/"cancelled" are named here so the whole
// vocabulary reads in one place.
const STATUS_MAP: Record<string, StatusVariant> = {
active: "success",
trialing: "info",
past_due: "warning",
cancelled: "danger",
}
const FACETS: DataTableFacet<SubscriptionRow>[] = [
{
columnId: "status",
title: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Trialing", value: "trialing" },
{ label: "Past due", value: "past_due" },
{ label: "Cancelled", value: "cancelled" },
],
},
{
columnId: "plan",
title: "Plan",
options: ["Free", "Starter", "Team", "Enterprise"].map((plan) => ({
label: plan,
value: plan,
})),
},
]
export function SubscriptionTable({ rows }: { rows: SubscriptionRow[] }) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const columns = React.useMemo<DataTableColumnDef<SubscriptionRow>[]>(
() => [
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
cell: ({ row }) => <TruncateCell maxWidth={220}>{row.original.company}</TruncateCell>,
meta: { label: "Workspace" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => <span className="text-muted-foreground">{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: "mrr",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
// Zero is not "nothing recorded" — it is a free tier, a trial, or an
// account that has stopped paying, which reads as a dash, not as $0.
cell: ({ row }) => (
<span className="block text-right tabular-nums">
{row.original.mrr > 0 ? (
formatCurrency(row.original.mrr, "USD", { maximumFractionDigits: 0 })
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
),
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "startedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Started" />,
cell: ({ row }) => (
<DateCell date={row.original.startedAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Started" },
},
{
accessorKey: "renewsAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Renews" />,
cell: ({ row }) => (
<DateCell date={row.original.renewsAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Renews" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.company}`}
actions={[
{ label: "Open subscription", icon: <ArrowRightIcon />, onSelect: () => {} },
{ label: "View invoices", icon: <ReceiptTextIcon />, onSelect: () => {} },
{
label: "Cancel subscription",
icon: <BanIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
],
[]
)
return (
<Widget titleId={titleId} data-widget="widget-saas-subscriptions-subscription-table" title="Subscriptions" description="Every subscription ever opened, newest first">
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={8}
searchKey="company"
searchPlaceholder="Search workspaces…"
facets={FACETS}
enableRowSelection={false}
getRowId={(subscription) => subscription.id}
initialSorting={[{ id: "startedAt", desc: true }]}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-subscriptions-subscription-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { ArrowRightIcon, BanIcon, ReceiptTextIcon } 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 { Widget } from "@/components/ui/widget"
import { type SubscriptionRow } from "../data"
// StatusBadge resolves none of these on its own: "trialing" and "past_due" are
// not in its defaults, and "active"/"cancelled" are named here so the whole
// vocabulary reads in one place.
const STATUS_MAP: Record<string, StatusVariant> = {
active: "success",
trialing: "info",
past_due: "warning",
cancelled: "danger",
}
const FACETS: DataTableFacet<SubscriptionRow>[] = [
{
columnId: "status",
title: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Trialing", value: "trialing" },
{ label: "Past due", value: "past_due" },
{ label: "Cancelled", value: "cancelled" },
],
},
{
columnId: "plan",
title: "Plan",
options: ["Free", "Starter", "Team", "Enterprise"].map((plan) => ({
label: plan,
value: plan,
})),
},
]
export function SubscriptionTable({ rows }: { rows: SubscriptionRow[] }) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const columns = React.useMemo<DataTableColumnDef<SubscriptionRow>[]>(
() => [
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Workspace" />,
cell: ({ row }) => <TruncateCell maxWidth={220}>{row.original.company}</TruncateCell>,
meta: { label: "Workspace" },
},
{
accessorKey: "plan",
header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
cell: ({ row }) => <span className="text-muted-foreground">{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: "mrr",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
// Zero is not "nothing recorded" — it is a free tier, a trial, or an
// account that has stopped paying, which reads as a dash, not as $0.
cell: ({ row }) => (
<span className="block text-right tabular-nums">
{row.original.mrr > 0 ? (
formatCurrency(row.original.mrr, "USD", { maximumFractionDigits: 0 })
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
),
meta: { align: "right", label: "MRR" },
},
{
accessorKey: "startedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Started" />,
cell: ({ row }) => (
<DateCell date={row.original.startedAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Started" },
},
{
accessorKey: "renewsAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Renews" />,
cell: ({ row }) => (
<DateCell date={row.original.renewsAt} className="text-muted-foreground" />
),
meta: { align: "right", label: "Renews" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.company}`}
actions={[
{ label: "Open subscription", icon: <ArrowRightIcon />, onSelect: () => {} },
{ label: "View invoices", icon: <ReceiptTextIcon />, onSelect: () => {} },
{
label: "Cancel subscription",
icon: <BanIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => {},
},
]}
/>
),
},
],
[]
)
return (
<Widget titleId={titleId} data-widget="widget-saas-subscriptions-subscription-table" title="Subscriptions" description="Every subscription ever opened, newest first">
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={8}
searchKey="company"
searchPlaceholder="Search workspaces…"
facets={FACETS}
enableRowSelection={false}
getRowId={(subscription) => subscription.id}
initialSorting={[{ id: "startedAt", desc: true }]}
/>
</Widget>
)
}/**
* What /subscriptions reads. The book is `db.subscriptions`, one per account,
* joined to `db.customers` for the workspace behind it and `db.plans` for the
* tier it sits on. Movements are read off the rows' own dates — a subscription
* that started inside the window is new, one that cancelled inside it is churn
* — so opening plus new minus churn closes on the book exactly. Nothing here
* reads a clock: every window ends at REFERENCE_DATE.
*/
import { getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
type Member,
type Subscription,
} from "@/lib/sample-data"
// The plans are reference rows nothing writes. The subscriptions and the
// accounts are read per call, never held at module scope: a cancellation or a
// new account since the server started is in the next book.
const PLANS = new Map(db.plans.all().map((plan) => [plan.id, plan]))
const subscriptions = (): Subscription[] => db.subscriptions.all()
const accountsById = () => new Map(db.customers.all().map((customer) => [customer.id, customer]))
// What a tier charges a seat a month, read off the accounts paying it today.
// A churned account's own `mrrCents` is zeroed the moment it churns, so the
// rate has to come from an account still on that tier — otherwise every
// cancellation would be worth nothing and churn would read as zero.
function seatPrices(): Map<string, number> {
const prices = new Map<string, number>()
for (const customer of db.customers.all()) {
if (customer.mrrCents > 0 && !prices.has(customer.plan)) {
prices.set(customer.plan, customer.mrrCents / customer.seats)
}
}
return prices
}
/** What a subscription bills a month, in cents. A trial bills nothing. */
function billedCents(subscription: Subscription, prices: Map<string, number>): number {
if (subscription.status === "trialing") return 0
const tier = (PLANS.get(subscription.planId)?.name ?? "").toLowerCase()
return subscription.seats * (prices.get(tier) ?? 0)
}
const DAY_MS = 86_400_000
const dollars = (cents: number): number => cents / 100
/** 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))
}
/** Whether a subscription was on the book at an instant. */
function live(subscription: Subscription, at: Date): boolean {
if (subscription.startedAt > at) return false
return !subscription.cancelledAt || subscription.cancelledAt > at
}
/** The book at an instant, in dollars. */
function bookAt(at: Date): number {
const prices = seatPrices()
return dollars(
subscriptions()
.filter((subscription) => live(subscription, at))
.reduce((total, subscription) => total + billedCents(subscription, prices), 0)
)
}
/** How many months of movement the waterfall covers. */
export const WINDOW_MONTHS = 12
const OPENED_AT = monthStart(WINDOW_MONTHS)
/**
* How the book got from one close to the next. Two movements, not the usual
* four: an account moving up or down a tier leaves no trace in the rows, and
* an invented expansion would need an invented contraction to cancel it — so
* opening plus new minus churn closes on the book to the cent.
*/
export function mrrMovements(): { label: string; value: number; isTotal?: boolean }[] {
let won = 0
let lost = 0
const prices = seatPrices()
for (const subscription of subscriptions()) {
if (subscription.startedAt > OPENED_AT) won += billedCents(subscription, prices)
const { cancelledAt } = subscription
if (cancelledAt && cancelledAt > OPENED_AT) lost += billedCents(subscription, prices)
}
return [
{ label: "Opening", value: Math.round(bookAt(OPENED_AT)) },
{ label: "New", value: Math.round(dollars(won)) },
{ label: "Churn", value: -Math.round(dollars(lost)) },
{ label: "Closing", value: 0, isTotal: true },
]
}
const liveNow = () => subscriptions().filter((subscription) => live(subscription, REFERENCE_DATE))
const trialing = () => liveNow().filter((subscription) => subscription.status === "trialing")
export type SubscriptionStat = {
key: string
label: string
value: number
description: string
format: "currency" | "number"
}
/** The four headline numbers: what the book is worth, and who is on it. */
export function subscriptionStats(): SubscriptionStat[] {
const prices = seatPrices()
const book = subscriptions()
const onBook = book.filter((subscription) => live(subscription, REFERENCE_DATE))
const billing = onBook.filter((subscription) => billedCents(subscription, prices) > 0)
const closing = bookAt(REFERENCE_DATE)
return [
{
key: "mrr",
label: "MRR",
value: Math.round(closing),
description: `across ${billing.length} paying subscriptions`,
format: "currency",
},
{
key: "live",
label: "Live subscriptions",
value: onBook.length,
description: `of ${book.length} ever opened`,
format: "number",
},
{
key: "trials",
label: "In trial",
value: onBook.filter((subscription) => subscription.status === "trialing").length,
description: "not billing yet",
format: "number",
},
{
key: "arpa",
label: "Average account",
value: billing.length > 0 ? Math.round(closing / billing.length) : 0,
description: "a month, across the accounts that pay",
format: "currency",
},
]
}
/** How the live book splits across the four tiers, largest first. */
export function planMix(): { name: string; value: number }[] {
const counts = new Map<string, number>()
for (const subscription of liveNow()) {
const name = PLANS.get(subscription.planId)?.name ?? "Unknown"
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts]
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
}
/** How many subscriptions are on the book right now, for the middle of the donut. */
export function liveTotal(): number {
return liveNow().length
}
/** How many monthly cohorts the heatmap shows, and how far each is followed. */
export const COHORTS = 12
const COHORT_LABEL = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "2-digit",
timeZone: "UTC",
})
/** `months` months after `from`, on the same day of the month. */
function monthsAfter(from: Date, months: number): Date {
return new Date(
Date.UTC(
from.getUTCFullYear(),
from.getUTCMonth() + months,
from.getUTCDate(),
from.getUTCHours(),
from.getUTCMinutes()
)
)
}
export type Cohorts = { rows: string[]; columns: string[]; values: number[][] }
/**
* Retention by signup cohort. A cohort is the accounts that opened in one
* month; a cell is the share of them still on the book that many months later.
* A month a cohort has not lived through yet is left empty rather than counted
* as a loss — the grid draws those as "no data".
*/
export function churnCohorts(): Cohorts {
const starts = Array.from({ length: COHORTS }, (_, index) => monthStart(COHORTS - index))
const book = subscriptions()
return {
rows: starts.map((start) => COHORT_LABEL.format(start)),
columns: Array.from({ length: COHORTS }, (_, month) => `M${month}`),
values: starts.map((start, index) => {
const end = monthStart(COHORTS - index - 1)
const cohort = book.filter(
(subscription) => subscription.startedAt >= start && subscription.startedAt < end
)
return Array.from({ length: COHORTS }, (_, month) => {
// Read at the close of the month, not its start: a cohort's own month
// opens before its accounts exist, and "M0" is how many of them made it
// to the end of the month they arrived in.
const at = monthsAfter(start, month + 1)
// The future has no retention to report.
if (at > REFERENCE_DATE || cohort.length === 0) return Number.NaN
const kept = cohort.filter((subscription) => live(subscription, at)).length
return (kept / cohort.length) * 100
})
}),
}
}
// The trial the data itself describes: every account in trial was opened
// somewhere in the last three weeks, so three weeks is the window it is on.
const TRIAL_DAYS = 21
export type Trial = {
id: string
company: string
contact: string
contactAvatar?: string
plan: string
seats: number
endsAt: Date
daysLeft: number
}
/** The trials closest to running out, soonest first. */
export function trialsExpiring(): Trial[] {
const accounts = accountsById()
return trialing()
.map((subscription) => {
const account = accounts.get(subscription.customerId)
const endsAt = new Date(subscription.startedAt.getTime() + TRIAL_DAYS * DAY_MS)
return {
id: subscription.id,
company: account?.company ?? subscription.customerId,
contact: account?.name ?? "Unknown",
contactAvatar: account?.avatarUrl,
plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
seats: subscription.seats,
endsAt,
daysLeft: Math.max(0, Math.ceil((endsAt.getTime() - REFERENCE_DATE.getTime()) / DAY_MS)),
}
})
.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())
.slice(0, 6)
}
export type SubscriptionRow = {
id: string
company: string
plan: string
status: Subscription["status"]
seats: number
mrr: number
startedAt: Date
renewsAt: Date
}
/** Every subscription ever opened, newest first. */
export function subscriptionRows(): SubscriptionRow[] {
const accounts = accountsById()
return subscriptions()
.map((subscription) => ({
id: subscription.id,
company: accounts.get(subscription.customerId)?.company ?? subscription.customerId,
plan: PLANS.get(subscription.planId)?.name ?? "Unknown",
status: subscription.status,
seats: subscription.seats,
mrr: dollars(subscription.mrrCents),
startedAt: subscription.startedAt,
renewsAt: subscription.renewsAt,
}))
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
}
/** 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",
year: "numeric",
timeZone: "UTC",
})
/** The window the movements cover. */
export function lastUpdated(): string {
return `${WINDOW_MONTHS} months to ${MONTH_NAME.format(REFERENCE_DATE)}`
}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 Subscriptions dashboard page