"use client"; import * as React from "react"; import { cn } from "./lib/utils"; export interface KeypadProps extends Omit, "onSubmit" | "defaultValue"> { /** Maximum length of the PIN code (default: 4) */ maxLength?: number; /** Controlled PIN value */ value?: string; /** Default initial PIN value for uncontrolled mode */ defaultValue?: string; /** Callback fired whenever the entered PIN value changes */ onValueChange?: (pin: string) => void; /** Callback fired when ENTER is pressed or PIN reaches maxLength */ onSubmit?: (pin: string) => void; /** Callback fired when CLEAR is pressed */ onClear?: () => void; /** Whether the PIN digits are masked (default: true) */ masked?: boolean; /** Whether to show a toggle button to reveal/mask the PIN (default: true) */ allowMaskToggle?: boolean; /** Whether to synthesize tactile mechanical key click audio via Web Audio API (default: true) */ enableSound?: boolean; /** Whether all keypad inputs are disabled */ disabled?: boolean; /** Turnstile security validation status */ status?: "idle" | "validating" | "success" | "error"; /** Custom status or error message beneath the display */ statusMessage?: string; /** Header title on the ticket frame (default: "BOX OFFICE TURNSTILE") */ title?: string; /** Terminal identifier code (default: "TERMINAL #07") */ terminalId?: string; /** Gate identifier (default: "GATE A-01") */ gate?: string; /** Whether CLR button acts as backspace or clears all digits (default: "backspace") */ clearMode?: "backspace" | "all"; /** Whether to auto-submit when maxLength is reached (default: false) */ autoSubmit?: boolean; /** Additional CSS class names */ className?: string; } interface KeypadKeyConfig { id: string; label: string; subLabel: string; action: "digit" | "clear" | "enter"; digit?: string; } const DEFAULT_KEY_CONFIGS: KeypadKeyConfig[] = [ { id: "1", label: "1", subLabel: "---", action: "digit", digit: "1" }, { id: "2", label: "2", subLabel: "ABC", action: "digit", digit: "2" }, { id: "3", label: "3", subLabel: "DEF", action: "digit", digit: "3" }, { id: "4", label: "4", subLabel: "GHI", action: "digit", digit: "4" }, { id: "5", label: "5", subLabel: "JKL", action: "digit", digit: "5" }, { id: "6", label: "6", subLabel: "MNO", action: "digit", digit: "6" }, { id: "7", label: "7", subLabel: "PQRS", action: "digit", digit: "7" }, { id: "8", label: "8", subLabel: "TUV", action: "digit", digit: "8" }, { id: "9", label: "9", subLabel: "WXYZ", action: "digit", digit: "9" }, { id: "clear", label: "CLR", subLabel: "BACK", action: "clear" }, { id: "0", label: "0", subLabel: "+ / OPER", action: "digit", digit: "0" }, { id: "enter", label: "ENT", subLabel: "ADMIT", action: "enter" }, ]; /** * Synthesizes acoustic mechanical turnstile switch sounds using the browser Web Audio API. * Zero external audio assets or network requests required. */ function playMechanicalKeySound( type: "digit" | "clear" | "enter" | "error" | "success" = "digit" ): void { if (typeof window === "undefined") return; try { const AudioCtx = window.AudioContext; if (!AudioCtx) return; const ctx = new AudioCtx(); const now = ctx.currentTime; if (type === "digit") { // Tactile stamped spring-loaded key strike const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "square"; osc.frequency.setValueAtTime(620, now); osc.frequency.exponentialRampToValueAtTime(140, now + 0.035); gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.035); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.04); } else if (type === "clear") { // Deeper relay release click const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; osc.frequency.setValueAtTime(320, now); osc.frequency.exponentialRampToValueAtTime(90, now + 0.045); gain.gain.setValueAtTime(0.25, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.045); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.05); } else if (type === "enter") { // Dual affirmative turnstile gate unlock ping [784, 1046.5].forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(freq, now + i * 0.04); gain.gain.setValueAtTime(0.18, now + i * 0.04); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.04 + 0.12); osc.connect(gain); gain.connect(ctx.destination); osc.start(now + i * 0.04); osc.stop(now + i * 0.04 + 0.13); }); } else if (type === "error") { // Harsh rejection buzz const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sawtooth"; osc.frequency.setValueAtTime(130, now); gain.gain.setValueAtTime(0.25, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.16); osc.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.17); } else if (type === "success") { // High harmonic turnstile gate admission chime [523.25, 659.25, 783.99, 1046.5].forEach((freq, idx) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; osc.frequency.setValueAtTime(freq, now + idx * 0.05); gain.gain.setValueAtTime(0.22, now + idx * 0.05); gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.05 + 0.2); osc.connect(gain); gain.connect(ctx.destination); osc.start(now + idx * 0.05); osc.stop(now + idx * 0.05 + 0.22); }); } } catch { // Gracefully handle browsers blocking AudioContext without user gesture } } export function Keypad({ maxLength = 4, value: controlledValue, defaultValue = "", onValueChange, onSubmit, onClear, masked: initialMasked = true, allowMaskToggle = true, enableSound = true, disabled = false, status = "idle", statusMessage, title = "BOX OFFICE TURNSTILE", terminalId = "TERMINAL #07", gate = "GATE A-01", clearMode = "backspace", autoSubmit = false, className, ...props }: KeypadProps) { const [internalPin, setInternalPin] = React.useState(defaultValue.slice(0, maxLength)); const [isMasked, setIsMasked] = React.useState(initialMasked); const [activeKeyId, setActiveKeyId] = React.useState(null); const [pulseKeyId, setPulseKeyId] = React.useState(null); const keypadRef = React.useRef(null); const isControlled = controlledValue !== undefined; const pin = (isControlled ? controlledValue : internalPin).slice(0, maxLength); // Audio trigger on status change React.useEffect(() => { if (!enableSound) return; if (status === "success") { playMechanicalKeySound("success"); } else if (status === "error") { playMechanicalKeySound("error"); } }, [status, enableSound]); const updatePin = React.useCallback( (nextPin: string) => { const sanitized = nextPin.replace(/\D/g, "").slice(0, maxLength); if (!isControlled) { setInternalPin(sanitized); } onValueChange?.(sanitized); if (autoSubmit && sanitized.length === maxLength) { onSubmit?.(sanitized); } }, [maxLength, isControlled, onValueChange, autoSubmit, onSubmit] ); const triggerVisualPulse = (keyId: string) => { setActiveKeyId(keyId); setPulseKeyId(keyId); setTimeout(() => { setActiveKeyId(null); }, 120); setTimeout(() => { setPulseKeyId(null); }, 350); }; const handleDigitPress = React.useCallback( (digit: string) => { if (disabled || pin.length >= maxLength) return; if (enableSound) playMechanicalKeySound("digit"); triggerVisualPulse(digit); updatePin(pin + digit); }, [disabled, pin, maxLength, enableSound, updatePin] ); const handleClearPress = React.useCallback(() => { if (disabled) return; if (enableSound) playMechanicalKeySound("clear"); triggerVisualPulse("clear"); if (clearMode === "all" || pin.length <= 1) { updatePin(""); onClear?.(); } else { updatePin(pin.slice(0, -1)); } }, [disabled, enableSound, clearMode, pin, updatePin, onClear]); const handleEnterPress = React.useCallback(() => { if (disabled) return; if (enableSound) playMechanicalKeySound("enter"); triggerVisualPulse("enter"); onSubmit?.(pin); }, [disabled, enableSound, onSubmit, pin]); // Handle hardware keyboard interactions const handleKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; if (e.key >= "0" && e.key <= "9") { e.preventDefault(); handleDigitPress(e.key); } else if (e.key === "Backspace") { e.preventDefault(); handleClearPress(); } else if (e.key === "Escape") { e.preventDefault(); if (enableSound) playMechanicalKeySound("clear"); triggerVisualPulse("clear"); updatePin(""); onClear?.(); } else if (e.key === "Enter") { e.preventDefault(); handleEnterPress(); } }; const statusToneClasses = React.useMemo(() => { switch (status) { case "success": return { indicator: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.7)]", label: "text-success font-bold", border: "border-success/60", bg: "bg-success/10", }; case "error": return { indicator: "bg-destructive shadow-[0_0_8px_rgba(239,68,68,0.7)]", label: "text-destructive font-bold animate-pulse", border: "border-destructive/60", bg: "bg-destructive/10", }; case "validating": return { indicator: "bg-warning animate-ping", label: "text-warning font-bold", border: "border-warning/60", bg: "bg-warning/10", }; default: return { indicator: "bg-accent animate-pulse", label: "text-muted-foreground", border: "border-border", bg: "bg-secondary/40", }; } }, [status]); const resolvedStatusMessage = React.useMemo(() => { if (statusMessage) return statusMessage; switch (status) { case "success": return "PASSCODE VALIDATED · TURNSTILE UNLOCKED"; case "error": return "INVALID PIN · ACCESS DENIED · RETRY"; case "validating": return "VERIFYING CREDENTIALS WITH GATEWAY..."; default: return `ENTER ${maxLength}-DIGIT BOX OFFICE ACCESS PIN`; } }, [statusMessage, status, maxLength]); return (
{/* Outer Ticket Punch Notches on Side Edges */}