Use it in Next.js
app/pay/checkout.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { loadVGSCollect } from "@vgs/collect-js";
import type { IVGSCollect, IVGSCollectForm, VGSCollectFormState } from "@vgs/collect-js-react";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
type Submit = (cardholder: string) => Promise<unknown>;
const vaultId = process.env.NEXT_PUBLIC_VGS_VAULT_ID!;
// Load Collect.js once per page, even when Strict Mode runs effects twice.
// The loader resolves to the global VGSCollect object; the React package ships its type.
let collect: Promise<IVGSCollect> | undefined;
const loadCollect = () =>
(collect ??= loadVGSCollect({ vaultId, environment: "sandbox", version: "4.0.1" }) as Promise<IVGSCollect>);
// The fields are iframes and don't read Tailwind, so their text is styled here.
const css = {
color: "#F4F4F6",
"font-size": "19px",
"font-family": "ui-monospace, SFMono-Regular, Menlo, monospace",
"&::placeholder": { color: "#4a4f61" },
};
function VgsFields({ slot, onReady }: { slot: CardFieldsSlot; onReady: (submit: Submit | null) => void }) {
useEffect(() => {
let gone = false;
let form: IVGSCollectForm | undefined;
// Runs on every keystroke and focus change, with one entry per field.
const stateCallback = (state: VGSCollectFormState) => {
if (!state) return;
const fields = Object.values(state);
slot.onChange({
complete: fields.length === 3 && fields.every((f) => f.isValid),
brand: state.card_number?.cardType ?? null, // "visa", "mastercard", "amex"...
});
};
(async () => {
const VGSCollect = await loadCollect();
// Collect.js 4 replaces the deprecated create() with session().
const f = await VGSCollect.session({ vaultId, env: "sandbox", stateCallback });
form = f;
if (gone) return f.unmount();
f.field("#vgs-number", {
type: "card-number",
name: "card_number",
validations: ["required", "validCardNumber"],
// Required by the 2.1.1 types. List the brands you take: in our test an empty array rejected every card.
validCardBrands: [{ type: "visa" }, { type: "mastercard" }, { type: "amex" }],
placeholder: "1234 5678 9012 3456",
autoComplete: "cc-number",
css,
});
f.field("#vgs-expiry", {
type: "card-expiration-date",
name: "card_exp",
validations: ["required", "validCardExpirationDate"],
yearLength: 2,
placeholder: "MM / YY",
autoComplete: "cc-exp",
css,
});
const cvc = f.field("#vgs-cvc", {
type: "card-security-code",
name: "card_cvc",
validations: ["required", "validCardSecurityCode"],
placeholder: "123",
autoComplete: "cc-csc",
css,
});
cvc.on("focus", () => slot.onChange({ focus: "cvc" })); // flips the card
cvc.on("blur", () => slot.onChange({ focus: null }));
// Pay sends the three fields through your vault's inbound route.
// Your server receives aliases in their place, never the card number.
onReady(
(cardholder) =>
new Promise((resolve, reject) =>
f.submit(
"/api/vgs/charge",
{ data: (fields: Record<string, unknown>) => ({ ...fields, cardholder }) },
(status, data) =>
typeof status === "number" && status < 300 ? resolve(data) : reject(new Error("That card didn't go through.")),
() => reject(new Error("Check the card details.")),
),
),
);
})();
return () => {
gone = true;
onReady(null);
form?.unmount();
};
}, [slot, onReady]);
// Collect.js sizes its iframe from the box it lands in, so let it fill the slot's box.
const box = `${slot.fieldClassName} [&_iframe]:h-full`;
return (
<div className="space-y-3">
<div>
<label className={slot.labelClassName}>Card number</label>
<div id="vgs-number" className={box} />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className={slot.labelClassName}>Expiry</label>
<div id="vgs-expiry" className={box} />
</div>
<div className="flex-1">
<label className={slot.labelClassName}>CVC</label>
<div id="vgs-cvc" className={box} />
</div>
</div>
</div>
);
}
// The piece is a fixed 1000 x 660 stage. Scale it down to fit narrow screens.
function useFitScale() {
const [scale, setScale] = useState(1);
useEffect(() => {
const fit = () => setScale(Math.min(1, window.innerWidth / 1000));
fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, []);
return scale;
}
export default function VgsCardCheckout() {
const router = useRouter();
const scale = useFitScale();
const submit = useRef<Submit | null>(null);
const setSubmit = useRef((s: Submit | null) => (submit.current = s)).current;
return (
<div className="overflow-hidden" style={{ height: 660 * scale }}>
<CardCheckout
className="origin-top-left"
style={{ transform: `scale(${scale})` }}
resetAfter={null}
renderCardFields={(slot) => <VgsFields slot={slot} onReady={setSubmit} />}
onConfirm={async ({ name }) => {
if (!submit.current) throw new Error("The card fields haven't loaded yet.");
await submit.current(name);
router.push("/thanks");
}}
/>
</div>
);
}
app/pay/page.tsx
import VgsCardCheckout from "./checkout";
export default function PayPage() {
return <VgsCardCheckout />;
}
app/api/vgs/charge/route.ts
import { ProxyAgent, fetch } from "undici";
// Server only. Your inbound route forwards the Pay request here, with aliases
// (like "tok_sandbox_...") where the card number, expiry and CVC were.
type AliasedCard = { card_number: string; card_exp: string; card_cvc: string; cardholder: string };
// Your outbound route reveals the aliases on the way to your card processor.
// Username, password and sandbox.pem come from the VGS dashboard.
const vgsOutbound = new ProxyAgent({
uri: `https://${process.env.VGS_VAULT_ID}.sandbox.verygoodproxy.com:8443`,
token: `Basic ${Buffer.from(`${process.env.VGS_PROXY_USERNAME}:${process.env.VGS_PROXY_PASSWORD}`).toString("base64")}`,
requestTls: { ca: process.env.VGS_SANDBOX_PEM }, // VGS re-signs TLS, so trust its certificate
});
export async function POST(req: Request) {
const card = (await req.json()) as AliasedCard;
// Your processor's charge endpoint. Map the field names to what it expects.
const res = await fetch(process.env.PROCESSOR_CHARGE_URL!, {
method: "POST",
dispatcher: vgsOutbound,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.PROCESSOR_SECRET_KEY}` },
body: JSON.stringify({
number: card.card_number,
expiry: card.card_exp,
cvc: card.card_cvc,
name: card.cardholder,
}),
});
if (!res.ok) return Response.json({ error: "declined" }, { status: 402 });
return Response.json({ ok: true });
}