Update homepage routes to include language and mode
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Storefront Unit Tests (push) Successful in 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 40s
Test / API Integration Tests (push) Successful in 1m0s
This commit is contained in:
+13
-7
@@ -4,18 +4,20 @@ import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
|
|||||||
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
||||||
import {
|
import {
|
||||||
isLocale,
|
isLocale,
|
||||||
localizedPath,
|
isMode,
|
||||||
|
localizedModePath,
|
||||||
routeIdFromSlug,
|
routeIdFromSlug,
|
||||||
type Locale,
|
type Locale,
|
||||||
type RouteId,
|
type RouteId,
|
||||||
} from '@/lib/localization/config';
|
} from '@/lib/localization/config';
|
||||||
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
||||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
interface PendingRouteProps {
|
interface PendingRouteProps {
|
||||||
params: Promise<{ locale: string; slug: string }>;
|
params: Promise<{ locale: string; mode: string; slug: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
||||||
@@ -39,19 +41,21 @@ function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveParams(params: PendingRouteProps['params']) {
|
async function resolveParams(params: PendingRouteProps['params']) {
|
||||||
const { locale: localeValue, slug } = await params;
|
const { locale: localeValue, mode: modeValue, slug } = await params;
|
||||||
if (!isLocale(localeValue)) notFound();
|
if (!isLocale(localeValue)) notFound();
|
||||||
|
if (!isMode(modeValue)) notFound();
|
||||||
const routeId = routeIdFromSlug(localeValue, slug);
|
const routeId = routeIdFromSlug(localeValue, slug);
|
||||||
if (!routeId || routeId === 'home') notFound();
|
if (!routeId || routeId === 'home') notFound();
|
||||||
return { locale: localeValue as Locale, routeId };
|
return { locale: localeValue as Locale, mode: modeValue as ThemePreference, routeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
||||||
const { locale, routeId } = await resolveParams(params);
|
const { locale, mode, routeId } = await resolveParams(params);
|
||||||
const messages = getMessages(locale).shell;
|
const messages = getMessages(locale).shell;
|
||||||
const label = routeLabel(messages, routeId);
|
const label = routeLabel(messages, routeId);
|
||||||
return buildLocalizedMetadata({
|
return buildLocalizedMetadata({
|
||||||
locale,
|
locale,
|
||||||
|
mode,
|
||||||
routeId,
|
routeId,
|
||||||
title: `${label} | ${messages.metadata.pendingTitle}`,
|
title: `${label} | ${messages.metadata.pendingTitle}`,
|
||||||
description: messages.metadata.pendingDescription,
|
description: messages.metadata.pendingDescription,
|
||||||
@@ -60,7 +64,7 @@ export async function generateMetadata({ params }: PendingRouteProps): Promise<M
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PendingRoute({ params }: PendingRouteProps) {
|
export default async function PendingRoute({ params }: PendingRouteProps) {
|
||||||
const { locale, routeId } = await resolveParams(params);
|
const { locale, mode, routeId } = await resolveParams(params);
|
||||||
const messages = getMessages(locale).shell;
|
const messages = getMessages(locale).shell;
|
||||||
const label = routeLabel(messages, routeId);
|
const label = routeLabel(messages, routeId);
|
||||||
|
|
||||||
@@ -82,7 +86,9 @@ export default async function PendingRoute({ params }: PendingRouteProps) {
|
|||||||
body={messages.states.pendingBody}
|
body={messages.states.pendingBody}
|
||||||
>
|
>
|
||||||
<StateActions>
|
<StateActions>
|
||||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
<StateAction href={localizedModePath('home', locale, mode)}>
|
||||||
|
{messages.states.backHome}
|
||||||
|
</StateAction>
|
||||||
</StateActions>
|
</StateActions>
|
||||||
</StateShell>
|
</StateShell>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/actions/Button';
|
||||||
|
import { Accordion } from '@/components/controls/Accordion';
|
||||||
|
import { SegmentedControl } from '@/components/controls/SegmentedControl';
|
||||||
|
import { Tabs } from '@/components/controls/Tabs';
|
||||||
|
import { Alert } from '@/components/feedback/Alert';
|
||||||
|
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
|
||||||
|
import { FormField } from '@/components/forms/FormField';
|
||||||
|
import { TextInput } from '@/components/forms/TextInput';
|
||||||
|
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
|
||||||
|
import { Dialog } from '@/components/overlays/Dialog';
|
||||||
|
import type { ComponentLabCopy } from '@/content/development/component-lab';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import styles from './page.module.css';
|
||||||
|
|
||||||
|
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [segment, setSegment] = useState('comfortable');
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
return (
|
||||||
|
<div className={styles.interactiveGrid}>
|
||||||
|
<section className={styles.group} aria-labelledby="lab-actions">
|
||||||
|
<h2 id="lab-actions">Actions</h2>
|
||||||
|
<div className={styles.cluster}>
|
||||||
|
<Button>{copy.actions.primary}</Button>
|
||||||
|
<Button intent="conversion">{copy.actions.conversion}</Button>
|
||||||
|
<Button loading>{copy.actions.loading}</Button>
|
||||||
|
<Button disabled>{copy.actions.disabled}</Button>
|
||||||
|
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
|
||||||
|
{copy.dialog.open}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
label={copy.menu.label}
|
||||||
|
items={[
|
||||||
|
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
|
||||||
|
{
|
||||||
|
id: 'archive',
|
||||||
|
label: copy.menu.second,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.group} aria-labelledby="lab-form">
|
||||||
|
<h2 id="lab-form">Form controls</h2>
|
||||||
|
<FormField
|
||||||
|
id="fixture-email"
|
||||||
|
label={copy.form.label}
|
||||||
|
supportingText={copy.form.supporting}
|
||||||
|
error={copy.form.error}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="fixture-email"
|
||||||
|
type="email"
|
||||||
|
placeholder={copy.form.placeholder}
|
||||||
|
invalid
|
||||||
|
describedBy="fixture-email-help fixture-email-error"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Checkbox label={copy.form.checkbox} />
|
||||||
|
<Switch label={copy.form.switch} />
|
||||||
|
<SegmentedControl
|
||||||
|
label="Density"
|
||||||
|
value={segment}
|
||||||
|
onChange={setSegment}
|
||||||
|
options={[
|
||||||
|
{ value: 'comfortable', label: 'Comfortable' },
|
||||||
|
{ value: 'compact', label: 'Compact' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className={styles.group} aria-labelledby="lab-controls">
|
||||||
|
<h2 id="lab-controls">Selection and disclosure</h2>
|
||||||
|
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
|
||||||
|
<Accordion items={copy.accordion.items} />
|
||||||
|
</section>
|
||||||
|
<Alert tone="warning" title={copy.alert.title}>
|
||||||
|
{copy.alert.body}
|
||||||
|
</Alert>
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
title={copy.dialog.title}
|
||||||
|
description={copy.dialog.description}
|
||||||
|
closeLabel={copy.dialog.close}
|
||||||
|
returnFocusRef={triggerRef}
|
||||||
|
>
|
||||||
|
<p>{copy.dialog.content}</p>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.main {
|
||||||
|
min-block-size: 100dvh;
|
||||||
|
}
|
||||||
|
.interactiveGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
.group {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-inline-size: 0;
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: 1px solid var(--border-standard);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-elevated);
|
||||||
|
}
|
||||||
|
.group h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--type-heading-3-size);
|
||||||
|
}
|
||||||
|
.cluster {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.interactiveGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
||||||
|
import { Comparison } from '@/components/marketing/Comparison';
|
||||||
|
import { CTA } from '@/components/marketing/CTA';
|
||||||
|
import { Metric } from '@/components/marketing/Evidence';
|
||||||
|
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
||||||
|
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
||||||
|
import { Workflow } from '@/components/marketing/Workflow';
|
||||||
|
import { getComponentLabCopy } from '@/content/development/component-lab';
|
||||||
|
import { isLocale, isMode, type Locale } from '@/lib/localization/config';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
||||||
|
import styles from './page.module.css';
|
||||||
|
|
||||||
|
export const metadata = { robots: { index: false, follow: false } };
|
||||||
|
|
||||||
|
export default async function ComponentLabPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; mode: string }>;
|
||||||
|
}) {
|
||||||
|
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
||||||
|
const { locale, mode } = await params;
|
||||||
|
if (!isLocale(locale)) notFound();
|
||||||
|
if (!isMode(mode)) notFound();
|
||||||
|
const resolvedLocale: Locale = locale;
|
||||||
|
const copy = getComponentLabCopy(resolvedLocale);
|
||||||
|
return (
|
||||||
|
<main id="main-content" className={styles.main}>
|
||||||
|
<Section>
|
||||||
|
<Container>
|
||||||
|
<Stack gap="large">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={copy.developmentLabel}
|
||||||
|
title={copy.title}
|
||||||
|
body={copy.body}
|
||||||
|
headingLevel={1}
|
||||||
|
/>
|
||||||
|
<ComponentLabInteractive copy={copy} />
|
||||||
|
<Workflow label={copy.title} items={copy.workflow} />
|
||||||
|
<Comparison
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: copy.comparison.beforeTitle,
|
||||||
|
items: copy.comparison.before,
|
||||||
|
tone: 'problem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: copy.comparison.afterTitle,
|
||||||
|
items: copy.comparison.after,
|
||||||
|
tone: 'solution',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProductPreview
|
||||||
|
title={copy.preview.title}
|
||||||
|
caption={copy.preview.caption}
|
||||||
|
illustrativeLabel={copy.preview.illustrative}
|
||||||
|
rows={copy.preview.rows.map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
statusTone: index === 0 ? 'success' : 'warning',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
content={{
|
||||||
|
value: copy.metric.value,
|
||||||
|
label: copy.metric.label,
|
||||||
|
qualification: copy.metric.qualification,
|
||||||
|
status: 'research-only',
|
||||||
|
}}
|
||||||
|
statusLabel={copy.metric.status}
|
||||||
|
/>
|
||||||
|
<CTA
|
||||||
|
title={copy.cta.title}
|
||||||
|
body={copy.cta.body}
|
||||||
|
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm'
|
||||||
|
import { isLocale, isMode, type Locale } from '@/lib/localization/config'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
export default async function ForgotPasswordPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; mode: string }>
|
||||||
|
}) {
|
||||||
|
const { locale: localeValue, mode } = await params
|
||||||
|
if (!isLocale(localeValue)) notFound()
|
||||||
|
if (!isMode(mode)) notFound()
|
||||||
|
const locale = localeValue as Locale
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ForgotPasswordForm locale={locale} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import styles from '@/components/homepage/Homepage.module.css';
|
||||||
|
import {
|
||||||
|
ComparisonSection,
|
||||||
|
FaqSection,
|
||||||
|
FinalCtaSection,
|
||||||
|
HeroSection,
|
||||||
|
IntegrationsSection,
|
||||||
|
ModulesSection,
|
||||||
|
PricingSection,
|
||||||
|
ResultsSection,
|
||||||
|
RolesSection,
|
||||||
|
SecuritySection,
|
||||||
|
TrustSection,
|
||||||
|
WorkflowSection,
|
||||||
|
} from '@/components/homepage/sections';
|
||||||
|
import { buildHomepageContent } from '@/content/homepage-model';
|
||||||
|
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||||
|
import { isLocale, isMode, localizedModePath, type Locale } from '@/lib/localization/config';
|
||||||
|
import { getMessages } from '@/lib/localization/messages';
|
||||||
|
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
interface LocalePageProps {
|
||||||
|
params: Promise<{ locale: string; mode: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
||||||
|
const { locale: localeValue, mode: modeValue } = await params;
|
||||||
|
if (!isLocale(localeValue)) notFound();
|
||||||
|
if (!isMode(modeValue)) notFound();
|
||||||
|
const messages = getMessages(localeValue);
|
||||||
|
return buildLocalizedMetadata({
|
||||||
|
locale: localeValue,
|
||||||
|
mode: modeValue,
|
||||||
|
routeId: 'home',
|
||||||
|
title: messages.homepage.meta.title,
|
||||||
|
description: messages.homepage.meta.description,
|
||||||
|
indexable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||||
|
const { locale: localeValue, mode: modeValue } = await params;
|
||||||
|
if (!isLocale(localeValue)) notFound();
|
||||||
|
if (!isMode(modeValue)) notFound();
|
||||||
|
|
||||||
|
const locale: Locale = localeValue;
|
||||||
|
const mode: ThemePreference = modeValue;
|
||||||
|
const messages = getMessages(locale);
|
||||||
|
const content = buildHomepageContent(messages.homepage, messages.shell, locale);
|
||||||
|
const integrationConfig = getPublicIntegrationConfig();
|
||||||
|
const homePath = localizedModePath('home', locale, mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main id="main-content" className={styles.main} tabIndex={-1}>
|
||||||
|
<HeroSection
|
||||||
|
content={content.hero}
|
||||||
|
homePath={homePath}
|
||||||
|
/>
|
||||||
|
<TrustSection content={content.trust} />
|
||||||
|
<ComparisonSection content={content.comparison} />
|
||||||
|
<WorkflowSection content={content.workflow} />
|
||||||
|
<RolesSection content={content.roles} />
|
||||||
|
<ModulesSection content={content.modules} />
|
||||||
|
<ResultsSection content={content.results} />
|
||||||
|
<IntegrationsSection content={content.integrations} />
|
||||||
|
<SecuritySection content={content.security} />
|
||||||
|
<PricingSection
|
||||||
|
content={content.pricing}
|
||||||
|
locale={locale}
|
||||||
|
homePath={homePath}
|
||||||
|
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||||
|
/>
|
||||||
|
<FaqSection content={content.faq} />
|
||||||
|
<FinalCtaSection
|
||||||
|
content={content.final}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { SignInForm } from '@/components/auth/SignInForm'
|
||||||
|
import { isLocale, isMode, type Locale } from '@/lib/localization/config'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
export default async function SignInPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; mode: string }>
|
||||||
|
}) {
|
||||||
|
const { locale: localeValue, mode } = await params
|
||||||
|
if (!isLocale(localeValue)) notFound()
|
||||||
|
if (!isMode(mode)) notFound()
|
||||||
|
const locale = localeValue as Locale
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SignInForm locale={locale} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,81 +1,19 @@
|
|||||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
import { defaultMode, isLocale } from '@/lib/localization/config'
|
||||||
import { Comparison } from '@/components/marketing/Comparison';
|
import { isThemePreference, themeCookie } from '@/lib/theme/config'
|
||||||
import { CTA } from '@/components/marketing/CTA';
|
import { cookies } from 'next/headers'
|
||||||
import { Metric } from '@/components/marketing/Evidence';
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
|
||||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
|
||||||
import { Workflow } from '@/components/marketing/Workflow';
|
|
||||||
import { getComponentLabCopy } from '@/content/development/component-lab';
|
|
||||||
import { isLocale, type Locale } from '@/lib/localization/config';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
|
||||||
import styles from './page.module.css';
|
|
||||||
|
|
||||||
export const metadata = { robots: { index: false, follow: false } };
|
export const metadata = { robots: { index: false, follow: false } }
|
||||||
|
|
||||||
export default async function ComponentLabPage({
|
export default async function LegacyComponentLabPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>
|
||||||
}) {
|
}) {
|
||||||
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
const { locale } = await params
|
||||||
const { locale } = await params;
|
if (!isLocale(locale)) notFound()
|
||||||
if (!isLocale(locale)) notFound();
|
const cookieStore = await cookies()
|
||||||
const resolvedLocale: Locale = locale;
|
const cookieMode = cookieStore.get(themeCookie)?.value
|
||||||
const copy = getComponentLabCopy(resolvedLocale);
|
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode
|
||||||
return (
|
redirect(`/${locale}/${mode}/component-lab`)
|
||||||
<main id="main-content" className={styles.main}>
|
|
||||||
<Section>
|
|
||||||
<Container>
|
|
||||||
<Stack gap="large">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={copy.developmentLabel}
|
|
||||||
title={copy.title}
|
|
||||||
body={copy.body}
|
|
||||||
headingLevel={1}
|
|
||||||
/>
|
|
||||||
<ComponentLabInteractive copy={copy} />
|
|
||||||
<Workflow label={copy.title} items={copy.workflow} />
|
|
||||||
<Comparison
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: copy.comparison.beforeTitle,
|
|
||||||
items: copy.comparison.before,
|
|
||||||
tone: 'problem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: copy.comparison.afterTitle,
|
|
||||||
items: copy.comparison.after,
|
|
||||||
tone: 'solution',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ProductPreview
|
|
||||||
title={copy.preview.title}
|
|
||||||
caption={copy.preview.caption}
|
|
||||||
illustrativeLabel={copy.preview.illustrative}
|
|
||||||
rows={copy.preview.rows.map((row, index) => ({
|
|
||||||
...row,
|
|
||||||
statusTone: index === 0 ? 'success' : 'warning',
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
content={{
|
|
||||||
value: copy.metric.value,
|
|
||||||
label: copy.metric.label,
|
|
||||||
qualification: copy.metric.qualification,
|
|
||||||
status: 'research-only',
|
|
||||||
}}
|
|
||||||
statusLabel={copy.metric.status}
|
|
||||||
/>
|
|
||||||
<CTA
|
|
||||||
title={copy.cta.title}
|
|
||||||
body={copy.cta.body}
|
|
||||||
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm'
|
import {
|
||||||
import { isLocale, type Locale } from '@/lib/localization/config'
|
defaultMode,
|
||||||
import { notFound } from 'next/navigation'
|
isLocale,
|
||||||
import { Suspense } from 'react'
|
localizedModePath,
|
||||||
|
type Locale,
|
||||||
|
} from '@/lib/localization/config'
|
||||||
|
import { isThemePreference, themeCookie } from '@/lib/theme/config'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default async function ForgotPasswordPage({
|
export default async function LegacyForgotPasswordPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>
|
||||||
}) {
|
}) {
|
||||||
const { locale: localeValue } = await params
|
const { locale: localeValue } = await params
|
||||||
if (!isLocale(localeValue)) notFound()
|
if (!isLocale(localeValue)) notFound()
|
||||||
const locale = localeValue as Locale
|
const cookieStore = await cookies()
|
||||||
|
const cookieMode = cookieStore.get(themeCookie)?.value
|
||||||
return (
|
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode
|
||||||
<Suspense fallback={null}>
|
redirect(localizedModePath('forgot-password', localeValue as Locale, mode))
|
||||||
<ForgotPasswordForm locale={locale} />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
|||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
const headerPreference = requestHeaders.get('x-theme-preference');
|
||||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
||||||
const preference: ThemePreference = isThemePreference(cookiePreference)
|
const preference: ThemePreference = isThemePreference(headerPreference)
|
||||||
|
? headerPreference
|
||||||
|
: isThemePreference(cookiePreference)
|
||||||
? cookiePreference
|
? cookiePreference
|
||||||
: 'system';
|
: 'system';
|
||||||
const resolvedTheme = serverResolvedTheme(preference);
|
const resolvedTheme = serverResolvedTheme(preference);
|
||||||
@@ -89,6 +92,7 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
|||||||
/>
|
/>
|
||||||
<SiteFooter
|
<SiteFooter
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={preference}
|
||||||
messages={messages.shell}
|
messages={messages.shell}
|
||||||
/>
|
/>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,77 +1,25 @@
|
|||||||
import styles from '@/components/homepage/Homepage.module.css';
|
|
||||||
import {
|
import {
|
||||||
ComparisonSection,
|
defaultMode,
|
||||||
FaqSection,
|
isLocale,
|
||||||
FinalCtaSection,
|
localizedModePath,
|
||||||
HeroSection,
|
type Locale,
|
||||||
IntegrationsSection,
|
} from '@/lib/localization/config';
|
||||||
ModulesSection,
|
import { isThemePreference, themeCookie } from '@/lib/theme/config';
|
||||||
PricingSection,
|
import { cookies } from 'next/headers';
|
||||||
ResultsSection,
|
import { notFound, redirect } from 'next/navigation';
|
||||||
RolesSection,
|
|
||||||
SecuritySection,
|
|
||||||
TrustSection,
|
|
||||||
WorkflowSection,
|
|
||||||
} from '@/components/homepage/sections';
|
|
||||||
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';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
interface LocalePageProps {
|
interface LocaleRedirectPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
async function preferredMode() {
|
||||||
const { locale: localeValue } = await params;
|
const cookieStore = await cookies();
|
||||||
if (!isLocale(localeValue)) notFound();
|
const cookieMode = cookieStore.get(themeCookie)?.value;
|
||||||
const messages = getMessages(localeValue);
|
return isThemePreference(cookieMode) ? cookieMode : defaultMode;
|
||||||
return buildLocalizedMetadata({
|
|
||||||
locale: localeValue,
|
|
||||||
routeId: 'home',
|
|
||||||
title: messages.homepage.meta.title,
|
|
||||||
description: messages.homepage.meta.description,
|
|
||||||
indexable: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
export default async function LocaleRedirectPage({ params }: LocaleRedirectPageProps) {
|
||||||
const { locale: localeValue } = await params;
|
const { locale: localeValue } = await params;
|
||||||
if (!isLocale(localeValue)) notFound();
|
if (!isLocale(localeValue)) notFound();
|
||||||
|
redirect(localizedModePath('home', localeValue as Locale, await preferredMode()));
|
||||||
const locale: Locale = localeValue;
|
|
||||||
const messages = getMessages(locale);
|
|
||||||
const content = buildHomepageContent(messages.homepage, messages.shell, locale);
|
|
||||||
const integrationConfig = getPublicIntegrationConfig();
|
|
||||||
const homePath = localizedPath('home', locale);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
|
||||||
<HeroSection
|
|
||||||
content={content.hero}
|
|
||||||
homePath={homePath}
|
|
||||||
/>
|
|
||||||
<TrustSection content={content.trust} />
|
|
||||||
<ComparisonSection content={content.comparison} />
|
|
||||||
<WorkflowSection content={content.workflow} />
|
|
||||||
<RolesSection content={content.roles} />
|
|
||||||
<ModulesSection content={content.modules} />
|
|
||||||
<ResultsSection content={content.results} />
|
|
||||||
<IntegrationsSection content={content.integrations} />
|
|
||||||
<SecuritySection content={content.security} />
|
|
||||||
<PricingSection
|
|
||||||
content={content.pricing}
|
|
||||||
locale={locale}
|
|
||||||
homePath={homePath}
|
|
||||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
|
||||||
/>
|
|
||||||
<FaqSection content={content.faq} />
|
|
||||||
<FinalCtaSection
|
|
||||||
content={content.final}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { SignInForm } from '@/components/auth/SignInForm'
|
import {
|
||||||
import { isLocale, type Locale } from '@/lib/localization/config'
|
defaultMode,
|
||||||
import { notFound } from 'next/navigation'
|
isLocale,
|
||||||
import { Suspense } from 'react'
|
localizedModePath,
|
||||||
|
type Locale,
|
||||||
|
} from '@/lib/localization/config'
|
||||||
|
import { isThemePreference, themeCookie } from '@/lib/theme/config'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default async function SignInPage({
|
export default async function LegacySignInPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>
|
||||||
}) {
|
}) {
|
||||||
const { locale: localeValue } = await params
|
const { locale: localeValue } = await params
|
||||||
if (!isLocale(localeValue)) notFound()
|
if (!isLocale(localeValue)) notFound()
|
||||||
const locale = localeValue as Locale
|
const cookieStore = await cookies()
|
||||||
|
const cookieMode = cookieStore.get(themeCookie)?.value
|
||||||
return (
|
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode
|
||||||
<Suspense fallback={null}>
|
redirect(localizedModePath('sign-in', localeValue as Locale, mode))
|
||||||
<SignInForm locale={locale} />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||||
import { type ThemePreference } from '@/lib/theme/config';
|
import { type ThemePreference } from '@/lib/theme/config';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { LocalePill } from './LocalePill';
|
import { LocalePill } from './LocalePill';
|
||||||
@@ -13,7 +13,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthHeader({ locale, themePreference }: Props) {
|
export function AuthHeader({ locale, themePreference }: Props) {
|
||||||
const homePath = localizedPath('home', locale);
|
const homePath = localizedModePath('home', locale, themePreference);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import styles from './SiteHeader.module.css';
|
import styles from './SiteHeader.module.css';
|
||||||
|
|
||||||
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
|
export function BrandLink({
|
||||||
|
locale,
|
||||||
|
mode,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
mode: ThemePreference;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={styles.brand}
|
className={styles.brand}
|
||||||
href={localizedPath('home', locale)}
|
href={localizedModePath('home', locale, mode)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
|
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import styles from './SiteHeader.module.css';
|
import styles from './SiteHeader.module.css';
|
||||||
@@ -9,6 +10,7 @@ export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq'
|
|||||||
|
|
||||||
interface HeaderNavigationProps {
|
interface HeaderNavigationProps {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
mode: ThemePreference;
|
||||||
label: string;
|
label: string;
|
||||||
labels: Record<HeaderNavId, string>;
|
labels: Record<HeaderNavId, string>;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
@@ -19,6 +21,7 @@ const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing
|
|||||||
|
|
||||||
export function HeaderNavigation({
|
export function HeaderNavigation({
|
||||||
locale,
|
locale,
|
||||||
|
mode,
|
||||||
label,
|
label,
|
||||||
labels,
|
labels,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
@@ -35,7 +38,7 @@ export function HeaderNavigation({
|
|||||||
return () => window.removeEventListener('hashchange', updateHash);
|
return () => window.removeEventListener('hashchange', updateHash);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const home = localizedPath('home', locale);
|
const home = localizedModePath('home', locale, mode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
|
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
|
||||||
|
|||||||
@@ -36,11 +36,8 @@ export function LocalePill({ locale }: Props) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
persistLocale(next);
|
persistLocale(next);
|
||||||
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||||
router.push(
|
const target = localeSwitchUrl(new URL(window.location.href), next, routeId);
|
||||||
localeSwitchUrl(new URL(window.location.href), next, routeId).pathname +
|
router.push(`${target.pathname}${target.search}${target.hash}`);
|
||||||
new URL(window.location.href).search +
|
|
||||||
new URL(window.location.href).hash,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const otherLocales = locales.filter((l) => l !== locale);
|
const otherLocales = locales.filter((l) => l !== locale);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||||
import { isLocale, localizedPath } from '@/lib/localization/config';
|
import {
|
||||||
|
isLocale,
|
||||||
|
localizedModePath,
|
||||||
|
modeFromPathname,
|
||||||
|
} from '@/lib/localization/config';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { StateAction, StateActions, StateShell } from './StateShell';
|
import { StateAction, StateActions, StateShell } from './StateShell';
|
||||||
|
|
||||||
@@ -9,11 +13,14 @@ export function LocalizedNotFound() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const localeValue = pathname.split('/').filter(Boolean)[0];
|
const localeValue = pathname.split('/').filter(Boolean)[0];
|
||||||
const locale = isLocale(localeValue) ? localeValue : 'en';
|
const locale = isLocale(localeValue) ? localeValue : 'en';
|
||||||
|
const mode = modeFromPathname(pathname);
|
||||||
const messages = getClientShellMessages(locale);
|
const messages = getClientShellMessages(locale);
|
||||||
return (
|
return (
|
||||||
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
|
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
|
||||||
<StateActions>
|
<StateActions>
|
||||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
<StateAction href={localizedModePath('home', locale, mode)}>
|
||||||
|
{messages.states.backHome}
|
||||||
|
</StateAction>
|
||||||
</StateActions>
|
</StateActions>
|
||||||
</StateShell>
|
</StateShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
import { ActionLink } from '@/components/actions/ActionLink';
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
import type { ShellMessages } from '@/lib/localization/messages';
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||||
import { accountCreateUrl } from '@/lib/account-urls';
|
import { accountCreateUrl } from '@/lib/account-urls';
|
||||||
import type { ThemePreference } from '@/lib/theme/config';
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
@@ -11,8 +11,8 @@ import { LocaleSelector } from './LocaleSelector';
|
|||||||
import styles from './SiteHeader.module.css';
|
import styles from './SiteHeader.module.css';
|
||||||
import { ThemeSelector } from './ThemeSelector';
|
import { ThemeSelector } from './ThemeSelector';
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
function signInUrl(locale: Locale, mode: ThemePreference): string {
|
||||||
return localizedPath('sign-in', locale);
|
return localizedModePath('sign-in', locale, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileNavigationProps {
|
interface MobileNavigationProps {
|
||||||
@@ -102,6 +102,7 @@ export function MobileNavigation({
|
|||||||
|
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={themePreference}
|
||||||
label={messages.header.navigationLabel}
|
label={messages.header.navigationLabel}
|
||||||
labels={messages.header.nav}
|
labels={messages.header.nav}
|
||||||
mobile
|
mobile
|
||||||
@@ -127,7 +128,7 @@ export function MobileNavigation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.drawerActions}>
|
<div className={styles.drawerActions}>
|
||||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
<ActionLink href={signInUrl(locale, themePreference)} variant="button-primary">
|
||||||
{messages.header.login}
|
{messages.header.login}
|
||||||
</ActionLink>
|
</ActionLink>
|
||||||
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
||||||
|
|||||||
@@ -1,36 +1,50 @@
|
|||||||
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
import { StatusBadge } from '@/components/foundation/StatusBadge';
|
||||||
import { accountCreateUrl } from '@/lib/account-urls';
|
import { accountCreateUrl } from '@/lib/account-urls';
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
import type { ShellMessages } from '@/lib/localization/messages';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import styles from './SiteFooter.module.css';
|
import styles from './SiteFooter.module.css';
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
function signInUrl(locale: Locale, mode: ThemePreference): string {
|
||||||
return localizedPath('sign-in', locale);
|
return localizedModePath('sign-in', locale, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteFooterProps {
|
interface SiteFooterProps {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
mode: ThemePreference;
|
||||||
messages: ShellMessages;
|
messages: ShellMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
|
function HashLink({
|
||||||
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
|
locale,
|
||||||
|
mode,
|
||||||
|
hash,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
mode: ThemePreference;
|
||||||
|
hash: string;
|
||||||
|
children: string;
|
||||||
|
}) {
|
||||||
|
return <a href={`${localizedModePath('home', locale, mode)}#${hash}`}>{children}</a>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PendingRouteLink({
|
function PendingRouteLink({
|
||||||
locale,
|
locale,
|
||||||
|
mode,
|
||||||
routeId,
|
routeId,
|
||||||
label,
|
label,
|
||||||
pending,
|
pending,
|
||||||
}: {
|
}: {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
mode: ThemePreference;
|
||||||
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
|
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
|
||||||
label: string;
|
label: string;
|
||||||
pending: string;
|
pending: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
|
<a className={styles.legalLink} href={localizedModePath(routeId, locale, mode)}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<StatusBadge>{pending}</StatusBadge>
|
<StatusBadge>{pending}</StatusBadge>
|
||||||
</a>
|
</a>
|
||||||
@@ -46,7 +60,7 @@ function PendingItem({ label, pending }: { label: string; pending: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
|
||||||
const footer = messages.footer;
|
const footer = messages.footer;
|
||||||
const year = new Date().getUTCFullYear();
|
const year = new Date().getUTCFullYear();
|
||||||
|
|
||||||
@@ -74,22 +88,22 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<h2 id="footer-product">{footer.groups.product}</h2>
|
<h2 id="footer-product">{footer.groups.product}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<HashLink locale={locale} hash="workflow">
|
<HashLink locale={locale} mode={mode} hash="workflow">
|
||||||
{footer.links.workflow}
|
{footer.links.workflow}
|
||||||
</HashLink>
|
</HashLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<HashLink locale={locale} hash="modules">
|
<HashLink locale={locale} mode={mode} hash="modules">
|
||||||
{footer.links.modules}
|
{footer.links.modules}
|
||||||
</HashLink>
|
</HashLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<HashLink locale={locale} hash="pricing">
|
<HashLink locale={locale} mode={mode} hash="pricing">
|
||||||
{footer.links.pricing}
|
{footer.links.pricing}
|
||||||
</HashLink>
|
</HashLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<HashLink locale={locale} hash="faq">
|
<HashLink locale={locale} mode={mode} hash="faq">
|
||||||
{footer.links.faq}
|
{footer.links.faq}
|
||||||
</HashLink>
|
</HashLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -112,7 +126,7 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<h2 id="footer-account">{footer.groups.account}</h2>
|
<h2 id="footer-account">{footer.groups.account}</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href={signInUrl(locale)}>
|
<a href={signInUrl(locale, mode)}>
|
||||||
{footer.links.login}
|
{footer.links.login}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -125,6 +139,7 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<li>
|
<li>
|
||||||
<PendingRouteLink
|
<PendingRouteLink
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={mode}
|
||||||
routeId="privacy"
|
routeId="privacy"
|
||||||
label={footer.links.privacy}
|
label={footer.links.privacy}
|
||||||
pending={footer.pending}
|
pending={footer.pending}
|
||||||
@@ -133,6 +148,7 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<li>
|
<li>
|
||||||
<PendingRouteLink
|
<PendingRouteLink
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={mode}
|
||||||
routeId="terms"
|
routeId="terms"
|
||||||
label={footer.links.terms}
|
label={footer.links.terms}
|
||||||
pending={footer.pending}
|
pending={footer.pending}
|
||||||
@@ -141,6 +157,7 @@ export function SiteFooter({ locale, messages }: SiteFooterProps) {
|
|||||||
<li>
|
<li>
|
||||||
<PendingRouteLink
|
<PendingRouteLink
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={mode}
|
||||||
routeId="accessibility"
|
routeId="accessibility"
|
||||||
label={footer.links.accessibility}
|
label={footer.links.accessibility}
|
||||||
pending={footer.pending}
|
pending={footer.pending}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, type Locale } from '@/lib/localization/config';
|
||||||
import type { ShellMessages } from '@/lib/localization/messages';
|
import type { ShellMessages } from '@/lib/localization/messages';
|
||||||
import type { ThemePreference } from '@/lib/theme/config';
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import { ActionLink } from '@/components/actions/ActionLink';
|
import { ActionLink } from '@/components/actions/ActionLink';
|
||||||
@@ -10,8 +10,8 @@ import { MobileNavigation } from './MobileNavigation';
|
|||||||
import styles from './SiteHeader.module.css';
|
import styles from './SiteHeader.module.css';
|
||||||
import { ThemePill } from './ThemePill';
|
import { ThemePill } from './ThemePill';
|
||||||
|
|
||||||
function signInUrl(locale: Locale): string {
|
function signInUrl(locale: Locale, mode: ThemePreference): string {
|
||||||
return localizedPath('sign-in', locale);
|
return localizedModePath('sign-in', locale, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteHeaderProps {
|
interface SiteHeaderProps {
|
||||||
@@ -24,11 +24,12 @@ export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProp
|
|||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<BrandLink locale={locale} label={messages.brandLabel} />
|
<BrandLink locale={locale} mode={themePreference} label={messages.brandLabel} />
|
||||||
|
|
||||||
<div className={styles.desktopNavigation}>
|
<div className={styles.desktopNavigation}>
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
mode={themePreference}
|
||||||
label={messages.header.navigationLabel}
|
label={messages.header.navigationLabel}
|
||||||
labels={messages.header.nav}
|
labels={messages.header.nav}
|
||||||
/>
|
/>
|
||||||
@@ -41,7 +42,7 @@ export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProp
|
|||||||
>
|
>
|
||||||
<LocalePill locale={locale} />
|
<LocalePill locale={locale} />
|
||||||
<ThemePill initialPreference={themePreference} />
|
<ThemePill initialPreference={themePreference} />
|
||||||
<ActionLink href={signInUrl(locale)} variant="button-primary">
|
<ActionLink href={signInUrl(locale, themePreference)} variant="button-primary">
|
||||||
{messages.header.login}
|
{messages.header.login}
|
||||||
</ActionLink>
|
</ActionLink>
|
||||||
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
<ActionLink href={accountCreateUrl(locale)} variant="button-conversion">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
||||||
import { type ThemePreference } from '@/lib/theme/config';
|
import { type ThemePreference } from '@/lib/theme/config';
|
||||||
|
import { modeSwitchUrl, routeIdFromPathname } from '@/lib/localization/config';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import styles from './ThemePill.module.css';
|
import styles from './ThemePill.module.css';
|
||||||
|
|
||||||
@@ -17,17 +19,15 @@ const labels: Record<ThemePreference, string> = {
|
|||||||
|
|
||||||
export function ThemePill({ initialPreference }: Props) {
|
export function ThemePill({ initialPreference }: Props) {
|
||||||
const [pref, setPref] = useState<ThemePreference>(initialPreference);
|
const [pref, setPref] = useState<ThemePreference>(initialPreference);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPref(initialPreference);
|
const handler = (event: Event) => {
|
||||||
}, [initialPreference]);
|
const next = (event as CustomEvent<ThemePreference>).detail;
|
||||||
|
if (next === 'light' || next === 'dark' || next === 'system') setPref(next);
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => {
|
|
||||||
const stored = localStorage.getItem('hpc.theme.preference');
|
|
||||||
if (stored === 'light' || stored === 'dark' || stored === 'system') setPref(stored);
|
|
||||||
};
|
};
|
||||||
window.addEventListener(themePreferenceEvent, handler);
|
window.addEventListener(themePreferenceEvent, handler);
|
||||||
return () => window.removeEventListener(themePreferenceEvent, handler);
|
return () => window.removeEventListener(themePreferenceEvent, handler);
|
||||||
@@ -45,6 +45,9 @@ export function ThemePill({ initialPreference }: Props) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
persistTheme(next);
|
persistTheme(next);
|
||||||
setPref(next);
|
setPref(next);
|
||||||
|
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||||
|
const target = modeSwitchUrl(new URL(window.location.href), next, routeId);
|
||||||
|
router.push(`${target.pathname}${target.search}${target.hash}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const options: ThemePreference[] = ['light', 'dark', 'system'];
|
const options: ThemePreference[] = ['light', 'dark', 'system'];
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
|
||||||
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
|
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
|
||||||
|
import { modeSwitchUrl, routeIdFromPathname } from '@/lib/localization/config';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useId, useState } from 'react';
|
import { useEffect, useId, useState } from 'react';
|
||||||
import styles from './Controls.module.css';
|
import styles from './Controls.module.css';
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ export function ThemeSelector({
|
|||||||
compact = false,
|
compact = false,
|
||||||
}: ThemeSelectorProps) {
|
}: ThemeSelectorProps) {
|
||||||
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
|
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const routeId = routeIdFromPathname(pathname) ?? 'home';
|
||||||
const statusId = useId();
|
const statusId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,6 +62,9 @@ export function ThemeSelector({
|
|||||||
const nextPreference = event.target.value as ThemePreference;
|
const nextPreference = event.target.value as ThemePreference;
|
||||||
setPreference(nextPreference);
|
setPreference(nextPreference);
|
||||||
persistTheme(nextPreference);
|
persistTheme(nextPreference);
|
||||||
|
window.location.assign(
|
||||||
|
modeSwitchUrl(new URL(window.location.href), nextPreference, routeId),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{themePreferences.map((option) => (
|
{themePreferences.map((option) => (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
import { localizedModePath, modeFromPathname, type Locale } from '@/lib/localization/config'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import styles from './AuthShell.module.css'
|
import styles from './AuthShell.module.css'
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ interface AuthShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
|
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
|
||||||
|
const mode = modeFromPathname(usePathname())
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
@@ -21,7 +23,7 @@ export function AuthShell({ locale, title, subtitle, children }: AuthShellProps)
|
|||||||
>
|
>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<a href={localizedPath('home', locale)}>
|
<a href={localizedModePath('home', locale, mode)}>
|
||||||
<Image
|
<Image
|
||||||
src="/rentaldrivego.png"
|
src="/rentaldrivego.png"
|
||||||
alt="RentalDriveGo"
|
alt="RentalDriveGo"
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { TextInput } from '@/components/forms/TextInput'
|
|||||||
import { FormField } from '@/components/forms/FormField'
|
import { FormField } from '@/components/forms/FormField'
|
||||||
import { AuthShell } from '@/components/auth/AuthShell'
|
import { AuthShell } from '@/components/auth/AuthShell'
|
||||||
import { API_BASE } from '@/lib/api'
|
import { API_BASE } from '@/lib/api'
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config'
|
import { localizedModePath, modeFromPathname, type Locale } from '@/lib/localization/config'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import styles from './AuthForms.module.css'
|
import styles from './AuthForms.module.css'
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ const dicts: Record<string, Dict> = {
|
|||||||
|
|
||||||
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict
|
const dict = (dicts[locale] ?? dicts.en) as Dict
|
||||||
|
const mode = modeFromPathname(usePathname())
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
@@ -94,7 +96,7 @@ export function ForgotPasswordForm({ locale }: { locale: Locale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const signInHref = localizedPath('sign-in', locale)
|
const signInHref = localizedModePath('sign-in', locale, mode)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { Button } from '@/components/actions/Button';
|
|||||||
import { FormField } from '@/components/forms/FormField';
|
import { FormField } from '@/components/forms/FormField';
|
||||||
import { AuthShell } from '@/components/auth/AuthShell';
|
import { AuthShell } from '@/components/auth/AuthShell';
|
||||||
import { API_BASE } from '@/lib/api';
|
import { API_BASE } from '@/lib/api';
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, modeFromPathname, type Locale } from '@/lib/localization/config';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useState } from 'react';
|
import { Suspense, useState } from 'react';
|
||||||
import styles from './AuthForms.module.css';
|
import styles from './AuthForms.module.css';
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ const dicts: Record<string, Dict> = {
|
|||||||
function ResetPasswordContent({ locale }: { locale: Locale }) {
|
function ResetPasswordContent({ locale }: { locale: Locale }) {
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const mode = modeFromPathname(usePathname());
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -139,8 +140,8 @@ function ResetPasswordContent({ locale }: { locale: Locale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const signInHref = localizedPath('sign-in', locale);
|
const signInHref = localizedModePath('sign-in', locale, mode);
|
||||||
const forgotHref = localizedPath('forgot-password', locale);
|
const forgotHref = localizedModePath('forgot-password', locale, mode);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { FormField } from '@/components/forms/FormField';
|
|||||||
import { AuthShell } from '@/components/auth/AuthShell';
|
import { AuthShell } from '@/components/auth/AuthShell';
|
||||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api';
|
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api';
|
||||||
import { classNames } from '@/lib/components/classNames';
|
import { classNames } from '@/lib/components/classNames';
|
||||||
import { localizedPath, type Locale } from '@/lib/localization/config';
|
import { localizedModePath, modeFromPathname, type Locale } from '@/lib/localization/config';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import styles from './AuthForms.module.css';
|
import styles from './AuthForms.module.css';
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ const dicts: Record<string, Dict> = {
|
|||||||
export function SignInForm({ locale }: { locale: Locale }) {
|
export function SignInForm({ locale }: { locale: Locale }) {
|
||||||
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
const dict = (dicts[locale] ?? dicts.en) as Dict;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const mode = modeFromPathname(usePathname());
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -236,7 +237,7 @@ export function SignInForm({ locale }: { locale: Locale }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hrefBase = localizedPath('forgot-password', locale);
|
const hrefBase = localizedModePath('forgot-password', locale, mode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import rawManifest from '@/contracts/phase9/route-manifest.json';
|
import rawManifest from '@/contracts/phase9/route-manifest.json';
|
||||||
|
import {
|
||||||
|
isThemePreference,
|
||||||
|
themePreferences,
|
||||||
|
type ThemePreference,
|
||||||
|
} from '@/lib/theme/config';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const locales = ['en', 'fr', 'ar'] as const;
|
export const locales = ['en', 'fr', 'ar'] as const;
|
||||||
@@ -7,6 +12,7 @@ export type Direction = 'ltr' | 'rtl';
|
|||||||
export type RouteId = 'home' | 'sign-in' | 'forgot-password' | 'reset-password' | 'privacy' | 'terms' | 'accessibility';
|
export type RouteId = 'home' | 'sign-in' | 'forgot-password' | 'reset-password' | 'privacy' | 'terms' | 'accessibility';
|
||||||
|
|
||||||
export const defaultLocale: Locale = 'en';
|
export const defaultLocale: Locale = 'en';
|
||||||
|
export const defaultMode: ThemePreference = 'system';
|
||||||
export const localeCookie = 'hpc-locale';
|
export const localeCookie = 'hpc-locale';
|
||||||
export const localeStorageKey = 'hpc.locale';
|
export const localeStorageKey = 'hpc.locale';
|
||||||
export const localeCookieMaxAgeSeconds = 31_536_000;
|
export const localeCookieMaxAgeSeconds = 31_536_000;
|
||||||
@@ -53,12 +59,20 @@ export function getDirection(locale: Locale): Direction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function localizedPath(routeId: RouteId, locale: Locale): string {
|
export function localizedPath(routeId: RouteId, locale: Locale): string {
|
||||||
|
return localizedModePath(routeId, locale, defaultMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizedModePath(
|
||||||
|
routeId: RouteId,
|
||||||
|
locale: Locale,
|
||||||
|
mode: ThemePreference,
|
||||||
|
): string {
|
||||||
const route = routeManifest.routes.find((item) => item.id === routeId);
|
const route = routeManifest.routes.find((item) => item.id === routeId);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
throw new Error(`Unknown route ID: ${routeId}`);
|
throw new Error(`Unknown route ID: ${routeId}`);
|
||||||
}
|
}
|
||||||
const slug = route.slugs[locale];
|
const slug = route.slugs[locale];
|
||||||
return slug.length > 0 ? `/${locale}/${slug}` : `/${locale}`;
|
return slug.length > 0 ? `/${locale}/${mode}/${slug}` : `/${locale}/${mode}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeById(routeId: RouteId) {
|
export function routeById(routeId: RouteId) {
|
||||||
@@ -90,7 +104,10 @@ export function localeFromAcceptLanguage(headerValue: string | null): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function localeSwitchUrl(current: URL, targetLocale: Locale, routeId: RouteId): URL {
|
export function localeSwitchUrl(current: URL, targetLocale: Locale, routeId: RouteId): URL {
|
||||||
const target = new URL(localizedPath(routeId, targetLocale), current.origin);
|
const target = new URL(
|
||||||
|
localizedModePath(routeId, targetLocale, modeFromPathname(current.pathname)),
|
||||||
|
current.origin,
|
||||||
|
);
|
||||||
for (const key of campaignQueryAllowlist) {
|
for (const key of campaignQueryAllowlist) {
|
||||||
const value = current.searchParams.get(key);
|
const value = current.searchParams.get(key);
|
||||||
if (value !== null) target.searchParams.set(key, value);
|
if (value !== null) target.searchParams.set(key, value);
|
||||||
@@ -104,6 +121,19 @@ export function localeSwitchUrl(current: URL, targetLocale: Locale, routeId: Rou
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function modeSwitchUrl(current: URL, targetMode: ThemePreference, routeId: RouteId): URL {
|
||||||
|
const locale = localeFromPathname(current.pathname) ?? defaultLocale;
|
||||||
|
const target = new URL(localizedModePath(routeId, locale, targetMode), current.origin);
|
||||||
|
target.search = current.search;
|
||||||
|
|
||||||
|
const route = routeById(routeId);
|
||||||
|
const hash = current.hash.replace(/^#/, '');
|
||||||
|
if (routeManifest.switching.preserveValidHash && route.hashes.includes(hash)) {
|
||||||
|
target.hash = hash;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
export function routeIdFromSlug(locale: Locale, slug: string): RouteId | null {
|
export function routeIdFromSlug(locale: Locale, slug: string): RouteId | null {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
try {
|
try {
|
||||||
@@ -131,24 +161,50 @@ export function routeIdFromAnyLocaleSlug(slug: string): RouteId | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routeIdFromPathname(pathname: string): RouteId | null {
|
export function routeIdFromPathname(pathname: string): RouteId | null {
|
||||||
const [localeValue, ...segments] = pathname.split('/').filter(Boolean);
|
const [localeValue, modeValue, ...segments] = pathname.split('/').filter(Boolean);
|
||||||
if (!isLocale(localeValue)) return null;
|
if (!isLocale(localeValue)) return null;
|
||||||
|
if (!isThemePreference(modeValue)) return null;
|
||||||
if (segments.length === 0) return 'home';
|
if (segments.length === 0) return 'home';
|
||||||
return routeIdFromSlug(localeValue, segments.join('/'));
|
return routeIdFromSlug(localeValue, segments.join('/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localizedUrl(origin: URL, routeId: RouteId, locale: Locale): URL {
|
export function localeFromPathname(pathname: string): Locale | null {
|
||||||
return new URL(localizedPath(routeId, locale), origin);
|
const [localeValue] = pathname.split('/').filter(Boolean);
|
||||||
|
return isLocale(localeValue) ? localeValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hreflangMap(origin: URL, routeId: RouteId): Record<string, string> {
|
export function modeFromPathname(pathname: string): ThemePreference {
|
||||||
|
const [, modeValue] = pathname.split('/').filter(Boolean);
|
||||||
|
return isThemePreference(modeValue) ? modeValue : defaultMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMode(value: unknown): value is ThemePreference {
|
||||||
|
return isThemePreference(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizedUrl(
|
||||||
|
origin: URL,
|
||||||
|
routeId: RouteId,
|
||||||
|
locale: Locale,
|
||||||
|
mode: ThemePreference = defaultMode,
|
||||||
|
): URL {
|
||||||
|
return new URL(localizedModePath(routeId, locale, mode), origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hreflangMap(
|
||||||
|
origin: URL,
|
||||||
|
routeId: RouteId,
|
||||||
|
mode: ThemePreference = defaultMode,
|
||||||
|
): Record<string, string> {
|
||||||
const entries = locales.map((locale) => [
|
const entries = locales.map((locale) => [
|
||||||
locale,
|
locale,
|
||||||
new URL(localizedPath(routeId, locale), origin).href,
|
new URL(localizedModePath(routeId, locale, mode), origin).href,
|
||||||
]);
|
]);
|
||||||
entries.push([
|
entries.push([
|
||||||
'x-default',
|
'x-default',
|
||||||
new URL(localizedPath(routeId, routeManifest.hreflang.xDefaultLocale), origin).href,
|
new URL(localizedModePath(routeId, routeManifest.hreflang.xDefaultLocale, mode), origin).href,
|
||||||
]);
|
]);
|
||||||
return Object.fromEntries(entries);
|
return Object.fromEntries(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { themePreferences as modes };
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
hreflangMap,
|
hreflangMap,
|
||||||
localizedPath,
|
localizedModePath,
|
||||||
locales,
|
locales,
|
||||||
|
defaultMode,
|
||||||
type Locale,
|
type Locale,
|
||||||
type RouteId,
|
type RouteId,
|
||||||
} from '@/lib/localization/config';
|
} from '@/lib/localization/config';
|
||||||
|
import type { ThemePreference } from '@/lib/theme/config';
|
||||||
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface LocalizedMetadataInput {
|
interface LocalizedMetadataInput {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
mode?: ThemePreference;
|
||||||
routeId: RouteId;
|
routeId: RouteId;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -18,6 +21,7 @@ interface LocalizedMetadataInput {
|
|||||||
|
|
||||||
export function buildLocalizedMetadata({
|
export function buildLocalizedMetadata({
|
||||||
locale,
|
locale,
|
||||||
|
mode = defaultMode,
|
||||||
routeId,
|
routeId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -26,7 +30,7 @@ export function buildLocalizedMetadata({
|
|||||||
const origin = getSiteOrigin();
|
const origin = getSiteOrigin();
|
||||||
const publicReleaseApproved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
const publicReleaseApproved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
||||||
const canIndex = publicReleaseApproved && indexable;
|
const canIndex = publicReleaseApproved && indexable;
|
||||||
const canonicalPath = localizedPath(routeId, locale);
|
const canonicalPath = localizedModePath(routeId, locale, mode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: origin,
|
metadataBase: origin,
|
||||||
@@ -34,7 +38,7 @@ export function buildLocalizedMetadata({
|
|||||||
description,
|
description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: canonicalPath,
|
canonical: canonicalPath,
|
||||||
languages: hreflangMap(origin, routeId),
|
languages: hreflangMap(origin, routeId, mode),
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
|
|||||||
+98
-45
@@ -1,14 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
defaultMode,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
|
isMode,
|
||||||
isLocale,
|
isLocale,
|
||||||
localeCookie,
|
localeCookie,
|
||||||
localeCookieMaxAgeSeconds,
|
localeCookieMaxAgeSeconds,
|
||||||
localeFromAcceptLanguage,
|
localeFromAcceptLanguage,
|
||||||
localizedPath,
|
localizedModePath,
|
||||||
routeIdFromAnyLocaleSlug,
|
routeIdFromAnyLocaleSlug,
|
||||||
routeIdFromSlug,
|
routeIdFromSlug,
|
||||||
} from '@/lib/localization/config';
|
} from '@/lib/localization/config';
|
||||||
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
||||||
|
import {
|
||||||
|
isThemePreference,
|
||||||
|
themeCookie,
|
||||||
|
themeCookieMaxAgeSeconds,
|
||||||
|
} from '@/lib/theme/config';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
@@ -39,6 +46,38 @@ function resolveApiOrigin(): string | undefined {
|
|||||||
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
|
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRequestLocale(request: NextRequest) {
|
||||||
|
const cookieLocale = request.cookies.get(localeCookie)?.value;
|
||||||
|
return isLocale(cookieLocale)
|
||||||
|
? cookieLocale
|
||||||
|
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestMode(request: NextRequest) {
|
||||||
|
const cookieMode = request.cookies.get(themeCookie)?.value;
|
||||||
|
return isThemePreference(cookieMode) ? cookieMode : defaultMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocaleCookie(response: NextResponse, locale: string): void {
|
||||||
|
response.cookies.set(localeCookie, locale, {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: localeCookieMaxAgeSeconds,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModeCookie(response: NextResponse, mode: string): void {
|
||||||
|
response.cookies.set(themeCookie, mode, {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: themeCookieMaxAgeSeconds,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function proxy(request: NextRequest): NextResponse {
|
export function proxy(request: NextRequest): NextResponse {
|
||||||
const nonce = createNonce();
|
const nonce = createNonce();
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
@@ -49,20 +88,13 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
requestHeaders.set('Content-Security-Policy', csp);
|
requestHeaders.set('Content-Security-Policy', csp);
|
||||||
|
|
||||||
if (request.nextUrl.pathname === '/') {
|
if (request.nextUrl.pathname === '/') {
|
||||||
const cookieLocale = request.cookies.get(localeCookie)?.value;
|
const locale = resolveRequestLocale(request);
|
||||||
const locale = isLocale(cookieLocale)
|
const mode = resolveRequestMode(request);
|
||||||
? cookieLocale
|
|
||||||
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
|
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = `/${locale}`;
|
destination.pathname = `/${locale}/${mode}`;
|
||||||
const response = NextResponse.redirect(destination);
|
const response = NextResponse.redirect(destination);
|
||||||
response.cookies.set(localeCookie, locale, {
|
setLocaleCookie(response, locale);
|
||||||
path: '/',
|
setModeCookie(response, mode);
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: localeCookieMaxAgeSeconds,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
return applySecurityHeaders(response, csp);
|
return applySecurityHeaders(response, csp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,32 +104,34 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
destination.pathname = `/${pathSegments.slice(1).join('/')}`;
|
destination.pathname = `/${pathSegments.slice(1).join('/')}`;
|
||||||
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
}
|
}
|
||||||
|
if (isLocale(pathSegments[0]) && isMode(pathSegments[1]) && pathSegments[2] === 'storefront') {
|
||||||
// Redirect locale-less paths (e.g. /sign-in → /en/sign-in), but not proxied app paths or files.
|
|
||||||
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
|
||||||
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
|
||||||
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
|
||||||
const cookieLocale = request.cookies.get(localeCookie)?.value;
|
|
||||||
const locale = isLocale(cookieLocale)
|
|
||||||
? cookieLocale
|
|
||||||
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
|
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = `/${locale}${request.nextUrl.pathname}`;
|
destination.pathname = `/${pathSegments.slice(2).join('/')}`;
|
||||||
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLocale(firstSegment) && request.nextUrl.pathname === `/${firstSegment}/subscription`) {
|
// Redirect locale-less paths (e.g. /sign-in → /en/system/sign-in), but not proxied app paths or files.
|
||||||
|
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
||||||
|
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
||||||
|
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
||||||
|
const locale = resolveRequestLocale(request);
|
||||||
|
const mode = resolveRequestMode(request);
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`;
|
||||||
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLocale(pathSegments[0]) &&
|
||||||
|
isMode(pathSegments[1]) &&
|
||||||
|
request.nextUrl.pathname === `/${pathSegments[0]}/${pathSegments[1]}/subscription`
|
||||||
|
) {
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = '/dashboard/subscription';
|
destination.pathname = '/dashboard/subscription';
|
||||||
const response = NextResponse.redirect(destination);
|
const response = NextResponse.redirect(destination);
|
||||||
response.cookies.set(localeCookie, firstSegment, {
|
setLocaleCookie(response, pathSegments[0]);
|
||||||
path: '/',
|
setModeCookie(response, pathSegments[1]);
|
||||||
sameSite: 'lax',
|
response.cookies.set(dashboardLanguageCookie, pathSegments[0], {
|
||||||
maxAge: localeCookieMaxAgeSeconds,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
response.cookies.set(dashboardLanguageCookie, firstSegment, {
|
|
||||||
path: '/',
|
path: '/',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: localeCookieMaxAgeSeconds,
|
maxAge: localeCookieMaxAgeSeconds,
|
||||||
@@ -107,29 +141,48 @@ export function proxy(request: NextRequest): NextResponse {
|
|||||||
return applySecurityHeaders(response, csp);
|
return applySecurityHeaders(response, csp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLocale(firstSegment)) {
|
if (isLocale(pathSegments[0]) && !isMode(pathSegments[1])) {
|
||||||
const segments = request.nextUrl.pathname.split('/').filter(Boolean);
|
const mode = resolveRequestMode(request);
|
||||||
const slug = segments.slice(1).join('/');
|
const slug = pathSegments.slice(1).join('/');
|
||||||
if (slug && !routeIdFromSlug(firstSegment, slug)) {
|
if (!slug) {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = localizedModePath('home', pathSegments[0], mode);
|
||||||
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
|
}
|
||||||
|
const routeId = routeIdFromSlug(pathSegments[0], slug) ?? routeIdFromAnyLocaleSlug(slug);
|
||||||
|
if (routeId) {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = localizedModePath(routeId, pathSegments[0], mode);
|
||||||
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
|
}
|
||||||
|
if (slug === 'component-lab') {
|
||||||
|
const destination = request.nextUrl.clone();
|
||||||
|
destination.pathname = `/${pathSegments[0]}/${mode}/component-lab`;
|
||||||
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocale(pathSegments[0]) && isMode(pathSegments[1])) {
|
||||||
|
requestHeaders.set('x-theme-preference', pathSegments[1]);
|
||||||
|
const slug = pathSegments.slice(2).join('/');
|
||||||
|
if (slug && !routeIdFromSlug(pathSegments[0], slug)) {
|
||||||
const routeId = routeIdFromAnyLocaleSlug(slug);
|
const routeId = routeIdFromAnyLocaleSlug(slug);
|
||||||
if (routeId) {
|
if (routeId) {
|
||||||
const destination = request.nextUrl.clone();
|
const destination = request.nextUrl.clone();
|
||||||
destination.pathname = localizedPath(routeId, firstSegment);
|
destination.pathname = localizedModePath(routeId, pathSegments[0], pathSegments[1]);
|
||||||
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
const explicitLocale = request.nextUrl.pathname.split('/')[1];
|
const explicitLocale = pathSegments[0];
|
||||||
if (isLocale(explicitLocale)) {
|
if (isLocale(explicitLocale)) {
|
||||||
response.cookies.set(localeCookie, explicitLocale, {
|
setLocaleCookie(response, explicitLocale);
|
||||||
path: '/',
|
}
|
||||||
sameSite: 'lax',
|
const explicitMode = pathSegments[1];
|
||||||
maxAge: localeCookieMaxAgeSeconds,
|
if (isMode(explicitMode)) {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
setModeCookie(response, explicitMode);
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return applySecurityHeaders(response, csp);
|
return applySecurityHeaders(response, csp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
test(`${locale} component fixture has no detectable accessibility violations`, async ({
|
test(`${locale} component fixture has no detectable accessibility violations`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(`/${locale}/component-lab`);
|
await page.goto(`/${locale}/system/component-lab`);
|
||||||
const results = await new AxeBuilder({ page }).analyze();
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
expect(results.violations).toEqual([]);
|
expect(results.violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||||
test(`${locale} homepage has no detectable WCAG A/AA violations`, async ({ page }) => {
|
test(`${locale} homepage has no detectable WCAG A/AA violations`, async ({ page }) => {
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/system`);
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||||
.analyze();
|
.analyze();
|
||||||
@@ -12,7 +12,7 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('homepage keeps a coherent heading outline', async ({ page }) => {
|
test('homepage keeps a coherent heading outline', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
const levels = await page
|
const levels = await page
|
||||||
.locator('main h1, main h2, main h3')
|
.locator('main h1, main h2, main h3')
|
||||||
.evaluateAll((headings) => headings.map((heading) => Number(heading.tagName.slice(1))));
|
.evaluateAll((headings) => headings.map((heading) => Number(heading.tagName.slice(1))));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||||
test(`${locale} demo dialog has no detectable WCAG A/AA violations`, async ({ page }) => {
|
test(`${locale} demo dialog has no detectable WCAG A/AA violations`, async ({ page }) => {
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/system`);
|
||||||
await page.locator('main button[data-demo-source="hero"]').click();
|
await page.locator('main button[data-demo-source="hero"]').click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/${theme}`);
|
||||||
const results = await new AxeBuilder({ page })
|
const results = await new AxeBuilder({ page })
|
||||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||||
.analyze();
|
.analyze();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||||
test(`${locale} component fixture renders without horizontal overflow`, async ({ page }) => {
|
test(`${locale} component fixture renders without horizontal overflow`, async ({ page }) => {
|
||||||
await page.goto(`/${locale}/component-lab`);
|
await page.goto(`/${locale}/system/component-lab`);
|
||||||
await expect(page.locator('h1')).toBeVisible();
|
await expect(page.locator('h1')).toBeVisible();
|
||||||
const overflow = await page.evaluate(
|
const overflow = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
@@ -15,7 +15,7 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
test('component controls support keyboard interaction and dialog focus return', async ({
|
test('component controls support keyboard interaction and dialog focus return', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/en/component-lab');
|
await page.goto('/en/system/component-lab');
|
||||||
const firstTab = page.getByRole('tab', { name: 'Reservations' });
|
const firstTab = page.getByRole('tab', { name: 'Reservations' });
|
||||||
await firstTab.focus();
|
await firstTab.focus();
|
||||||
await page.keyboard.press('ArrowRight');
|
await page.keyboard.press('ArrowRight');
|
||||||
@@ -36,7 +36,7 @@ test('component fixture reflows at narrow mobile and 200 percent zoom equivalent
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.setViewportSize({ width: 320, height: 800 });
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
await page.goto('/fr/component-lab');
|
await page.goto('/fr/system/component-lab');
|
||||||
await page.evaluate(() => (document.body.style.zoom = '2'));
|
await page.evaluate(() => (document.body.style.zoom = '2'));
|
||||||
const overflow = await page.evaluate(
|
const overflow = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ for (const scenario of [
|
|||||||
{ locale: 'ar', dir: 'rtl' },
|
{ locale: 'ar', dir: 'rtl' },
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`${scenario.locale} homepage assembles the approved section sequence`, async ({ page }) => {
|
test(`${scenario.locale} homepage assembles the approved section sequence`, async ({ page }) => {
|
||||||
await page.goto(`/${scenario.locale}`);
|
await page.goto(`/${scenario.locale}/system`);
|
||||||
|
|
||||||
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
||||||
await expect(page.locator('main h1')).toHaveCount(1);
|
await expect(page.locator('main h1')).toHaveCount(1);
|
||||||
@@ -19,7 +19,7 @@ for (const scenario of [
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(`${scenario.locale} unresolved actions remain non-navigable`, async ({ page }) => {
|
test(`${scenario.locale} unresolved actions remain non-navigable`, async ({ page }) => {
|
||||||
await page.goto(`/${scenario.locale}`);
|
await page.goto(`/${scenario.locale}/system`);
|
||||||
|
|
||||||
const disabledActions = page.locator('main [aria-disabled="true"]');
|
const disabledActions = page.locator('main [aria-disabled="true"]');
|
||||||
await expect(disabledActions).toHaveCount(2);
|
await expect(disabledActions).toHaveCount(2);
|
||||||
@@ -28,22 +28,22 @@ for (const scenario of [
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('Arabic preview preserves left-to-right vehicle identifiers', async ({ page }) => {
|
test('Arabic preview preserves left-to-right vehicle identifiers', async ({ page }) => {
|
||||||
await page.goto('/ar');
|
await page.goto('/ar/system');
|
||||||
const identifiers = page.locator('[role="table"] bdi');
|
const identifiers = page.locator('[role="table"] bdi');
|
||||||
await expect(identifiers).toHaveCount(2);
|
await expect(identifiers).toHaveCount(2);
|
||||||
await expect(identifiers.first()).toHaveAttribute('dir', 'ltr');
|
await expect(identifiers.first()).toHaveAttribute('dir', 'ltr');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('homepage navigation targets resolve to real sections', async ({ page }) => {
|
test('homepage navigation targets resolve to real sections', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
for (const hash of ['product', 'workflow', 'modules', 'pricing', 'faq']) {
|
for (const hash of ['product', 'workflow', 'modules', 'pricing', 'faq']) {
|
||||||
await expect(page.locator(`#${hash}`)).toHaveCount(1);
|
await expect(page.locator(`#${hash}`)).toHaveCount(1);
|
||||||
await expect(page.locator(`header a[href="/en#${hash}"]`)).toHaveCount(2);
|
await expect(page.locator(`header a[href="/en/system#${hash}"]`)).toHaveCount(2);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('FAQ uses native disclosure behavior', async ({ page }) => {
|
test('FAQ uses native disclosure behavior', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
const firstItem = page.locator('#faq details').first();
|
const firstItem = page.locator('#faq details').first();
|
||||||
await expect(firstItem).toHaveAttribute('open', '');
|
await expect(firstItem).toHaveAttribute('open', '');
|
||||||
await firstItem.locator('summary').click();
|
await firstItem.locator('summary').click();
|
||||||
@@ -51,7 +51,7 @@ test('FAQ uses native disclosure behavior', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('pricing recommends a plan from the selected fleet band', async ({ page }) => {
|
test('pricing recommends a plan from the selected fleet band', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
|
|
||||||
await expect(page.locator('#pricing [data-plan="launch"]')).toHaveAttribute(
|
await expect(page.locator('#pricing [data-plan="launch"]')).toHaveAttribute(
|
||||||
'data-recommended',
|
'data-recommended',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const localeData = {
|
|||||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||||
test(`${locale} demo flow validates and succeeds through the local adapter`, async ({ page }) => {
|
test(`${locale} demo flow validates and succeeds through the local adapter`, async ({ page }) => {
|
||||||
const labels = localeData[locale];
|
const labels = localeData[locale];
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/system`);
|
||||||
await page.locator('main button[data-demo-source="hero"]').click();
|
await page.locator('main button[data-demo-source="hero"]').click();
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog', { name: labels.title });
|
const dialog = page.getByRole('dialog', { name: labels.title });
|
||||||
@@ -41,12 +41,12 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(dialog.getByRole('heading', { name: labels.success })).toBeVisible();
|
await expect(dialog.getByRole('heading', { name: labels.success })).toBeVisible();
|
||||||
await expect(page).toHaveURL(new RegExp(`/${locale}$`));
|
await expect(page).toHaveURL(new RegExp(`/${locale}/system$`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('duplicate clicks create only one request while submission is pending', async ({ page }) => {
|
test('duplicate clicks create only one request while submission is pending', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
await page.locator('main button[data-demo-source="hero"]').click();
|
await page.locator('main button[data-demo-source="hero"]').click();
|
||||||
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
||||||
await dialog.locator('#fullName').fill('Avery Example');
|
await dialog.locator('#fullName').fill('Avery Example');
|
||||||
@@ -59,7 +59,7 @@ test('duplicate clicks create only one request while submission is pending', asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('demo form does not place personal data in the URL or analytics sink', async ({ page }) => {
|
test('demo form does not place personal data in the URL or analytics sink', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
await page.locator('main button[data-demo-source="hero"]').click();
|
await page.locator('main button[data-demo-source="hero"]').click();
|
||||||
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
||||||
await dialog.locator('#fullName').fill('Private Example');
|
await dialog.locator('#fullName').fill('Private Example');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
test('mobile navigation opens modally, closes with Escape, and returns focus', async ({ page }) => {
|
test('mobile navigation opens modally, closes with Escape, and returns focus', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
const trigger = page.getByRole('button', { name: 'Open navigation menu' });
|
const trigger = page.getByRole('button', { name: 'Open navigation menu' });
|
||||||
await trigger.focus();
|
await trigger.focus();
|
||||||
await trigger.click();
|
await trigger.click();
|
||||||
@@ -19,7 +19,7 @@ test('mobile navigation opens modally, closes with Escape, and returns focus', a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('skip navigation moves focus to the main landmark', async ({ page }) => {
|
test('skip navigation moves focus to the main landmark', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
const skip = page.getByRole('link', { name: 'Skip to main content' });
|
const skip = page.getByRole('link', { name: 'Skip to main content' });
|
||||||
await expect(skip).toBeFocused();
|
await expect(skip).toBeFocused();
|
||||||
@@ -32,7 +32,7 @@ test.describe('primary navigation visibility', () => {
|
|||||||
test.use({ viewport: { width: 1024, height: 768 } });
|
test.use({ viewport: { width: 1024, height: 768 } });
|
||||||
|
|
||||||
test('keeps the complete navbar visible at common laptop widths', async ({ page }) => {
|
test('keeps the complete navbar visible at common laptop widths', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
|
|
||||||
const navigation = page.getByRole('navigation', { name: 'Primary navigation' }).first();
|
const navigation = page.getByRole('navigation', { name: 'Primary navigation' }).first();
|
||||||
await expect(navigation).toBeVisible();
|
await expect(navigation).toBeVisible();
|
||||||
@@ -47,7 +47,7 @@ test.describe('mobile primary navigation destinations', () => {
|
|||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
test('keeps every navbar destination in the mobile drawer', async ({ page }) => {
|
test('keeps every navbar destination in the mobile drawer', async ({ page }) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
await page.getByRole('button', { name: 'Open navigation menu' }).click();
|
await page.getByRole('button', { name: 'Open navigation menu' }).click();
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog', { name: 'Primary navigation' });
|
const dialog = page.getByRole('dialog', { name: 'Primary navigation' });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
test('server shell remains readable with JavaScript disabled', async ({ browser }) => {
|
test('server shell remains readable with JavaScript disabled', async ({ browser }) => {
|
||||||
const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: 'dark' });
|
const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: 'dark' });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('/ar');
|
await page.goto('/ar/dark');
|
||||||
await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
|
await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
|
||||||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
|
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
|
||||||
await expect(page.locator('h1')).toBeVisible();
|
await expect(page.locator('h1')).toBeVisible();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ for (const locale of ['en', 'fr', 'ar'] as const) {
|
|||||||
for (const width of widths) {
|
for (const width of widths) {
|
||||||
test(`${locale} has no horizontal overflow at ${width}px`, async ({ page }) => {
|
test(`${locale} has no horizontal overflow at ${width}px`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/system`);
|
||||||
const overflow = await page.evaluate(
|
const overflow = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
);
|
);
|
||||||
@@ -19,6 +19,6 @@ test('shell remains operable at a 320 CSS pixel reflow equivalent to 200 percent
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.setViewportSize({ width: 640, height: 800 });
|
await page.setViewportSize({ width: 640, height: 800 });
|
||||||
await page.goto('/fr');
|
await page.goto('/fr/system');
|
||||||
await expect(page.getByRole('button', { name: 'Ouvrir le menu de navigation' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Ouvrir le menu de navigation' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ for (const scenario of [
|
|||||||
{ locale: 'ar', dir: 'rtl', title: /RentalDriveGo/ },
|
{ locale: 'ar', dir: 'rtl', title: /RentalDriveGo/ },
|
||||||
] as const) {
|
] as const) {
|
||||||
test(`${scenario.locale} shell is server rendered`, async ({ page, request }) => {
|
test(`${scenario.locale} shell is server rendered`, async ({ page, request }) => {
|
||||||
const raw = await request.get(`/${scenario.locale}`);
|
const raw = await request.get(`/${scenario.locale}/system`);
|
||||||
const html = await raw.text();
|
const html = await raw.text();
|
||||||
expect(raw.status()).toBe(200);
|
expect(raw.status()).toBe(200);
|
||||||
expect(html).toMatch(
|
expect(html).toMatch(
|
||||||
new RegExp(`<html[^>]*lang=\"${scenario.locale}\"[^>]*dir=\"${scenario.dir}\"`),
|
new RegExp(`<html[^>]*lang=\"${scenario.locale}\"[^>]*dir=\"${scenario.dir}\"`),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await page.goto(`/${scenario.locale}`);
|
const response = await page.goto(`/${scenario.locale}/system`);
|
||||||
expect(response?.status()).toBe(200);
|
expect(response?.status()).toBe(200);
|
||||||
await expect(page.locator('html')).toHaveAttribute('lang', scenario.locale);
|
await expect(page.locator('html')).toHaveAttribute('lang', scenario.locale);
|
||||||
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
||||||
@@ -23,7 +23,7 @@ for (const scenario of [
|
|||||||
await expect(page.locator('footer')).toBeVisible();
|
await expect(page.locator('footer')).toBeVisible();
|
||||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
new RegExp(`/${scenario.locale}$`),
|
new RegExp(`/${scenario.locale}/system$`),
|
||||||
);
|
);
|
||||||
for (const language of ['en', 'fr', 'ar', 'x-default']) {
|
for (const language of ['en', 'fr', 'ar', 'x-default']) {
|
||||||
await expect(page.locator(`link[rel="alternate"][hreflang="${language}"]`)).toHaveCount(1);
|
await expect(page.locator(`link[rel="alternate"][hreflang="${language}"]`)).toHaveCount(1);
|
||||||
@@ -41,18 +41,18 @@ test('root locale detection honors Accept-Language', async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveURL(/\/ar$/);
|
await expect(page).toHaveURL(/\/ar\/system$/);
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('locale switching keeps the semantic route and approved campaign state', async ({ page }) => {
|
test('locale switching keeps the semantic route and approved campaign state', async ({ page }) => {
|
||||||
await page.goto('/en/privacy?utm_source=qa&email=drop-me#ignored');
|
await page.goto('/en/system/privacy?utm_source=qa&email=drop-me#ignored');
|
||||||
await page.getByLabel('Language').first().selectOption('fr');
|
await page.getByLabel('Language').first().selectOption('fr');
|
||||||
await expect(page).toHaveURL('/fr/confidentialite?utm_source=qa');
|
await expect(page).toHaveURL('/fr/system/confidentialite?utm_source=qa');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pending route shells are localized and noindex', async ({ page }) => {
|
test('pending route shells are localized and noindex', async ({ page }) => {
|
||||||
await page.goto('/fr/confidentialite');
|
await page.goto('/fr/system/confidentialite');
|
||||||
await expect(page.locator('h1')).toContainText('Confidentialité');
|
await expect(page.locator('h1')).toContainText('Confidentialité');
|
||||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', /noindex/);
|
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', /noindex/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
for (const locale of ['fr', 'ar'] as const) {
|
for (const locale of ['fr', 'ar'] as const) {
|
||||||
test(`${locale} shell tolerates long content expansion`, async ({ page }) => {
|
test(`${locale} shell tolerates long content expansion`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 900 });
|
await page.setViewportSize({ width: 390, height: 900 });
|
||||||
await page.goto(`/${locale}`);
|
await page.goto(`/${locale}/system`);
|
||||||
await page
|
await page
|
||||||
.locator('main article h3')
|
.locator('main article h3')
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test('explicit dark theme is present on the server-rendered root', async ({ brow
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const response = await page.goto('/en');
|
const response = await page.goto('/en/dark');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
const csp = response?.headers()['content-security-policy'] ?? '';
|
const csp = response?.headers()['content-security-policy'] ?? '';
|
||||||
@@ -28,7 +28,7 @@ test('explicit dark theme is present on the server-rendered root', async ({ brow
|
|||||||
test('system preference resolves before interaction', async ({ browser }) => {
|
test('system preference resolves before interaction', async ({ browser }) => {
|
||||||
const context = await browser.newContext({ colorScheme: 'dark' });
|
const context = await browser.newContext({ colorScheme: 'dark' });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'system');
|
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'system');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
await context.close();
|
await context.close();
|
||||||
@@ -37,7 +37,7 @@ test('system preference resolves before interaction', async ({ browser }) => {
|
|||||||
test('system follows operating-system changes while explicit preferences ignore them', async ({
|
test('system follows operating-system changes while explicit preferences ignore them', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/en');
|
await page.goto('/en/system');
|
||||||
const selector = page.getByLabel('Theme').first();
|
const selector = page.getByLabel('Theme').first();
|
||||||
|
|
||||||
await selector.selectOption('system');
|
await selector.selectOption('system');
|
||||||
@@ -47,6 +47,7 @@ test('system follows operating-system changes while explicit preferences ignore
|
|||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
|
||||||
await selector.selectOption('dark');
|
await selector.selectOption('dark');
|
||||||
|
await expect(page).toHaveURL('/en/dark');
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"fixtureRoute": "/{locale}/component-lab",
|
"fixtureRoute": "/{locale}/system/component-lab",
|
||||||
"productionGuard": "COMPONENT_FIXTURES_ENABLED=true",
|
"productionGuard": "COMPONENT_FIXTURES_ENABLED=true",
|
||||||
"locales": ["en", "fr", "ar"],
|
"locales": ["en", "fr", "ar"],
|
||||||
"themes": ["light", "dark", "system"],
|
"themes": ["light", "dark", "system"],
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ describe('destination registry', () => {
|
|||||||
expect(
|
expect(
|
||||||
resolveDestination('privacy', 'fr', { demoSubmissionEnabled: false, demoMode: 'blocked' })
|
resolveDestination('privacy', 'fr', { demoSubmissionEnabled: false, demoMode: 'blocked' })
|
||||||
.href,
|
.href,
|
||||||
).toBe('/fr/confidentialite');
|
).toBe('/fr/system/confidentialite');
|
||||||
expect(
|
expect(
|
||||||
resolveDestination('sign-in', 'en', { demoSubmissionEnabled: false, demoMode: 'blocked' }).href,
|
resolveDestination('sign-in', 'en', { demoSubmissionEnabled: false, demoMode: 'blocked' }).href,
|
||||||
).toBe('/en/sign-in');
|
).toBe('/en/system/sign-in');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,33 +27,33 @@ describe('localization foundations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('generates and resolves localized stable route paths', () => {
|
it('generates and resolves localized stable route paths', () => {
|
||||||
expect(localizedPath('home', 'ar')).toBe('/ar');
|
expect(localizedPath('home', 'ar')).toBe('/ar/system');
|
||||||
expect(localizedPath('privacy', 'fr')).toBe('/fr/confidentialite');
|
expect(localizedPath('privacy', 'fr')).toBe('/fr/system/confidentialite');
|
||||||
expect(localizedPath('sign-in', 'ar')).toBe('/ar/تسجيل-الدخول');
|
expect(localizedPath('sign-in', 'ar')).toBe('/ar/system/تسجيل-الدخول');
|
||||||
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
||||||
expect(routeIdFromSlug('fr', 'privacy')).toBeNull();
|
expect(routeIdFromSlug('fr', 'privacy')).toBeNull();
|
||||||
expect(routeIdFromAnyLocaleSlug('privacy')).toBe('privacy');
|
expect(routeIdFromAnyLocaleSlug('privacy')).toBe('privacy');
|
||||||
expect(routeIdFromAnyLocaleSlug('confidentialite')).toBe('privacy');
|
expect(routeIdFromAnyLocaleSlug('confidentialite')).toBe('privacy');
|
||||||
expect(routeIdFromAnyLocaleSlug('unknown')).toBeNull();
|
expect(routeIdFromAnyLocaleSlug('unknown')).toBeNull();
|
||||||
expect(routeIdFromPathname('/ar/%D8%A7%D9%84%D8%AE%D8%B5%D9%88%D8%B5%D9%8A%D8%A9')).toBe(
|
expect(
|
||||||
'privacy',
|
routeIdFromPathname('/ar/system/%D8%A7%D9%84%D8%AE%D8%B5%D9%88%D8%B5%D9%8A%D8%A9'),
|
||||||
);
|
).toBe('privacy');
|
||||||
expect(routeIdFromPathname('/de')).toBeNull();
|
expect(routeIdFromPathname('/de')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates reciprocal hreflang values', () => {
|
it('generates reciprocal hreflang values', () => {
|
||||||
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
||||||
expect(links.en).toBe('https://approved.test/en');
|
expect(links.en).toBe('https://approved.test/en/system');
|
||||||
expect(links.ar).toBe('https://approved.test/ar');
|
expect(links.ar).toBe('https://approved.test/ar/system');
|
||||||
expect(links['x-default']).toBe('https://approved.test/en');
|
expect(links['x-default']).toBe('https://approved.test/en/system');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves only approved campaign query values and valid hashes', () => {
|
it('preserves only approved campaign query values and valid hashes', () => {
|
||||||
const current = new URL(
|
const current = new URL(
|
||||||
'https://approved.test/en?utm_source=campaign&email=private%40approved.test#gibberish',
|
'https://approved.test/en/system?utm_source=campaign&email=private%40approved.test#gibberish',
|
||||||
);
|
);
|
||||||
const switched = localeSwitchUrl(current, 'fr', 'home');
|
const switched = localeSwitchUrl(current, 'fr', 'home');
|
||||||
expect(switched.pathname).toBe('/fr');
|
expect(switched.pathname).toBe('/fr/system');
|
||||||
expect(switched.search).toBe('?utm_source=campaign');
|
expect(switched.search).toBe('?utm_source=campaign');
|
||||||
expect(switched.hash).toBe('');
|
expect(switched.hash).toBe('');
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('PricingSection', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PricingSection content={content} locale="en" homePath="/en" demoSubmissionEnabled />,
|
<PricingSection content={content} locale="en" homePath="/en/system" demoSubmissionEnabled />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('[data-plan="launch"]')).toHaveAttribute(
|
expect(container.querySelector('[data-plan="launch"]')).toHaveAttribute(
|
||||||
@@ -34,7 +34,9 @@ describe('PricingSection', () => {
|
|||||||
|
|
||||||
it('keeps unapproved public prices out of the rendered section', () => {
|
it('keeps unapproved public prices out of the rendered section', () => {
|
||||||
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
||||||
render(<PricingSection content={content} locale="en" homePath="/en" demoSubmissionEnabled />);
|
render(
|
||||||
|
<PricingSection content={content} locale="en" homePath="/en/system" demoSubmissionEnabled />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(screen.getAllByText('Starting price pending approval')).toHaveLength(2);
|
expect(screen.getAllByText('Starting price pending approval')).toHaveLength(2);
|
||||||
expect(screen.getByText('Custom pricing')).toBeInTheDocument();
|
expect(screen.getByText('Custom pricing')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ function request(input: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProxyTestResponse = {
|
||||||
|
kind: 'next' | 'redirect';
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runProxy(proxy: (request: never) => unknown, input: ReturnType<typeof request>) {
|
||||||
|
return proxy(input as never) as ProxyTestResponse;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
nextServer.next.mockClear();
|
nextServer.next.mockClear();
|
||||||
nextServer.redirect.mockClear();
|
nextServer.redirect.mockClear();
|
||||||
@@ -52,7 +61,7 @@ describe('homepage proxy app boundaries', () => {
|
|||||||
it('does not localize storefront proxy paths', async () => {
|
it('does not localize storefront proxy paths', async () => {
|
||||||
const { proxy } = await import('@/proxy');
|
const { proxy } = await import('@/proxy');
|
||||||
|
|
||||||
const response = proxy(request('https://rentaldrivego.ma/storefront/en') as never) as any;
|
const response = runProxy(proxy, request('https://rentaldrivego.ma/storefront/en'));
|
||||||
|
|
||||||
expect(response.kind).toBe('next');
|
expect(response.kind).toBe('next');
|
||||||
expect(nextServer.redirect).not.toHaveBeenCalled();
|
expect(nextServer.redirect).not.toHaveBeenCalled();
|
||||||
@@ -61,7 +70,45 @@ describe('homepage proxy app boundaries', () => {
|
|||||||
it('redirects locale-prefixed storefront paths back to the storefront proxy mount', async () => {
|
it('redirects locale-prefixed storefront paths back to the storefront proxy mount', async () => {
|
||||||
const { proxy } = await import('@/proxy');
|
const { proxy } = await import('@/proxy');
|
||||||
|
|
||||||
const response = proxy(request('https://rentaldrivego.ma/en/storefront/en') as never) as any;
|
const response = runProxy(proxy, request('https://rentaldrivego.ma/en/storefront/en'));
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
kind: 'redirect',
|
||||||
|
url: 'https://rentaldrivego.ma/storefront/en',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects the root path to locale and mode segments', async () => {
|
||||||
|
const { proxy } = await import('@/proxy');
|
||||||
|
|
||||||
|
const input = request('https://rentaldrivego.ma/');
|
||||||
|
input.headers.set('accept-language', 'fr;q=1,en;q=0.8');
|
||||||
|
const response = runProxy(proxy, input);
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
kind: 'redirect',
|
||||||
|
url: 'https://rentaldrivego.ma/fr/system',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects legacy localized slugs to mode-aware localized slugs', async () => {
|
||||||
|
const { proxy } = await import('@/proxy');
|
||||||
|
|
||||||
|
const response = runProxy(proxy, request('https://rentaldrivego.ma/en/privacy'));
|
||||||
|
|
||||||
|
expect(response).toMatchObject({
|
||||||
|
kind: 'redirect',
|
||||||
|
url: 'https://rentaldrivego.ma/en/system/privacy',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects locale-and-mode storefront paths back to the storefront proxy mount', async () => {
|
||||||
|
const { proxy } = await import('@/proxy');
|
||||||
|
|
||||||
|
const response = runProxy(
|
||||||
|
proxy,
|
||||||
|
request('https://rentaldrivego.ma/en/dark/storefront/en'),
|
||||||
|
);
|
||||||
|
|
||||||
expect(response).toMatchObject({
|
expect(response).toMatchObject({
|
||||||
kind: 'redirect',
|
kind: 'redirect',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ for (const scenario of [
|
|||||||
scenario.theme,
|
scenario.theme,
|
||||||
);
|
);
|
||||||
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
||||||
await page.goto(`/${scenario.locale}/component-lab`);
|
await page.goto(`/${scenario.locale}/${scenario.theme}/component-lab`);
|
||||||
await expect(page).toHaveScreenshot(
|
await expect(page).toHaveScreenshot(
|
||||||
`component-system-${scenario.locale}-${scenario.theme}-${scenario.width}.png`,
|
`component-system-${scenario.locale}-${scenario.theme}-${scenario.width}.png`,
|
||||||
{ fullPage: true },
|
{ fullPage: true },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ for (const scenario of scenarios) {
|
|||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await page.goto(`/${scenario.locale}`);
|
await page.goto(`/${scenario.locale}/${scenario.theme}`);
|
||||||
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ for (const scenario of [
|
|||||||
}) => {
|
}) => {
|
||||||
test.skip(test.info().project.name !== 'chromium', 'Canonical visual baselines use Chromium.');
|
test.skip(test.info().project.name !== 'chromium', 'Canonical visual baselines use Chromium.');
|
||||||
await page.emulateMedia({ colorScheme: scenario.theme });
|
await page.emulateMedia({ colorScheme: scenario.theme });
|
||||||
await page.goto(`/${scenario.locale}`);
|
await page.goto(`/${scenario.locale}/${scenario.theme}`);
|
||||||
await page.locator('main button[data-demo-source="hero"]').click();
|
await page.locator('main button[data-demo-source="hero"]').click();
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(dialog).toHaveScreenshot(`demo-${scenario.locale}-${scenario.theme}-initial.png`);
|
await expect(dialog).toHaveScreenshot(`demo-${scenario.locale}-${scenario.theme}-initial.png`);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ for (const scenario of scenarios) {
|
|||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await page.goto(`/${scenario.locale}`);
|
await page.goto(`/${scenario.locale}/${scenario.theme}`);
|
||||||
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ test('mobile navigation open baseline', async ({ page }) => {
|
|||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await page.goto('/ar');
|
await page.goto('/ar/dark');
|
||||||
await page.getByRole('button', { name: 'فتح قائمة التنقل' }).click();
|
await page.getByRole('button', { name: 'فتح قائمة التنقل' }).click();
|
||||||
await expect(page).toHaveScreenshot('mobile-ar-dark-navigation-open.png', { fullPage: true });
|
await expect(page).toHaveScreenshot('mobile-ar-dark-navigation-open.png', { fullPage: true });
|
||||||
});
|
});
|
||||||
@@ -57,7 +57,7 @@ test('mobile navigation open baseline', async ({ page }) => {
|
|||||||
test('keyboard focus baseline', async ({ page }) => {
|
test('keyboard focus baseline', async ({ page }) => {
|
||||||
test.skip(test.info().project.name !== 'chromium');
|
test.skip(test.info().project.name !== 'chromium');
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await page.goto('/en');
|
await page.goto('/en/light');
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
await expect(page).toHaveScreenshot('desktop-en-light-focus.png', { fullPage: true });
|
await expect(page).toHaveScreenshot('desktop-en-light-focus.png', { fullPage: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user