phase 16 implementation
This commit is contained in:
@@ -5,8 +5,10 @@ import '@/styles/component-tokens.css';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { SiteFooter } from '@/components/app-shell/SiteFooter';
|
||||
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
|
||||
import { SiteHeader } from '@/components/app-shell/SiteHeader';
|
||||
import { ThemeController } from '@/components/app-shell/ThemeController';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
|
||||
@@ -32,6 +34,7 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const requestHeaders = await headers();
|
||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||
const cookieStore = await cookies();
|
||||
@@ -61,9 +64,23 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
<a className="skip-link" href="#main-content">
|
||||
{messages.shell.skipToContent}
|
||||
</a>
|
||||
<SiteHeader locale={locale} messages={messages.shell} themePreference={preference} />
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
themePreference={preference}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
{children}
|
||||
<SiteFooter locale={locale} messages={messages.shell} />
|
||||
<DemoDialogHost
|
||||
locale={locale}
|
||||
messages={messages.homepage.form}
|
||||
enabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<SiteFooter
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { Accordion } from '@/components/controls/Accordion';
|
||||
import { Icon } from '@/components/foundation/Icon';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
||||
import { CTA } from '@/components/marketing/CTA';
|
||||
import { Comparison } from '@/components/marketing/Comparison';
|
||||
@@ -13,6 +14,7 @@ import { Callout } from '@/components/surfaces/Callout';
|
||||
import { Card } from '@/components/surfaces/Card';
|
||||
import { Eyebrow, Heading, Text } from '@/components/typography/Typography';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
@@ -43,6 +45,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const content = buildHomepageContent(messages.homepage, messages.shell);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const homePath = localizedPath('home', locale);
|
||||
|
||||
return (
|
||||
@@ -57,13 +60,16 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
</Heading>
|
||||
<Text variant="lead">{content.hero.body}</Text>
|
||||
<div className={styles.heroActions} role="group" aria-label={content.hero.eyebrow}>
|
||||
<ActionLink
|
||||
href={homePath}
|
||||
variant="button-conversion"
|
||||
disabledReason={content.hero.primary.disabledReason}
|
||||
>
|
||||
{content.hero.primary.label}
|
||||
</ActionLink>
|
||||
<DemoTrigger
|
||||
label={content.hero.primary.label}
|
||||
source="hero"
|
||||
size="large"
|
||||
disabledReason={
|
||||
integrationConfig.demoSubmissionEnabled
|
||||
? undefined
|
||||
: content.hero.primary.disabledReason
|
||||
}
|
||||
/>
|
||||
<ActionLink
|
||||
href={homePath}
|
||||
variant="button-primary"
|
||||
@@ -326,6 +332,18 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
body={content.final.body}
|
||||
primary={content.final.primary}
|
||||
secondary={content.final.secondary}
|
||||
primaryNode={
|
||||
<DemoTrigger
|
||||
label={content.final.primary.label}
|
||||
source="final-cta"
|
||||
size="large"
|
||||
disabledReason={
|
||||
integrationConfig.demoSubmissionEnabled
|
||||
? undefined
|
||||
: content.final.primary.disabledReason
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
demoSubmissionEnvelopeSchema,
|
||||
zodFieldErrors,
|
||||
type DemoSubmissionResult,
|
||||
} from '@/lib/demo/schema';
|
||||
import { submitDemoLead } from '@/lib/demo/service';
|
||||
import { getIntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { isApplicationJson } from '@/lib/security/content-type';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const maximumBodyBytes = 16_384;
|
||||
const responseHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
result: Extract<DemoSubmissionResult, { ok: false }>,
|
||||
): NextResponse<DemoSubmissionResult> {
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
function isAllowedOrigin(
|
||||
request: Request,
|
||||
approvedOrigin: string | undefined,
|
||||
production: boolean,
|
||||
) {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) return !production;
|
||||
const requestOrigin = new URL(request.url).origin;
|
||||
if (origin === requestOrigin) return true;
|
||||
return approvedOrigin ? origin === new URL(approvedOrigin).origin : false;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse<DemoSubmissionResult>> {
|
||||
const correlationId = randomUUID();
|
||||
let environment;
|
||||
try {
|
||||
environment = getIntegrationEnvironment();
|
||||
} catch {
|
||||
return failure(503, {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
formErrorCode: 'integration_environment_invalid',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.headers.get('x-rdg-form') !== 'demo-v1') {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_request_marker',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(request, environment.SITE_ORIGIN, environment.NODE_ENV === 'production')) {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_origin',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!isApplicationJson(contentType)) {
|
||||
return failure(415, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'unsupported_content_type',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (new TextEncoder().encode(raw).byteLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'malformed_json',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = demoSubmissionEnvelopeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
fieldErrors: zodFieldErrors(parsed.error),
|
||||
formErrorCode: 'validation_failed',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.guard.honeypot.length > 0) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'abuse',
|
||||
formErrorCode: 'request_rejected',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioHeader = request.headers.get('x-rdg-test-scenario');
|
||||
const testScenario =
|
||||
environment.NODE_ENV !== 'production' &&
|
||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
||||
['success', 'duplicate', 'timeout', 'integration-error'].includes(scenarioHeader ?? '')
|
||||
? (scenarioHeader as 'success' | 'duplicate' | 'timeout' | 'integration-error')
|
||||
: undefined;
|
||||
|
||||
const result = await submitDemoLead(parsed.data.lead, {
|
||||
environment,
|
||||
...(testScenario ? { testScenario } : {}),
|
||||
});
|
||||
|
||||
const status = result.ok
|
||||
? 202
|
||||
: result.category === 'timeout'
|
||||
? 504
|
||||
: result.category === 'configuration'
|
||||
? 503
|
||||
: result.category === 'duplicate'
|
||||
? 409
|
||||
: 502;
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import type { Locale } from '@/lib/localization/config';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
@@ -14,9 +15,15 @@ interface MobileNavigationProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
themePreference: ThemePreference;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
export function MobileNavigation({ locale, messages, themePreference }: MobileNavigationProps) {
|
||||
export function MobileNavigation({
|
||||
locale,
|
||||
messages,
|
||||
themePreference,
|
||||
demoEnabled,
|
||||
}: MobileNavigationProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -122,10 +129,11 @@ export function MobileNavigation({ locale, messages, themePreference }: MobileNa
|
||||
label={messages.header.login}
|
||||
reason={messages.header.loginUnavailable}
|
||||
/>
|
||||
<DisabledAction
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
reason={messages.header.demoUnavailable}
|
||||
conversion
|
||||
source="mobile-header"
|
||||
fullWidth
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import styles from './SiteFooter.module.css';
|
||||
@@ -6,6 +7,7 @@ import styles from './SiteFooter.module.css';
|
||||
interface SiteFooterProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
|
||||
@@ -40,7 +42,7 @@ function PendingItem({ label, pending }: { label: string; pending: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
||||
export function SiteFooter({ locale, messages, demoEnabled }: SiteFooterProps) {
|
||||
const footer = messages.footer;
|
||||
const year = new Date().getUTCFullYear();
|
||||
|
||||
@@ -92,7 +94,12 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
||||
<PendingItem label={footer.links.contact} pending={footer.pending} />
|
||||
</li>
|
||||
<li>
|
||||
<PendingItem label={footer.links.demo} pending={footer.pending} />
|
||||
<DemoTrigger
|
||||
label={footer.links.demo}
|
||||
source="footer"
|
||||
size="small"
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -261,3 +261,9 @@ html[dir='rtl'] .drawer {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 559px) {
|
||||
.mobileDemoTrigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Locale } from '@/lib/localization/config';
|
||||
import type { ShellMessages } from '@/lib/localization/messages';
|
||||
import type { ThemePreference } from '@/lib/theme/config';
|
||||
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
|
||||
import { BrandLink } from './BrandLink';
|
||||
import { DisabledAction } from './DisabledAction';
|
||||
import { HeaderNavigation } from './HeaderNavigation';
|
||||
@@ -13,9 +14,10 @@ interface SiteHeaderProps {
|
||||
locale: Locale;
|
||||
messages: ShellMessages;
|
||||
themePreference: ThemePreference;
|
||||
demoEnabled: boolean;
|
||||
}
|
||||
|
||||
export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProps) {
|
||||
export function SiteHeader({ locale, messages, themePreference, demoEnabled }: SiteHeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.inner}>
|
||||
@@ -48,20 +50,28 @@ export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProp
|
||||
compact
|
||||
/>
|
||||
<DisabledAction label={messages.header.login} reason={messages.header.loginUnavailable} />
|
||||
<DisabledAction
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
reason={messages.header.demoUnavailable}
|
||||
conversion
|
||||
source="header"
|
||||
size="small"
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.mobileActions}>
|
||||
<DisabledAction
|
||||
<DemoTrigger
|
||||
label={messages.header.demo}
|
||||
reason={messages.header.demoUnavailable}
|
||||
conversion
|
||||
source="mobile-header"
|
||||
size="small"
|
||||
className={styles.mobileDemoTrigger}
|
||||
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
|
||||
/>
|
||||
<MobileNavigation
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
themePreference={themePreference}
|
||||
demoEnabled={demoEnabled}
|
||||
/>
|
||||
<MobileNavigation locale={locale} messages={messages} themePreference={themePreference} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -6,11 +6,11 @@ interface FormFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
optionalLabel?: string;
|
||||
supportingText?: string;
|
||||
error?: string;
|
||||
optionalLabel?: string | undefined;
|
||||
supportingText?: string | undefined;
|
||||
error?: string | undefined;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
@@ -58,7 +58,7 @@ export function Fieldset({
|
||||
}: {
|
||||
legend: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
className?: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={classNames(styles.fieldset, className)}>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BidiText } from '@/components/foundation/BidiText';
|
||||
import { classNames } from '@/lib/components/classNames';
|
||||
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
type InputHTMLAttributes,
|
||||
type SelectHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
import styles from './TextInput.module.css';
|
||||
|
||||
export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
|
||||
@@ -8,21 +13,18 @@ export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
|
||||
interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
type?: TextInputType;
|
||||
invalid?: boolean;
|
||||
describedBy?: string;
|
||||
describedBy?: string | undefined;
|
||||
bidiValue?: boolean;
|
||||
}
|
||||
|
||||
export function TextInput({
|
||||
type = 'text',
|
||||
invalid = false,
|
||||
describedBy,
|
||||
bidiValue = false,
|
||||
className,
|
||||
...props
|
||||
}: TextInputProps) {
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||
{ type = 'text', invalid = false, describedBy, bidiValue = false, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
dir={dir}
|
||||
className={classNames(styles.control, className)}
|
||||
@@ -31,11 +33,11 @@ export function TextInput({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
invalid?: boolean;
|
||||
describedBy?: string;
|
||||
describedBy?: string | undefined;
|
||||
characterCount?: { current: number; max: number; label: string };
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ export function TextArea({
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
invalid?: boolean;
|
||||
describedBy?: string;
|
||||
describedBy?: string | undefined;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.notice p,
|
||||
.statusText {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.spanAll {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.success {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.success h3,
|
||||
.success p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.honeypot {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.spanAll {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.notice {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import type { DemoSource } from '@/lib/demo/schema';
|
||||
|
||||
export const demoOpenEventName = 'rdg:demo-open';
|
||||
|
||||
export interface DemoOpenEventDetail {
|
||||
source: DemoSource;
|
||||
trigger: HTMLElement;
|
||||
}
|
||||
|
||||
interface DemoTriggerProps {
|
||||
label: string;
|
||||
source: DemoSource;
|
||||
disabledReason?: string | undefined;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
fullWidth?: boolean;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
export function DemoTrigger({
|
||||
label,
|
||||
source,
|
||||
disabledReason,
|
||||
size = 'medium',
|
||||
fullWidth = false,
|
||||
className,
|
||||
}: DemoTriggerProps) {
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<ActionLink
|
||||
href="/"
|
||||
variant="button-conversion"
|
||||
disabledReason={disabledReason}
|
||||
className={className}
|
||||
>
|
||||
{label}
|
||||
</ActionLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
intent="conversion"
|
||||
size={size}
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
data-demo-source={source}
|
||||
onClick={(event) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<DemoOpenEventDetail>(demoOpenEventName, {
|
||||
detail: { source, trigger: event.currentTarget },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { Card } from '@/components/surfaces/Card';
|
||||
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './CTA.module.css';
|
||||
|
||||
export interface CTAAction {
|
||||
@@ -17,6 +18,8 @@ interface CTAProps {
|
||||
secondary?: CTAAction;
|
||||
headingLevel?: HeadingLevel;
|
||||
layout?: 'inline' | 'section' | 'card';
|
||||
primaryNode?: ReactNode;
|
||||
secondaryNode?: ReactNode;
|
||||
}
|
||||
|
||||
export function CTA({
|
||||
@@ -27,6 +30,8 @@ export function CTA({
|
||||
secondary,
|
||||
headingLevel = 2,
|
||||
layout = 'section',
|
||||
primaryNode,
|
||||
secondaryNode,
|
||||
}: CTAProps) {
|
||||
return (
|
||||
<Card
|
||||
@@ -43,22 +48,25 @@ export function CTA({
|
||||
{body ? <Text variant="supporting">{body}</Text> : null}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<ActionLink
|
||||
href={primary.href ?? '/'}
|
||||
variant="button-conversion"
|
||||
{...(primary.disabledReason ? { disabledReason: primary.disabledReason } : {})}
|
||||
>
|
||||
{primary.label}
|
||||
</ActionLink>
|
||||
{secondary ? (
|
||||
{primaryNode ?? (
|
||||
<ActionLink
|
||||
href={secondary.href ?? '/'}
|
||||
variant="button-primary"
|
||||
{...(secondary.disabledReason ? { disabledReason: secondary.disabledReason } : {})}
|
||||
href={primary.href ?? '/'}
|
||||
variant="button-conversion"
|
||||
{...(primary.disabledReason ? { disabledReason: primary.disabledReason } : {})}
|
||||
>
|
||||
{secondary.label}
|
||||
{primary.label}
|
||||
</ActionLink>
|
||||
) : null}
|
||||
)}
|
||||
{secondaryNode ??
|
||||
(secondary ? (
|
||||
<ActionLink
|
||||
href={secondary.href ?? '/'}
|
||||
variant="button-primary"
|
||||
{...(secondary.disabledReason ? { disabledReason: secondary.disabledReason } : {})}
|
||||
>
|
||||
{secondary.label}
|
||||
</ActionLink>
|
||||
) : null)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -234,25 +234,40 @@
|
||||
"language": "لغة العرض المفضلة",
|
||||
"message": "ما الموضوع الذي ينبغي أن يركز عليه العرض؟",
|
||||
"submit": "إرسال طلب العرض",
|
||||
"privacy": "بإرسال النموذج، فإنك توافق على التواصل معك بخصوص هذا الطلب. يحتاج نص إشعار الخصوصية إلى اعتماد قانوني.",
|
||||
"privacy": "يتحقق وضع غير الإنتاج من سير العمل ثم يتخلص من الطلب. لا يُرسل أي عميل محتمل إلى نظام مبيعات. يظل نص الخصوصية والموافقة النهائي محظورًا إلى حين الاعتماد القانوني.",
|
||||
"close": "إغلاق",
|
||||
"required": "مطلوب",
|
||||
"emailError": "أدخل عنوان بريد إلكتروني صالحًا للعمل.",
|
||||
"requiredError": "هذا الحقل مطلوب.",
|
||||
"fleetOptions": [
|
||||
"اختر خيارًا",
|
||||
"من 1 إلى 25 مركبة",
|
||||
"من 26 إلى 100 مركبة",
|
||||
"من 101 إلى 500 مركبة",
|
||||
"أكثر من 500 مركبة",
|
||||
"غير متأكد بعد"
|
||||
"من 1 إلى 9 مركبات",
|
||||
"من 10 إلى 24 مركبة",
|
||||
"من 25 إلى 49 مركبة",
|
||||
"من 50 إلى 99 مركبة",
|
||||
"من 100 إلى 249 مركبة",
|
||||
"250 مركبة أو أكثر"
|
||||
],
|
||||
"langOptions": ["English", "Français", "العربية"],
|
||||
"successTitle": "تم تسجيل طلب العرض",
|
||||
"successBody": "اكتمل مسار الحجز في هذا النموذج الأولي. لم يُرسل أي طلب إلى نظام مبيعات فعلي.",
|
||||
"submitAnother": "إرسال طلب آخر",
|
||||
"submissionError": "تعذر على النموذج الأولي إكمال الطلب. ما زالت بياناتك متاحة. حاول مرة أخرى.",
|
||||
"errorSummary": "راجع الحقول المميزة ثم حاول مرة أخرى."
|
||||
"errorSummary": "راجع الحقول المميزة ثم حاول مرة أخرى.",
|
||||
"optional": "اختياري",
|
||||
"messageHelp": "لا تُدرج معلومات شخصية حساسة أو معلومات دفع.",
|
||||
"messageCount": "حرفًا",
|
||||
"submitting": "جارٍ إرسال الطلب",
|
||||
"retry": "حاول مرة أخرى",
|
||||
"closeBlocked": "انتظر حتى يكتمل الإرسال الحالي قبل الإغلاق.",
|
||||
"invalidOptionError": "اختر خيارًا صالحًا.",
|
||||
"tooLongError": "هذا الإدخال طويل جدًا.",
|
||||
"configurationError": "إرسال طلب العرض غير متاح لأن اعتماد تكامل الإنتاج لم يكتمل.",
|
||||
"timeoutError": "انتهت مهلة الطلب. لا تزال بياناتك متاحة؛ حاول مرة أخرى.",
|
||||
"duplicateError": "تمت معالجة هذا الطلب بالفعل. لم يُنشأ عميل محتمل ثانٍ.",
|
||||
"rateLimitError": "تم استلام عدد كبير جدًا من الطلبات. حاول مرة أخرى لاحقًا.",
|
||||
"genericError": "تعذر إكمال الطلب. لا تزال بياناتك متاحة.",
|
||||
"localModeLabel": "موصل محلي آمن"
|
||||
},
|
||||
"preview": {
|
||||
"today": "اليوم",
|
||||
|
||||
@@ -246,25 +246,40 @@
|
||||
"language": "Preferred demo language",
|
||||
"message": "What should the demo focus on?",
|
||||
"submit": "Request my demo",
|
||||
"privacy": "By submitting, you agree to be contacted about this request. Privacy notice text requires legal approval.",
|
||||
"privacy": "Non-production mode validates the workflow and discards the request. No lead is sent to a sales system. Final privacy and consent text remains blocked pending legal approval.",
|
||||
"close": "Close",
|
||||
"required": "Required",
|
||||
"emailError": "Enter a valid work email address.",
|
||||
"requiredError": "This field is required.",
|
||||
"fleetOptions": [
|
||||
"Select an option",
|
||||
"1–25 vehicles",
|
||||
"26–100 vehicles",
|
||||
"101–500 vehicles",
|
||||
"More than 500 vehicles",
|
||||
"Not sure yet"
|
||||
"1–9 vehicles",
|
||||
"10–24 vehicles",
|
||||
"25–49 vehicles",
|
||||
"50–99 vehicles",
|
||||
"100–249 vehicles",
|
||||
"250 or more vehicles"
|
||||
],
|
||||
"langOptions": ["English", "Français", "العربية"],
|
||||
"successTitle": "Demo request recorded",
|
||||
"successBody": "This prototype has completed the booking flow. No request was sent to a real sales system.",
|
||||
"submitAnother": "Submit another request",
|
||||
"submissionError": "The prototype could not complete the request. Your entries are still available. Try again.",
|
||||
"errorSummary": "Review the highlighted fields and try again."
|
||||
"errorSummary": "Review the highlighted fields and try again.",
|
||||
"optional": "Optional",
|
||||
"messageHelp": "Do not include sensitive personal or payment information.",
|
||||
"messageCount": "characters",
|
||||
"submitting": "Submitting request",
|
||||
"retry": "Try again",
|
||||
"closeBlocked": "Wait for the current submission to finish before closing.",
|
||||
"invalidOptionError": "Select a valid option.",
|
||||
"tooLongError": "This entry is too long.",
|
||||
"configurationError": "Demo submission is not available because production integration approval is incomplete.",
|
||||
"timeoutError": "The request timed out. Your entries are still available; try again.",
|
||||
"duplicateError": "This request has already been processed. No second lead was created.",
|
||||
"rateLimitError": "Too many requests were received. Try again later.",
|
||||
"genericError": "The request could not be completed. Your entries are still available.",
|
||||
"localModeLabel": "Safe local adapter"
|
||||
},
|
||||
"preview": {
|
||||
"today": "Today",
|
||||
|
||||
@@ -265,25 +265,40 @@
|
||||
"language": "Langue souhaitée pour la démonstration",
|
||||
"message": "Sur quoi la démonstration doit-elle se concentrer ?",
|
||||
"submit": "Demander ma démonstration",
|
||||
"privacy": "En envoyant ce formulaire, vous acceptez d’être contacté au sujet de cette demande. Le texte de confidentialité nécessite une validation juridique.",
|
||||
"privacy": "Le mode hors production valide le parcours puis supprime la demande. Aucun prospect n’est envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans l’attente de l’approbation juridique.",
|
||||
"close": "Fermer",
|
||||
"required": "Obligatoire",
|
||||
"emailError": "Saisissez une adresse e-mail professionnelle valide.",
|
||||
"requiredError": "Ce champ est obligatoire.",
|
||||
"fleetOptions": [
|
||||
"Sélectionnez une option",
|
||||
"1 à 25 véhicules",
|
||||
"26 à 100 véhicules",
|
||||
"101 à 500 véhicules",
|
||||
"Plus de 500 véhicules",
|
||||
"Je ne sais pas encore"
|
||||
"1 à 9 véhicules",
|
||||
"10 à 24 véhicules",
|
||||
"25 à 49 véhicules",
|
||||
"50 à 99 véhicules",
|
||||
"100 à 249 véhicules",
|
||||
"250 véhicules ou plus"
|
||||
],
|
||||
"langOptions": ["English", "Français", "العربية"],
|
||||
"successTitle": "Demande de démonstration enregistrée",
|
||||
"successBody": "Ce prototype a terminé le parcours de réservation. Aucune demande n’a été envoyée à un véritable système commercial.",
|
||||
"submitAnother": "Envoyer une autre demande",
|
||||
"submissionError": "Le prototype n’a pas pu terminer la demande. Vos informations sont conservées. Réessayez.",
|
||||
"errorSummary": "Vérifiez les champs signalés, puis réessayez."
|
||||
"errorSummary": "Vérifiez les champs signalés, puis réessayez.",
|
||||
"optional": "Facultatif",
|
||||
"messageHelp": "N’incluez aucune donnée personnelle sensible ni information de paiement.",
|
||||
"messageCount": "caractères",
|
||||
"submitting": "Envoi de la demande",
|
||||
"retry": "Réessayer",
|
||||
"closeBlocked": "Attendez la fin de l’envoi en cours avant de fermer.",
|
||||
"invalidOptionError": "Sélectionnez une option valide.",
|
||||
"tooLongError": "Cette saisie est trop longue.",
|
||||
"configurationError": "L’envoi d’une demande de démonstration est indisponible car l’intégration de production n’est pas encore approuvée.",
|
||||
"timeoutError": "La demande a expiré. Vos informations sont toujours disponibles ; réessayez.",
|
||||
"duplicateError": "Cette demande a déjà été traitée. Aucun deuxième prospect n’a été créé.",
|
||||
"rateLimitError": "Trop de demandes ont été reçues. Réessayez plus tard.",
|
||||
"genericError": "La demande n’a pas pu être terminée. Vos informations sont toujours disponibles.",
|
||||
"localModeLabel": "Adaptateur local sécurisé"
|
||||
},
|
||||
"preview": {
|
||||
"today": "Aujourd’hui",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { validateAnalyticsEvent, type AnalyticsContext, type AnalyticsEventName } from './events';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__rdgAnalyticsTestSink?: Array<{ name: AnalyticsEventName; context: AnalyticsContext }>;
|
||||
}
|
||||
}
|
||||
|
||||
function analyticsMode(): 'disabled' | 'test' {
|
||||
return process.env.NEXT_PUBLIC_ANALYTICS_MODE === 'test' ? 'test' : 'disabled';
|
||||
}
|
||||
|
||||
function consentGranted(): boolean {
|
||||
return document.documentElement.dataset.analyticsConsent === 'granted';
|
||||
}
|
||||
|
||||
export function trackAnalytics(name: AnalyticsEventName, context: AnalyticsContext): boolean {
|
||||
const event = validateAnalyticsEvent(name, context);
|
||||
if (!event || analyticsMode() !== 'test' || !consentGranted()) return false;
|
||||
window.__rdgAnalyticsTestSink ??= [];
|
||||
window.__rdgAnalyticsTestSink.push(event);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const analyticsEventNames = [
|
||||
'page_view',
|
||||
'section_view',
|
||||
'navigation_click',
|
||||
'locale_change',
|
||||
'theme_change',
|
||||
'mobile_nav_open',
|
||||
'mobile_nav_close',
|
||||
'product_tour_open',
|
||||
'product_tour_step',
|
||||
'product_tour_complete',
|
||||
'product_tour_close',
|
||||
'demo_open',
|
||||
'demo_submit_attempt',
|
||||
'demo_validation_error',
|
||||
'demo_submit_success',
|
||||
'demo_submit_failure',
|
||||
'demo_close',
|
||||
'faq_open',
|
||||
] as const;
|
||||
|
||||
export type AnalyticsEventName = (typeof analyticsEventNames)[number];
|
||||
|
||||
const safeText = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(80)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/);
|
||||
|
||||
export const analyticsContextSchema = z
|
||||
.object({
|
||||
routeId: safeText,
|
||||
locale: z.enum(['en', 'fr', 'ar']),
|
||||
direction: z.enum(['ltr', 'rtl']),
|
||||
resolvedTheme: z.enum(['light', 'dark']),
|
||||
source: safeText.optional(),
|
||||
step: z.number().int().min(1).max(100).optional(),
|
||||
errorCode: safeText.optional(),
|
||||
errorCount: z.number().int().min(1).max(20).optional(),
|
||||
reason: safeText.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AnalyticsContext = z.output<typeof analyticsContextSchema>;
|
||||
|
||||
export interface AnalyticsEventDefinition {
|
||||
name: AnalyticsEventName;
|
||||
purpose: string;
|
||||
consentCategory: 'analytics';
|
||||
personalData: false;
|
||||
}
|
||||
|
||||
export const analyticsEventRegistry: readonly AnalyticsEventDefinition[] = analyticsEventNames.map(
|
||||
(name) => ({
|
||||
name,
|
||||
purpose: `Phase 9 approved ${name} interaction measurement.`,
|
||||
consentCategory: 'analytics',
|
||||
personalData: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const forbiddenPayloadKeys = new Set([
|
||||
'name',
|
||||
'email',
|
||||
'company',
|
||||
'message',
|
||||
'phone',
|
||||
'fieldvalue',
|
||||
'serverresponse',
|
||||
]);
|
||||
|
||||
export function validateAnalyticsEvent(name: unknown, context: unknown) {
|
||||
const event = z.enum(analyticsEventNames).safeParse(name);
|
||||
const payload = analyticsContextSchema.safeParse(context);
|
||||
if (!event.success || !payload.success) return null;
|
||||
if (Object.keys(payload.data).some((key) => forbiddenPayloadKeys.has(key.toLowerCase()))) {
|
||||
return null;
|
||||
}
|
||||
return { name: event.data, context: payload.data } as const;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
|
||||
export class BlockedLeadDestinationAdapter implements LeadDestinationAdapter {
|
||||
readonly name = 'blocked';
|
||||
|
||||
async submitLead(): Promise<LeadDestinationResult> {
|
||||
return {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
retryable: false,
|
||||
code: 'lead_destination_not_approved',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { BlockedLeadDestinationAdapter } from './blocked';
|
||||
import { LocalLeadDestinationAdapter } from './local';
|
||||
import type { LeadDestinationAdapter } from './types';
|
||||
|
||||
export function createLeadDestinationAdapter(
|
||||
environment: IntegrationEnvironment,
|
||||
): LeadDestinationAdapter {
|
||||
const approvedLocalRuntime =
|
||||
environment.NODE_ENV !== 'production' || environment.DEMO_LOCAL_TEST_MODE;
|
||||
if (approvedLocalRuntime && environment.DEMO_SUBMISSION_MODE === 'local') {
|
||||
return new LocalLeadDestinationAdapter();
|
||||
}
|
||||
return new BlockedLeadDestinationAdapter();
|
||||
}
|
||||
|
||||
export type { IntegrationContext, LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
|
||||
function abortError(): Error {
|
||||
return new DOMException('Operation aborted', 'AbortError');
|
||||
}
|
||||
|
||||
export class LocalLeadDestinationAdapter implements LeadDestinationAdapter {
|
||||
readonly name = 'local-discard';
|
||||
|
||||
async submitLead(
|
||||
_lead: Parameters<LeadDestinationAdapter['submitLead']>[0],
|
||||
context: Parameters<LeadDestinationAdapter['submitLead']>[1],
|
||||
): Promise<LeadDestinationResult> {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
|
||||
switch (context.testScenario) {
|
||||
case 'duplicate':
|
||||
return { ok: false, category: 'duplicate', retryable: false, code: 'local_duplicate' };
|
||||
case 'integration-error':
|
||||
return { ok: false, category: 'integration', retryable: true, code: 'local_error' };
|
||||
case 'timeout':
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 30_000);
|
||||
context.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'success':
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
externalId: `local_${context.correlationId}`,
|
||||
acceptedAt: new Date().toISOString(),
|
||||
mode: 'local',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DemoLead } from '../schema';
|
||||
|
||||
export interface IntegrationContext {
|
||||
correlationId: string;
|
||||
idempotencyKey: string;
|
||||
signal: AbortSignal;
|
||||
environment: 'development' | 'test' | 'production';
|
||||
testScenario?: 'success' | 'duplicate' | 'timeout' | 'integration-error';
|
||||
}
|
||||
|
||||
export type LeadDestinationResult =
|
||||
| { ok: true; externalId: string; acceptedAt: string; mode: 'local' }
|
||||
| {
|
||||
ok: false;
|
||||
category: 'duplicate' | 'integration' | 'timeout' | 'configuration';
|
||||
retryable: boolean;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export interface LeadDestinationAdapter {
|
||||
readonly name: string;
|
||||
submitLead(lead: DemoLead, context: IntegrationContext): Promise<LeadDestinationResult>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DemoSubmissionResult } from './schema';
|
||||
|
||||
interface StoredOperation {
|
||||
expiresAt: number;
|
||||
result: Promise<DemoSubmissionResult>;
|
||||
}
|
||||
|
||||
const globalStore = globalThis as typeof globalThis & {
|
||||
__rdgDemoIdempotencyStore?: Map<string, StoredOperation>;
|
||||
};
|
||||
|
||||
const store = globalStore.__rdgDemoIdempotencyStore ?? new Map<string, StoredOperation>();
|
||||
globalStore.__rdgDemoIdempotencyStore = store;
|
||||
|
||||
function purgeExpired(now: number) {
|
||||
for (const [key, operation] of store) {
|
||||
if (operation.expiresAt <= now) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeIdempotent(
|
||||
key: string,
|
||||
ttlSeconds: number,
|
||||
operation: () => Promise<DemoSubmissionResult>,
|
||||
): Promise<DemoSubmissionResult> {
|
||||
const now = Date.now();
|
||||
purgeExpired(now);
|
||||
const existing = store.get(key);
|
||||
if (existing) return existing.result;
|
||||
|
||||
const result = operation();
|
||||
store.set(key, { expiresAt: now + ttlSeconds * 1_000, result });
|
||||
|
||||
try {
|
||||
const settled = await result;
|
||||
if (!settled.ok && settled.retryable) store.delete(key);
|
||||
return settled;
|
||||
} catch (error) {
|
||||
store.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIdempotencyStoreForTests() {
|
||||
store.clear();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const sensitiveKeys = new Set([
|
||||
'fullname',
|
||||
'name',
|
||||
'workemail',
|
||||
'email',
|
||||
'company',
|
||||
'market',
|
||||
'message',
|
||||
'phone',
|
||||
'fieldvalue',
|
||||
'serverresponse',
|
||||
]);
|
||||
|
||||
export function redactSensitive(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactSensitive);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
sensitiveKeys.has(key.toLowerCase()) ? '[REDACTED]' : redactSensitive(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function containsSensitiveKey(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsSensitiveKey);
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return Object.entries(value).some(
|
||||
([key, child]) => sensitiveKeys.has(key.toLowerCase()) || containsSensitiveKey(child),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const fleetSizeValues = ['1-9', '10-24', '25-49', '50-99', '100-249', '250+'] as const;
|
||||
export const demoSourceValues = ['header', 'mobile-header', 'hero', 'final-cta', 'footer'] as const;
|
||||
|
||||
const compactText = (maximum: number) =>
|
||||
z
|
||||
.string()
|
||||
.transform((value) => value.trim().replace(/\s+/g, ' '))
|
||||
.pipe(z.string().min(1, 'required').max(maximum, 'too_long'));
|
||||
|
||||
const optionalCompactText = (maximum: number) =>
|
||||
z
|
||||
.string()
|
||||
.transform((value) => value.trim().replace(/\s+/g, ' '))
|
||||
.pipe(z.string().max(maximum, 'too_long'))
|
||||
.transform((value) => (value.length === 0 ? undefined : value))
|
||||
.optional();
|
||||
|
||||
export const demoLeadSchema = z
|
||||
.object({
|
||||
fullName: compactText(120),
|
||||
workEmail: z
|
||||
.string()
|
||||
.transform((value) => value.trim().toLowerCase())
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1, 'required')
|
||||
.max(254, 'too_long')
|
||||
.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'invalid_email'),
|
||||
),
|
||||
company: compactText(160),
|
||||
fleetSize: z.enum(fleetSizeValues, { error: 'invalid_fleet_size' }),
|
||||
market: optionalCompactText(100),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
message: optionalCompactText(1_000),
|
||||
idempotencyKey: z.string().uuid('invalid_idempotency_key'),
|
||||
source: z.enum(demoSourceValues, { error: 'invalid_source' }),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const demoSubmissionEnvelopeSchema = z
|
||||
.object({
|
||||
lead: demoLeadSchema,
|
||||
guard: z
|
||||
.object({
|
||||
honeypot: z.string().max(200).default(''),
|
||||
startedAtMs: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DemoLeadInput = z.input<typeof demoLeadSchema>;
|
||||
export type DemoLead = z.output<typeof demoLeadSchema>;
|
||||
export type DemoSubmissionEnvelope = z.output<typeof demoSubmissionEnvelopeSchema>;
|
||||
export type DemoSource = (typeof demoSourceValues)[number];
|
||||
|
||||
export type DemoFailureCategory =
|
||||
| 'validation'
|
||||
| 'consent'
|
||||
| 'duplicate'
|
||||
| 'rate-limit'
|
||||
| 'integration'
|
||||
| 'timeout'
|
||||
| 'configuration'
|
||||
| 'abuse';
|
||||
|
||||
export type DemoSubmissionResult =
|
||||
| {
|
||||
ok: true;
|
||||
submissionId: string;
|
||||
acceptedAt: string;
|
||||
nextAction: { type: 'success'; mode: 'local' };
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
category: DemoFailureCategory;
|
||||
fieldErrors?: Record<string, string>;
|
||||
formErrorCode: string;
|
||||
retryable: boolean;
|
||||
correlationId?: string;
|
||||
};
|
||||
|
||||
export function zodFieldErrors(error: z.ZodError): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.at(-1);
|
||||
if (typeof path !== 'string' || result[path]) continue;
|
||||
result[path] = issue.message;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { createLeadDestinationAdapter, type LeadDestinationAdapter } from './adapters';
|
||||
import { executeIdempotent } from './idempotency';
|
||||
import type { DemoLead, DemoSubmissionResult } from './schema';
|
||||
|
||||
export interface SubmitDemoOptions {
|
||||
environment: IntegrationEnvironment;
|
||||
adapter?: LeadDestinationAdapter;
|
||||
testScenario?: 'success' | 'duplicate' | 'timeout' | 'integration-error';
|
||||
}
|
||||
|
||||
export async function submitDemoLead(
|
||||
lead: DemoLead,
|
||||
options: SubmitDemoOptions,
|
||||
): Promise<DemoSubmissionResult> {
|
||||
const { environment } = options;
|
||||
const adapter = options.adapter ?? createLeadDestinationAdapter(environment);
|
||||
|
||||
return executeIdempotent(
|
||||
lead.idempotencyKey,
|
||||
environment.DEMO_IDEMPOTENCY_TTL_SECONDS,
|
||||
async () => {
|
||||
const correlationId = randomUUID();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), environment.DEMO_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const destinationResult = await adapter.submitLead(lead, {
|
||||
correlationId,
|
||||
idempotencyKey: lead.idempotencyKey,
|
||||
signal: controller.signal,
|
||||
environment: environment.NODE_ENV,
|
||||
...(options.testScenario ? { testScenario: options.testScenario } : {}),
|
||||
});
|
||||
|
||||
if (!destinationResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
category: destinationResult.category,
|
||||
formErrorCode: destinationResult.code,
|
||||
retryable: destinationResult.retryable,
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
submissionId: destinationResult.externalId,
|
||||
acceptedAt: destinationResult.acceptedAt,
|
||||
nextAction: { type: 'success', mode: destinationResult.mode },
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut =
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === 'AbortError');
|
||||
return {
|
||||
ok: false,
|
||||
category: timedOut ? 'timeout' : 'integration',
|
||||
formErrorCode: timedOut ? 'lead_destination_timeout' : 'lead_destination_failure',
|
||||
retryable: true,
|
||||
correlationId,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { localizedPath, type Locale, type RouteId } from '@/lib/localization/config';
|
||||
|
||||
export type DestinationStatus = 'active' | 'disabled' | 'placeholder' | 'blocked';
|
||||
export type DestinationKind =
|
||||
| 'internal-route'
|
||||
| 'external-url'
|
||||
| 'modal'
|
||||
| 'drawer'
|
||||
| 'form'
|
||||
| 'scheduler';
|
||||
|
||||
export type DestinationKey =
|
||||
| 'book-demo'
|
||||
| 'contact-sales'
|
||||
| 'product-tour'
|
||||
| 'pricing'
|
||||
| 'login'
|
||||
| 'privacy'
|
||||
| 'terms'
|
||||
| 'accessibility'
|
||||
| 'cookie-settings'
|
||||
| 'support';
|
||||
|
||||
export interface DestinationReference {
|
||||
key: DestinationKey;
|
||||
kind: DestinationKind;
|
||||
status: DestinationStatus;
|
||||
routeId?: RouteId;
|
||||
href?: string;
|
||||
featureFlag?: string;
|
||||
issueId?: string;
|
||||
external?: boolean;
|
||||
allowedLocales: readonly Locale[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const allLocales = ['en', 'fr', 'ar'] as const satisfies readonly Locale[];
|
||||
|
||||
const baseDestinations: Record<DestinationKey, DestinationReference> = {
|
||||
'book-demo': {
|
||||
key: 'book-demo',
|
||||
kind: 'modal',
|
||||
status: 'blocked',
|
||||
featureFlag: 'demoSubmissionEnabled',
|
||||
issueId: 'P9-002/P9-003',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'Production CRM and legal consent decisions are not approved.',
|
||||
},
|
||||
'contact-sales': {
|
||||
key: 'contact-sales',
|
||||
kind: 'external-url',
|
||||
status: 'blocked',
|
||||
issueId: 'P9-015',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'No approved sales destination exists.',
|
||||
},
|
||||
'product-tour': {
|
||||
key: 'product-tour',
|
||||
kind: 'modal',
|
||||
status: 'blocked',
|
||||
issueId: 'P9-012',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'No approved product-tour medium or transcript exists.',
|
||||
},
|
||||
pricing: {
|
||||
key: 'pricing',
|
||||
kind: 'internal-route',
|
||||
status: 'placeholder',
|
||||
issueId: 'P9-008',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'Public pricing and packaging are not approved.',
|
||||
},
|
||||
login: {
|
||||
key: 'login',
|
||||
kind: 'external-url',
|
||||
status: 'blocked',
|
||||
issueId: 'P9-014',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'No approved login origin or return-path behavior exists.',
|
||||
},
|
||||
privacy: {
|
||||
key: 'privacy',
|
||||
kind: 'internal-route',
|
||||
status: 'placeholder',
|
||||
routeId: 'privacy',
|
||||
issueId: 'P9-003',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'The privacy destination resolves to a gated placeholder pending legal copy.',
|
||||
},
|
||||
terms: {
|
||||
key: 'terms',
|
||||
kind: 'internal-route',
|
||||
status: 'placeholder',
|
||||
routeId: 'terms',
|
||||
issueId: 'P9-003',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'The terms destination resolves to a gated placeholder pending legal copy.',
|
||||
},
|
||||
accessibility: {
|
||||
key: 'accessibility',
|
||||
kind: 'internal-route',
|
||||
status: 'placeholder',
|
||||
routeId: 'accessibility',
|
||||
issueId: 'P13-002',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'The accessibility destination resolves to a gated placeholder pending approval.',
|
||||
},
|
||||
'cookie-settings': {
|
||||
key: 'cookie-settings',
|
||||
kind: 'modal',
|
||||
status: 'blocked',
|
||||
issueId: 'P9-004',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'Consent categories and a consent-management mechanism are not approved.',
|
||||
},
|
||||
support: {
|
||||
key: 'support',
|
||||
kind: 'external-url',
|
||||
status: 'blocked',
|
||||
issueId: 'P9-015',
|
||||
allowedLocales: allLocales,
|
||||
reason: 'No approved public support destination exists.',
|
||||
},
|
||||
};
|
||||
|
||||
export interface DestinationRuntimeContext {
|
||||
demoSubmissionEnabled: boolean;
|
||||
demoMode: 'blocked' | 'local';
|
||||
}
|
||||
|
||||
export function destinationRegistry(
|
||||
context: DestinationRuntimeContext,
|
||||
): Record<DestinationKey, DestinationReference> {
|
||||
const demo = baseDestinations['book-demo'];
|
||||
return {
|
||||
...baseDestinations,
|
||||
'book-demo': {
|
||||
...demo,
|
||||
status: context.demoSubmissionEnabled && context.demoMode === 'local' ? 'active' : 'blocked',
|
||||
reason:
|
||||
context.demoSubmissionEnabled && context.demoMode === 'local'
|
||||
? 'Local validation adapter is active; no external lead is created.'
|
||||
: demo.reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDestination(
|
||||
key: DestinationKey,
|
||||
locale: Locale,
|
||||
context: DestinationRuntimeContext,
|
||||
): DestinationReference {
|
||||
const destination = destinationRegistry(context)[key];
|
||||
if (!destination.allowedLocales.includes(locale)) {
|
||||
return {
|
||||
...destination,
|
||||
status: 'blocked',
|
||||
reason: 'Destination is unavailable for this locale.',
|
||||
};
|
||||
}
|
||||
if (destination.routeId) {
|
||||
return { ...destination, href: localizedPath(destination.routeId, locale) };
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'server-only';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const booleanText = z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true');
|
||||
|
||||
const environmentSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
NEXT_PUBLIC_DEMO_ENABLED: booleanText,
|
||||
DEMO_SUBMISSION_MODE: z.enum(['blocked', 'local']).default('blocked'),
|
||||
DEMO_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(250).max(15_000).default(4_000),
|
||||
DEMO_IDEMPOTENCY_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
|
||||
DEMO_TEST_SCENARIOS_ENABLED: booleanText,
|
||||
DEMO_LOCAL_TEST_MODE: booleanText,
|
||||
NEXT_PUBLIC_ANALYTICS_MODE: z.enum(['disabled', 'test']).default('disabled'),
|
||||
SITE_ORIGIN: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export type IntegrationEnvironment = z.infer<typeof environmentSchema>;
|
||||
|
||||
export interface EnvironmentValidationResult {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
environment?: IntegrationEnvironment;
|
||||
}
|
||||
|
||||
export function validateIntegrationEnvironment(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
): EnvironmentValidationResult {
|
||||
const inferredNodeEnv =
|
||||
source.NODE_ENV === 'production' || source.NODE_ENV === 'test'
|
||||
? source.NODE_ENV
|
||||
: 'development';
|
||||
const normalizedSource = {
|
||||
...source,
|
||||
DEMO_SUBMISSION_MODE:
|
||||
source.DEMO_SUBMISSION_MODE ?? (inferredNodeEnv === 'production' ? 'blocked' : 'local'),
|
||||
};
|
||||
const parsed = environmentSchema.safeParse(normalizedSource);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
|
||||
};
|
||||
}
|
||||
|
||||
const environment = parsed.data;
|
||||
const errors: string[] = [];
|
||||
const localTestOrigin =
|
||||
environment.SITE_ORIGIN &&
|
||||
['127.0.0.1', 'localhost'].includes(new URL(environment.SITE_ORIGIN).hostname);
|
||||
const productionLocalTest =
|
||||
environment.NODE_ENV === 'production' && environment.DEMO_LOCAL_TEST_MODE && localTestOrigin;
|
||||
if (
|
||||
environment.NODE_ENV === 'production' &&
|
||||
environment.NEXT_PUBLIC_DEMO_ENABLED &&
|
||||
!productionLocalTest
|
||||
) {
|
||||
errors.push(
|
||||
'NEXT_PUBLIC_DEMO_ENABLED must remain false until an approved production lead adapter and legal consent decision are implemented.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
environment.NODE_ENV === 'production' &&
|
||||
environment.DEMO_SUBMISSION_MODE === 'local' &&
|
||||
!productionLocalTest
|
||||
) {
|
||||
errors.push(
|
||||
'DEMO_SUBMISSION_MODE=local is prohibited outside an explicit loopback test runtime.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
environment.NODE_ENV === 'production' &&
|
||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
||||
!productionLocalTest
|
||||
) {
|
||||
errors.push(
|
||||
'DEMO_TEST_SCENARIOS_ENABLED is prohibited outside an explicit loopback test runtime.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
environment.NODE_ENV === 'production' &&
|
||||
environment.NEXT_PUBLIC_ANALYTICS_MODE !== 'disabled'
|
||||
) {
|
||||
errors.push('Analytics must remain disabled until provider and consent approval are recorded.');
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { ok: false, errors, environment } : { ok: true, errors, environment };
|
||||
}
|
||||
|
||||
export function getIntegrationEnvironment(): IntegrationEnvironment {
|
||||
const result = validateIntegrationEnvironment();
|
||||
if (!result.ok || !result.environment) {
|
||||
throw new Error(`Invalid Phase 14 integration environment: ${result.errors.join(' | ')}`);
|
||||
}
|
||||
return result.environment;
|
||||
}
|
||||
|
||||
export interface PublicIntegrationConfig {
|
||||
demoSubmissionEnabled: boolean;
|
||||
demoMode: 'blocked' | 'local';
|
||||
analyticsMode: 'disabled' | 'test';
|
||||
}
|
||||
|
||||
export function getPublicIntegrationConfig(): PublicIntegrationConfig {
|
||||
const environment = getIntegrationEnvironment();
|
||||
const developmentDefault = environment.NODE_ENV !== 'production';
|
||||
const demoSubmissionEnabled =
|
||||
environment.DEMO_SUBMISSION_MODE === 'local' &&
|
||||
(environment.NEXT_PUBLIC_DEMO_ENABLED || developmentDefault);
|
||||
return {
|
||||
demoSubmissionEnabled,
|
||||
demoMode: demoSubmissionEnabled ? 'local' : 'blocked',
|
||||
analyticsMode: environment.NEXT_PUBLIC_ANALYTICS_MODE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PublicIntegrationConfig } from './environment';
|
||||
|
||||
export interface FeatureFlags {
|
||||
demoSubmissionEnabled: boolean;
|
||||
crmIntegrationEnabled: false;
|
||||
schedulerEnabled: false;
|
||||
analyticsEnabled: boolean;
|
||||
marketingConsentEnabled: false;
|
||||
productTourEnabled: false;
|
||||
pricingDestinationEnabled: false;
|
||||
loginDestinationEnabled: false;
|
||||
}
|
||||
|
||||
export function buildFeatureFlags(config: PublicIntegrationConfig): FeatureFlags {
|
||||
return {
|
||||
demoSubmissionEnabled: config.demoSubmissionEnabled,
|
||||
crmIntegrationEnabled: false,
|
||||
schedulerEnabled: false,
|
||||
analyticsEnabled: config.analyticsMode === 'test',
|
||||
marketingConsentEnabled: false,
|
||||
productTourEnabled: false,
|
||||
pricingDestinationEnabled: false,
|
||||
loginDestinationEnabled: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Accept JSON requests with optional media-type parameters while rejecting
|
||||
* look-alike types such as application/jsonp or application/json-patch+json.
|
||||
*/
|
||||
export function isApplicationJson(contentType: string | null): boolean {
|
||||
if (!contentType) return false;
|
||||
const [mediaType] = contentType.split(';', 1);
|
||||
return mediaType?.trim().toLowerCase() === 'application/json';
|
||||
}
|
||||
Reference in New Issue
Block a user