'use client'; import { Button } from '@/components/actions/Button'; import { ErrorSummary, type FormErrorItem } from '@/components/forms/ErrorSummary'; import { FormField } from '@/components/forms/FormField'; import { Select, TextArea, TextInput } from '@/components/forms/TextInput'; import { LiveRegion } from '@/components/foundation/LiveRegion'; import { StatusBadge } from '@/components/foundation/StatusBadge'; import { Dialog } from '@/components/overlays/Dialog'; import type { HomepageMessages } from '@/lib/localization/messages'; import { trackAnalytics } from '@/lib/analytics/client'; import type { AnalyticsContext } from '@/lib/analytics/events'; import { demoLeadSchema, fleetSizeValues, type DemoSource, type DemoSubmissionResult, } from '@/lib/demo/schema'; import type { Locale } from '@/lib/localization/config'; import { getDirection } from '@/lib/localization/config'; import { useEffect, useMemo, useRef, useState } from 'react'; import { demoOpenEventName, type DemoOpenEventDetail } from './DemoTrigger'; import styles from './DemoDialogHost.module.css'; interface DemoDialogHostProps { locale: Locale; messages: HomepageMessages['form']; enabled: boolean; } type FormStatus = 'idle' | 'submitting' | 'success' | 'error'; type FieldErrors = Record; function messageForCode(code: string, messages: DemoDialogHostProps['messages']): string { switch (code) { case 'required': return messages.requiredError; case 'invalid_email': return messages.emailError; case 'invalid_fleet_size': case 'invalid_source': case 'invalid_idempotency_key': return messages.invalidOptionError; case 'too_long': return messages.tooLongError; default: return messages.genericError; } } function formMessage( result: Extract, messages: DemoDialogHostProps['messages'], ) { switch (result.category) { case 'configuration': case 'consent': return messages.configurationError; case 'timeout': return messages.timeoutError; case 'duplicate': return messages.duplicateError; case 'rate-limit': return messages.rateLimitError; case 'validation': return messages.errorSummary; case 'integration': case 'abuse': return messages.genericError; } } function analyticsContext( locale: Locale, source: DemoSource, extra: Partial> = {}, ): AnalyticsContext { const theme = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; return { routeId: 'home', locale, direction: getDirection(locale), resolvedTheme: theme, source, ...extra, }; } export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProps) { const [open, setOpen] = useState(false); const [source, setSource] = useState('hero'); const [status, setStatus] = useState('idle'); const [fieldErrors, setFieldErrors] = useState({}); const [formError, setFormError] = useState(''); const [messageLength, setMessageLength] = useState(0); const formRef = useRef(null); const firstFieldRef = useRef(null); const returnFocusRef = useRef(null); const idempotencyKeyRef = useRef(''); const startedAtRef = useRef(0); const closeTrackedRef = useRef(false); const summaryErrors = useMemo( () => Object.entries(fieldErrors).map(([fieldId, code]) => ({ fieldId, message: messageForCode(code, messages), })), [fieldErrors, messages], ); useEffect(() => { const handleOpen = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail || !enabled) return; returnFocusRef.current = detail.trigger; setSource(detail.source); closeTrackedRef.current = false; setOpen(true); setStatus('idle'); setFieldErrors({}); setFormError(''); idempotencyKeyRef.current = crypto.randomUUID(); startedAtRef.current = Date.now(); trackAnalytics('demo_open', analyticsContext(locale, detail.source)); }; window.addEventListener(demoOpenEventName, handleOpen); return () => window.removeEventListener(demoOpenEventName, handleOpen); }, [enabled, locale]); const reset = () => { formRef.current?.reset(); setMessageLength(0); setStatus('idle'); setFieldErrors({}); setFormError(''); idempotencyKeyRef.current = crypto.randomUUID(); startedAtRef.current = Date.now(); window.requestAnimationFrame(() => firstFieldRef.current?.focus()); }; const close = () => { if (status === 'submitting' || closeTrackedRef.current) return; closeTrackedRef.current = true; setOpen(false); trackAnalytics('demo_close', analyticsContext(locale, source, { reason: 'user' })); }; return ( { if (!nextOpen) close(); else { closeTrackedRef.current = false; setOpen(true); } }} title={messages.title} description={messages.body} closeLabel={messages.close} initialFocusRef={firstFieldRef} returnFocusRef={returnFocusRef} dismissible={status !== 'submitting'} > {status === 'success' ? (
{messages.localModeLabel}

{messages.successTitle}

{messages.successBody}

) : (
{ event.preventDefault(); if (status === 'submitting') return; const form = event.currentTarget; const values = new FormData(form); const leadInput = { fullName: String(values.get('fullName') ?? ''), workEmail: String(values.get('workEmail') ?? ''), company: String(values.get('company') ?? ''), fleetSize: String(values.get('fleetSize') ?? ''), market: String(values.get('market') ?? ''), preferredLanguage: String(values.get('preferredLanguage') ?? locale), message: String(values.get('message') ?? ''), idempotencyKey: idempotencyKeyRef.current || crypto.randomUUID(), source, }; const clientResult = demoLeadSchema.safeParse(leadInput); if (!clientResult.success) { const nextErrors: FieldErrors = {}; for (const issue of clientResult.error.issues) { const field = issue.path.at(-1); if (typeof field === 'string' && !nextErrors[field]) nextErrors[field] = issue.message; } setFieldErrors(nextErrors); setFormError(messages.errorSummary); trackAnalytics( 'demo_validation_error', analyticsContext(locale, source, { errorCount: Object.keys(nextErrors).length }), ); window.requestAnimationFrame(() => { const firstInvalid = form.querySelector('[aria-invalid="true"]'); firstInvalid?.focus(); }); return; } setStatus('submitting'); setFieldErrors({}); setFormError(''); trackAnalytics('demo_submit_attempt', analyticsContext(locale, source)); try { const response = await fetch('/api/demo', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-RDG-Form': 'demo-v1' }, body: JSON.stringify({ lead: clientResult.data, guard: { honeypot: String(values.get('website') ?? ''), startedAtMs: startedAtRef.current, }, }), }); const result = (await response.json()) as DemoSubmissionResult; if (result.ok) { setStatus('success'); trackAnalytics('demo_submit_success', analyticsContext(locale, source)); return; } const serverErrors = Object.fromEntries( Object.entries(result.fieldErrors ?? {}).map(([field, code]) => [field, code]), ); setFieldErrors(serverErrors); setFormError(formMessage(result, messages)); setStatus('error'); trackAnalytics( 'demo_submit_failure', analyticsContext(locale, source, { errorCode: result.category }), ); window.requestAnimationFrame(() => { const firstInvalid = form.querySelector('[aria-invalid="true"]'); firstInvalid?.focus(); }); } catch { setFormError(messages.genericError); setStatus('error'); trackAnalytics( 'demo_submit_failure', analyticsContext(locale, source, { errorCode: 'network' }), ); } }} >
{messages.localModeLabel}

{messages.privacy}

{formError ? ( 0 ? summaryErrors : [{ fieldId: 'demo-submit', message: formError }] } /> ) : null}