Fazekit

Code

"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>
  );
}

More in App Pages

View all →