Pricing
Plans side by side: what each costs, what it includes and which one to pick.
Three plans with a period control
pricing-01Starter, Team and Business side by side, priced monthly or yearly at the reader's choice — the yearly price with its total and its saving — the middle plan tinted and labelled as the recommended one.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Plans } from "./pricing-01.client"
import { BILLING, EYEBROW, FINE_PRINT, HEADLINE, LEDE, PLANS, SALES_LINK } from "./pricing-01.data"
/**
* Three plans side by side under one claim, and the control that prices them
* monthly or yearly. The middle plan is the recommended one: its frame wears
* the brand tint and its strip the label — a fill and a word, never a heavier
* stroke. Each plan says who it is for, its price for the chosen period (a
* year's total and its saving when yearly), the way in and what it adds to
* the plan before it.
*
* One column on a phone and a tablet, three from `lg`. A server component;
* the period control and the plans it prices are the one island, and every
* word and price is in `./pricing-01.data`.
*/
export function Pricing01() {
const titleId = React.useId()
return (
<section
data-section="pricing-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10">
<Plans plans={PLANS} billing={BILLING} />
</div>
<p className="mt-8 flex flex-wrap items-center justify-center gap-x-1 gap-y-1 text-center text-sm text-muted-foreground">
{FINE_PRINT}
<Link href={SALES_LINK.href} className={cn(buttonVariants({ variant: "link" }), "h-auto px-0")}>
{SALES_LINK.label}
</Link>
</p>
</div>
</section>
)
}
export default Pricing01"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Billing, Plan } from "./pricing-01.data"
export type BillingCycle = "monthly" | "yearly"
/** Whole dollars: "$1,188", never "$1,188.00". */
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
/** Fills `{name}` placeholders in a line of copy with the figures. */
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** What a year saves against twelve monthly bills: in dollars, and as a share of them. */
function yearlySaving(plan: Pick<Plan, "monthly" | "yearly">) {
const amount = (plan.monthly - plan.yearly) * 12
return { amount, share: plan.monthly > 0 ? amount / (plan.monthly * 12) : 0 }
}
/** One plan: its name, who it is for, the price for the chosen period and what it includes. */
function PlanCard({ plan, cycle, billing }: { plan: Plan; cycle: BillingCycle; billing: Billing }) {
const nameId = React.useId()
const yearly = cycle === "yearly"
const saving = yearlySaving(plan)
return (
// A subgrid of the list's rows: every plan's strip takes the height of the
// tallest, so the three prices stand on one line whatever the copy above.
<Card
data-recommended={plan.recommended || undefined}
className={cn("row-span-2 grid grid-rows-subgrid", plan.recommended && "bg-brand-muted")}
>
<CardHeader className="content-start">
<CardTitle>
<h3 id={nameId} className="text-base">
{plan.name}
</h3>
</CardTitle>
<CardDescription className="text-pretty">{plan.audience}</CardDescription>
{plan.recommended ? (
<CardAction>
<Badge>{billing.recommended}</Badge>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5">
<div>
<p className="flex items-baseline gap-1">
<span data-slot="plan-price" className="type-numeral text-4xl">
{dollars(yearly ? plan.yearly : plan.monthly)}
</span>
<span className="text-sm text-muted-foreground">
<span aria-hidden="true">/</span>
<span className="sr-only">per </span>
{billing.period}
</span>
</p>
<p data-slot="plan-billing" className="mt-2 flex min-h-5 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground tabular-nums">
{yearly ? (
<>
<span>{fill(billing.billedYearly, { total: dollars(plan.yearly * 12) })}</span>
<Badge className="bg-success-muted text-success">{fill(billing.saving, { amount: dollars(saving.amount) })}</Badge>
</>
) : (
billing.billedMonthly
)}
</p>
</div>
{/* Three plans, three "Start free trial" links: each is described by
its plan's name, so a list of links still says which is which. */}
<Link
href={plan.cta.href}
aria-describedby={nameId}
className={cn(buttonVariants({ variant: plan.recommended ? "default" : "outline", size: "lg" }), "h-10 w-full")}
>
{plan.cta.label}
</Link>
<div className="border-t border-border pt-5">
<p className="text-sm font-medium">{plan.includesLead}</p>
<ul className="mt-3 flex flex-col gap-2.5 text-sm">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{feature}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
)
}
/**
* The period control and the plans it prices. The period is the only state:
* monthly shows each plan's monthly price, yearly its price a month when
* billed yearly, with the year's total and what it saves. The control is the
* kit's SegmentedControl — one radio group, moved with the arrow keys — and
* its yearly option carries the best saving on offer. Every plan and word
* arrives as plain props from the section.
*/
export function Plans({ plans, billing }: { plans: readonly Plan[]; billing: Billing }) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const best = Math.max(0, ...plans.map((plan) => yearlySaving(plan).share))
return (
<div className="flex flex-col items-center">
<SegmentedControl
aria-label={billing.label}
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: billing.monthly },
{
value: "yearly",
label: (
<>
{billing.yearly}
<span className="ml-1.5 rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success tabular-nums">
{fill(billing.yearlyNote, { percent: formatPercent(best, { maximumFractionDigits: 0 }) })}
</span>
</>
),
},
]}
/>
<ul className="mt-10 grid w-full grid-cols-1 gap-4 md:mt-12 lg:grid-cols-3">
{plans.map((plan) => (
<li key={plan.id} className="row-span-2 grid min-w-0 grid-rows-subgrid">
<PlanCard plan={plan} cycle={cycle} billing={billing} />
</li>
))}
</ul>
</div>
)
}/**
* Every word and price `pricing-01` sets. The plans are Northwind's own —
* the analytics workspace the kit's dashboards are — priced per workspace in
* whole US dollars, each with its monthly price and its price per month when
* billed yearly. The saving is counted from those two by the section, so it
* can never disagree with the prices beside it. Replace the plans and the
* section sells yours.
*/
export type PlanLink = { label: string; href: string }
export type Plan = {
/** Stable, so a test or a link can name a plan. */
id: string
name: string
/** Who the plan is for, in one line. */
audience: string
/** Whole dollars a month, billed monthly. */
monthly: number
/** Whole dollars a month, billed yearly. */
yearly: number
/** The line above the features: "Everything in Starter, plus". */
includesLead: string
features: readonly string[]
cta: PlanLink
/** The plan the section recommends; it wears the tint and the label. */
recommended?: boolean
}
export const EYEBROW = "Pricing"
export const HEADLINE = "Pick the plan your team will review its numbers in"
export const LEDE =
"Every plan includes unlimited dashboards, the full list of connectors to choose from and a 14-day trial of Business. Prices are per workspace, never per row."
/**
* The words around the prices: the period control's name and options, how a
* price is billed, what a year saves and the recommended plan's label.
* `{percent}`, `{total}` and `{amount}` are filled in with the figures.
*/
export const BILLING = {
label: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
yearlyNote: "Save up to {percent}",
period: "month",
billedMonthly: "Billed monthly",
billedYearly: "{total} billed yearly",
saving: "Save {amount} a year",
recommended: "Most popular",
}
export type Billing = typeof BILLING
export const PLANS: readonly Plan[] = [
{
id: "starter",
name: "Starter",
audience: "For a founder or a small team putting its numbers in one place.",
monthly: 39,
yearly: 32,
includesLead: "Includes",
features: ["Up to 5 members", "3 data sources", "Refreshed daily", "12 months of history", "Scheduled email reports"],
cta: { label: "Start free trial", href: "/sign-up?plan=starter" },
},
{
id: "team",
name: "Team",
audience: "For teams that review revenue, retention and pipeline every week.",
monthly: 119,
yearly: 99,
includesLead: "Everything in Starter, plus",
features: ["Up to 25 members", "10 data sources", "Refreshed hourly", "Unlimited history", "Alerts and forecasts", "Questions in plain language"],
cta: { label: "Start free trial", href: "/sign-up?plan=team" },
recommended: true,
},
{
id: "business",
name: "Business",
audience: "For companies where finance signs off on every figure.",
monthly: 399,
yearly: 329,
includesLead: "Everything in Team, plus",
features: ["Unlimited members", "Unlimited sources", "Refreshed every 5 minutes", "SSO, SCIM and an audit log", "Permissions down to the row", "Priority support, 4-hour response"],
cta: { label: "Start free trial", href: "/sign-up?plan=business" },
},
]
export const FINE_PRINT = "Prices in US dollars, before sales tax. Paying by invoice or need more than one workspace?"
export const SALES_LINK: PlanLink = { label: "Talk to sales", href: "/contact" }npx shadcn@latest add @vibra/pricing-01Two plans and an Enterprise band
pricing-02Team and Business priced monthly or yearly — yearly with the monthly price struck beside it — and under them Enterprise as a full-width band with its terms and the way to ask for them.
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Plans } from "./pricing-02.client"
import { BILLING, ENTERPRISE, EYEBROW, HEADLINE, LEDE, PLANS } from "./pricing-02.data"
/**
* Two self-serve plans side by side, and under them the one that is agreed
* rather than listed: Enterprise as a full-width band with what it adds and
* the way to ask for it. The two plans are priced monthly or yearly at the
* reader's choice — yearly with the monthly price struck beside the new one —
* and the recommended plan wears the brand tint and its label.
*
* The plans sit two up from `lg` with their features in two columns; the band
* sets its terms in three columns beside the way in. A server component; the
* period control and the plans are the one island, and every word and price
* is in `./pricing-02.data`.
*/
export function Pricing02() {
const titleId = React.useId()
const enterpriseId = React.useId()
return (
<section
data-section="pricing-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid gap-x-16 gap-y-6 lg:grid-cols-12 lg:items-end">
<div className="lg:col-span-7">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
</div>
<p className="text-base/7 text-pretty text-muted-foreground lg:col-span-5">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<Plans plans={PLANS} billing={BILLING} />
</div>
<Card className="mt-4">
<CardHeader>
<CardTitle>
<h3 id={enterpriseId} className="text-base">
{ENTERPRISE.name}
</h3>
</CardTitle>
<CardDescription className="text-pretty">{ENTERPRISE.audience}</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-12">
<ul className="grid gap-x-6 gap-y-2.5 text-sm sm:grid-cols-2 xl:grid-cols-3">
{ENTERPRISE.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{feature}
</li>
))}
</ul>
<div className="flex flex-col gap-2 lg:items-end">
<Link
href={ENTERPRISE.cta.href}
aria-describedby={enterpriseId}
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}
>
{ENTERPRISE.cta.label}
</Link>
<p className="text-xs text-muted-foreground">{ENTERPRISE.note}</p>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default Pricing02"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Billing, Plan } from "./pricing-02.data"
export type BillingCycle = "monthly" | "yearly"
/** Whole dollars: "$1,200", never "$1,200.00". */
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** One plan: the price for the period on the left of its sheet, the way in on the right, then all it includes. */
function PlanCard({ plan, cycle, billing }: { plan: Plan; cycle: BillingCycle; billing: Billing }) {
const nameId = React.useId()
const yearly = cycle === "yearly"
return (
<Card
data-recommended={plan.recommended ? true : undefined}
className={cn("row-span-2 grid grid-rows-subgrid", plan.recommended && "bg-brand-muted")}
>
<CardHeader className="content-start">
<CardTitle>
<h3 id={nameId} className="text-base">
{plan.name}
</h3>
</CardTitle>
<CardDescription className="text-pretty">{plan.audience}</CardDescription>
{plan.recommended ? (
<CardAction>
<Badge>{plan.recommended}</Badge>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="flex flex-wrap items-baseline gap-x-2">
<span data-slot="plan-price" className="type-numeral text-4xl">
{dollars(yearly ? plan.yearly : plan.monthly)}
</span>
{/* Yearly, the monthly price stands struck beside it: the
saving shown as the two prices, and read as "was". */}
{yearly ? (
<s data-slot="plan-compare" className="text-lg text-muted-foreground tabular-nums">
<span className="sr-only">{billing.was} </span>
{dollars(plan.monthly)}
</s>
) : null}
<span className="text-sm text-muted-foreground">
<span aria-hidden="true">/</span>
<span className="sr-only">per </span>
{billing.period}
</span>
</p>
<p data-slot="plan-billing" className="mt-1.5 text-sm text-muted-foreground tabular-nums">
{yearly ? fill(billing.billedYearly, { total: dollars(plan.yearly * 12) }) : billing.billedMonthly}
</p>
</div>
<Link
href={plan.cta.href}
aria-describedby={nameId}
className={cn(buttonVariants({ variant: plan.recommended ? "default" : "outline", size: "lg" }), "h-10 px-5 max-sm:w-full")}
>
{plan.cta.label}
</Link>
</div>
<ul className="grid gap-x-6 gap-y-2.5 border-t border-border pt-5 text-sm sm:grid-cols-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{feature}
</li>
))}
</ul>
</CardContent>
</Card>
)
}
/**
* The period control and the two plans it prices. Yearly shows each plan's
* price a month billed yearly with the monthly price struck beside it, and
* the year's total under it. The control is the kit's SegmentedControl; every
* plan and word arrives as plain props from the section.
*/
export function Plans({ plans, billing }: { plans: readonly Plan[]; billing: Billing }) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<p className="text-sm text-muted-foreground">{billing.unit}</p>
<SegmentedControl
aria-label={billing.label}
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: billing.monthly },
{
value: "yearly",
label: (
<>
{billing.yearly}
<span className="ms-1.5 rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success">
{billing.yearlyNote}
</span>
</>
),
},
]}
/>
</div>
<ul className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
{plans.map((plan) => (
<li key={plan.id} className="row-span-2 grid min-w-0 grid-rows-subgrid">
<PlanCard plan={plan} cycle={cycle} billing={billing} />
</li>
))}
</ul>
</div>
)
}/**
* Every word and price `pricing-02` sets: Northwind's two self-serve plans,
* each with its monthly price and its price a month billed yearly in whole US
* dollars, and the Enterprise terms that are agreed rather than listed.
* Replace the plans and the band and the section sells yours.
*/
export type PlanLink = { label: string; href: string }
export type Plan = {
id: string
name: string
audience: string
/** Whole dollars a month, billed monthly. */
monthly: number
/** Whole dollars a month, billed yearly. */
yearly: number
features: readonly string[]
cta: PlanLink
/** The label on the recommended plan; only one plan carries one. */
recommended?: string
}
export const EYEBROW = "Pricing"
export const HEADLINE = "Two plans, and a conversation when you need more"
export const LEDE =
"Start on Team and move to Business when finance needs sign-off, single sign-on or the audit log. Enterprise adds what a procurement team asks for."
/**
* The words around the prices. `{total}` is filled in with a year's total;
* `was` is read before a struck-through monthly price.
*/
export const BILLING = {
label: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
yearlyNote: "2 months free",
period: "month",
billedMonthly: "Billed monthly",
billedYearly: "Billed {total} yearly",
was: "was",
unit: "Per workspace, in US dollars",
}
export type Billing = typeof BILLING
export const PLANS: readonly Plan[] = [
{
id: "team",
name: "Team",
audience: "For teams that review the numbers every week.",
monthly: 120,
yearly: 100,
features: [
"Up to 25 members",
"10 data sources",
"Refreshed hourly",
"Unlimited history",
"Alerts and forecasts",
"Questions in plain language",
"Scheduled board packs",
"Email support, next-day response",
],
cta: { label: "Start free trial", href: "/sign-up?plan=team" },
},
{
id: "business",
name: "Business",
audience: "For companies where finance signs off on every figure.",
monthly: 390,
yearly: 325,
features: [
"Unlimited members",
"Unlimited sources",
"Refreshed every 5 minutes",
"SSO and SCIM",
"Permissions down to the row",
"Audit log, kept for a year",
"API and SQL access",
"Priority support, 4-hour response",
],
cta: { label: "Start free trial", href: "/sign-up?plan=business" },
recommended: "Best for finance teams",
},
]
export const ENTERPRISE = {
name: "Enterprise",
audience: "For companies with a security review, a procurement process and teams in more than one region.",
features: [
"Data residency in the US or the EU",
"99.9% uptime, written into the contract",
"SAML, SCIM and custom roles",
"A security review and a signed DPA",
"A named success manager",
"Invoices, purchase orders and annual terms",
],
cta: { label: "Talk to sales", href: "/contact?plan=enterprise" } satisfies PlanLink,
note: "Most rollouts take four weeks.",
}npx shadcn@latest add @vibra/pricing-02Plans compared line by line
pricing-03Four plans summarised with their price for the chosen period and their way in, then every feature compared across them in groups — data, analysis, sharing, security and support.
import * as React from "react"
import { PlanComparison } from "./pricing-03.client"
import { BILLING, EYEBROW, GROUPS, HEADLINE, LEDE, PLANS, ROWS } from "./pricing-03.data"
/**
* The pricing page for a reader who reads every line: the period control,
* the four plans summarised with their price for it and their way in, and
* under them every feature compared across the plans in groups — data,
* analysis, sharing, security and support. The table is the kit's
* ComparisonTable: a real table whose checks and dashes are read as words,
* the recommended plan's column tinted and marked, the feature names pinned
* while the plans scroll past them on a phone.
*
* The summaries sit two up on a phone and four across from `lg`. A server
* component; the control, the summaries and the table are the one island,
* and every word, price and value is in `./pricing-03.data`.
*/
export function Pricing03() {
const titleId = React.useId()
return (
<section
data-section="pricing-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10">
<PlanComparison plans={PLANS} rows={ROWS} groups={GROUPS} billing={BILLING} />
</div>
</div>
</section>
)
}
export default Pricing03"use client"
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { formatCurrency, formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ComparisonTable, type ComparisonGroup, type ComparisonRow } from "@/components/ui/comparison-table"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Billing, Plan } from "./pricing-03.data"
export type BillingCycle = "monthly" | "yearly"
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** What a year saves against twelve monthly bills, in dollars and as a share of them; nothing for a plan with no list price. */
function yearlySaving(plan: Pick<Plan, "monthly" | "yearly">) {
if (plan.monthly === null || plan.yearly === null || plan.monthly === 0) return { amount: 0, share: 0 }
return { amount: (plan.monthly - plan.yearly) * 12, share: (plan.monthly - plan.yearly) / plan.monthly }
}
/** A plan's price for the period, or null when it is priced with the customer. */
function priceFor(plan: Plan, cycle: BillingCycle): number | null {
return cycle === "yearly" ? plan.yearly : plan.monthly
}
/** The summary above the table: each plan's price for the period and its way in. */
function PlanSummary({ plan, cycle, billing }: { plan: Plan; cycle: BillingCycle; billing: Billing }) {
const nameId = React.useId()
const price = priceFor(plan, cycle)
return (
// A subgrid of the list's rows: where a narrow card drops the label under
// the title, its neighbours' strips grow with it and the prices stay level.
<Card
data-recommended={plan.recommended || undefined}
className={cn("row-span-2 grid grid-rows-subgrid", plan.recommended && "bg-brand-muted")}
>
<CardHeader>
<CardTitle>
<h3 id={nameId} className="text-sm">
{plan.name}
</h3>
</CardTitle>
{plan.recommended ? (
<CardAction>
<Badge>{billing.recommended}</Badge>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div>
<p className="flex items-baseline gap-1">
<span data-slot="plan-price" className="type-numeral text-3xl">
{price === null ? billing.custom : dollars(price)}
</span>
{price === null ? null : (
<span className="text-sm text-muted-foreground">
<span aria-hidden="true">/</span>
<span className="sr-only">per </span>
{billing.period}
</span>
)}
</p>
<p data-slot="plan-billing" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground tabular-nums">
{price === null ? (
billing.customNote
) : cycle === "yearly" ? (
<>
<span>{fill(billing.billedYearly, { total: dollars(price * 12) })}</span>
<Badge className="bg-success-muted text-success">
{fill(billing.saving, { amount: dollars(yearlySaving(plan).amount) })}
</Badge>
</>
) : (
billing.billedMonthly
)}
</p>
</div>
<Link
href={plan.cta.href}
aria-describedby={nameId}
className={cn(buttonVariants({ variant: plan.recommended ? "default" : "outline" }), "mt-auto w-full")}
>
{plan.cta.label}
</Link>
</CardContent>
</Card>
)
}
/**
* The period control, a summary of each plan priced for it, and the full
* comparison under them — the kit's ComparisonTable, one feature to a row
* with every answer read out in words, the recommended plan's column tinted
* and marked. The table's column captions carry the prices too, so a reader
* deep in the table still sees what each column costs. Every plan, row and
* word arrives as plain props from the section.
*/
export function PlanComparison({
plans,
rows,
groups,
billing,
}: {
plans: readonly Plan[]
rows: readonly ComparisonRow[]
groups: readonly ComparisonGroup[]
billing: Billing
}) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const best = Math.max(0, ...plans.map((plan) => yearlySaving(plan).share))
const columns = plans.map((plan) => {
const price = priceFor(plan, cycle)
return {
id: plan.id,
label: plan.name,
caption: price === null ? billing.custom : fill(billing.caption, { price: dollars(price) }),
highlighted: plan.recommended,
}
})
return (
<div className="flex flex-col items-center">
<SegmentedControl
aria-label={billing.label}
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: billing.monthly },
{
value: "yearly",
label: (
<>
{billing.yearly}
<span className="ml-1.5 rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success tabular-nums">
{fill(billing.yearlyNote, { percent: formatPercent(best, { maximumFractionDigits: 0 }) })}
</span>
</>
),
},
]}
/>
{/* From `lg` the summaries stand in the table's own five columns — the
terms over the feature names, each plan over its column — because
the table is laid out fixed, a fifth of the width to a column. */}
<div className="mt-10 grid w-full grid-cols-2 gap-3 md:mt-12 lg:grid-cols-5 lg:gap-4">
<p className="col-span-2 text-sm text-pretty text-muted-foreground lg:col-span-1 lg:self-end lg:pb-1">{billing.terms}</p>
<ul className="col-span-2 grid grid-cols-subgrid gap-y-3 lg:col-span-4">
{plans.map((plan) => (
<li key={plan.id} className="row-span-2 grid min-w-0 grid-rows-subgrid">
<PlanSummary plan={plan} cycle={cycle} billing={billing} />
</li>
))}
</ul>
</div>
<div className="mt-6 w-full md:mt-8">
<ComparisonTable
aria-label={billing.table}
columns={columns}
rows={[...rows]}
groups={[...groups]}
className="lg:table-fixed"
/>
</div>
</div>
)
}/**
* Every word and price `pricing-03` sets: Northwind's four plans, priced in
* whole US dollars a month (monthly, and billed yearly), and the comparison
* under them — each line one feature across the four plans, grouped the way
* a buyer checks them. A value is `true` or `false` for a yes or a no, a
* written amount, or `null` where the line does not apply. Replace the plans
* and the lines and the table compares yours.
*/
import type { ComparisonGroup, ComparisonRow } from "@/components/ui/comparison-table"
export type PlanLink = { label: string; href: string }
export type Plan = {
id: "starter" | "team" | "business" | "enterprise"
name: string
/** Whole dollars a month, billed monthly; null for a plan priced with the customer. */
monthly: number | null
/** Whole dollars a month, billed yearly; null for a plan priced with the customer. */
yearly: number | null
cta: PlanLink
recommended?: boolean
}
export const EYEBROW = "Compare plans"
export const HEADLINE = "Every plan, line by line"
export const LEDE =
"What each plan includes, grouped the way a buyer checks it: data, analysis, sharing, then security and support."
/** The words around the prices. `{total}`, `{amount}`, `{percent}` and `{price}` are filled in with the figures. */
export const BILLING = {
label: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
yearlyNote: "Save up to {percent}",
period: "month",
billedMonthly: "Billed monthly",
billedYearly: "{total} billed yearly",
saving: "Save {amount} a year",
/** A column's caption in the table: `{price}` is its price for the period. */
caption: "{price} a month",
custom: "Custom",
customNote: "Priced with your team",
recommended: "Most popular",
/** The terms that hold for every plan, set over the feature names. */
terms: "Prices per workspace, in US dollars. Every plan starts with a 14-day trial of Business and can move up or down at any time.",
/** Names the table for assistive technology. */
table: "Plans compared, feature by feature",
}
export type Billing = typeof BILLING
export const PLANS: readonly Plan[] = [
{ id: "starter", name: "Starter", monthly: 39, yearly: 32, cta: { label: "Start free trial", href: "/sign-up?plan=starter" } },
{ id: "team", name: "Team", monthly: 119, yearly: 99, cta: { label: "Start free trial", href: "/sign-up?plan=team" }, recommended: true },
{ id: "business", name: "Business", monthly: 399, yearly: 329, cta: { label: "Start free trial", href: "/sign-up?plan=business" } },
{ id: "enterprise", name: "Enterprise", monthly: null, yearly: null, cta: { label: "Talk to sales", href: "/contact?plan=enterprise" } },
]
/** One cell's answer: a yes or a no, a written amount, or null where the line does not apply. */
type Value = ComparisonRow["values"][string]
/** A line of the table, its values in the plans' order: Starter, Team, Business, Enterprise. */
function row(id: string, label: string, values: [Value, Value, Value, Value], help?: string): ComparisonRow {
const [starter, team, business, enterprise] = values
return { id, label, ...(help ? { help } : {}), values: { starter, team, business, enterprise } }
}
export const ROWS: readonly ComparisonRow[] = [
row("members", "Members", ["5", "25", "Unlimited", "Unlimited"]),
row("sources", "Data sources", ["3", "10", "Unlimited", "Unlimited"]),
row("refresh", "Refresh", ["Daily", "Hourly", "Every 5 minutes", "Every minute"]),
row("history", "History", ["12 months", "Unlimited", "Unlimited", "Unlimited"]),
row("definitions", "Versioned metric definitions", [false, true, true, true], "Each with an owner who approves changes"),
row("questions", "Questions in plain language", [false, true, true, true]),
row("forecasts", "Forecasts", [false, true, true, true], "From pipeline and renewals, rebuilt nightly"),
row("alerts", "Alerts", [false, true, true, true]),
row("dashboards", "Dashboards", ["Unlimited", "Unlimited", "Unlimited", "Unlimited"]),
row("packs", "Scheduled board packs", [false, true, true, true]),
row("api", "API and SQL access", [false, false, true, true]),
row("embeds", "Embedded dashboards", [false, false, true, true], "Signed links that expire"),
row("sso", "SSO and SCIM", [false, false, true, true]),
row("permissions", "Permissions down to the row", [false, false, true, true]),
row("audit", "Audit log", [false, false, "1 year", "As long as you need"]),
row("residency", "Data residency", ["US", "US", "US or EU", "US or EU"]),
row("uptime", "Uptime in the contract", [false, false, false, "99.9%"]),
row("support", "Support", ["Email", "Email, next day", "4-hour response", "Named manager"]),
]
export const GROUPS: readonly ComparisonGroup[] = [
{ label: "Data", rows: ["members", "sources", "refresh", "history"] },
{ label: "Analysis", rows: ["definitions", "questions", "forecasts", "alerts"] },
{ label: "Sharing", rows: ["dashboards", "packs", "api", "embeds"] },
{ label: "Security and support", rows: ["sso", "permissions", "audit", "residency", "uptime", "support"] },
]npx shadcn@latest add @vibra/pricing-03Usage estimate with a slider
pricing-04A slider for the product events a month and a switch for yearly billing, the bill line by line — the platform fee, then each band at its rate — and the estimate it adds up to, recounted as the slider moves.
import * as React from "react"
import { UsageEstimate } from "./pricing-04.client"
import { BANDS, COPY, EYEBROW, HEADLINE, LEDE, PLATFORM, USAGE, YEARLY_DISCOUNT } from "./pricing-04.data"
/**
* Usage pricing as a calculator: the reader moves a slider to the product
* events their workspace reads in a month, turns yearly billing on or off,
* and sees the bill line by line — the platform fee with its included events,
* then each band at its rate — beside the estimate it adds up to, the yearly
* total and its saving, the way in and what every workspace includes.
*
* The usage card takes seven columns from `lg` and the estimate five; on a
* phone the estimate follows the breakdown. A server component; the
* calculator is the one island, and every rate and word is in
* `./pricing-04.data`.
*/
export function Pricing04() {
const titleId = React.useId()
return (
<section
data-section="pricing-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<UsageEstimate usage={USAGE} rates={{ platform: PLATFORM, bands: BANDS, discount: YEARLY_DISCOUNT }} copy={COPY} />
</div>
</div>
</section>
)
}
export default Pricing04"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCompact, formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableRow } from "@/components/ui/table"
import type { Band, Copy } from "./pricing-04.data"
export type Rates = { platform: { monthly: number; included: number }; bands: readonly Band[]; discount: number }
export type EstimateLine = { key: string; from: number; upTo: number | null; count: number; rate: number | null; amount: number }
/** A month's bill: its lines, their total, a year of it and what a year saves against monthly. */
export type Estimate = { lines: EstimateLine[]; perMonth: number; perYear: number; saving: number }
/** Dollars and cents, the way a bill is written: "$189.00". */
const money = (value: number) => formatCurrency(value, "USD")
/** "20M", "2.5M": events at a glance. */
const events = (value: number) => formatCompact(value)
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** Rounds to whole cents, so every line and the total are written as they are added. */
const cents = (value: number) => Math.round(value * 100) / 100
/**
* The bill for a month's events: the platform fee, then each band's events at
* its rate per million, with the yearly share taken off every line when billed
* yearly. The total is the sum of the lines as written, to the cent.
*/
export function estimate(count: number, rates: Rates, yearly: boolean): Estimate {
const factor = yearly ? 1 - rates.discount : 1
const lines: EstimateLine[] = [
{ key: "platform", from: 0, upTo: rates.platform.included, count: Math.min(count, rates.platform.included), rate: null, amount: cents(rates.platform.monthly * factor) },
...rates.bands.map((band) => {
const inBand = Math.max(0, Math.min(count, band.upTo ?? Infinity) - band.from)
const rate = cents(band.rate * factor)
return { key: `${band.from}`, from: band.from, upTo: band.upTo, count: inBand, rate, amount: cents((inBand / 1_000_000) * rate) }
}),
]
const perMonth = cents(lines.reduce((sum, line) => sum + line.amount, 0))
const listPerMonth = yearly ? estimate(count, rates, false).perMonth : perMonth
return { lines, perMonth, perYear: cents(perMonth * 12), saving: cents((listPerMonth - perMonth) * 12) }
}
/**
* The calculator: a slider for the month's events, the switch for yearly
* billing, the bill line by line, and the estimate beside it. The slider is
* the kit's, named by its label and read out in words ("20 million"); every
* figure is recounted as it moves, and the estimate is a live region, so a
* reader who cannot see it hears the new total. Every rate and word arrives
* as plain props from the section.
*/
export function UsageEstimate({
usage,
rates,
copy,
}: {
usage: { label: string; min: number; max: number; step: number; initial: number; scale: readonly number[] }
rates: Rates
copy: Copy
}) {
const [count, setCount] = React.useState(usage.initial)
const [yearly, setYearly] = React.useState(false)
const labelId = React.useId()
const switchId = React.useId()
const bill = estimate(count, rates, yearly)
return (
<div className="grid gap-4 lg:grid-cols-12">
<Card className="lg:col-span-7">
<CardHeader>
<CardTitle>
<h3>{copy.usageTitle}</h3>
</CardTitle>
<CardDescription>{copy.usageDescription}</CardDescription>
<CardAction className="flex items-center gap-2.5">
<Label htmlFor={switchId} className="cursor-pointer gap-1.5">
{copy.yearly}
<span className="rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success max-sm:hidden">
{copy.yearlyNote}
</span>
</Label>
<Switch id={switchId} checked={yearly} onCheckedChange={(next) => setYearly(next)} />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<p id={labelId} className="type-label">
{usage.label}
</p>
<p className="flex items-baseline gap-1.5">
<span data-slot="usage-value" className="type-numeral text-3xl">
{events(count)}
</span>
<span className="text-sm text-muted-foreground">{copy.eventsUnit}</span>
</p>
</div>
{/* The kit Slider's own edge alignment: the thumb stays inside the
track at either end, the way the scale's end labels sit inside
it. Base UI places an edge thumb once it can measure, and inside
a streamed frame that is at hydration — its pre-hydration script
runs while the control has no size — so the thumb appears then,
not on the first paint. The centred alignment this replaced drew
it sooner, 6px past the track at either end. */}
<Slider
aria-labelledby={labelId}
value={count}
min={usage.min}
max={usage.max}
step={usage.step}
largeStep={usage.step * 10}
format={{ notation: "compact", compactDisplay: "long" }}
onValueChange={(next) => setCount(typeof next === "number" ? next : next[0])}
/>
<div aria-hidden="true" className="flex justify-between text-xs text-muted-foreground tabular-nums">
{usage.scale.map((mark) => (
<span key={mark}>{events(mark)}</span>
))}
</div>
</div>
<div className="overflow-hidden rounded-lg ring-1 ring-border">
<Table aria-label={copy.breakdown}>
<TableBody>
{bill.lines.map((line) => {
const idle = line.rate !== null && line.count === 0
const label =
line.rate === null
? fill(copy.platformLine, { included: events(rates.platform.included) })
: line.upTo === null
? fill(copy.bandTopLine, { from: events(line.from) })
: fill(copy.bandLine, { from: events(line.from), to: events(line.upTo) })
return (
<TableRow key={line.key} data-idle={idle || undefined} className="hover:bg-transparent data-idle:text-muted-foreground">
<TableHead scope="row" className="h-auto py-2.5 pl-4 font-normal whitespace-normal text-inherit">
<span className="block text-sm">{label}</span>
{line.rate !== null ? (
<span className="block text-xs text-muted-foreground tabular-nums">
{fill(copy.bandDetail, { count: events(line.count), rate: money(line.rate) })}
</span>
) : null}
</TableHead>
<TableCell data-slot="line-amount" className="py-2.5 pr-4 text-right tabular-nums">
{money(line.amount)}
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter className="bg-surface">
<TableRow className="hover:bg-transparent">
<TableHead scope="row" className="h-auto py-3 pl-4 text-foreground">
{copy.total}
</TableHead>
<TableCell data-slot="line-total" className="py-3 pr-4 text-right font-semibold tabular-nums">
{money(bill.perMonth)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>
<h3>{copy.estimateTitle}</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<output aria-live="polite" aria-atomic="true" className="block">
<span className="flex items-baseline gap-1.5">
<span data-slot="estimate-total" className="type-numeral text-4xl">
{money(bill.perMonth)}
</span>
<span className="text-sm text-muted-foreground">{copy.perMonth}</span>
</span>
<span data-slot="estimate-billing" className="mt-2 block text-sm text-muted-foreground tabular-nums">
{yearly ? fill(copy.billedYearly, { total: money(bill.perYear), saving: money(bill.saving) }) : copy.billedMonthly}
</span>
</output>
<Link href={copy.cta.href} className={cn(buttonVariants({ size: "lg" }), "h-10 w-full")}>
{copy.cta.label}
</Link>
<div className="border-t border-border pt-5">
<p className="text-sm font-medium">{copy.includesTitle}</p>
<ul className="mt-3 flex flex-col gap-2.5 text-sm">
{copy.includes.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</div>
<p className="flex flex-wrap items-center gap-x-1 text-sm text-muted-foreground">
{copy.salesText}
<Link href={copy.salesLink.href} className={cn(buttonVariants({ variant: "link" }), "h-auto px-0")}>
{copy.salesLink.label}
</Link>
</p>
</CardContent>
</Card>
</div>
)
}/**
* Every word and rate `pricing-04` sets: Northwind priced on usage — a
* platform fee that includes the first events, then a rate per million
* events in bands, less a share off every line when billed yearly. The
* section counts the estimate from these rates as the reader moves the
* slider, so the breakdown and the total can never disagree. Replace the
* rates and the words and the calculator prices your product.
*/
export type PlanLink = { label: string; href: string }
export const EYEBROW = "Usage pricing"
export const HEADLINE = "Pay for the events you track, not for the people who read them"
export const LEDE =
"Every member, dashboard and connector is included. The bill follows one number: the product events Northwind reads in a month."
/** The slider: product events a month, from a million to a hundred million, a million at a step. */
export const USAGE = {
label: "Product events a month",
min: 1_000_000,
max: 100_000_000,
step: 1_000_000,
initial: 20_000_000,
/** The marks under the track; the slider itself reads out its value. */
scale: [1_000_000, 25_000_000, 50_000_000, 75_000_000, 100_000_000],
}
/** The platform fee a month, in dollars, and the events it includes. */
export const PLATFORM = { monthly: 49, included: 2_000_000 }
/** A band of usage: events from `from` up to `upTo` (to the top of the scale when null), at `rate` dollars per million. */
export type Band = { from: number; upTo: number | null; rate: number }
export const BANDS: readonly Band[] = [
{ from: 2_000_000, upTo: 10_000_000, rate: 10 },
{ from: 10_000_000, upTo: 50_000_000, rate: 6 },
{ from: 50_000_000, upTo: null, rate: 4 },
]
/** Billed yearly, this share comes off every line. */
export const YEARLY_DISCOUNT = 0.15
/**
* The calculator's words. `{included}`, `{from}`, `{to}`, `{count}`,
* `{rate}`, `{total}` and `{saving}` are filled in with the figures.
*/
export const COPY = {
usageTitle: "Your usage",
usageDescription: "Move the slider to your monthly events",
eventsUnit: "events a month",
yearly: "Bill yearly",
yearlyNote: "15% off every line",
breakdown: "How the estimate adds up",
platformLine: "Platform, with the first {included} events",
bandLine: "{from} to {to} events",
bandTopLine: "Over {from} events",
bandDetail: "{count} at {rate} per million",
total: "Estimated total a month",
estimateTitle: "Your estimate",
perMonth: "a month",
billedMonthly: "Billed monthly on what you tracked",
billedYearly: "Billed {total} yearly, {saving} less than monthly",
includesTitle: "Every workspace includes",
includes: [
"Unlimited members and dashboards",
"Every connector, read-only",
"Alerts, forecasts and questions",
"SSO and an audit log",
],
cta: { label: "Start free trial", href: "/sign-up?plan=usage" } satisfies PlanLink,
salesText: "Tracking more than 100 million events a month?",
salesLink: { label: "Talk to sales", href: "/contact" } satisfies PlanLink,
}
export type Copy = typeof COPYnpx shadcn@latest add @vibra/pricing-04One plan with add-ons
pricing-05One plan with everything it includes, four add-ons to pick as checkbox cards, and a summary beside them that adds up the choice for the period — the month's total, and billed yearly the year's total and its saving.
import * as React from "react"
import { PlanBuilder } from "./pricing-05.client"
import { ADDONS, ADDONS_TITLE, BILLING, EYEBROW, HEADLINE, LEDE, PLAN, SUMMARY } from "./pricing-05.data"
/**
* One plan and the add-ons that go on it: the plan with its price for the
* period and everything it includes, four add-ons to pick from, and beside
* them what the choice comes to — the plan and each add-on on its own line,
* the month's total and, billed yearly, the year's total and its saving.
*
* From `lg` the summary stays in view beside the add-ons as the reader
* scrolls; on a phone it follows them. A server component; the period, the
* add-ons and the summary are the one island, and every word and price is in
* `./pricing-05.data`.
*/
export function Pricing05() {
const titleId = React.useId()
return (
<section
data-section="pricing-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10">
<PlanBuilder plan={PLAN} addons={ADDONS} addonsTitle={ADDONS_TITLE} billing={BILLING} summary={SUMMARY} />
</div>
</div>
</section>
)
}
export default Pricing05"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import type { Addon, Billing, Summary } from "./pricing-05.data"
export type BillingCycle = "monthly" | "yearly"
type Priced = { monthly: number; yearly: number }
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/**
* The month's price of the plan and the add-ons picked, for the period, and
* what a year of it saves against paying monthly.
*/
export function planTotal(plan: Priced, picked: readonly Priced[], cycle: BillingCycle) {
const sum = (key: keyof Priced) => [plan, ...picked].reduce((total, item) => total + item[key], 0)
const perMonth = sum(cycle)
return { perMonth, perYear: perMonth * 12, saving: cycle === "yearly" ? (sum("monthly") - perMonth) * 12 : 0 }
}
/**
* The plan, the add-ons and what they come to. Two pieces of state: the
* period, from the kit's SegmentedControl, and the add-ons picked, from the
* kit's SelectableCardGroup — checkboxes a reader tabs through and turns on
* with Space, each chosen card on the brand tint with its box ticked. The
* summary lists the plan and every add-on picked, and its total is a live
* region, so a reader who cannot see it hears the new figure. Every price and
* word arrives as plain props from the section.
*/
export function PlanBuilder({
plan,
addons,
addonsTitle,
billing,
summary,
}: {
plan: Priced & { name: string; audience: string; includesTitle: string; includes: readonly string[] }
addons: readonly Addon[]
addonsTitle: string
billing: Billing
summary: Summary
}) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const [picked, setPicked] = React.useState<string[]>([])
const planNameId = React.useId()
const addonsTitleId = React.useId()
const chosen = addons.filter((addon) => picked.includes(addon.id))
const bill = planTotal(plan, chosen, cycle)
const price = (item: Priced) => item[cycle]
return (
<div className="flex flex-col items-center">
<SegmentedControl
aria-label={billing.label}
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: billing.monthly },
{
value: "yearly",
label: (
<>
{billing.yearly}
<span className="ml-1.5 rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success">
{billing.yearlyNote}
</span>
</>
),
},
]}
/>
<div className="mt-10 grid w-full gap-8 md:mt-12 lg:grid-cols-12 lg:gap-6">
<div className="flex flex-col gap-10 lg:col-span-7">
<Card>
<CardHeader>
<CardTitle>
<h3 id={planNameId} className="text-base">
{plan.name}
</h3>
</CardTitle>
<CardDescription className="text-pretty">{plan.audience}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5 p-5 md:p-6">
<p className="flex items-baseline gap-1">
<span data-slot="plan-price" className="type-numeral text-4xl">
{dollars(price(plan))}
</span>
<span className="text-sm text-muted-foreground">
<span aria-hidden="true">/</span>
<span className="sr-only">per </span>
{billing.period}
</span>
</p>
<div className="border-t border-border pt-5">
<p className="text-sm font-medium">{plan.includesTitle}</p>
<ul className="mt-3 grid gap-x-6 gap-y-2.5 text-sm sm:grid-cols-2">
{plan.includes.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<div>
<h3 id={addonsTitleId} className="text-base font-semibold">
{addonsTitle}
</h3>
<SelectableCardGroup
aria-labelledby={addonsTitleId}
type="multiple"
value={picked}
onValueChange={setPicked}
columns={2}
className="mt-4"
options={addons.map((addon) => ({
value: addon.id,
title: addon.name,
description: addon.detail,
badge: (
<span data-slot="addon-price" className="text-xs text-muted-foreground tabular-nums">
+{dollars(price(addon))}
</span>
),
}))}
/>
</div>
</div>
<Card className="lg:sticky lg:top-24 lg:col-span-5 lg:self-start">
<CardHeader>
<CardTitle>
<h3>{summary.title}</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5 p-5 md:p-6">
<ul aria-label={summary.title} className="flex flex-col text-sm">
{[plan, ...chosen].map((item) => (
<li key={item.name} className="flex items-baseline justify-between gap-4 border-b border-border py-2.5 first:pt-0">
<span>{item.name}</span>
<span className="tabular-nums">{dollars(price(item))}</span>
</li>
))}
{chosen.length === 0 ? <li className="py-2.5 text-muted-foreground">{summary.none}</li> : null}
</ul>
<output aria-live="polite" aria-atomic="true" className="block">
<span className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium">{summary.total}</span>
<span data-slot="builder-total" className="type-numeral text-3xl">
{dollars(bill.perMonth)}
</span>
</span>
<span data-slot="builder-billing" className="mt-1.5 block text-right text-sm text-muted-foreground tabular-nums">
{cycle === "yearly"
? fill(summary.billedYearly, { total: dollars(bill.perYear), saving: dollars(bill.saving) })
: summary.billedMonthly}
</span>
</output>
<Link
href={summary.cta.href}
aria-describedby={planNameId}
className={cn(buttonVariants({ size: "lg" }), "h-10 w-full")}
>
{summary.cta.label}
</Link>
<p className="text-xs text-pretty text-muted-foreground">{summary.terms}</p>
</CardContent>
</Card>
</div>
</div>
)
}/**
* Every word and price `pricing-05` sets: Northwind sold as one plan, and the
* four add-ons a team can put on it, each priced in whole US dollars a month
* — billed monthly, and billed yearly, where a year costs ten months. The
* section adds up whatever the reader picks, so the summary can never
* disagree with the prices beside it. Replace the plan and the add-ons and
* the section sells yours.
*/
export type PlanLink = { label: string; href: string }
export type Addon = {
id: string
name: string
detail: string
/** Whole dollars a month, billed monthly. */
monthly: number
/** Whole dollars a month, billed yearly. */
yearly: number
}
export const EYEBROW = "One plan"
export const HEADLINE = "One plan with everything in it, and four things you can add"
export const LEDE =
"Northwind is one plan for every team. Add a faster refresh, single sign-on, warehouse write-back or priority support when you need them, and nothing else changes."
/** The period control's name and options; a year costs ten months, which is what the note says. */
export const BILLING = {
label: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
yearlyNote: "2 months free",
period: "month",
}
export type Billing = typeof BILLING
export const PLAN = {
name: "Northwind",
audience: "Everything a revenue team needs to review its numbers every week.",
monthly: 150,
yearly: 125,
includesTitle: "Included in the plan",
includes: [
"Up to 25 members",
"Every connector, read-only",
"Refreshed hourly",
"Unlimited history",
"Alerts and forecasts",
"Questions in plain language",
"Scheduled board packs",
"Email support, next-day response",
],
}
export const ADDONS_TITLE = "Add what you need"
export const ADDONS: readonly Addon[] = [
{ id: "refresh", name: "Five-minute refresh", detail: "Every source synced every 5 minutes instead of every hour.", monthly: 60, yearly: 50 },
{ id: "sso", name: "Single sign-on and SCIM", detail: "SAML sign-in, and people added and removed from your directory.", monthly: 84, yearly: 70 },
{ id: "writeback", name: "Warehouse write-back", detail: "Modelled tables written back to your warehouse every night.", monthly: 96, yearly: 80 },
{ id: "support", name: "Priority support", detail: "A 4-hour response and a named contact.", monthly: 72, yearly: 60 },
]
/** The summary's words. `{total}` and `{saving}` are filled in with the figures. */
export const SUMMARY = {
title: "Your plan",
none: "No add-ons yet. Pick any of the four and the total follows.",
total: "Total a month",
billedMonthly: "Billed monthly, cancel any time",
billedYearly: "Billed {total} yearly, {saving} less than monthly",
cta: { label: "Start free trial", href: "/sign-up?plan=northwind" } satisfies PlanLink,
terms: "14 days free, no card needed. Add-ons can be changed at any time.",
}
export type Summary = typeof SUMMARYnpx shadcn@latest add @vibra/pricing-05Per-member calculator with a stepper
pricing-06Two plans as radio cards priced per member, a stepper for how many members sign in and a switch for yearly billing, beside the bill — the month's total, the sum it came from and the year's saving — and a way in that carries the choice.
import * as React from "react"
import { SeatCalculator } from "./pricing-06.client"
import { COPY, EYEBROW, HEADLINE, LEDE, MEMBERS, PLANS } from "./pricing-06.data"
/**
* Per-member pricing as a calculator: the reader picks a plan, sets how many
* members will sign in with a stepper, turns yearly billing on or off, and
* sees the bill beside it — the plan and the count, the month's total, the
* sum it came from and, billed yearly, the year's total and its saving —
* with what the plan includes and a way in that carries the plan and the
* count through to sign-up.
*
* The plan and the stepper take seven columns from `lg` and the bill five;
* on a phone the bill follows the stepper. A server component; the calculator
* is the one island, and every price and word is in `./pricing-06.data`.
*/
export function Pricing06() {
const titleId = React.useId()
return (
<section
data-section="pricing-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid gap-x-16 gap-y-6 lg:grid-cols-12 lg:items-end">
<div className="lg:col-span-7">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
</div>
<p className="text-base/7 text-pretty text-muted-foreground lg:col-span-5">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<SeatCalculator plans={PLANS} limits={MEMBERS} copy={COPY} />
</div>
</div>
</section>
)
}
export default Pricing06"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon, MinusIcon, PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { clamp, formatCurrency, formatNumber } from "@/lib/format"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { NumberInput } from "@/components/ui/number-input"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import { Switch } from "@/components/ui/switch"
import type { Copy, Plan } from "./pricing-06.data"
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** A month of a plan for this many members, for the period, and what a year of it saves against monthly. */
export function seatTotal(plan: Pick<Plan, "monthly" | "yearly">, members: number, yearly: boolean) {
const perMonth = members * (yearly ? plan.yearly : plan.monthly)
return { perMonth, perYear: perMonth * 12, saving: yearly ? (members * plan.monthly - perMonth) * 12 : 0 }
}
/**
* The calculator: the plan as two radio cards, the members as a stepper, the
* switch for yearly billing, and beside them the bill. The stepper's buttons
* are named ("Add a member") and stop at the ends of the range, and the field
* between them takes a typed number and the arrow keys; the plan cards are
* the kit's SelectableCardGroup, one radio group moved with the arrow keys.
* The total is a live region, and the way in carries the plan and the count
* it was worked out for. Every price and word arrives as plain props.
*/
export function SeatCalculator({
plans,
limits,
copy,
}: {
plans: readonly Plan[]
limits: { min: number; max: number; initial: number }
copy: Copy
}) {
const [planId, setPlanId] = React.useState<Plan["id"]>(plans[0].id)
const [typed, setTyped] = React.useState<number | null>(limits.initial)
const [yearly, setYearly] = React.useState(false)
const planTitleId = React.useId()
const switchId = React.useId()
const membersId = React.useId()
const hintId = React.useId()
const plan = plans.find((entry) => entry.id === planId) ?? plans[0]
// While a number is half typed the bill follows the nearest count it can charge for.
const members = clamp(Math.round(typed ?? limits.min), limits.min, limits.max)
const bill = seatTotal(plan, members, yearly)
const people = (count: number) => `${formatNumber(count)} ${count === 1 ? copy.member : copy.members}`
const perMember = (entry: Plan) => dollars(yearly ? entry.yearly : entry.monthly)
const href = `${copy.cta.href}?${new URLSearchParams({ plan: plan.id, members: String(members) })}`
return (
// Each card as tall as what it holds: the bill runs longer than the choice.
<div className="grid gap-4 lg:grid-cols-12 lg:items-start">
<Card className="lg:col-span-7">
<CardHeader>
<CardTitle>
<h3 id={planTitleId}>{copy.planTitle}</h3>
</CardTitle>
<CardAction className="flex items-center gap-2.5">
<Label htmlFor={switchId} className="cursor-pointer gap-1.5">
{copy.yearly}
<span className="rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success max-sm:hidden">
{copy.yearlyNote}
</span>
</Label>
<Switch id={switchId} checked={yearly} onCheckedChange={(next) => setYearly(next)} />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<SelectableCardGroup
aria-labelledby={planTitleId}
type="single"
value={planId}
onValueChange={(next) => {
if (next) setPlanId(next as Plan["id"])
}}
columns={2}
options={plans.map((entry) => ({
value: entry.id,
title: entry.name,
description: (
<>
<span data-slot="member-price" className="block text-sm font-medium text-foreground tabular-nums">
{fill(copy.perMember, { price: perMember(entry) })}
</span>
<span className="mt-1 block">{entry.audience}</span>
</>
),
}))}
/>
<div className="flex flex-col gap-2 border-t border-border pt-5">
<Label htmlFor={membersId}>{copy.membersLabel}</Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
aria-label={copy.remove}
disabled={members <= limits.min}
onClick={() => setTyped(Math.max(limits.min, members - 1))}
>
<MinusIcon />
</Button>
<NumberInput
id={membersId}
aria-describedby={hintId}
value={typed}
onValueChange={setTyped}
min={limits.min}
max={limits.max}
precision={0}
hideControls
className="w-20"
/>
<Button
variant="outline"
size="icon"
aria-label={copy.add}
disabled={members >= limits.max}
onClick={() => setTyped(Math.min(limits.max, members + 1))}
>
<PlusIcon />
</Button>
</div>
<p id={hintId} className="flex flex-wrap items-center gap-x-1 text-sm text-muted-foreground">
{copy.membersHint} {copy.salesText}
<Link href={copy.salesLink.href} className={cn(buttonVariants({ variant: "link" }), "h-auto px-0")}>
{copy.salesLink.label}
</Link>
</p>
</div>
</CardContent>
</Card>
<Card className="lg:col-span-5">
<CardHeader>
<CardTitle>
<h3>{copy.summaryTitle}</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6 p-5 md:p-6">
<output aria-live="polite" aria-atomic="true" className="block">
<span data-slot="seat-line" className="block text-sm font-medium">
{fill(copy.line, { plan: plan.name, members: people(members) })}
</span>
<span className="mt-3 flex items-baseline gap-1.5">
<span data-slot="seat-total" className="type-numeral text-4xl">
{dollars(bill.perMonth)}
</span>
<span className="text-sm text-muted-foreground">{copy.perMonth}</span>
</span>
<span data-slot="seat-sum" className="mt-1 block text-sm text-muted-foreground tabular-nums">
{fill(copy.sum, { members: people(members), price: perMember(plan) })}
</span>
<span data-slot="seat-billing" className="mt-1 block text-sm text-muted-foreground tabular-nums">
{yearly ? fill(copy.billedYearly, { total: dollars(bill.perYear), saving: dollars(bill.saving) }) : copy.billedMonthly}
</span>
</output>
<Link href={href} className={cn(buttonVariants({ size: "lg" }), "h-10 w-full")}>
{fill(copy.cta.label, { members: people(members) })}
</Link>
<div className="border-t border-border pt-5">
<p className="text-sm font-medium">{fill(copy.includesTitle, { plan: plan.name })}</p>
<ul className="mt-3 flex flex-col gap-2.5 text-sm">
{plan.includes.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
</div>
)
}/**
* Every word and price `pricing-06` sets: Northwind priced per member — two
* plans, each a whole-dollar price per member a month, billed monthly or
* billed yearly, where a year costs ten months — and the stepper that sets
* how many members. The section multiplies them out as the reader changes
* either, so the total can never disagree with the price beside it. Replace
* the plans and the words and the calculator prices yours.
*/
export type Plan = {
id: "team" | "business"
name: string
audience: string
/** Whole dollars per member a month, billed monthly. */
monthly: number
/** Whole dollars per member a month, billed yearly. */
yearly: number
includes: readonly string[]
}
export const EYEBROW = "Per member"
export const HEADLINE = "Pay for the people who sign in, and no one else"
export const LEDE =
"Viewers who only receive reports are free. Choose a plan, set how many members sign in, and see the bill before you start."
export const PLANS: readonly Plan[] = [
{
id: "team",
name: "Team",
audience: "For teams that review the numbers every week.",
monthly: 12,
yearly: 10,
includes: ["Every connector, read-only", "Refreshed hourly", "Alerts and forecasts", "Questions in plain language"],
},
{
id: "business",
name: "Business",
audience: "For companies where finance signs off on every figure.",
monthly: 24,
yearly: 20,
includes: [
"Everything in Team",
"Refreshed every 5 minutes",
"SSO, SCIM and an audit log",
"Permissions down to the row",
"Priority support, 4-hour response",
],
},
]
/** The stepper: members from one to five hundred, twenty-five to begin with. */
export const MEMBERS = { min: 1, max: 500, initial: 25 }
/**
* The calculator's words. `{price}`, `{plan}`, `{members}`, `{total}` and
* `{saving}` are filled in with the figures; `{members}` reads "1 member" or
* "25 members".
*/
export const COPY = {
planTitle: "Choose a plan",
planLabel: "Plan",
perMember: "{price} per member a month",
yearly: "Bill yearly",
yearlyNote: "2 months free",
membersLabel: "Members",
membersHint: "Viewers who only receive reports are free.",
member: "member",
members: "members",
remove: "Remove a member",
add: "Add a member",
summaryTitle: "Your plan",
line: "{plan}, {members}",
sum: "{members} at {price} a month",
perMonth: "a month",
billedMonthly: "Billed monthly, cancel any time",
billedYearly: "Billed {total} yearly, {saving} less than monthly",
includesTitle: "{plan} includes",
cta: { label: "Start with {members}", href: "/sign-up" },
salesText: "More than 500 members?",
salesLink: { label: "Talk to sales", href: "/contact" },
}
export type Copy = typeof COPYnpx shadcn@latest add @vibra/pricing-06Plans beside the billing questions
pricing-07Two plans stacked under the claim and priced monthly or yearly, and beside them what every plan includes and the six questions finance asks before signing, answered in place.
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { buttonVariants } from "@/components/ui/button"
import { PlanStack } from "./pricing-07.client"
import { BILLING, EYEBROW, FAQ, HEADLINE, INCLUDED, LEDE, PLANS } from "./pricing-07.data"
/**
* Pricing with its answers beside it: the claim, the period control and the
* two plans stacked on one side, and on the other what every plan includes
* and the questions finance asks before signing — what a member is,
* switching periods, the end of the trial, discounts, refunds and paying —
* each answered in place in the kit's Accordion, the first one open.
*
* From `lg` the plans take five columns and the answers seven, staying in
* view while the plans scroll; on a phone the answers follow the plans. A
* server component; the period control and the plans are the one island,
* and every word and price is in `./pricing-07.data`.
*/
export function Pricing07() {
const titleId = React.useId()
return (
<section
data-section="pricing-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8">
<PlanStack plans={PLANS} billing={BILLING} />
</div>
</div>
{/* The answers stay in view while the plans scroll past them. */}
<div className="flex flex-col gap-10 lg:sticky lg:top-24 lg:col-span-7 lg:self-start lg:pt-1">
<div>
<p className="type-eyebrow">{INCLUDED.title}</p>
<ul className="mt-3 grid gap-x-6 gap-y-2.5 border-t border-rule pt-4 text-sm sm:grid-cols-2">
{INCLUDED.items.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</div>
<div>
<p className="type-eyebrow">{FAQ.title}</p>
{/* Each question is the heading of its own answer, one level under
the band's: the Accordion sets it as an h3. */}
<Accordion defaultValue={[FAQ.items[0].id]} className="mt-3 border-t border-rule">
{FAQ.items.map((item) => (
<AccordionItem key={item.id} value={item.id} className="border-rule">
<AccordionTrigger className="py-4 text-base">{item.question}</AccordionTrigger>
<AccordionContent className="pb-4 text-sm/6 text-pretty text-muted-foreground">{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
<p className="mt-6 flex flex-wrap items-center gap-x-1 text-sm text-muted-foreground">
{FAQ.more}
<Link href={FAQ.contact.href} className={cn(buttonVariants({ variant: "link" }), "h-auto px-0")}>
{FAQ.contact.label}
</Link>
</p>
</div>
</div>
</div>
</section>
)
}
export default Pricing07"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatPercent } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { Billing, Plan } from "./pricing-07.data"
export type BillingCycle = "monthly" | "yearly"
const dollars = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
/** What a year saves against twelve monthly bills: in dollars, and as a share of them. */
function yearlySaving(plan: Pick<Plan, "monthly" | "yearly">) {
const amount = (plan.monthly - plan.yearly) * 12
return { amount, share: plan.monthly > 0 ? amount / (plan.monthly * 12) : 0 }
}
/** One plan, set wide and short: its price and way in on one line, its highlights under them. */
function PlanRow({ plan, cycle, billing }: { plan: Plan; cycle: BillingCycle; billing: Billing }) {
const nameId = React.useId()
const yearly = cycle === "yearly"
return (
<Card data-recommended={plan.recommended || undefined} className={cn(plan.recommended && "bg-brand-muted")}>
<CardHeader>
<CardTitle>
<h3 id={nameId} className="text-base">
{plan.name}
</h3>
</CardTitle>
<CardDescription className="text-pretty">{plan.audience}</CardDescription>
{plan.recommended ? (
<CardAction>
<Badge>{billing.recommended}</Badge>
</CardAction>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-5 p-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="flex items-baseline gap-1">
<span data-slot="plan-price" className="type-numeral text-4xl">
{dollars(yearly ? plan.yearly : plan.monthly)}
</span>
<span className="text-sm text-muted-foreground">
<span aria-hidden="true">/</span>
<span className="sr-only">per </span>
{billing.period}
</span>
</p>
<p data-slot="plan-billing" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground tabular-nums">
{yearly ? (
<>
<span>{fill(billing.billedYearly, { total: dollars(plan.yearly * 12) })}</span>
<Badge className="bg-success-muted text-success">
{fill(billing.saving, { amount: dollars(yearlySaving(plan).amount) })}
</Badge>
</>
) : (
billing.billedMonthly
)}
</p>
</div>
<Link
href={plan.cta.href}
aria-describedby={nameId}
className={cn(buttonVariants({ variant: plan.recommended ? "default" : "outline", size: "lg" }), "h-10 px-5 max-sm:w-full")}
>
{plan.cta.label}
</Link>
</div>
<ul className="grid gap-x-6 gap-y-2 border-t border-border pt-4 text-sm sm:grid-cols-2">
{plan.highlights.map((item) => (
<li key={item} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</CardContent>
</Card>
)
}
/**
* The period control and the two plans it prices, stacked. The control is
* the kit's SegmentedControl, its yearly option carrying the best saving on
* offer; yearly prices each plan a month with the year's total under it.
* Every plan and word arrives as plain props from the section.
*/
export function PlanStack({ plans, billing }: { plans: readonly Plan[]; billing: Billing }) {
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const best = Math.max(0, ...plans.map((plan) => yearlySaving(plan).share))
return (
<div className="flex flex-col items-start gap-5">
<SegmentedControl
aria-label={billing.label}
value={cycle}
onValueChange={(value) => setCycle(value as BillingCycle)}
options={[
{ value: "monthly", label: billing.monthly },
{
value: "yearly",
label: (
<>
{billing.yearly}
<span className="ml-1.5 rounded-full bg-success-muted px-1.5 py-px text-2xs font-medium text-success tabular-nums">
{fill(billing.yearlyNote, { percent: formatPercent(best, { maximumFractionDigits: 0 }) })}
</span>
</>
),
},
]}
/>
<ul className="flex w-full flex-col gap-4">
{plans.map((plan) => (
<li key={plan.id}>
<PlanRow plan={plan} cycle={cycle} billing={billing} />
</li>
))}
</ul>
</div>
)
}/**
* Every word and price `pricing-07` sets: Northwind's two self-serve plans in
* whole US dollars a month, billed monthly or yearly, what every plan
* includes, and the questions finance asks before signing, answered plainly.
* Replace the plans and the answers and the section sells yours.
*/
export type PlanLink = { label: string; href: string }
export type Plan = {
id: string
name: string
audience: string
/** Whole dollars a month, billed monthly. */
monthly: number
/** Whole dollars a month, billed yearly. */
yearly: number
highlights: readonly string[]
cta: PlanLink
recommended?: boolean
}
export const EYEBROW = "Pricing"
export const HEADLINE = "Two plans, and straight answers about paying for them"
export const LEDE = "Most teams start on Team. The answers beside the plans cover what finance asks before it signs."
/** The words around the prices. `{percent}`, `{total}` and `{amount}` are filled in with the figures. */
export const BILLING = {
label: "Billing period",
monthly: "Monthly",
yearly: "Yearly",
yearlyNote: "Save up to {percent}",
period: "month",
billedMonthly: "Billed monthly",
billedYearly: "{total} billed yearly",
saving: "Save {amount} a year",
recommended: "Most popular",
}
export type Billing = typeof BILLING
export const PLANS: readonly Plan[] = [
{
id: "team",
name: "Team",
audience: "For teams that review the numbers every week.",
monthly: 119,
yearly: 99,
highlights: ["Up to 25 members", "10 data sources", "Refreshed hourly", "Alerts and forecasts"],
cta: { label: "Start free trial", href: "/sign-up?plan=team" },
recommended: true,
},
{
id: "business",
name: "Business",
audience: "For companies where finance signs off on every figure.",
monthly: 399,
yearly: 329,
highlights: ["Unlimited members", "Refreshed every 5 minutes", "SSO, SCIM and an audit log", "Priority support"],
cta: { label: "Start free trial", href: "/sign-up?plan=business" },
},
]
export const INCLUDED = {
title: "Every plan includes",
items: [
"A 14-day trial of Business, no card needed",
"Unlimited viewers of shared reports",
"Every connector, read-only",
"A change of plan at any time",
],
}
export type Question = { id: string; question: string; answer: string }
export const FAQ = {
title: "Questions about billing",
items: [
{
id: "members",
question: "What counts as a member?",
answer:
"Anyone who signs in to the workspace. People who only receive scheduled reports by email are free, and there is no limit on them.",
},
{
id: "switching",
question: "Can we move between monthly and yearly billing?",
answer:
"Yes, from the billing page at any time. Moving to yearly starts at once and credits what is left of the month; moving to monthly starts when the paid year ends.",
},
{
id: "trial",
question: "What happens when the trial ends?",
answer:
"Nothing is charged until you choose a plan. Without one, the workspace becomes read-only for 30 days and is then deleted, unless you ask us to keep it.",
},
{
id: "discounts",
question: "Do you discount for nonprofits and schools?",
answer: "Registered nonprofits and schools get 40% off any plan. Write to us from your organisation's address.",
},
{
id: "refunds",
question: "How do refunds work?",
answer:
"A yearly plan cancelled in its first 30 days is refunded in full. After that, the plan runs to the end of the year you paid for.",
},
{
id: "payment",
question: "How can we pay?",
answer: "By card on every plan, or by bank transfer against an invoice on a yearly Business plan.",
},
] satisfies Question[],
more: "Another question about paying?",
contact: { label: "Ask the billing team", href: "/contact?topic=billing" } satisfies PlanLink,
}npx shadcn@latest add @vibra/pricing-07