// Lead capture modal — window.__openBooking() // Security: CSRF token, CAPTCHA, honeypot, mirrored field validation, rate-limit UX const BookingModal = ({ onClose }) => { const TOTAL = 15; const MSG_MAX = 2000; const EMPTY = { name: '', email: '', phone: '', business: '', service: '', message: '', captchaAnswer: '', _hp: '' }; const [form, setForm] = React.useState(EMPTY); const [fieldErrors, setFieldErrors] = React.useState({}); const [captcha, setCaptcha] = React.useState(null); const [csrfToken, setCsrfToken] = React.useState(''); const [sent, setSent] = React.useState(false); const [busy, setBusy] = React.useState(false); const [countdown, setCountdown] = React.useState(TOTAL); const [rateLimited, setRateLimited] = React.useState(0); // seconds until retry const firstRef = React.useRef(null); const successRef = React.useRef(null); const set = (k) => (e) => { setForm(f => ({ ...f, [k]: e.target.value })); // Clear field error on change if (fieldErrors[k]) setFieldErrors(f => ({ ...f, [k]: false })); }; // ── Client-side validation mirrors backend rules ──────────────────────────── const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const PHONE_RE = /^\+?[\d\s\-\.\(\)]{7,20}$/; const SAFE_TEXT_RE = / { const e = {}; const n = form.name.trim(); const em = form.email.trim(); const ph = form.phone.trim(); const msg = form.message.trim(); if (!n) { e.name = true; } else if (n.length > 100) { e.name = true; } else if (SAFE_TEXT_RE.test(n)) { e.name = true; } if (!em) { e.email = true; } else if (!EMAIL_RE.test(em)) { e.email = true; } if (!ph) { e.phone = true; } else if (!PHONE_RE.test(ph)) { e.phone = true; } if (!form.business) { e.business = true; } if (!form.service) { e.service = true; } if (!form.captchaAnswer.trim()) { e.captcha = true; } if (msg.length > MSG_MAX) { e.message = true; } else if (SAFE_TEXT_RE.test(msg)) { e.message = true; } return e; }; // ── Load CAPTCHA + CSRF in parallel ──────────────────────────────────────── const base = window.__bookingApiBase || ''; const loadCaptcha = React.useCallback(async () => { setCaptcha(null); try { const res = await fetch(base + 'captcha.php', { credentials: 'same-origin' }); const data = await res.json(); setCaptcha(data); } catch { window.__toast?.('warning', 'Could not load verification — please refresh the page.'); } }, []); const loadCsrf = React.useCallback(async () => { try { const res = await fetch(base + 'csrf.php', { credentials: 'same-origin' }); const data = await res.json(); setCsrfToken(data.token || ''); } catch { // Non-fatal — submit will fail with a clear error if CSRF is missing } }, []); // ── Lifecycle ─────────────────────────────────────────────────────────────── React.useEffect(() => { // Fetch both in parallel Promise.all([loadCaptcha(), loadCsrf()]); const onKey = (e) => { if (e.key === 'Escape') close(); }; window.addEventListener('keydown', onKey); document.body.style.overflow = 'hidden'; setTimeout(() => firstRef.current?.focus(), 80); return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = ''; }; }, []); React.useEffect(() => { if (sent) setTimeout(() => successRef.current?.focus(), 60); }, [sent]); // Countdown auto-close React.useEffect(() => { if (!sent) return; if (countdown <= 0) { close(); return; } const id = setTimeout(() => setCountdown(c => c - 1), 1000); return () => clearTimeout(id); }, [sent, countdown]); // Rate-limit countdown display React.useEffect(() => { if (rateLimited <= 0) return; const id = setTimeout(() => setRateLimited(s => Math.max(0, s - 1)), 1000); return () => clearTimeout(id); }, [rateLimited]); // ── Close ─────────────────────────────────────────────────────────────────── const close = () => { onClose(); setTimeout(() => { setForm(EMPTY); setSent(false); setFieldErrors({}); setCountdown(TOTAL); setRateLimited(0); }, 350); }; // ── Submit ────────────────────────────────────────────────────────────────── const submit = async (e) => { e.preventDefault(); const errs = validate(); if (Object.keys(errs).length) { setFieldErrors(errs); const labels = { name: 'full name', email: 'valid email address', phone: 'phone number', business: 'business type', service: 'service you\'re interested in', captcha: 'verification answer', message: 'message (check length/content)', }; const missing = Object.keys(errs).map(k => labels[k]).filter(Boolean); window.__toast?.('warning', 'Required: ' + missing.join(', ') + '.'); return; } setFieldErrors({}); setBusy(true); try { const res = await fetch(base + 'submit.php', { method: 'POST', credentials: 'same-origin', // sends session cookie for CSRF headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: JSON.stringify({ name: form.name.trim(), email: form.email.trim().toLowerCase(), phone: form.phone.trim(), business: form.business, service: form.service, message: form.message.trim(), _hp: form._hp, _csrf: csrfToken, captchaAnswer: parseInt(form.captchaAnswer, 10), captchaToken: captcha?.token, captchaTs: captcha?.ts, }), }); const json = await res.json(); if (res.ok && json.ok) { setSent(true); } else if (res.status === 429) { // Rate limited — show countdown const wait = json.retry_after || 60; setRateLimited(wait); window.__toast?.('error', json.error || 'Too many requests — please wait before trying again.'); } else if (res.status === 403) { // CSRF / origin error — reload token and notify window.__toast?.('error', json.error || 'Security check failed — please refresh the page.'); loadCsrf(); } else { window.__toast?.('error', json.error || 'Something went wrong — please try again.'); if (json.refreshCaptcha) { setForm(f => ({ ...f, captchaAnswer: '' })); loadCaptcha(); } if (json.field_errors) { const fe = {}; json.field_errors.forEach(k => { fe[k] = true; }); setFieldErrors(fe); } } } catch { window.__toast?.('error', 'Network error — please check your connection and try again.'); } setBusy(false); }; // ── Render ────────────────────────────────────────────────────────────────── const barWidth = `${(countdown / TOTAL) * 100}%`; const msgLen = form.message.length; const msgNearMax = msgLen > MSG_MAX * 0.85; return (
{ if (e.target === e.currentTarget) close(); }}>
{/* ── Sticky header ────────────────────────────────────────────────── */}
{!sent && Free · No commitment}

{sent ? 'Request received!' : 'Book a free consultation'}

{/* ── Rate-limit banner ────────────────────────────────────────────── */} {rateLimited > 0 && !sent && (
Too many requests — please wait {rateLimited}s before trying again.
)} {/* ── Form ─────────────────────────────────────────────────────────── */} {!sent && (
{/* Honeypot — visually hidden, bots fill it */} {/* Name + Email */}
{/* Phone + Business type */}
{/* Service interest */}
{/* Message with character counter */}
{msgLen}/{MSG_MAX}