Files
Rental-operations-platform/src/components/integrations/DemoDialogHost.tsx
T
2026-06-25 20:08:39 -04:00

450 lines
16 KiB
TypeScript

'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<string, string>;
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<DemoSubmissionResult, { ok: false }>,
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<Pick<AnalyticsContext, 'errorCode' | 'errorCount' | 'reason'>> = {},
): 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<DemoSource>('hero');
const [status, setStatus] = useState<FormStatus>('idle');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState('');
const [messageLength, setMessageLength] = useState(0);
const formRef = useRef<HTMLFormElement>(null);
const firstFieldRef = useRef<HTMLInputElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const idempotencyKeyRef = useRef('');
const startedAtRef = useRef(0);
const closeTrackedRef = useRef(false);
const summaryErrors = useMemo<FormErrorItem[]>(
() =>
Object.entries(fieldErrors).map(([fieldId, code]) => ({
fieldId,
message: messageForCode(code, messages),
})),
[fieldErrors, messages],
);
useEffect(() => {
const handleOpen = (event: Event) => {
const detail = (event as CustomEvent<DemoOpenEventDetail>).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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
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' ? (
<section className={styles.success} aria-live="polite">
<StatusBadge tone="success">{messages.localModeLabel}</StatusBadge>
<h3>{messages.successTitle}</h3>
<p>{messages.successBody}</p>
<div className={styles.actions}>
<Button intent="primary" onClick={reset}>
{messages.submitAnother}
</Button>
<Button intent="secondary" onClick={close}>
{messages.close}
</Button>
</div>
</section>
) : (
<form
ref={formRef}
className={styles.form}
noValidate
onSubmit={async (event) => {
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<HTMLElement>('[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<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
});
} catch {
setFormError(messages.genericError);
setStatus('error');
trackAnalytics(
'demo_submit_failure',
analyticsContext(locale, source, { errorCode: 'network' }),
);
}
}}
>
<div className={styles.notice}>
<StatusBadge tone="info">{messages.localModeLabel}</StatusBadge>
<p>{messages.privacy}</p>
</div>
{formError ? (
<ErrorSummary
title={formError}
errors={
summaryErrors.length > 0
? summaryErrors
: [{ fieldId: 'demo-submit', message: formError }]
}
/>
) : null}
<div className={styles.grid}>
<FormField
id="fullName"
label={messages.name}
required
error={
fieldErrors.fullName ? messageForCode(fieldErrors.fullName, messages) : undefined
}
>
<TextInput
ref={firstFieldRef}
id="fullName"
name="fullName"
autoComplete="name"
required
maxLength={120}
invalid={Boolean(fieldErrors.fullName)}
describedBy={fieldErrors.fullName ? 'fullName-error' : undefined}
/>
</FormField>
<FormField
id="workEmail"
label={messages.email}
required
error={
fieldErrors.workEmail ? messageForCode(fieldErrors.workEmail, messages) : undefined
}
>
<TextInput
id="workEmail"
name="workEmail"
type="email"
inputMode="email"
autoComplete="email"
required
maxLength={254}
invalid={Boolean(fieldErrors.workEmail)}
describedBy={fieldErrors.workEmail ? 'workEmail-error' : undefined}
/>
</FormField>
<FormField
id="company"
label={messages.company}
required
error={
fieldErrors.company ? messageForCode(fieldErrors.company, messages) : undefined
}
>
<TextInput
id="company"
name="company"
autoComplete="organization"
required
maxLength={160}
invalid={Boolean(fieldErrors.company)}
describedBy={fieldErrors.company ? 'company-error' : undefined}
/>
</FormField>
<FormField
id="fleetSize"
label={messages.fleet}
required
error={
fieldErrors.fleetSize ? messageForCode(fieldErrors.fleetSize, messages) : undefined
}
>
<Select
id="fleetSize"
name="fleetSize"
required
defaultValue=""
invalid={Boolean(fieldErrors.fleetSize)}
describedBy={fieldErrors.fleetSize ? 'fleetSize-error' : undefined}
>
<option value="">{messages.fleetOptions[0]}</option>
{fleetSizeValues.map((value, index) => (
<option key={value} value={value}>
{messages.fleetOptions[index + 1]}
</option>
))}
</Select>
</FormField>
<FormField id="market" label={messages.market} optionalLabel={messages.optional}>
<TextInput id="market" name="market" autoComplete="country-name" maxLength={100} />
</FormField>
<FormField
id="preferredLanguage"
label={messages.language}
optionalLabel={messages.optional}
>
<Select id="preferredLanguage" name="preferredLanguage" defaultValue={locale}>
{(['en', 'fr', 'ar'] as const).map((value, index) => (
<option key={value} value={value}>
{messages.langOptions[index]}
</option>
))}
</Select>
</FormField>
<FormField
id="message"
label={messages.message}
optionalLabel={messages.optional}
supportingText={messages.messageHelp}
className={styles.spanAll}
error={
fieldErrors.message ? messageForCode(fieldErrors.message, messages) : undefined
}
>
<TextArea
id="message"
name="message"
rows={5}
maxLength={1000}
invalid={Boolean(fieldErrors.message)}
describedBy={fieldErrors.message ? 'message-help message-error' : 'message-help'}
characterCount={{ current: messageLength, max: 1000, label: messages.messageCount }}
onInput={(event) => setMessageLength(event.currentTarget.value.length)}
/>
</FormField>
</div>
<div className={styles.honeypot} aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" />
</div>
<div className={styles.actions}>
<Button
id="demo-submit"
type="submit"
intent="conversion"
loading={status === 'submitting'}
>
{status === 'submitting'
? messages.submitting
: status === 'error'
? messages.retry
: messages.submit}
</Button>
<Button
type="button"
intent="tertiary"
disabled={status === 'submitting'}
onClick={close}
>
{messages.close}
</Button>
</div>
<LiveRegion politeness="polite">
{status === 'submitting' ? messages.submitting : ''}
</LiveRegion>
</form>
)}
</Dialog>
);
}