{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "credit-card-dialog", "type": "registry:ui", "title": "Credit Card Dialog", "description": "Inline payment form — four zones separated by hairlines. Auto-format, auto-advance, brand detection.", "files": [ { "path": "registry/ruixenui/credit-card-dialog.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\n\n/**\n * Credit Card Form — Rauno Freiberg craft.\n *\n * An inline payment form. Four zones separated by hairlines.\n * Number, name, expiry + CVC, pay. The typography IS the design.\n *\n * Auto-format, auto-advance, brand detection.\n * Pay wakes up when all fields are complete.\n * Quiet focus redirection on incomplete submit.\n * Soft tick on keystrokes.\n */\n\n/* ── Types ── */\n\nexport interface CardData {\n number: string;\n name: string;\n expiry: string;\n cvc: string;\n}\n\nexport interface CreditCardDialogProps {\n onSubmit?: (card: CardData) => void;\n amount?: string;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst MONO =\n \"ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace\";\n\nconst CC_CSS = `.cc{--cc-ink:0,0,0;--cc-sep:rgba(0,0,0,0.08)}.dark .cc,[data-theme=\"dark\"] .cc{--cc-ink:255,255,255;--cc-sep:rgba(var(--cc-ink),0.06)}`;\nconst SEP = \"var(--cc-sep)\";\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003);\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 20) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.0;\n gain.gain.value = 0.04;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Brand ── */\n\ntype Brand = \"visa\" | \"mastercard\" | \"amex\" | null;\n\nfunction detectBrand(num: string): Brand {\n const d = num.replace(/\\D/g, \"\");\n if (!d) return null;\n if (/^4/.test(d)) return \"visa\";\n if (/^5[1-5]/.test(d) || /^2[2-7]/.test(d)) return \"mastercard\";\n if (/^3[47]/.test(d)) return \"amex\";\n return null;\n}\n\nfunction numMax(b: Brand) {\n return b === \"amex\" ? 15 : 16;\n}\nfunction cvcLen(b: Brand) {\n return b === \"amex\" ? 4 : 3;\n}\n\n/* ── Format ── */\n\nfunction strip(s: string) {\n return s.replace(/\\D/g, \"\");\n}\n\nfunction fmtNum(s: string, b: Brand): string {\n const d = strip(s).slice(0, numMax(b));\n if (b === \"amex\") {\n const p: string[] = [];\n if (d.length > 0) p.push(d.slice(0, 4));\n if (d.length > 4) p.push(d.slice(4, 10));\n if (d.length > 10) p.push(d.slice(10));\n return p.join(\" \");\n }\n const p: string[] = [];\n for (let i = 0; i < d.length; i += 4) p.push(d.slice(i, i + 4));\n return p.join(\" \");\n}\n\nfunction fmtExp(s: string): string {\n const d = strip(s).slice(0, 4);\n if (d.length <= 2) return d;\n return d.slice(0, 2) + \" / \" + d.slice(2);\n}\n\n/* ── Component ── */\n\nexport function CreditCardDialog({\n onSubmit,\n amount = \"$0.00\",\n sound = true,\n}: CreditCardDialogProps) {\n const [number, setNumber] = useState(\"\");\n const [name, setName] = useState(\"\");\n const [expiry, setExpiry] = useState(\"\");\n const [cvc, setCvc] = useState(\"\");\n const [focused, setFocused] = useState(null);\n\n const lastSound = useRef(0);\n const numRef = useRef(null);\n const nameRef = useRef(null);\n const expRef = useRef(null);\n const cvcRef = useRef(null);\n const payRef = useRef(null);\n\n const brand = detectBrand(number);\n const numD = strip(number);\n const expD = strip(expiry);\n\n const ready =\n numD.length >= numMax(brand) &&\n name.trim() !== \"\" &&\n expD.length >= 4 &&\n cvc.length >= cvcLen(brand);\n\n /* Auto-focus number on mount */\n useEffect(() => {\n const t = setTimeout(() => numRef.current?.focus(), 80);\n return () => clearTimeout(t);\n }, []);\n\n /* ── Handlers ── */\n\n const onNum = useCallback(\n (e: React.ChangeEvent) => {\n if (sound) playTick(lastSound);\n const d = strip(e.target.value);\n const b = detectBrand(d);\n const clamped = d.slice(0, numMax(b));\n setNumber(fmtNum(clamped, b));\n if (clamped.length >= numMax(b)) nameRef.current?.focus();\n },\n [sound],\n );\n\n const onName = useCallback(\n (e: React.ChangeEvent) => {\n if (sound) playTick(lastSound);\n setName(e.target.value);\n },\n [sound],\n );\n\n const onExp = useCallback(\n (e: React.ChangeEvent) => {\n if (sound) playTick(lastSound);\n const d = strip(e.target.value).slice(0, 4);\n setExpiry(fmtExp(d));\n if (d.length >= 4) cvcRef.current?.focus();\n },\n [sound],\n );\n\n const onCvc = useCallback(\n (e: React.ChangeEvent) => {\n if (sound) playTick(lastSound);\n const d = strip(e.target.value);\n const b = detectBrand(number);\n const clamped = d.slice(0, cvcLen(b));\n setCvc(clamped);\n if (clamped.length >= cvcLen(b)) payRef.current?.focus();\n },\n [sound, number],\n );\n\n const submit = useCallback(() => {\n const b = detectBrand(number);\n if (numD.length < numMax(b)) {\n numRef.current?.focus();\n return;\n }\n if (!name.trim()) {\n nameRef.current?.focus();\n return;\n }\n if (expD.length < 4) {\n expRef.current?.focus();\n return;\n }\n if (cvc.length < cvcLen(b)) {\n cvcRef.current?.focus();\n return;\n }\n if (sound) playTick(lastSound);\n onSubmit?.({ number: numD, name: name.trim(), expiry: expD, cvc });\n }, [number, numD, name, expD, cvc, sound, onSubmit]);\n\n /* Alpha helper */\n const al = (field: string, val: string) =>\n focused === field ? 0.85 : val ? 0.5 : 0.5;\n\n const inp: React.CSSProperties = {\n background: \"transparent\",\n border: \"none\",\n outline: \"none\",\n fontVariantNumeric: \"tabular-nums\",\n transition: \"color 0.15s\",\n };\n\n return (\n \n