import { useEffect, useRef, useState, type FormEvent } from 'react'; import { Link } from 'react-router'; import { useAnimation } from '../animation/orchestration/AnimationProvider'; import { analytics } from '../measurement'; import { Icon } from './Icons'; import { applyDemoIntent, parseDemoIntent, type DemoIntent } from './demo-intent.mjs'; import { DemoCompanion } from './DemoCompanion'; import { INQUIRY_FORM_ID, type ProjectIntent } from '../../../../packages/site/inquiry.mjs'; type ProjectType = ProjectIntent; type Fields = { name: string; email: string; business: string; website: string; projectType: ProjectType; message: string; consent: boolean; companyWebsite: string }; type FieldErrors = Partial>; const initial: Fields = { name: '', email: '', business: '', website: '', projectType: 'unsure', message: '', consent: false, companyWebsite: '' }; function validate(fields: Fields): FieldErrors { const errors: FieldErrors = {}; if (fields.name.trim().length < 2) errors.name = 'Please enter your name.'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email.trim())) errors.email = 'Please enter a valid email address.'; if (fields.business.trim().length < 2) errors.business = 'What is your business called?'; if (fields.website.trim()) { try { const url = new URL(fields.website.trim()); if (!['https:', 'http:'].includes(url.protocol) || !url.hostname.includes('.')) throw new Error(); } catch { errors.website = 'Enter a full website address, such as https://example.com, or leave this blank.'; } } if (fields.message.trim().length < 10) errors.message = 'Tell us a little more (at least 10 characters).'; if (!fields.consent) errors.consent = 'Please agree so we can respond to your request.'; return errors; } export function DemoForm({ submissionId: suppliedId = '', email, intentSearch = '' }: { submissionId?: string; email?: string; intentSearch?: string }) { const [fields, setFields] = useState(initial); const [errors, setErrors] = useState({}); const [status, setStatus] = useState<'idle' | 'pending' | 'accepted' | 'error'>('idle'); const [message, setMessage] = useState(''); const [enhanced, setEnhanced] = useState(false); const [submissionId, setSubmissionId] = useState(suppliedId); const [intent, setIntent] = useState(null); const edited = useRef(false); const intentApplied = useRef(false); const form = useRef(null); const feedback = useRef(null); const pending = useRef(false); const activeRequest = useRef(null); const { cue } = useAnimation(); useEffect(() => { setEnhanced(true); if (!suppliedId) setSubmissionId(globalThis.crypto?.randomUUID?.() ?? ''); return () => activeRequest.current?.abort(); }, [suppliedId]); useEffect(() => { // Initial HTML and hydration always agree. The character's finite choice is // applied once after hydration, only while the whole form remains untouched. if (intentApplied.current || edited.current) return; const next = parseDemoIntent(intentSearch); if (!next) return; intentApplied.current = true; setFields(previous => applyDemoIntent(previous, next, edited.current)); setIntent(next); }, [intentSearch]); useEffect(() => { if (status === 'accepted' || (status === 'error' && Object.keys(errors).length === 0)) feedback.current?.focus(); }, [status, errors]); function update(key: K, value: Fields[K]) { edited.current = true; if (key === 'projectType') setIntent(null); setFields(previous => ({ ...previous, [key]: value })); setErrors(previous => ({ ...previous, [key]: undefined })); if (status === 'error') { setStatus('idle'); setMessage(''); } } function focusError(next: FieldErrors) { const key = Object.keys(next)[0]; requestAnimationFrame(() => form.current?.querySelector(`[name="${key}"]`)?.focus()); } async function submit(event: FormEvent) { event.preventDefault(); if (pending.current) return; const nextErrors = validate(fields); setErrors(nextErrors); if (Object.keys(nextErrors).length) { setStatus('error'); setMessage('A couple of details need your attention.'); focusError(nextErrors); analytics.demoSubmitError('validation'); return; } pending.current = true; setStatus('pending'); setMessage('Sending your request…'); cue('inquiry.pending'); const controller = new AbortController(); activeRequest.current = controller; const timeout = window.setTimeout(() => controller.abort(), 25000); try { const response = await fetch('/api/demo', { method: 'POST', credentials: 'same-origin', signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ ...fields, name: fields.name.trim(), email: fields.email.trim(), business: fields.business.trim(), website: fields.website.trim(), message: fields.message.trim(), submissionId }), }); const result = await response.json().catch(() => null); if (response.ok && result?.ok === true && result.status === 'accepted' && typeof result.requestId === 'string') { setStatus('accepted'); setMessage('Your demo request has been accepted.'); cue('inquiry.accepted'); analytics.generateLead('smtp_accepted', result.requestId); } else { const safeErrors: FieldErrors = {}; for (const key of Object.keys(initial) as (keyof Fields)[]) if (typeof result?.fieldErrors?.[key] === 'string') safeErrors[key] = result.fieldErrors[key]; setErrors(safeErrors); setStatus('error'); cue('inquiry.failed'); const fallback = response.status === 429 ? 'Too many requests arrived at once. Please wait a few minutes and try again.' : response.status === 503 ? 'The message service is temporarily unavailable. Your request has not been confirmed. Please try again shortly.' : 'Your request could not be confirmed. Please try again.'; setMessage(typeof result?.message === 'string' ? result.message : fallback); analytics.demoSubmitError(response.status === 429 ? 'rate_limited' : response.status === 503 ? 'unavailable' : Object.keys(safeErrors).length ? 'validation' : 'server'); if (Object.keys(safeErrors).length) focusError(safeErrors); } } catch { setStatus('error'); setMessage('The connection was interrupted before we could confirm your request. It may still have been sent. Your details are still here; please avoid sending it again immediately.'); cue('inquiry.failed'); analytics.demoSubmitError('network'); } finally { window.clearTimeout(timeout); activeRequest.current = null; pending.current = false; } } const field = (name: keyof Fields) => ({ id: `demo-${name}`, name, 'aria-invalid': !!errors[name], 'aria-describedby': errors[name] ? `demo-${name}-error` : undefined }); const error = (name: keyof Fields) => errors[name] ? {errors[name]} : null; if (status === 'accepted') return

A very good first step

Message accepted.
Computer delighted.

Thanks for the introduction. Your demo request has been accepted for sending, and we’ll use the details you shared to follow up by email.

No need to send it twice. Go enjoy being good at your business.

Back to the workshop
; return

All fields required unless marked optional.