components/chargebee-card-form.tsx (Chargebee Components, Fields mode)
"use client";
import { useEffect, useRef, useState, type ComponentRef, type FormEvent } from "react";
import { CardComponent, CardCVV, CardExpiry, CardNumber, Provider } from "@chargebee/chargebee-js-react-wrapper";
import { loadChargebee, type CbFieldState, type CbInstance } from "@/lib/chargebee-js";
type Field = "number" | "expiry" | "cvv";
// The fields are iframes and can't read your CSS, so their text is styled here. Borders go on the wrappers.
const styles = {
base: { color: "#18181b", fontSize: "16px", fontFamily: "system-ui, sans-serif", "::placeholder": { color: "#a1a1aa" } },
invalid: { color: "#dc2626" },
};
const box = "rounded-md border border-zinc-300 px-3 py-3 focus-within:border-zinc-900";
export function ChargebeeCardForm({ itemPriceId }: { itemPriceId: string }) {
const [chargebee, setChargebee] = useState<CbInstance>();
const card = useRef<ComponentRef<typeof CardComponent>>(null);
const [done, setDone] = useState<Record<Field, boolean>>({ number: false, expiry: false, cvv: false });
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const [subscribed, setSubscribed] = useState(false);
// Chargebee.init() has to run before CardComponent mounts: the wrapper calls Chargebee.getInstance() itself.
useEffect(() => {
loadChargebee().then(setChargebee, (e: Error) => setError(e.message));
}, []);
// The wrapper types this as a React ChangeEvent; Chargebee actually passes its field state.
const onField = (e: unknown) => {
const state = e as CbFieldState;
if (state.field) setDone((d) => ({ ...d, [state.field!]: state.complete }));
setError(state.error?.message);
};
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
setBusy(true);
setError(undefined);
try {
const firstName = String(form.get("name") ?? "");
const { token } = (await card.current!.tokenize({ firstName })) as { token: string };
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, firstName, email: form.get("email"), itemPriceId }),
});
const data = (await res.json()) as { error?: string };
if (!res.ok) throw new Error(data.error);
setSubscribed(true);
} catch (err) {
setError((err as Error).message || "Couldn't subscribe. Try again.");
} finally {
setBusy(false);
}
}
if (subscribed) return <p className="font-medium">You're subscribed. Check your inbox for the receipt.</p>;
if (!chargebee) return error ? <p className="text-sm text-red-600">{error}</p> : <div className="h-52 animate-pulse rounded-md bg-zinc-100" />;
return (
<form onSubmit={onSubmit} className="grid gap-3">
<input name="email" type="email" required placeholder="Email" autoComplete="email" className="rounded-md border border-zinc-300 px-3 py-3" />
<input name="name" required placeholder="Name on card" autoComplete="cc-name" className="rounded-md border border-zinc-300 px-3 py-3" />
<Provider cbInstance={chargebee}>
<CardComponent ref={card} styles={styles} locale="en" placeholder={{ number: "Card number", expiry: "MM / YY", cvv: "CVC" }} className="grid gap-3">
<CardNumber className={box} onChange={onField} />
<div className="grid grid-cols-2 gap-3">
<CardExpiry className={box} onChange={onField} />
<CardCVV className={box} onChange={onField} />
</div>
</CardComponent>
</Provider>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy || !(done.number && done.expiry && done.cvv)} className="rounded-lg bg-zinc-900 px-5 py-3 font-medium text-white disabled:opacity-50">
{busy ? "Subscribing..." : "Subscribe"}
</button>
</form>
);
}
app/api/subscribe/route.ts (creates the customer and the subscription)
import Chargebee from "chargebee";
import { NextResponse } from "next/server";
const chargebee = new Chargebee({
site: process.env.CHARGEBEE_SITE!,
apiKey: process.env.CHARGEBEE_API_KEY!, // secret API key: server only, never NEXT_PUBLIC
});
// The plans this page sells. The browser names one; the server decides whether that's allowed.
const PLANS = new Set(["pro-USD-monthly", "pro-USD-yearly"]);
type Body = { token?: string; email?: string; firstName?: string; itemPriceId?: string };
export async function POST(req: Request) {
const { token, email, firstName, itemPriceId } = (await req.json()) as Body;
if (!token || !email || !itemPriceId || !PLANS.has(itemPriceId)) {
return NextResponse.json({ error: "Missing card, email or plan." }, { status: 400 });
}
try {
// token_id is the Chargebee.js token: it becomes the customer's card. It expires 30 minutes after tokenize().
const { customer, card } = await chargebee.customer.create({ email, first_name: firstName, token_id: token });
const { subscription } = await chargebee.subscription.createWithItems(customer.id, {
subscription_items: [{ item_price_id: itemPriceId }],
});
return NextResponse.json({ subscriptionId: subscription.id, status: subscription.status, last4: card?.last4 });
} catch (err) {
const message = (err as { message?: string }).message ?? "Chargebee didn't accept the card.";
return NextResponse.json({ error: message }, { status: 402 });
}
}