"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Check, CreditCard, Download, Loader2, Lock, Minus, Plus, ShieldCheck, Sparkles, Tag, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type CheckoutPlan = {
id: string;
name: string;
tagline: string;
monthly: number;
features: string[];
popular?: boolean;
minSeats?: number;
};
export type Country = { code: string; name: string; tax: number; taxLabel: string; postalLabel?: string };
export type Coupon = { code: string; percent: number; label: string };
export type CheckoutResult = {
plan: CheckoutPlan;
interval: "month" | "year";
seats: number;
coupon: Coupon | null;
country: string;
subtotal: number;
discount: number;
tax: number;
total: number;
email: string;
};
export interface PricingCheckoutPageProps {
brand?: string;
plans?: CheckoutPlan[];
countries?: Country[];
coupons?: Coupon[];
currency?: string;
/** Yearly discount, 0–1. */
yearlyDiscount?: number;
/** Resolve when payment succeeded; throw to show the error on the form. */
onPay?: (result: CheckoutResult) => Promise<void>;
className?: string;
}
const DEFAULT_PLANS: CheckoutPlan[] = [
{ id: "starter", name: "Starter", tagline: "For individuals and side projects", monthly: 12, features: ["3 projects", "Basic analytics", "Email support", "1 GB storage"] },
{ id: "pro", name: "Pro", tagline: "For growing product teams", monthly: 24, popular: true, features: ["Unlimited projects", "Advanced analytics", "Priority support", "100 GB storage", "SSO & audit log"], minSeats: 2 },
{ id: "business", name: "Business", tagline: "For organisations at scale", monthly: 48, features: ["Everything in Pro", "Custom roles", "99.99% SLA", "1 TB storage", "Dedicated manager"], minSeats: 5 },
];
const DEFAULT_COUNTRIES: Country[] = [
{ code: "US", name: "United States", tax: 0.0825, taxLabel: "Sales tax", postalLabel: "ZIP code" },
{ code: "GB", name: "United Kingdom", tax: 0.2, taxLabel: "VAT", postalLabel: "Postcode" },
{ code: "DE", name: "Germany", tax: 0.19, taxLabel: "VAT" },
{ code: "FR", name: "France", tax: 0.2, taxLabel: "VAT" },
{ code: "PL", name: "Poland", tax: 0.23, taxLabel: "VAT" },
{ code: "SE", name: "Sweden", tax: 0.25, taxLabel: "VAT" },
{ code: "CA", name: "Canada", tax: 0.13, taxLabel: "HST" },
];
const DEFAULT_COUPONS: Coupon[] = [
{ code: "LAUNCH20", percent: 20, label: "Launch week — 20% off" },
{ code: "TEAM50", percent: 50, label: "Team pilot — 50% off" },
];
// ---------- helpers ----------
const digits = (s: string) => s.replace(/\D/g, "");
const formatCard = (s: string) => digits(s).slice(0, 19).replace(/(\d{4})(?=\d)/g, "$1 ");
const formatExpiry = (s: string, prev: string) => {
const d = digits(s).slice(0, 4);
if (d.length === 1 && Number(d) > 1) return `0${d} / `;
if (d.length >= 3) return `${d.slice(0, 2)} / ${d.slice(2)}`;
if (d.length === 2 && prev.length < s.length) return `${d} / `;
return d;
};
function luhn(num: string) {
const d = digits(num);
if (d.length < 13) return false;
let sum = 0;
for (let i = 0; i < d.length; i++) {
let n = Number(d[d.length - 1 - i]);
if (i % 2) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
}
return sum % 10 === 0;
}
function expiryOk(v: string) {
const [mm, yy] = v.split("/").map((x) => Number(x.trim()));
if (!mm || mm > 12 || Number.isNaN(yy) || String(v.split("/")[1] ?? "").trim().length !== 2) return false;
const now = new Date();
const y = 2000 + yy;
return y > now.getFullYear() || (y === now.getFullYear() && mm >= now.getMonth() + 1);
}
type Form = { email: string; card: string; expiry: string; cvc: string; name: string; country: string; line1: string; city: string; postal: string };
type FormErrors = Partial<Record<keyof Form, string>>;
function validate(f: Form): FormErrors {
const e: FormErrors = {};
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(f.email.trim())) e.email = "Enter a valid email for your receipt.";
if (!luhn(f.card)) e.card = digits(f.card).length < 13 ? "Card number is incomplete." : "Card number is invalid.";
if (!expiryOk(f.expiry)) e.expiry = "Enter a future date as MM / YY.";
if (!/^\d{3,4}$/.test(f.cvc)) e.cvc = "3 or 4 digits.";
if (f.name.trim().length < 2) e.name = "Enter the name on the card.";
if (f.line1.trim().length < 3) e.line1 = "Enter your street address.";
if (f.city.trim().length < 2) e.city = "Enter a city.";
if (f.postal.trim().length < 3) e.postal = "Enter a postal code.";
return e;
}
const focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const inputBase =
"h-11 w-full rounded-lg border bg-background px-3.5 text-sm shadow-xs outline-none transition placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15";
export function PricingCheckoutPage({
brand = "Lumen",
plans = DEFAULT_PLANS,
countries = DEFAULT_COUNTRIES,
coupons = DEFAULT_COUPONS,
currency = "USD",
yearlyDiscount = 0.2,
onPay,
className,
}: PricingCheckoutPageProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const fmt = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency }), [currency]);
const [step, setStep] = React.useState<"plans" | "checkout" | "success">("plans");
const [interval, setCycle] = React.useState<"month" | "year">("year");
const [planId, setPlanId] = React.useState(plans.find((p) => p.popular)?.id ?? plans[0].id);
const [seats, setSeats] = React.useState(5);
const [couponInput, setCouponInput] = React.useState("");
const [coupon, setCoupon] = React.useState<Coupon | null>(null);
const [couponMsg, setCouponMsg] = React.useState("");
const [form, setForm] = React.useState<Form>({ email: "", card: "", expiry: "", cvc: "", name: "", country: countries[0].code, line1: "", city: "", postal: "" });
const [touched, setTouched] = React.useState<Partial<Record<keyof Form, boolean>>>({});
const [submitted, setSubmitted] = React.useState(false);
const [paying, setPaying] = React.useState(false);
const [payError, setPayError] = React.useState("");
const [orderNo, setOrderNo] = React.useState("");
const plan = plans.find((p) => p.id === planId) ?? plans[0];
const minSeats = plan.minSeats ?? 1;
const unit = interval === "year" ? plan.monthly * 12 * (1 - yearlyDiscount) : plan.monthly;
const subtotal = unit * seats;
const discount = coupon ? (subtotal * coupon.percent) / 100 : 0;
const country = countries.find((c) => c.code === form.country) ?? countries[0];
const tax = (subtotal - discount) * country.tax;
const total = subtotal - discount + tax;
const errors = validate(form);
const show = (k: keyof Form) => (submitted || touched[k] ? errors[k] : undefined);
const set = (k: keyof Form, v: string) => {
setForm((f) => ({ ...f, [k]: v }));
setPayError("");
};
const blur = (k: keyof Form) => () => setTouched((t) => ({ ...t, [k]: true }));
const choose = (id: string) => {
const p = plans.find((x) => x.id === id);
setPlanId(id);
setSeats((s) => Math.max(p?.minSeats ?? 1, s));
setStep("checkout");
};
const applyCoupon = () => {
const c = coupons.find((x) => x.code === couponInput.trim().toUpperCase());
if (!couponInput.trim()) return;
if (c) {
setCoupon(c);
setCouponMsg("");
setCouponInput("");
} else {
setCouponMsg("That code isn't valid or has expired.");
}
};
const pay = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
const first = (Object.keys(form) as (keyof Form)[]).find((k) => errors[k]);
if (first) {
document.getElementById(`${uid}-${first}`)?.focus();
return;
}
setPaying(true);
setPayError("");
try {
const result: CheckoutResult = { plan, interval, seats, coupon, country: country.code, subtotal, discount, tax, total, email: form.email.trim() };
if (onPay) await onPay(result);
else {
await new Promise((r) => setTimeout(r, 1500));
if (digits(form.card) === "4000000000000002") throw new Error("Your card was declined. Try a different card.");
}
setOrderNo(`${brand.slice(0, 2).toUpperCase()}-${String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, "0")}`);
setStep("success");
} catch (err) {
setPayError(err instanceof Error ? err.message : "Payment failed. Please try again.");
} finally {
setPaying(false);
}
};
const errorCount = submitted ? Object.keys(errors).length : 0;
const perLabel = interval === "year" ? "/ seat / year" : "/ seat / month";
const fade = reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: { opacity: 0, y: 16 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -12 } };
return (
<section className={cn("relative w-full overflow-hidden bg-background text-foreground", className)}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-80 bg-[radial-gradient(60%_100%_at_50%_0%,color-mix(in_oklab,var(--primary)_14%,transparent),transparent)]" />
<header className="relative mx-auto flex max-w-6xl items-center gap-3 px-4 py-5 sm:px-8">
<span className="grid size-8 place-items-center rounded-lg bg-foreground text-background">
<Sparkles className="size-4" />
</span>
<span className="font-semibold tracking-tight">{brand}</span>
<ol className="ml-auto flex items-center gap-2 text-xs font-medium text-muted-foreground" aria-label="Checkout progress">
{(["plans", "checkout", "success"] as const).map((s, i) => {
const idx = ["plans", "checkout", "success"].indexOf(step);
return (
<li key={s} className="flex items-center gap-2">
{i > 0 && <span className="h-px w-4 bg-border sm:w-8" aria-hidden />}
<span aria-current={idx === i ? "step" : undefined} className={cn("flex items-center gap-1.5", idx >= i && "text-foreground")}>
<span className={cn("grid size-5 place-items-center rounded-full border text-[10px]", idx > i && "border-foreground bg-foreground text-background", idx === i && "border-foreground")}>
{idx > i ? <Check className="size-3" strokeWidth={3} /> : i + 1}
</span>
<span className="hidden sm:inline">{["Plan", "Payment", "Done"][i]}</span>
</span>
</li>
);
})}
</ol>
</header>
<div className="relative mx-auto max-w-6xl px-4 pb-14 sm:px-8">
<AnimatePresence mode="wait" initial={false}>
{step === "plans" && (
<motion.div key="plans" {...fade} transition={{ duration: 0.3 }}>
<div className="mx-auto max-w-2xl pt-6 text-center">
<h1 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">Simple pricing that scales with your team</h1>
<p className="mt-3 text-muted-foreground">Start with a 14-day free trial. Cancel anytime.</p>
<div role="radiogroup" aria-label="Billing interval" className="mt-7 inline-grid grid-cols-2 rounded-full border bg-muted/60 p-1 text-sm">
{(["month", "year"] as const).map((iv) => (
<button
key={iv}
type="button"
role="radio"
aria-checked={interval === iv}
onClick={() => setCycle(iv)}
className={cn("relative rounded-full px-4 py-1.5 font-medium transition-colors", focusRing, interval === iv ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{interval === iv && <motion.span layoutId={`${uid}-iv`} className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="relative flex items-center gap-1.5">
{iv === "month" ? "Monthly" : "Yearly"}
{iv === "year" && <span className="rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-300">−{Math.round(yearlyDiscount * 100)}%</span>}
</span>
</button>
))}
</div>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{plans.map((p, i) => {
const price = interval === "year" ? p.monthly * (1 - yearlyDiscount) : p.monthly;
return (
<motion.article
key={p.id}
initial={reduce ? false : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 + i * 0.07 }}
className={cn(
"relative flex flex-col rounded-2xl border bg-card p-6 text-card-foreground shadow-xs",
p.popular && "border-transparent shadow-xl shadow-primary/10 ring-2 ring-primary md:-translate-y-2",
)}
>
{p.popular && <span className="absolute -top-3 left-6 rounded-full bg-primary px-2.5 py-0.5 text-xs font-semibold text-primary-foreground">Most popular</span>}
<h2 className="text-lg font-semibold">{p.name}</h2>
<p className="mt-1 text-sm text-muted-foreground">{p.tagline}</p>
<p className="mt-5 flex items-baseline gap-1">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={interval} initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="text-4xl font-semibold tracking-tight tabular-nums">
{fmt.format(price).replace(/\.00$/, "")}
</motion.span>
</AnimatePresence>
<span className="text-sm text-muted-foreground">/ seat / mo</span>
</p>
<p className="mt-1 h-4 text-xs text-muted-foreground">{interval === "year" ? `Billed ${fmt.format(price * 12).replace(/\.00$/, "")} yearly` : "Billed monthly"}</p>
<ul className="mt-6 flex-1 space-y-2.5 text-sm">
{p.features.map((f) => (
<li key={f} className="flex items-start gap-2">
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
{f}
</li>
))}
</ul>
<button
type="button"
onClick={() => choose(p.id)}
className={cn(
"mt-7 h-11 rounded-lg text-sm font-medium transition",
focusRing,
p.popular ? "bg-primary text-primary-foreground shadow-sm hover:brightness-110" : "border bg-background hover:bg-accent",
)}
>
Choose {p.name}
</button>
</motion.article>
);
})}
</div>
<p className="mt-8 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-4" /> 30-day money-back guarantee · Prices exclude tax
</p>
</motion.div>
)}
{step === "checkout" && (
<motion.div key="checkout" {...fade} transition={{ duration: 0.3 }}>
<button type="button" onClick={() => setStep("plans")} className={cn("mb-5 inline-flex items-center gap-1.5 rounded text-sm text-muted-foreground hover:text-foreground", focusRing)}>
<ArrowLeft className="size-4" /> Back to plans
</button>
<form noValidate onSubmit={pay} className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px] lg:gap-10">
<div className="space-y-8">
<p className="sr-only" aria-live="polite">
{errorCount ? `${errorCount} field${errorCount > 1 ? "s need" : " needs"} attention.` : ""}
</p>
<FormGroup title="Contact">
<Input id={`${uid}-email`} label="Email" type="email" autoComplete="email" value={form.email} onChange={(v) => set("email", v)} onBlur={blur("email")} error={show("email")} placeholder="[email protected]" />
</FormGroup>
<FormGroup title="Payment" aside={<span className="flex items-center gap-1 text-xs text-muted-foreground"><Lock className="size-3" /> Encrypted</span>}>
<div className="overflow-hidden rounded-xl border bg-card shadow-xs">
<div className="p-4 sm:p-5">
<CardPreview number={form.card} name={form.name} expiry={form.expiry} />
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Input
id={`${uid}-card`}
label="Card number"
className="col-span-2 sm:col-span-4"
inputMode="numeric"
autoComplete="cc-number"
value={form.card}
onChange={(v) => set("card", formatCard(v))}
onBlur={blur("card")}
error={show("card")}
placeholder="1234 1234 1234 1234"
icon={<CreditCard className="size-4" />}
mono
/>
<Input id={`${uid}-expiry`} label="Expiry" className="col-span-1 sm:col-span-2" inputMode="numeric" autoComplete="cc-exp" value={form.expiry} onChange={(v) => set("expiry", formatExpiry(v, form.expiry))} onBlur={blur("expiry")} error={show("expiry")} placeholder="MM / YY" mono />
<Input id={`${uid}-cvc`} label="CVC" className="col-span-1 sm:col-span-2" inputMode="numeric" autoComplete="cc-csc" value={form.cvc} onChange={(v) => set("cvc", digits(v).slice(0, 4))} onBlur={blur("cvc")} error={show("cvc")} placeholder="123" mono />
<Input id={`${uid}-name`} label="Name on card" className="col-span-2 sm:col-span-4" autoComplete="cc-name" value={form.name} onChange={(v) => set("name", v)} onBlur={blur("name")} error={show("name")} placeholder="Ada Lovelace" />
</div>
</div>
<p className="border-t bg-muted/40 px-4 py-2.5 text-xs text-muted-foreground sm:px-5">
Test card <span className="font-mono text-foreground">4242 4242 4242 4242</span> · any future date · decline with <span className="font-mono">4000 0000 0000 0002</span>
</p>
</div>
</FormGroup>
<FormGroup title="Billing address">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label htmlFor={`${uid}-country`} className="mb-1.5 block text-sm font-medium">
Country
</label>
<select id={`${uid}-country`} value={form.country} onChange={(e) => set("country", e.target.value)} autoComplete="country" className={cn(inputBase, "appearance-none")}>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<Input id={`${uid}-line1`} label="Address" className="col-span-2" autoComplete="address-line1" value={form.line1} onChange={(v) => set("line1", v)} onBlur={blur("line1")} error={show("line1")} placeholder="221 Market Street" />
<Input id={`${uid}-city`} label="City" autoComplete="address-level2" value={form.city} onChange={(v) => set("city", v)} onBlur={blur("city")} error={show("city")} />
<Input id={`${uid}-postal`} label={country.postalLabel ?? "Postal code"} autoComplete="postal-code" value={form.postal} onChange={(v) => set("postal", v.toUpperCase())} onBlur={blur("postal")} error={show("postal")} />
</div>
</FormGroup>
</div>
{/* Order summary */}
<aside className="rounded-2xl border bg-card text-card-foreground shadow-xs lg:sticky lg:top-6" aria-label="Order summary">
<div className="border-b p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Order summary</p>
<h2 className="mt-1 text-lg font-semibold">
{brand} {plan.name}
</h2>
</div>
<button type="button" onClick={() => setCycle(interval === "year" ? "month" : "year")} className={cn("rounded-full border px-2.5 py-1 text-xs font-medium transition hover:bg-accent", focusRing)}>
{interval === "year" ? "Yearly" : "Monthly"} · switch
</button>
</div>
<div className="mt-5 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium" id={`${uid}-seats-label`}>
Seats
</p>
<p className="text-xs text-muted-foreground">
{fmt.format(unit)} {perLabel}
{minSeats > 1 ? ` · min ${minSeats}` : ""}
</p>
</div>
<div className="inline-flex items-center rounded-lg border bg-background" role="group" aria-labelledby={`${uid}-seats-label`}>
<button type="button" onClick={() => setSeats((s) => Math.max(minSeats, s - 1))} disabled={seats <= minSeats} aria-label="Remove a seat" className={cn("grid size-9 place-items-center rounded-l-lg transition hover:bg-accent disabled:opacity-40", focusRing)}>
<Minus className="size-3.5" />
</button>
<input
aria-label="Number of seats"
inputMode="numeric"
value={seats}
onChange={(e) => setSeats(Math.min(500, Number(digits(e.target.value)) || minSeats))}
onBlur={() => setSeats((s) => Math.max(minSeats, s))}
className="h-9 w-12 border-x bg-transparent text-center text-sm font-medium tabular-nums outline-none focus-visible:bg-accent"
/>
<button type="button" onClick={() => setSeats((s) => Math.min(500, s + 1))} aria-label="Add a seat" className={cn("grid size-9 place-items-center rounded-r-lg transition hover:bg-accent", focusRing)}>
<Plus className="size-3.5" />
</button>
</div>
</div>
</div>
<div className="border-b p-5">
<AnimatePresence mode="wait" initial={false}>
{coupon ? (
<motion.div key="applied" initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/8 px-3 py-2 text-sm">
<Tag className="size-4 text-emerald-600 dark:text-emerald-400" />
<span className="min-w-0 flex-1">
<span className="font-mono font-medium">{coupon.code}</span>
<span className="block truncate text-xs text-muted-foreground">{coupon.label}</span>
</span>
<button type="button" onClick={() => setCoupon(null)} aria-label="Remove coupon" className={cn("grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", focusRing)}>
<X className="size-3.5" />
</button>
</motion.div>
) : (
<motion.div key="input" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<label htmlFor={`${uid}-coupon`} className="sr-only">
Coupon code
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Tag className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
id={`${uid}-coupon`}
value={couponInput}
onChange={(e) => {
setCouponInput(e.target.value);
setCouponMsg("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyCoupon();
}
}}
placeholder="Coupon (try LAUNCH20)"
aria-invalid={!!couponMsg}
aria-describedby={couponMsg ? `${uid}-coupon-err` : undefined}
className={cn(inputBase, "h-10 pl-8 uppercase placeholder:normal-case", couponMsg && "border-destructive")}
/>
</div>
<button type="button" onClick={applyCoupon} className={cn("h-10 rounded-lg border bg-background px-3.5 text-sm font-medium transition hover:bg-accent", focusRing)}>
Apply
</button>
</div>
{couponMsg && (
<p id={`${uid}-coupon-err`} role="alert" className="mt-1.5 text-xs font-medium text-destructive">
{couponMsg}
</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<dl className="space-y-2.5 p-5 text-sm">
<Row label={`${plan.name} × ${seats} seat${seats > 1 ? "s" : ""}`} value={fmt.format(subtotal)} />
{interval === "year" && <Row label="Yearly savings" value={`−${fmt.format(plan.monthly * 12 * seats * yearlyDiscount)}`} muted note="included" />}
<AnimatePresence initial={false}>
{coupon && (
<motion.div key="disc" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
<Row label={`Discount (${coupon.percent}%)`} value={`−${fmt.format(discount)}`} positive />
</motion.div>
)}
</AnimatePresence>
<Row label={`${country.taxLabel} (${(country.tax * 100).toFixed(country.tax * 100 % 1 ? 2 : 0)}%)`} value={fmt.format(tax)} />
<div className="flex items-baseline justify-between border-t pt-3">
<dt className="font-medium">Due today</dt>
<dd className="text-2xl font-semibold tracking-tight tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={total.toFixed(2)} initial={reduce ? false : { opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} className="inline-block">
{fmt.format(total)}
</motion.span>
</AnimatePresence>
</dd>
</div>
<p className="text-xs text-muted-foreground">
Renews {interval === "year" ? "yearly" : "monthly"} at {fmt.format(total)} unless cancelled.
</p>
</dl>
<div className="px-5 pb-5">
<AnimatePresence>
{payError && (
<motion.p role="alert" initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{payError}
</motion.p>
)}
</AnimatePresence>
<button type="submit" disabled={paying} aria-busy={paying} className={cn("relative inline-flex h-12 w-full items-center justify-center gap-2 overflow-hidden rounded-xl bg-primary text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/20 transition hover:brightness-110 disabled:cursor-wait", focusRing)}>
{paying ? <Loader2 className="size-4 animate-spin" /> : <Lock className="size-4" />}
{paying ? "Processing payment…" : `Pay ${fmt.format(total)}`}
{paying && !reduce && (
<motion.span aria-hidden className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent" initial={{ x: "-100%" }} animate={{ x: "300%" }} transition={{ duration: 1.1, repeat: Infinity, ease: "linear" }} />
)}
</button>
<p className="mt-3 text-center text-xs text-muted-foreground">By paying you agree to the Terms of Service.</p>
</div>
</aside>
</form>
</motion.div>
)}
{step === "success" && (
<motion.div key="success" {...fade} transition={{ duration: 0.35 }} className="mx-auto max-w-lg py-8 text-center" role="status">
<div className="relative mx-auto size-20">
{!reduce && (
<motion.span className="absolute inset-0 rounded-full bg-emerald-500/25" initial={{ scale: 0.6, opacity: 1 }} animate={{ scale: 1.8, opacity: 0 }} transition={{ duration: 1.1, delay: 0.25 }} />
)}
<motion.span initial={reduce ? false : { scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 280, damping: 15 }} className="relative grid size-20 place-items-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/30">
<svg viewBox="0 0 24 24" className="size-10" aria-hidden>
<motion.path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ delay: 0.3, duration: 0.45 }} />
</svg>
</motion.span>
</div>
<h1 className="mt-7 text-2xl font-semibold tracking-tight sm:text-3xl">Payment successful</h1>
<p className="mt-2 text-sm text-muted-foreground">
Welcome to {brand} {plan.name}! A receipt is on its way to <span className="font-medium text-foreground">{form.email.trim()}</span>.
</p>
<motion.div initial={reduce ? false : { opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.35 }} className="mt-8 overflow-hidden rounded-2xl border bg-card text-left shadow-xs">
<div className="flex items-center justify-between border-b border-dashed px-5 py-4 text-sm">
<span className="text-muted-foreground">Order</span>
<span className="font-mono font-medium">{orderNo}</span>
</div>
<dl className="space-y-2 px-5 py-4 text-sm">
<Row label={`${plan.name} · ${seats} seat${seats > 1 ? "s" : ""} · ${interval === "year" ? "yearly" : "monthly"}`} value={fmt.format(subtotal)} />
{coupon && <Row label={`Coupon ${coupon.code}`} value={`−${fmt.format(discount)}`} positive />}
<Row label={country.taxLabel} value={fmt.format(tax)} />
<div className="flex justify-between border-t pt-2 font-semibold">
<dt>Paid</dt>
<dd className="tabular-nums">{fmt.format(total)}</dd>
</div>
<p className="pt-1 text-xs text-muted-foreground">Card ending in {digits(form.card).slice(-4)}</p>
</dl>
</motion.div>
<div className="mt-6 flex flex-col justify-center gap-2 sm:flex-row">
<button type="button" className={cn("inline-flex h-11 items-center justify-center rounded-lg bg-foreground px-5 text-sm font-medium text-background transition hover:opacity-90", focusRing)}>
Go to dashboard
</button>
<button type="button" className={cn("inline-flex h-11 items-center justify-center gap-2 rounded-lg border px-5 text-sm font-medium transition hover:bg-accent", focusRing)}>
<Download className="size-4" /> Download receipt
</button>
</div>
<button
type="button"
onClick={() => {
setStep("plans");
setSubmitted(false);
setTouched({});
setForm((f) => ({ ...f, card: "", expiry: "", cvc: "" }));
setCoupon(null);
}}
className={cn("mt-6 rounded text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline", focusRing)}
>
Restart demo
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
function FormGroup({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
return (
<fieldset>
<div className="mb-3 flex items-center justify-between">
<legend className="text-base font-semibold">{title}</legend>
{aside}
</div>
{children}
</fieldset>
);
}
function Input({
id,
label,
value,
onChange,
onBlur,
error,
className,
icon,
mono,
...rest
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
onBlur?: () => void;
error?: string;
className?: string;
icon?: React.ReactNode;
mono?: boolean;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "value" | "id" | "onBlur" | "className">) {
return (
<div className={className}>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium">
{label}
</label>
<div className="relative">
{icon && <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">{icon}</span>}
<input
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
aria-invalid={!!error}
aria-describedby={error ? `${id}-err` : undefined}
className={cn(inputBase, icon && "pl-9", mono && "font-mono tracking-wide", error && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/15")}
{...rest}
/>
</div>
{error && (
<p id={`${id}-err`} className="mt-1.5 text-xs font-medium text-destructive">
{error}
</p>
)}
</div>
);
}
function Row({ label, value, muted, positive, note }: { label: string; value: string; muted?: boolean; positive?: boolean; note?: string }) {
return (
<div className={cn("flex items-baseline justify-between gap-3", muted && "text-muted-foreground", positive && "text-emerald-600 dark:text-emerald-400")}>
<dt className="min-w-0">
{label}
{note && <span className="ml-1.5 text-xs">({note})</span>}
</dt>
<dd className="shrink-0 tabular-nums">{value}</dd>
</div>
);
}
function CardPreview({ number, name, expiry }: { number: string; name: string; expiry: string }) {
const d = digits(number);
const groups = Array.from({ length: 4 }, (_, i) => (d.slice(i * 4, i * 4 + 4) + "••••").slice(0, 4));
return (
<div aria-hidden className="relative mx-auto aspect-[1.586] w-full max-w-[300px] overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-600 via-violet-600 to-fuchsia-600 p-5 text-white shadow-lg shadow-indigo-500/20">
<div className="absolute -right-10 -top-16 size-44 rounded-full bg-white/15 blur-xl" />
<div className="absolute -bottom-20 -left-10 size-48 rounded-full bg-black/15 blur-xl" />
<div className="relative flex h-full flex-col">
<div className="flex items-center justify-between">
<span className="h-7 w-9 rounded-md bg-gradient-to-br from-amber-200 to-amber-400 ring-1 ring-black/10" />
<svg viewBox="0 0 24 24" className="size-5 opacity-80">
<path d="M8 6a8 8 0 0 1 0 12M12 4a12 12 0 0 1 0 16M4 9a4 4 0 0 1 0 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</div>
<p className="mt-auto flex justify-between font-mono text-[15px] tracking-widest sm:text-base">
{groups.map((g, i) => (
<span key={i}>{g}</span>
))}
</p>
<div className="mt-3 flex items-end justify-between text-[11px] uppercase">
<span className="min-w-0 truncate">
<span className="block text-[9px] opacity-70">Card holder</span>
{name.trim() || "Your name"}
</span>
<span className="shrink-0 text-right">
<span className="block text-[9px] opacity-70">Expires</span>
{expiry.replace(/\s/g, "") || "MM/YY"}
</span>
</div>
</div>
</div>
);
}