450 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|