phase 16 implementation
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user