Blocksblock
Billing
Current plan, usage, invoices, and plan changes.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/billingnpm dependencies
- lucide-react@^1.51.0
Installs these registry items
Usage
"use client"
import { Billing } from "@/components/billing"
import { toast } from "@/components/ui/toast"
import { DEMO_NOW, plans } from "@/registry/particular/examples/demo-data"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const day = 24 * 60 * 60 * 1000
export function Demo() {
return (
<div className="mx-auto w-full max-w-5xl p-6">
<Billing
currentPlan={{ name: "Pro", amount: 2500, interval: "monthly", renewsAt: new Date(DEMO_NOW + 21 * day) }}
paymentMethod={{ brand: "Visa", last4: "4242" }}
usage={[
{ label: "Compute", value: 142, limit: 300, unit: "CU-hrs" },
{ label: "Bandwidth", value: 860, limit: 1000, unit: "GB" },
{ label: "Storage", value: 9.6, limit: 10, unit: "GB" },
]}
invoices={[
{ id: "INV-2026-0010", date: new Date(DEMO_NOW - 9 * day), amount: 2500, status: "paid", href: "#" },
{ id: "INV-2026-0009", date: new Date(DEMO_NOW - 40 * day), amount: 3175, status: "paid", href: "#" },
{ id: "INV-2026-0008", date: new Date(DEMO_NOW - 70 * day), amount: 2500, status: "open", href: "#" },
]}
plans={plans.map((plan) => ({ ...plan, current: plan.id === "pro" }))}
onChangePlan={async (planId, interval) => {
await wait(900)
toast.add({ title: `Switched to ${planId}`, description: `Billed ${interval}`, type: "success" })
}}
onManagePayment={async () => {
await wait(600)
toast.add({ title: "Opening the billing portalโฆ" })
}}
/>
</div>
)
}Source
"use client"
import * as React from "react"
import { CreditCardIcon, DownloadIcon } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PricingTable, type BillingInterval, type PricingPlan } from "@/components/ui/pricing-table"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UsageMeter } from "@/components/ui/usage-meter"
export type Invoice = {
id: string
date: Date | string
/** Amount in the smallest currency unit (cents). */
amount: number
status: "paid" | "open" | "void" | "uncollectible"
href?: string
}
export type BillingProps = {
currentPlan: { name: string; amount: number; interval: BillingInterval; renewsAt: Date | string }
paymentMethod?: { brand: string; last4: string }
usage: { label: string; value: number; limit: number; unit?: string }[]
invoices: Invoice[]
plans: PricingPlan[]
currency?: string
onChangePlan: (planId: string, interval: BillingInterval) => Promise<void>
onManagePayment: () => Promise<void>
}
const invoiceBadge = {
paid: "success",
open: "warning",
void: "secondary",
uncollectible: "destructive",
} as const
function messageOf(error: unknown) {
return error instanceof Error ? error.message : "Something went wrong. Try again."
}
function Billing({
currentPlan,
paymentMethod,
usage,
invoices,
plans,
currency = "USD",
onChangePlan,
onManagePayment,
}: BillingProps) {
const [pendingPlan, setPendingPlan] = React.useState<string | undefined>()
const [managing, setManaging] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const money = new Intl.NumberFormat("en-US", { style: "currency", currency })
const date = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
const changePlan = async (planId: string, interval: BillingInterval) => {
setPendingPlan(planId)
setError(null)
try {
await onChangePlan(planId, interval)
} catch (caught) {
setError(messageOf(caught))
} finally {
setPendingPlan(undefined)
}
}
const managePayment = async () => {
setManaging(true)
setError(null)
try {
await onManagePayment()
} catch (caught) {
setError(messageOf(caught))
} finally {
setManaging(false)
}
}
return (
<div className="flex flex-col gap-6">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{currentPlan.name} plan</CardTitle>
<CardDescription>
{money.format(currentPlan.amount / 100)} per {currentPlan.interval === "yearly" ? "year" : "month"}, renews{" "}
{date.format(new Date(currentPlan.renewsAt))}
</CardDescription>
<CardAction>
<Badge variant="brand">Active</Badge>
</CardAction>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<CreditCardIcon className="size-4" aria-hidden />
{paymentMethod ? `${paymentMethod.brand} ending in ${paymentMethod.last4}` : "No payment method"}
</span>
<Button variant="outline" size="sm" disabled={managing} onClick={() => void managePayment()}>
{managing && <Spinner data-icon="inline-start" />}
{paymentMethod ? "Update" : "Add payment method"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Usage this period</CardTitle>
<CardDescription>Resets on {date.format(new Date(currentPlan.renewsAt))}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{usage.map((meter) => (
<UsageMeter key={meter.label} {...meter} />
))}
</CardContent>
</Card>
</div>
<section aria-labelledby="billing-plans" className="flex flex-col gap-4">
<h2 id="billing-plans" className="font-heading text-base font-semibold">
Plans
</h2>
<PricingTable
plans={plans}
currency={currency}
defaultInterval={currentPlan.interval}
pendingPlanId={pendingPlan}
onSelect={(planId, interval) => void changePlan(planId, interval)}
/>
</section>
<section aria-labelledby="billing-invoices" className="flex flex-col gap-4">
<h2 id="billing-invoices" className="font-heading text-base font-semibold">
Invoices
</h2>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="w-0">
<span className="sr-only">Download</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-20 text-center text-muted-foreground">
No invoices yet.
</TableCell>
</TableRow>
) : (
invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>{date.format(new Date(invoice.date))}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{invoice.id}</TableCell>
<TableCell>
<Badge variant={invoiceBadge[invoice.status]} className="capitalize">
{invoice.status}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{money.format(invoice.amount / 100)}</TableCell>
<TableCell>
{invoice.href && (
<Button variant="ghost" size="icon-sm" aria-label={`Download ${invoice.id}`} nativeButton={false} render={<a href={invoice.href} />}>
<DownloadIcon />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</section>
</div>
)
}
export { Billing }