diff --git a/client/src/components/NameModal.css b/client/src/components/NameModal.css new file mode 100644 index 0000000..e4e7fe3 --- /dev/null +++ b/client/src/components/NameModal.css @@ -0,0 +1,100 @@ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 32px; + width: 360px; + max-width: 90vw; + text-align: center; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); +} + +.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); } +.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; line-height: 1.5; } + +.modal form { display: flex; flex-direction: column; gap: 12px; } + +.modal input { + width: 100%; + padding: 10px 14px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} +.modal input:focus { border-color: var(--accent); } + +.modal button { + width: 100%; + padding: 10px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 700; + transition: opacity 0.2s; +} +.modal button:hover:not(:disabled) { opacity: 0.85; } +.modal button:disabled { opacity: 0.4; cursor: not-allowed; } + +.name-error { + color: #f87171; + font-size: 0.8rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 6px; + padding: 6px 10px; + text-align: center; +} + +.pin-input { + letter-spacing: 0.3em !important; + font-size: 1.5rem !important; + text-align: center !important; + font-family: monospace !important; +} + +.back-btn { + background: transparent !important; + color: var(--text-muted) !important; + border: 1px solid var(--border) !important; + font-weight: 400 !important; + font-size: 0.8rem !important; +} + +@media (max-width: 640px) { + .modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; } + .modal { + width: 100%; + border-radius: 20px 20px 0 0; + padding: 12px 24px 32px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 -8px 40px rgba(0,0,0,0.6); + animation: slideUp 0.3s ease-out; + } + @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } + .drag-handle { + width: 40px; height: 4px; + background: var(--border); + border-radius: 2px; + margin: 0 auto 14px; + } + .modal h2 { font-size: 1.15rem; } + .modal p { font-size: 0.8rem; margin-bottom: 14px; } +} diff --git a/client/src/components/NameModal.jsx b/client/src/components/NameModal.jsx new file mode 100644 index 0000000..040a8dc --- /dev/null +++ b/client/src/components/NameModal.jsx @@ -0,0 +1,107 @@ +import { useState, useEffect, useRef } from 'react' +import { GROOMSMEN, validateGroomsman } from '../groommen' +import './NameModal.css' + +const STORAGE_KEY_PIN = 'cabo_voter_pin' + +export default function NameModal({ onSubmit }) { + const [name, setName] = useState('') + const [pin, setPin] = useState('') + const [error, setError] = useState('') + const [step, setStep] = useState(1) // 1 = name, 2 = pin + const inputRef = useRef(null) + + useEffect(() => { + if (step === 1) inputRef.current?.focus() + }, [step]) + + const handleNameNext = (e) => { + e.preventDefault() + if (!name.trim()) return + + const key = name.trim().toLowerCase().replace(/\s+/g, '') + if (!GROOMSMEN[key]) { + setError(`"${name}" is not on the guest list. Check with the groom.`) + return + } + setError('') + setStep(2) + // Pre-fill pin from localStorage if saved + const saved = localStorage.getItem(STORAGE_KEY_PIN) + if (saved) setPin(saved) + } + + const handlePinSubmit = (e) => { + e.preventDefault() + if (pin.length !== 4) { + setError('Enter the last 4 digits of your phone number.') + return + } + + const key = name.trim().toLowerCase().replace(/\s+/g, '') + if (!validateGroomsman(name, pin)) { + setError('Wrong PIN. Make sure you\'re using the correct name + last 4 digits.') + return + } + + localStorage.setItem(STORAGE_KEY_PIN, pin) + onSubmit(name.trim()) + } + + const handleBack = () => { + setStep(1) + setPin('') + setError('') + } + + return ( +
+
+
+ + {step === 1 ? ( + <> +

πŸ„ Who's Voting?

+

Enter your full name as it appears on the guest list.

+
+ { setName(e.target.value); setError('') }} + placeholder="e.g. Jon, Toph, Hans…" + maxLength={30} + autoComplete="off" + autoCapitalize="words" + /> + {error &&
{error}
} + +
+ + ) : ( + <> +

πŸ” Verify with PIN

+

Enter the last 4 digits of your phone number, {name.split(' ')[0]}.

+
+ { setPin(e.target.value.replace(/\D/g, '')); setError('') }} + placeholder="β€’β€’β€’β€’" + autoComplete="off" + className="pin-input" + /> + {error &&
{error}
} + + +
+ + )} +
+
+ ) +} diff --git a/client/src/groommen.js b/client/src/groommen.js new file mode 100644 index 0000000..98a4064 --- /dev/null +++ b/client/src/groommen.js @@ -0,0 +1,44 @@ +// Groomsmen: name (lowercase) β†’ last 4 digits of phone number +export const GROOMSMEN = { + jon: '7506', + toph: '8116', + hans: '6681', + janno: '2809', + jt: '3286', + Cordero: '0379', // no last name given + lester: '8014', + nick: '6044', + david: '5993', + poalo: '9922', // likely "Paolo" + justin: '2329', + benstewart: '1957', + joseph: '4976', + francis: '4934', + // Groom + chris: '0000', // Chris Mayor β€” placeholder; update with real last 4 if needed +} + +// Reverse map: last4 β†’ display name +export const GROOMSMEN_BY_PIN = Object.fromEntries( + Object.entries(GROOMSMEN).map(([name, pin]) => [pin, name]) +) + +/** + * Validate a name + 4-digit PIN combination. + * Name must match a key in GROOMSMEN (case-insensitive). + * PIN must match that entry's value. + */ +export function validateGroomsman(name, pin) { + const key = name.trim().toLowerCase().replace(/\s+/g, '') + const entry = GROOMSMEN[key] + if (!entry) return false + return entry === pin.trim() +} + +/** + * Resolve the canonical display name for a validated groomsman. + */ +export function getCanonicalName(name) { + const key = name.trim().toLowerCase().replace(/\s+/g, '') + return GROOMSMEN[key] ? key : name.trim() +}