Particular.Tech
Blocksblock

Billing

Current plan, usage, invoices, and plan changes.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/billing
npm dependencies
  • lucide-react@^1.51.0

Usage

billing-demo.tsx
"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

blocks/billing/billing.tsx
"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 }