/billing/plansPlan picker
A pricing page for the workspace you are in: the tier it is billed on with its seats filled, every other tier priced monthly or yearly, and a dialog that confirms the change.
The page is a server component inside AppShell, with nav.ts describing the navigation as plain data. It reads db.plans for the tiers and this workspace's db.subscriptions row for the tier the organisation is on and its renewal, and counts filled seats across the workspaces the organisation still runs — stated by one helper /people/directory renders too, so neither page can drift from the other — no price, saving or seat count is written in the block, and the yearly saving is the ratio between the two prices each plan already carries. actions.ts holds the mutation, a 'use server' file whose every export is an async function: upgradePlan is a server action over db.subscriptions returning Result, handed to the picker as a prop so the page itself stays on the server. The cycle switch and the confirmation dialog are the only client state; a failed change is announced in an alert, a successful one replaces the form with a confirmation whose heading takes focus, and nothing navigates on its own. Composes AppShell, PageHeader, Widget, UsageMeter, DescriptionList, Badge, Card, Button, FormSection, SegmentedControl, Dialog and AsyncButton.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut, upgradePlan } from "./actions"
import { CurrentPlan } from "./components/current-plan"
import { PlanPicker } from "./components/plan-picker"
import {
bestYearlySaving,
currentPlan,
currentUser,
daysUntilRenewal,
organisation,
planSummary,
plans,
renewsAt,
seatsSummary,
shellNotifications,
yearlySaving,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The price list. The page is a server component inside the shell: it reads the
* tiers and this workspace's subscription through `db`, and hands the picker
* the rows plus the server action that moves between them — the cycle switch
* and the confirmation dialog are the only client state.
*/
export default function BillingPlansPage() {
const tiers = plans()
const plan = currentPlan()
const org = organisation()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Plans"
description="What this organisation is billed on, and what every other tier would cost."
meta={`${seatsSummary()} · ${planSummary()} · dates in UTC`}
/>
<CurrentPlan
organisation={seatsSummary()}
organisationName={org.name}
planName={plan.name}
priceMonthlyCents={plan.priceMonthlyCents}
seatsIncluded={plan.seatsIncluded}
seatsInUse={org.seatsFilled}
renewsAt={renewsAt()}
daysUntilRenewal={daysUntilRenewal()}
/>
<PlanPicker
plans={tiers}
currentPlanId={plan.id}
renewsAt={renewsAt()}
savings={Object.fromEntries(tiers.map((tier) => [tier.id, yearlySaving(tier)]))}
bestSaving={bestYearlySaving()}
onUpgrade={upgradePlan}
/>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/billing-plansNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut, upgradePlan } from "./actions"
import { CurrentPlan } from "./components/current-plan"
import { PlanPicker } from "./components/plan-picker"
import {
bestYearlySaving,
currentPlan,
currentUser,
daysUntilRenewal,
organisation,
planSummary,
plans,
renewsAt,
seatsSummary,
shellNotifications,
yearlySaving,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The price list. The page is a server component inside the shell: it reads the
* tiers and this workspace's subscription through `db`, and hands the picker
* the rows plus the server action that moves between them — the cycle switch
* and the confirmation dialog are the only client state.
*/
export default function BillingPlansPage() {
const tiers = plans()
const plan = currentPlan()
const org = organisation()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Plans"
description="What this organisation is billed on, and what every other tier would cost."
meta={`${seatsSummary()} · ${planSummary()} · dates in UTC`}
/>
<CurrentPlan
organisation={seatsSummary()}
organisationName={org.name}
planName={plan.name}
priceMonthlyCents={plan.priceMonthlyCents}
seatsIncluded={plan.seatsIncluded}
seatsInUse={org.seatsFilled}
renewsAt={renewsAt()}
daysUntilRenewal={daysUntilRenewal()}
/>
<PlanPicker
plans={tiers}
currentPlanId={plan.id}
renewsAt={renewsAt()}
savings={Object.fromEntries(tiers.map((tier) => [tier.id, yearlySaving(tier)]))}
bestSaving={bestYearlySaving()}
onUpgrade={upgradePlan}
/>
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/billing/plans"
/**
* This product's navigation, as plain data. AppShell resolves the icon names,
* and the page hands it `ROUTE` as `activeHref` — so which item is current is
* settled by the route the page is installed at, not by the URL it happens to
* be framed under. Nothing here is a component, and nothing here says "I am the
* page you are on".
*
* Billing and Team are disclosures rather than links: an item with `items` is
* a button, so the leaf below it is what carries `aria-current`. `/billing` is
* not a page of its own, so it has no leaf for itself; `/people/directory` is, so it does.
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Analytics", href: "/analytics", icon: "chart-line" },
{ title: "Reports", href: "/reports", icon: "file-text", badge: 4 },
],
},
{
label: "Account",
items: [
{
title: "Billing",
href: "/billing",
icon: "credit-card",
items: [
{ title: "Plans", href: "/billing/plans" },
{ title: "Invoices", href: "/billing/invoices" },
{ title: "Usage", href: "/billing/usage" },
],
},
{
title: "Team",
href: "/people/directory",
icon: "users",
items: [
{ title: "Members", href: "/people/directory" },
{ title: "Roles", href: "/people/roles" },
{ title: "Invitations", href: "/people/invitations" },
],
},
],
},
],
// Pinned under the groups, the way the secondary links were.
footer: [
{ title: "Settings", href: "/settings", icon: "settings" },
{ title: "Support", href: "/support", icon: "life-buoy" },
],
}/**
* What this page reads, and the one thing it changes.
*
* The organisation behind these pages is Northwind, and `db.customers` holds
* one account row per workspace it runs — the rows sharing the Northwind name
* are its own, and the ones it has not closed are the ones it still runs. The
* workspace on show is Northwind Analytics, the name `settings-general` states; its
* `db.subscriptions` row says which tier the organisation is on and when the
* term ends, and `db.plans` is the price list. Seats are counted across the
* whole organisation, in the same words `/people/directory` uses. No price, saving or
* seat count is written here: every one of them is read off those rows, and
* "now" is `REFERENCE_DATE`.
*
* `upgradePlan` is the mutation, a server action returning `Result` like every
* other. Only the page and its server-rendered parts import this file, so the
* action stays on the server; the client islands are handed rows and the action
* itself as props.
*/
import { getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
type Member,
type Plan,
type Subscription,
} from "@/lib/sample-data"
export type { Plan }
const DAY_MS = 86_400_000
// Every read is per call, never held at module scope: a plan changed or a
// workspace closed since the server started is what the next render shows.
/** The workspace on show: one account row, which everything else hangs off. */
function accountRow() {
const customers = db.customers.all()
return customers.find((customer) => customer.company === "Northwind Analytics") ?? customers[0]
}
/** The workspaces the organisation still runs: a closed account fills no seats. */
const liveWorkspaces = () =>
db.customers
.all()
.filter((customer) => customer.company.startsWith("Northwind ") && customer.status !== "churned")
/** The organisation, as both `/billing` and `/people/directory` state it, counted per call. */
export function organisation(): { name: string; workspaces: number; seatsFilled: number } {
const live = liveWorkspaces()
return {
name: "Northwind",
workspaces: live.length,
seatsFilled: live.reduce((total, customer) => total + customer.seats, 0),
}
}
/**
* The seats sentence, written once. `/billing` and `/people/directory` both render it, so
* neither can drift from the other about how many seats are filled.
*/
export function seatsSummary(): string {
const { seatsFilled, workspaces } = organisation()
return `${seatsFilled} seats filled across the organisation's ${workspaces} workspaces`
}
/** The other half of it: what the tier the organisation is on includes. */
export function planSummary(): string {
const plan = currentPlan()
return `${plan.seatsIncluded} included in ${plan.name}`
}
/** The billing relationship behind this workspace, resolved per call. */
export function subscriptionRow(): Subscription {
return (
db.subscriptions.all().find((row) => row.customerId === accountRow().id) ?? db.subscriptions.all()[0]
)
}
/** Every tier, cheapest first — the order a price list reads in. */
export function plans(): Plan[] {
return db.plans.all().sort((a, b) => a.priceMonthlyCents - b.priceMonthlyCents)
}
/** The tier this workspace is on today. */
export function currentPlan(): Plan {
const { planId } = subscriptionRow()
return db.plans.all().find((plan) => plan.id === planId) ?? plans()[0]
}
/** When the current term ends and the next one is charged. */
export function renewsAt(): Date {
return subscriptionRow().renewsAt
}
/** Days left in the term, measured from REFERENCE_DATE rather than a clock. */
export function daysUntilRenewal(): number {
return Math.max(0, Math.round((renewsAt().getTime() - REFERENCE_DATE.getTime()) / DAY_MS))
}
/** What a year saves against twelve months of the same tier, as a 0..1 ratio. */
export function yearlySaving(plan: Plan): number {
if (plan.priceMonthlyCents === 0) return 0
return 1 - plan.priceYearlyCents / (plan.priceMonthlyCents * 12)
}
/** The best saving any tier offers — what the yearly segment advertises. */
export function bestYearlySaving(): number {
return Math.max(...plans().map(yearlySaving))
}
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 }))
}"use server"
/**
* Everything this page changes. A `"use server"` file rather than a directive
* inside each function: an inline one is only legal in a module the bundler
* knows is server-only, and `data.ts` is imported for its types by components
* that are not. Every export here is an async function returning `Result`, which
* is what the directive requires and what every caller reads.
*/
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"
import { subscriptionRow } from "./data"
/**
* Move this workspace onto another tier. The repository owns the row, so the
* change is visible to every reader in the process — the same seam a real
* billing provider slots into.
*/
export async function upgradePlan(
planId: string
): Promise<Result<{ planId: string; name: string }>> {
const plan = db.plans.all().find((row) => row.id === planId)
if (!plan) {
return {
ok: false,
error: { code: "unknown_plan", message: "That plan is no longer offered.", field: "planId" },
}
}
const subscription = subscriptionRow()
if (subscription.planId === planId) {
return {
ok: false,
error: {
code: "already_on_plan",
message: `This workspace is already on ${plan.name}.`,
field: "planId",
},
}
}
const saved = await db.subscriptions.update(subscription.id, { planId })
if (!saved.ok) return saved
return { ok: true, data: { planId, name: plan.name } }
}
/** Signing out is the shell's one action, and a server action for the same reason. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import { formatCurrency } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { DescriptionList } from "@/components/ui/description-list"
import { UsageMeter } from "@/components/ui/usage-meter"
import { Widget } from "@/components/ui/widget"
// Dates on this page are instants, and a subscription renews at 05:35 UTC:
// formatted in the reader's own zone that reads as the day before for anyone
// west of UTC. The page's meta line says the dates are UTC; this is what makes
// that true.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/** Nothing on this panel holds state, so it stays a plain function component. */
export function CurrentPlan({
organisation,
organisationName,
planName,
priceMonthlyCents,
seatsIncluded,
seatsInUse,
renewsAt,
daysUntilRenewal,
}: {
/** The seats sentence, written once in data.ts and shared with `/people/directory`. */
organisation: string
organisationName: string
planName: string
priceMonthlyCents: number
seatsIncluded: number
seatsInUse: number
renewsAt: Date
daysUntilRenewal: number
}) {
const over = seatsInUse - seatsIncluded
return (
<Widget
title="Current plan"
description={`${organisationName} · ${organisation}`}
actions={<Badge variant="secondary">{planName}</Badge>}
>
<div className="grid gap-4 md:grid-cols-2">
<UsageMeter
label="Seats filled"
used={seatsInUse}
limit={seatsIncluded}
unit="seats"
tone="auto"
resetsAt={
over > 0
? `${over} over the ${seatsIncluded} the plan includes, across the organisation.`
: `${seatsIncluded - seatsInUse} still free on this plan, across the organisation.`
}
/>
<DescriptionList
size="sm"
items={[
{
term: "Rate",
description: `${formatCurrency(priceMonthlyCents / 100, "USD", { maximumFractionDigits: 0 })} a month`,
},
{
term: "Renews",
description: `${DAY.format(renewsAt)} · in ${daysUntilRenewal} days`,
},
]}
/>
</div>
</Widget>
)
}"use client"
import { CheckIcon } from "lucide-react"
import { formatCurrency, formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import type { Plan } from "../data"
export type BillingCycle = "monthly" | "yearly"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** One tier in the price list, priced for whichever cycle the reader picked. */
export function PlanCard({
plan,
cycle,
current,
saving,
onChoose,
}: {
plan: Plan
cycle: BillingCycle
current: boolean
/** What a year saves against twelve months of this tier, as a 0..1 ratio. */
saving: number
onChoose: () => void
}) {
const yearly = cycle === "yearly"
const priceCents = yearly ? plan.priceYearlyCents : plan.priceMonthlyCents
const free = plan.priceMonthlyCents === 0
return (
<li className="flex">
<Card
data-current={current || undefined}
className="w-full gap-3 data-current:bg-brand-muted"
>
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardAction>
{current ? (
<Badge variant="default">Current plan</Badge>
) : plan.popular ? (
<Badge variant="outline">Most popular</Badge>
) : null}
</CardAction>
</CardHeader>
{/* flex-1 so the footers of four cards of different heights line up. */}
<CardContent className="flex flex-1 flex-col gap-3">
<p className="flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tabular-nums">{money(priceCents)}</span>
<span className="text-xs text-muted-foreground">
{free ? "always" : yearly ? "per year" : "per month"}
</span>
</p>
<p className="text-xs text-muted-foreground">
{free
? `${plan.seatsIncluded} seats, no card needed`
: yearly
? `${money(Math.round(plan.priceYearlyCents / 12))} a month, billed yearly — saves ${formatPercent(saving, { maximumFractionDigits: 0 })}`
: `${money(plan.priceYearlyCents)} a year saves ${formatPercent(saving, { maximumFractionDigits: 0 })}`}
</p>
<ul className="flex flex-col gap-1.5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<CheckIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-success" />
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="border-t pt-3">
{current ? (
<p className="text-xs text-muted-foreground">
This is the plan {plan.name === "Free" ? "you are on" : "this workspace is billed on"}.
</p>
) : (
<Button variant={plan.popular ? "default" : "outline"} size="sm" onClick={onChoose}>
Switch to {plan.name}
</Button>
)}
</CardFooter>
</Card>
</li>
)
}"use client"
import * as React from "react"
import { formatPercent } from "@/lib/format"
import { FormSection } from "@/components/ui/form-section"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Plan } from "../data"
import { PlanCard, type BillingCycle } from "./plan-card"
import { UpgradeDialog, type PlanChange } from "./upgrade-dialog"
/**
* The price list and the one control that changes it. The cycle and which tier
* is current are the only state here: the tiers themselves, and the action that
* moves between them, come from the page.
*/
export function PlanPicker({
plans,
currentPlanId,
renewsAt,
savings,
bestSaving,
onUpgrade,
}: {
plans: Plan[]
currentPlanId: string
renewsAt: Date
/** What a year saves against twelve months, per plan id. */
savings: Record<string, number>
bestSaving: number
onUpgrade: (planId: string) => Promise<PlanChange>
}) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const [planId, setPlanId] = React.useState(currentPlanId)
const [pending, setPending] = React.useState<Plan | null>(null)
const current = plans.find((plan) => plan.id === planId) ?? plans[0]
return (
<FormSection
title="Choose a plan"
description="Every tier is billed per workspace, and a change takes effect on the next invoice."
actions={
<SegmentedControl
size="sm"
aria-label="Billing cycle"
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: "Monthly" },
{
value: "yearly",
label: `Yearly · save up to ${formatPercent(bestSaving, { maximumFractionDigits: 0 })}`,
},
]}
/>
}
>
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
cycle={cycle}
current={plan.id === planId}
saving={savings[plan.id] ?? 0}
onChoose={() => setPending(plan)}
/>
))}
</ul>
{pending ? (
<UpgradeDialog
plan={pending}
currentPlan={current}
cycle={cycle}
renewsAt={renewsAt}
open
onOpenChange={(open) => {
if (!open) setPending(null)
}}
onConfirm={onUpgrade}
onChanged={setPlanId}
/>
) : null}
</FormSection>
)
}"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import type { Plan } from "../data"
import type { BillingCycle } from "./plan-card"
export type PlanChange =
| { ok: true; data: { planId: string; name: string } }
| { ok: false; error: { code: string; message: string; field?: string } }
// Dates on this page are instants, and a subscription renews at 05:35 UTC:
// formatted in the reader's own zone that reads as the day before for anyone
// west of UTC. The page's meta line says the dates are UTC; this is what makes
// that true.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* Confirms a change of tier and reports what came back. A failed change is
* said out loud in an alert rather than swallowed, and a successful one
* replaces the form with a confirmation whose heading takes focus — nothing
* navigates on its own.
*/
export function UpgradeDialog({
plan,
currentPlan,
cycle,
renewsAt,
open,
onOpenChange,
onConfirm,
onChanged,
}: {
plan: Plan
currentPlan: Plan
cycle: BillingCycle
renewsAt: Date
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (planId: string) => Promise<PlanChange>
onChanged: (planId: string) => void
}) {
// Mounted by the picker only while a tier is pending, so closing the dialog
// unmounts it and this state is re-armed by construction.
const [error, setError] = React.useState<string | null>(null)
const [done, setDone] = React.useState<string | null>(null)
const headingRef = React.useRef<HTMLHeadingElement>(null)
// The form is gone once the change lands; the heading that replaced it is
// where a reader — and a screen reader — should be.
React.useEffect(() => {
if (done) headingRef.current?.focus()
}, [done])
const yearly = cycle === "yearly"
const price = yearly ? plan.priceYearlyCents : plan.priceMonthlyCents
const was = yearly ? currentPlan.priceYearlyCents : currentPlan.priceMonthlyCents
const cadence = yearly ? "a year" : "a month"
const difference = price - was
async function confirm() {
setError(null)
const result = await onConfirm(plan.id)
if (!result.ok) {
setError(result.error.message)
return
}
setDone(result.data.name)
onChanged(result.data.planId)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle ref={headingRef} tabIndex={-1} className="outline-none">
{done ? `${done} is now this workspace's plan` : `Switch to ${plan.name}?`}
</DialogTitle>
<DialogDescription>
{done
? `The change is in force now. The next invoice, on ${DAY.format(renewsAt)}, is charged at the new rate.`
: difference === 0
? `${plan.name} costs the same as ${currentPlan.name}.`
: `${money(price)} ${cadence}, ${difference > 0 ? "up" : "down"} ${money(Math.abs(difference))} from ${currentPlan.name}. The change takes effect on ${DAY.format(renewsAt)}.`}
</DialogDescription>
</DialogHeader>
{done ? null : (
<div className="flex flex-col gap-2 text-sm">
<p className="text-muted-foreground">
{plan.seatsIncluded} seats are included, and every feature below comes with the tier.
</p>
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
</p>
) : null}
</div>
)}
<DialogFooter>
{done ? (
<Button onClick={() => onOpenChange(false)}>Done</Button>
) : (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<AsyncButton onClick={confirm}>Confirm change</AsyncButton>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}