"use client";
import * as React from "react";
import { motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------------------------------------------------- */
/* Card rules */
/* -------------------------------------------------------------------------- */
export type CardBrand = "visa" | "mastercard" | "amex" | "discover";
export type CardField = "number" | "expiry" | "cvc" | "name";
export interface CardInputValue {
/** Card number digits only (no spaces). */
number: string;
/** Brand detected from the IIN prefix, or null while unknown. */
brand: CardBrand | null;
/** 1–12, or null until two digits are typed. */
expiryMonth: number | null;
/** Four-digit year, or null until complete. */
expiryYear: number | null;
cvc: string;
name: string;
/** Validation messages for fields that are currently invalid (empty fields included). */
errors: Partial<Record<CardField, string>>;
/** True when every field is valid. */
complete: boolean;
}
type BrandRule = { label: string; lengths: number[]; gaps: number[]; cvc: number };
const BRANDS: Record<CardBrand, BrandRule> = {
visa: { label: "Visa", lengths: [13, 16, 19], gaps: [4, 8, 12, 16], cvc: 3 },
mastercard: { label: "Mastercard", lengths: [16], gaps: [4, 8, 12], cvc: 3 },
amex: { label: "American Express", lengths: [15], gaps: [4, 10], cvc: 4 },
discover: { label: "Discover", lengths: [16, 19], gaps: [4, 8, 12, 16], cvc: 3 },
};
const UNKNOWN: BrandRule = { label: "Card", lengths: [16], gaps: [4, 8, 12, 16], cvc: 3 };
const MAX_UNKNOWN = 19;
/** Detects the brand from the issuer identification number (IIN) prefix. */
export function detectCardBrand(digits: string): CardBrand | null {
if (/^4/.test(digits)) return "visa";
if (/^3[47]/.test(digits)) return "amex";
if (/^5[1-5]/.test(digits)) return "mastercard";
if (digits.length >= 4) {
const p4 = Number(digits.slice(0, 4));
if (p4 >= 2221 && p4 <= 2720) return "mastercard";
}
if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
if (digits.length >= 6) {
const p6 = Number(digits.slice(0, 6));
if (p6 >= 622126 && p6 <= 622925) return "discover";
}
return null;
}
/** Luhn (mod 10) checksum. */
export function luhnCheck(digits: string): boolean {
if (!/^\d+$/.test(digits)) return false;
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return sum % 10 === 0;
}
const ruleFor = (brand: CardBrand | null) => (brand ? BRANDS[brand] : UNKNOWN);
const maxLength = (brand: CardBrand | null) => (brand ? Math.max(...BRANDS[brand].lengths) : MAX_UNKNOWN);
function formatNumber(digits: string, brand: CardBrand | null) {
const { gaps } = ruleFor(brand);
let out = "";
for (let i = 0; i < digits.length; i++) {
if (i > 0 && gaps.includes(i)) out += " ";
out += digits[i];
}
return out;
}
/** Caret index in the formatted string that sits right after `count` digits. */
const caretAfter = (count: number, brand: CardBrand | null) =>
count + ruleFor(brand).gaps.filter((g) => g > 0 && g < count).length;
const expiryDigits = (text: string) => text.replace(/\D/g, "").slice(0, 4);
function formatExpiry(digits: string, inserting: boolean) {
if (digits.length < 2) return digits;
if (digits.length === 2) return inserting ? `${digits} / ` : digits;
return `${digits.slice(0, 2)} / ${digits.slice(2)}`;
}
type Today = { year: number; month: number };
function validate(
fields: Record<CardField, string>,
requireName: boolean,
today: Today | null,
): Partial<Record<CardField, string>> {
const errors: Partial<Record<CardField, string>> = {};
const brand = detectCardBrand(fields.number);
const rule = ruleFor(brand);
if (!fields.number) errors.number = "Enter a card number.";
else if (!rule.lengths.includes(fields.number.length) && fields.number.length < maxLength(brand))
errors.number = "Card number is incomplete.";
else if (!rule.lengths.includes(fields.number.length) || !luhnCheck(fields.number)) errors.number = "Card number is invalid.";
const exp = expiryDigits(fields.expiry);
const month = Number(exp.slice(0, 2));
if (!exp) errors.expiry = "Enter an expiry date.";
else if (exp.length >= 2 && (month < 1 || month > 12)) errors.expiry = "Month must be between 01 and 12.";
else if (exp.length < 4) errors.expiry = "Expiry date is incomplete.";
else if (today) {
const year = 2000 + Number(exp.slice(2));
if (year < today.year || (year === today.year && month < today.month)) errors.expiry = "This card has expired.";
else if (year > today.year + 20) errors.expiry = "Expiry year is too far ahead.";
}
if (!fields.cvc) errors.cvc = "Enter the security code.";
else if (fields.cvc.length !== rule.cvc) errors.cvc = `Security code is ${rule.cvc} digits.`;
if (requireName && !fields.name.trim()) errors.name = "Enter the name on the card.";
return errors;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export type CardPreviewStyle = "graphite" | "accent" | "paper";
export interface CardInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children"> {
/** Called after every edit with structured, validated data. Nothing is sent or stored by the component. */
onChange?: (value: CardInputValue) => void;
/** Show the animated card preview. */
showPreview?: boolean;
/** Flip the preview to its back while the security code is focused (Amex keeps its code on the front). */
flipOnCvc?: boolean;
/** Move focus to the next field once a field is complete and valid. */
autoAdvance?: boolean;
/** Treat the cardholder name as required. */
requireName?: boolean;
/** Preview card finish. */
cardStyle?: CardPreviewStyle;
/** Focus ring, caret and accent-finish colour (any CSS colour). */
accent?: string;
/** Placeholder (and hint) for the number field — keep it a public test number. */
numberPlaceholder?: string;
disabled?: boolean;
}
function BrandMark({ brand, className = "" }: { brand: CardBrand | null; className?: string }) {
// Generic text badges — deliberately not the networks' trademarked logos.
if (!brand) return null;
const base = `inline-flex select-none items-center leading-none ${className}`;
switch (brand) {
case "visa":
return <span className={`${base} text-[1.05em] font-black italic tracking-[-0.04em]`}>VISA</span>;
case "mastercard":
return <span className={`${base} text-[0.62em] font-bold uppercase tracking-[0.12em]`}>Mastercard</span>;
case "amex":
return <span className={`${base} rounded-[3px] border border-current px-1 py-0.5 text-[0.62em] font-bold tracking-[0.1em]`}>AMEX</span>;
case "discover":
return <span className={`${base} text-[0.62em] font-bold uppercase tracking-[0.18em]`}>Discover</span>;
}
}
/**
* Payment card entry UI — interface only. It formats, detects the brand by IIN prefix, validates
* with Luhn and expiry rules and reports structured data through `onChange`; it never transmits or
* stores anything. Use a PCI-compliant processor (hosted fields / tokenisation) for real payments.
*/
export function CardInput({
onChange,
showPreview = true,
flipOnCvc = true,
autoAdvance = true,
requireName = true,
cardStyle = "graphite",
accent = "#FF7448",
numberPlaceholder = "4242 4242 4242 4242",
disabled = false,
className = "",
style,
...props
}: CardInputProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const id = (f: string) => `ci-${uid}-${f}`;
const [number, setNumber] = React.useState("");
const [expiry, setExpiry] = React.useState("");
const [cvc, setCvc] = React.useState("");
const [name, setName] = React.useState("");
const [touched, setTouched] = React.useState<Record<CardField, boolean>>({ number: false, expiry: false, cvc: false, name: false });
const [focused, setFocused] = React.useState<CardField | null>(null);
// Today's month is captured on first focus (an event), keeping render pure and SSR-safe.
const [today, setToday] = React.useState<Today | null>(null);
const numberRef = React.useRef<HTMLInputElement>(null);
const expiryRef = React.useRef<HTMLInputElement>(null);
const cvcRef = React.useRef<HTMLInputElement>(null);
const nameRef = React.useRef<HTMLInputElement>(null);
const pendingCaret = React.useRef<number | null>(null);
const brand = detectCardBrand(number);
const rule = ruleFor(brand);
const formattedNumber = formatNumber(number, brand);
const fields = { number, expiry, cvc, name };
const errors = validate(fields, requireName, today);
const expDigits = expiryDigits(expiry);
// Errors appear after a field is left, or as soon as a full-length entry is wrong.
const visible: Partial<Record<CardField, string>> = {};
if (errors.number && (touched.number || number.length >= maxLength(brand) || (number.length === 16 && brand !== "amex")))
visible.number = errors.number;
if (errors.expiry && (touched.expiry || expDigits.length === 4 || (expDigits.length >= 2 && Number(expDigits.slice(0, 2)) > 12)))
visible.expiry = errors.expiry;
if (errors.cvc && touched.cvc) visible.cvc = errors.cvc;
if (errors.name && touched.name) visible.name = errors.name;
const emit = (patch: Partial<Record<CardField, string>>) => {
if (!onChange) return;
const next = { ...fields, ...patch };
const nextErrors = validate(next, requireName, today);
const exp = expiryDigits(next.expiry);
const month = exp.length >= 2 ? Number(exp.slice(0, 2)) : null;
onChange({
number: next.number,
brand: detectCardBrand(next.number),
expiryMonth: month && month >= 1 && month <= 12 ? month : null,
expiryYear: exp.length === 4 ? 2000 + Number(exp.slice(2)) : null,
cvc: next.cvc,
name: next.name,
errors: nextErrors,
complete: Object.keys(nextErrors).length === 0,
});
};
// Restore the caret after reformatting the card number (spaces shift it otherwise).
React.useLayoutEffect(() => {
const pos = pendingCaret.current;
const el = numberRef.current;
if (pos === null || !el) return;
pendingCaret.current = null;
if (document.activeElement === el) el.setSelectionRange(pos, pos);
});
const focusField = (el: HTMLInputElement | null) => {
if (!el) return;
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
};
/* --------------------------------- handlers -------------------------------- */
const onNumberChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const el = event.currentTarget;
const raw = el.value;
const inputType = (event.nativeEvent as InputEvent).inputType ?? "";
const caret = el.selectionStart ?? raw.length;
let digits = raw.replace(/\D/g, "");
let before = raw.slice(0, caret).replace(/\D/g, "").length;
// A separator was deleted: remove the neighbouring digit instead, as the user intended.
if (digits === number && raw.length < formattedNumber.length) {
if (inputType === "deleteContentForward") digits = digits.slice(0, before) + digits.slice(before + 1);
else if (before > 0) {
digits = digits.slice(0, before - 1) + digits.slice(before);
before -= 1;
}
}
const nextBrand = detectCardBrand(digits);
digits = digits.slice(0, maxLength(nextBrand));
before = Math.min(before, digits.length);
const nextCvc = cvc.slice(0, ruleFor(nextBrand).cvc);
setNumber(digits);
if (nextCvc !== cvc) setCvc(nextCvc);
pendingCaret.current = caretAfter(before, nextBrand);
emit({ number: digits, cvc: nextCvc });
const nextRule = ruleFor(nextBrand);
const full = digits.length === maxLength(nextBrand) || (digits.length === 16 && nextRule.lengths.includes(16));
if (autoAdvance && inputType.startsWith("insert") && before === digits.length && full && nextRule.lengths.includes(digits.length) && luhnCheck(digits)) {
focusField(expiryRef.current);
}
};
const onExpiryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.currentTarget.value;
const inputType = (event.nativeEvent as InputEvent).inputType ?? "";
const deleting = inputType.startsWith("delete");
let digits: string;
// Autofill may supply MM/YYYY.
const full = raw.match(/^\s*(\d{1,2})\s*\/\s*(\d{4})\s*$/);
if (full) digits = full[1].padStart(2, "0") + full[2].slice(2);
else {
digits = raw.replace(/\D/g, "");
if (deleting && digits === expDigits) digits = digits.slice(0, -1); // deleted the " / "
if (digits.length === 1 && digits > "1") digits = `0${digits}`; // "4" → "04"
digits = digits.slice(0, 4);
}
const text = formatExpiry(digits, !deleting);
setExpiry(text);
emit({ expiry: text });
const month = Number(digits.slice(0, 2));
if (autoAdvance && !deleting && digits.length === 4 && month >= 1 && month <= 12) focusField(cvcRef.current);
};
const onCvcChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const digits = event.currentTarget.value.replace(/\D/g, "").slice(0, rule.cvc);
const inputType = (event.nativeEvent as InputEvent).inputType ?? "";
setCvc(digits);
emit({ cvc: digits });
if (autoAdvance && inputType.startsWith("insert") && digits.length === rule.cvc) focusField(nameRef.current);
};
const onNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value.slice(0, 40);
setName(value);
emit({ name: value });
};
/** Backspace in an empty field steps back to the previous one. */
const stepBack = (event: React.KeyboardEvent<HTMLInputElement>, prev: "number" | "expiry" | "cvc") => {
if (event.key !== "Backspace" || event.currentTarget.value !== "") return;
const target = prev === "number" ? numberRef.current : prev === "expiry" ? expiryRef.current : cvcRef.current;
if (!target) return;
event.preventDefault();
focusField(target);
};
const focusProps = (field: CardField) => ({
onFocus: () => {
setFocused(field);
if (!today) {
const d = new Date();
setToday({ year: d.getFullYear(), month: d.getMonth() + 1 });
}
},
onBlur: () => {
setFocused((f) => (f === field ? null : f));
setTouched((t) => (t[field] ? t : { ...t, [field]: true }));
},
"aria-invalid": visible[field] ? true : undefined,
"aria-describedby": [field === "number" ? id("number-hint") : null, visible[field] ? id(`${field}-error`) : null].filter(Boolean).join(" ") || undefined,
disabled,
});
/* ---------------------------------- render --------------------------------- */
const inputClass = (field: CardField) =>
`h-11 w-full min-w-0 rounded-xl border bg-white px-3.5 text-[15px] tabular-nums text-zinc-950 caret-[var(--ci-accent)] outline-hidden transition-[border-color,box-shadow] duration-150 placeholder:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none dark:bg-zinc-950 dark:text-zinc-50 dark:placeholder:text-zinc-600 ${
visible[field]
? "border-rose-500/80 focus:shadow-[0_0_0_3px_rgba(244,63,94,0.18)] dark:border-rose-400/80"
: "border-zinc-900/15 hover:border-zinc-900/30 focus:border-[var(--ci-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ci-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30"
}`;
const labelClass = "mb-1.5 block font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400";
const errorText = (field: CardField) =>
visible[field] ? (
<p id={id(`${field}-error`)} className="mt-1.5 text-[12px] font-medium leading-snug text-rose-600 dark:text-rose-400">
{visible[field]}
</p>
) : null;
const flipped = flipOnCvc && focused === "cvc" && brand !== "amex";
// Preview number: typed digits, then placeholder dots up to the brand's usual length.
const templateLength = Math.max(brand === "amex" ? 15 : 16, number.length);
let previewNumber = "";
for (let i = 0; i < templateLength; i++) {
if (i > 0 && rule.gaps.includes(i)) previewNumber += " ";
previewNumber += number[i] ?? "•";
}
const previewExpiry = `${expDigits.slice(0, 2).padEnd(2, "•")}/${expDigits.slice(2).padEnd(2, "•")}`;
const finish =
cardStyle === "accent"
? "bg-[linear-gradient(135deg,var(--ci-accent),color-mix(in_srgb,var(--ci-accent)_55%,#000))] text-zinc-950 border-black/10"
: cardStyle === "paper"
? "bg-[linear-gradient(135deg,#FBFAF7,#ECE9E1)] text-zinc-900 border-zinc-900/10"
: "bg-[radial-gradient(120%_90%_at_100%_0%,color-mix(in_srgb,var(--ci-accent)_30%,transparent),transparent_55%),linear-gradient(135deg,#27272a,#09090b)] text-white border-white/10";
const muted = cardStyle === "graphite" ? "text-white/55" : "text-current opacity-60";
const spot = (field: CardField) =>
`rounded-md px-1.5 py-1 -mx-1.5 -my-1 transition-[box-shadow] duration-200 motion-reduce:transition-none ${
focused === field ? (cardStyle === "graphite" ? "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.45)]" : "shadow-[inset_0_0_0_1px_rgba(0,0,0,0.35)]") : ""
}`;
const faceBase = `absolute inset-0 overflow-hidden rounded-2xl border shadow-[0_24px_48px_-28px_rgba(0,0,0,0.6)] ${finish} [backface-visibility:hidden] [-webkit-backface-visibility:hidden]`;
const flipTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 26, mass: 0.9 };
const preview = (
<div aria-hidden="true" className="mx-auto w-full max-w-[320px] [perspective:1100px]">
<motion.div
initial={false}
animate={{ rotateY: flipped && !reduceMotion ? 180 : 0 }}
transition={flipTransition}
className="relative aspect-[1.586] w-full [transform-style:preserve-3d]"
>
{/* Front */}
<div className={`${faceBase} flex flex-col justify-between p-5 ${reduceMotion && flipped ? "opacity-0" : ""}`}>
<div className="flex items-start justify-between">
<svg viewBox="0 0 40 30" className="h-7 w-9">
<rect x="0.5" y="0.5" width="39" height="29" rx="6" fill="#D9B97A" stroke="rgba(0,0,0,0.25)" />
<path d="M0.5 10.5h12M0.5 19.5h12M27.5 10.5h12M27.5 19.5h12M12.5 0.5v29M27.5 0.5v29" stroke="rgba(0,0,0,0.28)" fill="none" />
</svg>
<div className="flex h-7 items-center text-[15px]">
<BrandMark brand={brand} />
{!brand && <span className={`font-mono text-[10px] uppercase tracking-[0.14em] ${muted}`}>Card</span>}
</div>
</div>
<div className="flex items-end justify-between gap-2">
<span className={`font-mono text-[clamp(13px,5.4cqw,19px)] tracking-[0.06em] tabular-nums ${spot("number")}`}>{previewNumber}</span>
{brand === "amex" && (
<span className={`font-mono text-[11px] tabular-nums ${spot("cvc")}`}>{cvc.padEnd(4, "•")}</span>
)}
</div>
<div className="flex items-end justify-between gap-4">
<div className={`min-w-0 ${spot("name")}`}>
<div className={`font-mono text-[8px] uppercase tracking-[0.16em] ${muted}`}>Card holder</div>
<div className="truncate text-[13px] font-medium uppercase tracking-[0.06em]">{name.trim() || "Your name"}</div>
</div>
<div className={`shrink-0 text-right ${spot("expiry")}`}>
<div className={`font-mono text-[8px] uppercase tracking-[0.16em] ${muted}`}>Expires</div>
<div className="font-mono text-[13px] tabular-nums">{previewExpiry}</div>
</div>
</div>
</div>
{/* Back */}
<div
className={`${faceBase} ${reduceMotion ? (flipped ? "" : "opacity-0") : "[transform:rotateY(180deg)]"}`}
>
<div className="mt-6 h-10 w-full bg-black/80" />
<div className="mx-5 mt-5 flex items-center gap-3">
<div className="h-8 flex-1 rounded-sm bg-[repeating-linear-gradient(-45deg,rgba(255,255,255,0.9)_0_6px,rgba(228,228,231,0.9)_6px_12px)]" />
<div className={`rounded-md bg-white px-2.5 py-1.5 font-mono text-[13px] tabular-nums text-zinc-900 ${focused === "cvc" ? "shadow-[0_0_0_2px_var(--ci-accent)]" : ""}`}>
{cvc.padEnd(rule.cvc, "•")}
</div>
</div>
<div className={`mx-5 mt-3 font-mono text-[8px] uppercase tracking-[0.16em] ${muted}`}>Security code</div>
</div>
</motion.div>
</div>
);
return (
<div
{...props}
className={`@container w-full ${className}`}
style={{ ["--ci-accent" as string]: accent, ...style }}
>
<div className={`grid grid-cols-1 gap-6 ${showPreview ? "@[640px]:grid-cols-[minmax(0,300px)_minmax(0,1fr)] @[640px]:items-center @[640px]:gap-8" : ""}`}>
{showPreview && <div className="@container">{preview}</div>}
<fieldset className="min-w-0 space-y-4" disabled={disabled}>
<legend className="sr-only">Card details</legend>
<div>
<label htmlFor={id("number")} className={labelClass}>
Card number
</label>
<div className="relative">
<input
ref={numberRef}
id={id("number")}
type="text"
inputMode="numeric"
autoComplete="cc-number"
autoCorrect="off"
spellCheck={false}
placeholder={numberPlaceholder}
value={formattedNumber}
onChange={onNumberChange}
{...focusProps("number")}
className={`${inputClass("number")} pr-24 font-mono tracking-[0.04em]`}
/>
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-3.5 flex items-center text-[13px] text-zinc-700 dark:text-zinc-300">
{brand ? (
<BrandMark brand={brand} />
) : (
<svg viewBox="0 0 24 16" className="h-4 w-6 text-zinc-400 dark:text-zinc-600" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="0.65" y="0.65" width="22.7" height="14.7" rx="2.5" />
<path d="M0.65 5h22.7" />
</svg>
)}
</span>
</div>
<p id={id("number-hint")} className="sr-only" aria-live="polite">
{brand ? `${BRANDS[brand].label} detected.` : ""}
</p>
{errorText("number")}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<label htmlFor={id("expiry")} className={labelClass}>
Expiry
</label>
<input
ref={expiryRef}
id={id("expiry")}
type="text"
inputMode="numeric"
autoComplete="cc-exp"
autoCorrect="off"
spellCheck={false}
placeholder="MM / YY"
maxLength={7}
value={expiry}
onChange={onExpiryChange}
onKeyDown={(event) => stepBack(event, "number")}
{...focusProps("expiry")}
className={`${inputClass("expiry")} font-mono`}
/>
{errorText("expiry")}
</div>
<div className="min-w-0">
<label htmlFor={id("cvc")} className={labelClass}>
CVC
</label>
<input
ref={cvcRef}
id={id("cvc")}
type="text"
inputMode="numeric"
autoComplete="cc-csc"
autoCorrect="off"
spellCheck={false}
placeholder={rule.cvc === 4 ? "4 digits" : "3 digits"}
maxLength={rule.cvc}
value={cvc}
onChange={onCvcChange}
onKeyDown={(event) => stepBack(event, "expiry")}
{...focusProps("cvc")}
className={`${inputClass("cvc")} font-mono`}
/>
{errorText("cvc")}
</div>
</div>
<div>
<label htmlFor={id("name")} className={labelClass}>
Name on card{requireName ? "" : " (optional)"}
</label>
<input
ref={nameRef}
id={id("name")}
type="text"
autoComplete="cc-name"
autoCapitalize="words"
spellCheck={false}
placeholder="Ada Lovelace"
value={name}
onChange={onNameChange}
onKeyDown={(event) => stepBack(event, "cvc")}
{...focusProps("name")}
className={inputClass("name")}
/>
{errorText("name")}
</div>
</fieldset>
</div>
</div>
);
}