redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
@@ -1,63 +0,0 @@
'use client'
import { LucideIcon } from 'lucide-react'
interface Feature {
title: string
body: string
icon?: LucideIcon
}
interface FeatureAlternatingProps {
features: Feature[]
}
export function FeatureAlternating({ features }: FeatureAlternatingProps) {
return (
<div className="space-y-16 lg:space-y-20">
{features.map((feature, index) => {
const Icon = feature.icon
const isEven = index % 2 === 0
return (
<div
key={feature.title}
className={`grid gap-12 items-center ${isEven ? 'lg:grid-cols-[1.1fr_0.9fr]' : 'lg:grid-cols-[0.9fr_1.1fr]'}`}
>
<div className={isEven ? 'order-1 lg:order-none' : 'order-2 lg:order-none'}>
<div className="site-panel">
<div className="flex items-start gap-3">
{Icon && <Icon className="mt-1 h-6 w-6 shrink-0 text-orange-600 dark:text-orange-500" />}
<div>
<h2 className="text-2xl font-black text-blue-950 dark:text-white sm:text-3xl">
{feature.title}
</h2>
<p className="mt-4 text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
{feature.body}
</p>
</div>
</div>
</div>
</div>
<div
className={`order-2 lg:order-none ${isEven ? 'order-2 lg:order-none' : 'order-1 lg:order-none'}`}
>
<div className="site-panel-muted flex h-48 items-center justify-center sm:h-56 lg:h-80">
{Icon ? (
<Icon className="h-24 w-24 text-orange-600/20 dark:text-orange-500/20" />
) : (
<div className="text-center">
<p className="text-sm font-semibold text-stone-400 dark:text-stone-600">
{feature.title}
</p>
</div>
)}
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -1,64 +0,0 @@
import React, { isValidElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const preferenceState = vi.hoisted(() => ({ language: 'en' as 'en' | 'fr' | 'ar' }))
vi.mock('@/components/MarketplaceShell', () => ({
useMarketplacePreferences: () => ({ language: preferenceState.language, theme: 'light' }),
}))
import FooterContentPage from './FooterContentPage'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
describe('FooterContentPage', () => {
beforeEach(() => {
preferenceState.language = 'en'
})
it('uses the current marketplace language when no forced language is provided', () => {
preferenceState.language = 'fr'
const page = FooterContentPage({ slug: 'contact-sales' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).toContain('Besoin de plus de détails')
})
it('lets static app policy pages force their own locale', () => {
preferenceState.language = 'en'
const page = FooterContentPage({ slug: 'terms-of-service', forcedLanguage: 'fr' })
const text = collectText(page).join(' ')
expect(text).toContain('Informations du pied de page')
expect(text).not.toContain('Footer information')
})
it('marks Arabic content as right-aligned', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'ar' })
const elements = collectElements(page)
expect(elements.some((element) => String(element.props.className ?? '').includes('text-right'))).toBe(true)
})
it('turns plain URLs embedded in policy paragraphs into safe anchors', () => {
const page = FooterContentPage({ slug: 'privacy-policy', forcedLanguage: 'en' })
const links = collectElements(page).filter((element) => element.type === 'a')
expect(links.some((link) => String(link.props.href).startsWith('https://'))).toBe(true)
expect(links.every((link) => link.props.href === collectText(link).join(''))).toBe(true)
})
})
@@ -1,107 +0,0 @@
'use client'
import React from 'react'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import { type FooterPageSlug, getFooterPageContent } from '@/lib/footerContent'
import { type MarketplaceLanguage } from '@/lib/i18n'
const pageMeta = {
en: {
kicker: 'Footer information',
cta: 'Need more details?',
support: 'Contact the RentalDriveGo team through our official channels for business, support, or partnership requests.',
},
fr: {
kicker: 'Informations du pied de page',
cta: 'Besoin de plus de détails ?',
support: "Contactez l'équipe RentalDriveGo via nos canaux officiels pour toute demande commerciale, support ou partenariat.",
},
ar: {
kicker: 'معلومات التذييل',
cta: 'هل تحتاج إلى مزيد من التفاصيل؟',
support: 'تواصل مع فريق RentalDriveGo عبر القنوات الرسمية للاستفسارات التجارية أو الدعم أو الشراكات.',
},
} as const
const urlPattern = /(https?:\/\/[^\s]+)/g
function renderParagraphText(paragraph: string) {
const parts = paragraph.split(urlPattern)
return parts.map((part, index) => {
if (urlPattern.test(part)) {
urlPattern.lastIndex = 0
const match = part.match(/^(https?:\/\/[^\s]+?)([.,!?;:]*)$/)
const href = match?.[1] ?? part
const trailing = match?.[2] ?? ''
return (
<span key={`${part}-${index}`}>
<a
href={href}
className="text-orange-700 underline decoration-orange-300 underline-offset-4 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
>
{href}
</a>
{trailing}
</span>
)
}
urlPattern.lastIndex = 0
return <span key={`${part}-${index}`}>{part}</span>
})
}
export default function FooterContentPage({ slug, forcedLanguage }: { slug: FooterPageSlug; forcedLanguage?: MarketplaceLanguage }) {
const { language } = useMarketplacePreferences()
const activeLanguage = forcedLanguage ?? language
const content = getFooterPageContent(activeLanguage, slug)
const meta = pageMeta[activeLanguage]
const isArabic = activeLanguage === 'ar'
return (
<main className="site-page">
<div className="site-section mx-auto max-w-4xl">
<section className="site-panel overflow-hidden p-0">
<div className="site-panel-muted rounded-none border-0 border-b border-stone-100/80 px-6 py-10 sm:px-10 sm:py-14 dark:border-blue-900">
<p className="site-kicker">
{meta.kicker}
</p>
<h1 className="site-title">
{content.title}
</h1>
</div>
<div className={`space-y-6 px-6 py-10 sm:px-10 sm:py-12 ${isArabic ? 'text-right' : 'text-left'}`}>
{content.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
{content.sections?.map((section) => (
<section key={section.heading} className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900 dark:text-stone-100">
{section.heading}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={`${section.heading}-${paragraph}`} className="text-lg leading-8 text-stone-700 dark:text-stone-300">
{renderParagraphText(paragraph)}
</p>
))}
</section>
))}
<div className="rounded-3xl border border-orange-200 bg-orange-50 px-6 py-5 dark:border-orange-800 dark:bg-orange-950/30">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-orange-800 dark:text-orange-400">
{meta.cta}
</p>
<p className="mt-2 text-base leading-7 text-stone-700 dark:text-stone-300">{meta.support}</p>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -1,75 +0,0 @@
'use client'
import { useState } from 'react'
import { LucideIcon } from 'lucide-react'
interface Step {
number: string
title: string
description: string
icon?: LucideIcon
}
interface HowItWorksProps {
steps: Step[]
kicker?: string
title?: string
}
export function HowItWorks({ steps, kicker = 'GETTING STARTED', title = 'How It Works' }: HowItWorksProps) {
const [activeStep, setActiveStep] = useState(0)
return (
<section className="site-section">
<div className="site-panel">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">{title}</h2>
<div className="mt-12 space-y-4 lg:space-y-6">
{steps.map((step, index) => {
const Icon = step.icon
const isActive = activeStep === index
return (
<button
key={step.number}
onClick={() => setActiveStep(index)}
className={`w-full flex gap-6 rounded-[1.5rem] border p-6 transition-all sm:p-8 ${
isActive
? 'border-orange-600 bg-orange-50 shadow-lg dark:border-orange-500 dark:bg-orange-950/30'
: 'border-stone-200/80 bg-white/85 hover:-translate-y-1 dark:border-blue-800 dark:bg-blue-950/40'
}`}
>
<div className="flex shrink-0 flex-col items-center">
<div
className={`flex h-12 w-12 items-center justify-center rounded-full font-bold text-white transition-all sm:h-14 sm:w-14 ${
isActive
? 'scale-110 bg-orange-600 shadow-lg dark:bg-orange-500'
: 'bg-orange-600 dark:bg-orange-500'
}`}
>
{step.number}
</div>
{index < steps.length - 1 && (
<div className="mt-4 h-8 w-1 bg-gradient-to-b from-orange-600 to-transparent dark:from-orange-500" />
)}
</div>
<div className="flex-1 pt-1 text-left">
<div className="flex items-start gap-3">
{Icon && (
<Icon className="mt-1 h-5 w-5 shrink-0 text-orange-600 dark:text-orange-400" />
)}
<div>
<h3 className="text-lg font-bold text-blue-950 dark:text-white">{step.title}</h3>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-300">{step.description}</p>
</div>
</div>
</div>
</button>
)
})}
</div>
</div>
</section>
)
}
@@ -1,123 +0,0 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function MarketplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
<div className="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{secondaryItems.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} FleetOS. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './MarketplaceHeader'
describe('MarketplaceHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
)
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
})
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
})
it('routes existing companies to their workspace and new owners to sign-up', () => {
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
})
it('normalizes company initials for the owner workspace pill', () => {
expect(companyInitial('atlas cars')).toBe('A')
expect(companyInitial(' زاكورة كار ')).toBe('ز')
})
})
@@ -1,237 +0,0 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
home: string
features: string
pricing: string
signIn: string
ownerSignIn: string
theme: string
light: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
}
export function localeMenuPositionClass(language: Language): string {
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
}
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
}
export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase()
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
return (
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="FleetOS"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>FleetOS</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.home}
</Link>
<Link
href="/features"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.features}
</Link>
<Link
href="/pricing"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.pricing}
</Link>
<a
href={signInHref}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</a>
{companyName ? (
<a
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
{companyInitial(companyName)}
</span>
{companyName}
</a>
) : (
<a
href={ownerWorkspaceHref(dashboardUrl, companyName)}
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</a>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('MarketplaceShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn(() => null),
},
})
expect(hasCachedEmployeeProfile()).toBe(false)
})
it('detects the cached employee profile marker written by dashboard sign-in', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
},
})
expect(hasCachedEmployeeProfile()).toBe(true)
})
it('clears the cached employee profile marker', () => {
const removeItem = vi.fn()
vi.stubGlobal('window', {
localStorage: {
removeItem,
},
})
clearCachedEmployeeProfile()
expect(removeItem).toHaveBeenCalledWith('employee_profile')
})
})
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell'
describe('MarketplaceShell footer content registry', () => {
it('exposes exactly the supported marketplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -1,368 +0,0 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
home: string
features: string
pricing: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
home: 'Home',
features: 'Features',
pricing: 'Pricing',
signIn: 'Sign in',
ownerSignIn: 'Get Started',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Preferences',
},
fr: {
home: 'Accueil',
features: 'Fonctionnalités',
pricing: 'Tarifs',
signIn: 'Connexion',
ownerSignIn: 'Démarrer',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences',
},
ar: {
home: 'الرئيسية',
features: 'الميزات',
pricing: 'الأسعار',
signIn: 'تسجيل الدخول',
ownerSignIn: 'ابدأ الآن',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'التفضيلات',
},
}
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
export function getFooterContent(language: MarketplaceLanguage): {
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
{ label: 'Sécurité', href: footerPageHref.security },
{ label: 'Conformité', href: footerPageHref.compliance },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: footerPageHref['about-us'] },
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
{ label: 'الأمان', href: footerPageHref.security },
{ label: 'الامتثال', href: footerPageHref.compliance },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: footerPageHref['about-us'] },
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
{ label: 'Security', href: footerPageHref.security },
{ label: 'Compliance', href: footerPageHref.compliance },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export function hasCachedEmployeeProfile(): boolean {
if (typeof window === 'undefined') return false
try {
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
} catch {
return false
}
}
export function clearCachedEmployeeProfile() {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
} catch {}
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code
}
return null
}
type PreferencesContextValue = {
language: MarketplaceLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: MarketplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useMarketplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
return context
}
export default function MarketplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
if (!hasCachedEmployeeProfile()) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include',
})
if (!response.ok) {
if (response.status === 401) {
clearCachedEmployeeProfile()
}
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
function syncScopedPreferencesForSession() {
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme)
}
setHydrated(true)
void syncCompanyBrand()
}, [])
useEffect(() => {
function handleWindowFocus() {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
function handleAuthMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string }
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
}
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('message', handleAuthMessage)
document.addEventListener('visibilitychange', handleWindowFocus)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('message', handleAuthMessage)
document.removeEventListener('visibilitychange', handleWindowFocus)
}
}, [language, theme])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, pathname, router])
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme
if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}, [theme, hydrated])
const value = useMemo(
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
[language, theme, companyName],
)
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
@@ -1,45 +0,0 @@
'use client'
import { useState } from 'react'
export function PricingToggle({
onToggle,
}: {
onToggle: (isAnnual: boolean) => void
}) {
const [isAnnual, setIsAnnual] = useState(false)
const handleToggle = () => {
const newValue = !isAnnual
setIsAnnual(newValue)
onToggle(newValue)
}
return (
<div className="flex justify-center">
<div className="inline-flex items-center gap-4 rounded-full border border-stone-200 bg-white/80 p-1 dark:border-blue-800 dark:bg-blue-950/40">
<button
onClick={() => !isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
!isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Monthly
</button>
<button
onClick={() => isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Annual
<span className="ml-2 text-xs font-bold text-orange-200 dark:text-orange-300">-20%</span>
</button>
</div>
</div>
)
}
@@ -1,86 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Stat {
label: string
value: number
suffix?: string
}
interface StatsStripProps {
stats: Stat[]
kicker?: string
}
function Counter({ value, suffix = '' }: { value: number; suffix?: string }) {
const [count, setCount] = useState(0)
const ref = useRef<HTMLParagraphElement>(null)
const hasAnimated = useRef(false)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !hasAnimated.current) {
hasAnimated.current = true
const duration = 2000
const steps = 60
const increment = value / steps
let current = 0
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
current = Math.floor(value * progress)
setCount(current)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
animate()
}
},
{ threshold: 0.1 }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => {
if (ref.current) {
observer.unobserve(ref.current)
}
}
}, [value])
return (
<p ref={ref} className="text-3xl font-black text-blue-950 dark:text-white sm:text-4xl">
{count.toLocaleString()}
{suffix}
</p>
)
}
export function StatsStrip({ stats, kicker = 'BY THE NUMBERS' }: StatsStripProps) {
return (
<section className="site-section">
<div className="site-panel">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">Our Impact</h2>
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col">
<Counter value={stat.value} suffix={stat.suffix} />
<p className="mt-4 text-sm font-semibold text-stone-600 dark:text-stone-300">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
)
}
@@ -1,116 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Testimonial {
quote: string
author: string
role: string
company?: string
image?: string
}
interface TestimonialsSectionProps {
testimonials: Testimonial[]
kicker?: string
title?: string
}
export function TestimonialsSection({
testimonials,
kicker = 'WHAT CUSTOMERS SAY',
title = 'Testimonials',
}: TestimonialsSectionProps) {
const [activeIndex, setActiveIndex] = useState(0)
const autoScrollRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
autoScrollRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => {
if (autoScrollRef.current) clearInterval(autoScrollRef.current)
}
}, [testimonials.length])
const handlePrev = () => {
setActiveIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
}
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}
if (!testimonials || testimonials.length === 0) return null
return (
<section className="site-section">
<div className="flex flex-col">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">{title}</h2>
<div className="mt-12 relative">
<div className="site-panel">
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-lg leading-8 text-blue-950 dark:text-stone-200 sm:text-xl">
"{testimonials[activeIndex].quote}"
</p>
<div className="mt-8 flex flex-col gap-2">
<p className="font-bold text-blue-950 dark:text-white">{testimonials[activeIndex].author}</p>
<p className="text-sm text-stone-600 dark:text-stone-400">
{testimonials[activeIndex].role}
{testimonials[activeIndex].company && ` at ${testimonials[activeIndex].company}`}
</p>
</div>
</div>
{testimonials[activeIndex].image && (
<img
src={testimonials[activeIndex].image}
alt={testimonials[activeIndex].author}
className="h-16 w-16 rounded-full object-cover"
/>
)}
</div>
</div>
{testimonials.length > 1 && (
<div className="mt-6 flex justify-center gap-4">
<button
onClick={handlePrev}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Previous testimonial"
>
Prev
</button>
<div className="flex items-center gap-2">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`h-2 w-2 rounded-full transition ${
index === activeIndex ? 'bg-orange-600 dark:bg-orange-500' : 'bg-stone-300 dark:bg-stone-600'
}`}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
<button
onClick={handleNext}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Next testimonial"
>
Next
</button>
</div>
)}
</div>
</div>
</section>
)
}
@@ -1,234 +0,0 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import Image from 'next/image'
import Link from 'next/link'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function WorkspaceTabs() {
const { language, theme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const copy = {
en: {
badge: 'Unified Workspace',
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
intro:
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
sectionBadge: 'RentalDriveGo Marketplace',
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
exploreVehicles: 'Explore vehicles',
viewPricing: 'View pricing',
nowServing: 'Now serving',
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
promoBody:
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
customerPaths: 'Customer Paths',
ownerSignIn: 'Owner sign in',
pricing: 'Pricing',
workspaceAreas: 'Workspace Areas',
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
},
fr: {
badge: 'Espace unifié',
title: 'Un point dentrée sur le port 3000 pour la découverte marketplace, les opérations de lentreprise et ladministration de la plateforme.',
intro:
'Les applications restent des interfaces Next.js séparées en interne, mais la barre de navigation mène directement à chaque espace sans avoir à changer de port manuellement.',
sectionBadge: 'Marketplace RentalDriveGo',
sectionTitle: 'Parcourez les flottes locales sans perdre lidentité de lentreprise qui gère la location.',
sectionBodyA: 'La découverte commence ici. La réservation et le paiement se font sur le site de réservation propre à chaque entreprise.',
sectionBodyB: 'Les clients accèdent à ce site de réservation seulement après avoir choisi un véhicule dans la flotte dune entreprise.',
exploreVehicles: 'Explorer les véhicules',
viewPricing: 'Voir les tarifs',
nowServing: 'Disponible',
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et laccès à la réservation restent ancrés dans la marketplace.',
promoBody:
'Lespace entreprise et les opérations de la plateforme restent accessibles depuis la navigation, tandis que la réservation de marque commence après le choix dun véhicule.',
customerPaths: 'Parcours client',
ownerSignIn: 'Connexion propriétaire',
pricing: 'Tarifs',
workspaceAreas: 'Espaces de travail',
workspaceA: 'Espace entreprise pour la flotte, l’équipe, la facturation et lanalyse.',
workspaceB: 'Opérations plateforme pour le support global et la supervision multi-entreprises.',
workspaceC: 'Le transfert vers la réservation de marque commence seulement après le choix dun véhicule précis.',
},
ar: {
badge: 'مساحة موحدة',
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
intro:
'ما زالت التطبيقات تعمل كواجهات Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل من دون الحاجة إلى البحث عن المنافذ.',
sectionBadge: 'سوق RentalDriveGo',
sectionTitle: 'تصفح أساطيل التأجير المحلية مع الحفاظ على هوية الشركة التي تدير الحجز.',
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
exploreVehicles: 'استكشف السيارات',
viewPricing: 'عرض الأسعار',
nowServing: 'المتاح الآن',
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
promoBody:
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
customerPaths: 'مسارات العملاء',
ownerSignIn: 'دخول المالك',
pricing: 'الأسعار',
workspaceAreas: 'مساحات العمل',
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
workspaceB: 'عمليات المنصة للدعم والإشراف على الشركات على مستوى المنصة.',
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
},
}[language]
return (
<main className={`min-h-screen transition-colors ${
theme === 'dark'
? 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.20),transparent_28%),linear-gradient(180deg,#0f1f4a,#112d6e)]'
: 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.08),transparent_28%),linear-gradient(180deg,#f5f8ff,white)]'
}`}>
<div className="shell py-16 sm:py-20">
<div className="max-w-4xl">
<div className="flex items-center gap-4">
<Link href="/" aria-label="RentalDriveGo home">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={72}
height={72}
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
theme === 'dark' ? 'border border-orange-500/40 bg-blue-950' : 'border border-orange-200 bg-white'
}`}
/>
</Link>
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.badge}
</p>
</div>
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.title}
</h1>
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.intro}
</p>
</div>
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
theme === 'dark'
? 'border-blue-900 bg-blue-950 shadow-black/20'
: 'border-stone-200 bg-white shadow-stone-200/40'
}`}>
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.sectionBadge}
</p>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<h2 className={`text-2xl font-black tracking-tight ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.sectionTitle}
</h2>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyA}
</p>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyB}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/features"
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'bg-orange-400 text-blue-950 hover:bg-orange-300'
: 'bg-blue-950 text-white hover:bg-blue-900/40'
}`}
>
{copy.exploreVehicles}
</Link>
<Link
href="/pricing"
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'border-blue-800 text-slate-200 hover:border-blue-600 hover:bg-blue-900/40'
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
}`}
>
{copy.viewPricing}
</Link>
</div>
</div>
</div>
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
theme === 'dark' ? 'bg-blue-950' : 'bg-stone-50'
}`}>
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#06132e,#0d1b38)] p-8 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-300">{copy.nowServing}</p>
<h3 className="mt-4 text-3xl font-black tracking-tight">
{copy.promoTitle}
</h3>
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
{copy.promoBody}
</p>
</div>
<div className="grid gap-4">
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.customerPaths}</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/features" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles}
</Link>
<a className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href={`${dashboardUrl}/sign-in`}>
{copy.ownerSignIn}
</a>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href="/pricing">
{copy.pricing}
</Link>
</div>
</div>
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.workspaceAreas}</p>
<ul className={`mt-4 space-y-3 text-sm ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
<li>{copy.workspaceA}</li>
<li>{copy.workspaceB}</li>
<li>{copy.workspaceC}</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,59 @@
.link {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
gap: var(--space-2);
font-weight: 650;
text-underline-offset: 0.18em;
}
.inline {
min-block-size: auto;
font-weight: inherit;
text-decoration-thickness: 0.08em;
}
.navigation {
color: var(--text-primary);
text-decoration: none;
}
.navigation:hover {
color: var(--link-hover);
text-decoration: underline;
}
.standalone {
text-decoration: none;
}
.standalone:hover {
text-decoration: underline;
}
.button-primary,
.button-conversion {
justify-content: center;
padding-block: var(--space-3);
padding-inline: var(--space-5);
border-radius: var(--radius-sm);
color: var(--text-inverse);
text-decoration: none;
}
.button-primary {
background: var(--interactive-primary);
}
.button-primary:hover {
background: var(--interactive-primary-hover);
color: var(--text-inverse);
}
.button-conversion {
background: var(--interactive-conversion);
}
.button-conversion:hover {
background: var(--interactive-conversion-hover);
color: var(--text-inverse);
}
.muted {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.disabled {
cursor: not-allowed;
color: var(--control-disabled-text);
text-decoration: none;
}
@@ -0,0 +1,76 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import styles from './ActionLink.module.css';
type LinkVariant =
| 'inline'
| 'navigation'
| 'standalone'
| 'button-primary'
| 'button-conversion'
| 'muted';
interface ActionLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
href: string;
children: ReactNode;
variant?: LinkVariant;
icon?: ApprovedIconName;
external?: boolean;
newTab?: boolean;
disabledReason?: string;
}
export function ActionLink({
href,
children,
variant = 'inline',
icon,
external = false,
newTab = false,
disabledReason,
className,
...props
}: ActionLinkProps) {
if (disabledReason) {
return (
<span
className={classNames(styles.link, styles[variant], styles.disabled, className)}
aria-disabled="true"
title={disabledReason}
>
<span>{children}</span>
<span className="visually-hidden">: {disabledReason}</span>
</span>
);
}
const isExternal = external || /^https?:\/\//.test(href);
const trailingIcon = icon ?? (isExternal ? 'external' : undefined);
const content = (
<>
<span>{children}</span>
{trailingIcon ? (
<Icon
name={trailingIcon}
size="sm"
directionBehavior={
trailingIcon === 'chevron-forward' || trailingIcon === 'arrow-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
{newTab ? <span className="visually-hidden"> (opens in a new tab)</span> : null}
</>
);
const shared = {
className: classNames(styles.link, styles[variant], className),
...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
...props,
};
return (
<a href={href} {...shared}>
{content}
</a>
);
}
@@ -0,0 +1,139 @@
.button {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 750;
line-height: 1.25;
text-align: center;
text-decoration: none;
transition:
background-color var(--duration-fast) var(--easing-productive),
border-color var(--duration-fast) var(--easing-productive),
color var(--duration-fast) var(--easing-productive),
transform var(--duration-fast) var(--easing-productive);
}
.button:active:not(:disabled) {
transform: translateY(1px);
}
.button:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.small {
min-block-size: 2.5rem;
padding-block: var(--space-2);
padding-inline: var(--space-3);
font-size: var(--type-label-size);
}
.medium {
padding-block: var(--space-3);
padding-inline: var(--space-5);
}
.large {
min-block-size: 3.25rem;
padding-block: var(--space-3);
padding-inline: var(--space-6);
font-size: var(--type-body-large-size);
}
.primary {
background: var(--interactive-primary);
color: var(--text-inverse);
}
.primary:hover:not(:disabled) {
background: var(--interactive-primary-hover);
}
.conversion {
background: var(--interactive-conversion);
color: var(--text-inverse);
}
.conversion:hover:not(:disabled) {
background: var(--interactive-conversion-hover);
}
.secondary {
border-color: var(--interactive-primary);
background: var(--surface-primary);
color: var(--interactive-primary);
}
.secondary:hover:not(:disabled) {
background: var(--status-info-surface);
}
.tertiary {
border-color: var(--border-standard);
background: var(--surface-elevated);
color: var(--text-primary);
}
.tertiary:hover:not(:disabled) {
border-color: var(--border-strong);
background: var(--surface-muted);
}
.ghost {
background: transparent;
color: var(--text-primary);
}
.ghost:hover:not(:disabled) {
background: var(--surface-muted);
}
.destructive {
background: var(--interactive-danger);
color: var(--text-inverse);
}
.destructive:hover:not(:disabled) {
filter: brightness(0.92);
}
.link {
min-block-size: 2.75rem;
padding-inline: 0;
background: transparent;
color: var(--link-default);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.link:hover:not(:disabled) {
color: var(--link-hover);
}
.iconOnly {
aspect-ratio: 1;
padding: var(--space-3);
}
.fullWidth {
inline-size: 100%;
}
.loading {
position: relative;
}
.spinner {
position: absolute;
animation: spin 0.9s linear infinite;
}
.loadingContent {
display: inline-flex;
align-items: center;
gap: var(--space-2);
opacity: 0;
}
.label {
min-inline-size: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
.spinner {
animation-duration: 1.8s;
}
}
@media (forced-colors: active) {
.button {
border-color: ButtonText;
}
}
@@ -0,0 +1,97 @@
'use client';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import styles from './Button.module.css';
export type ButtonIntent =
| 'primary'
| 'conversion'
| 'secondary'
| 'tertiary'
| 'ghost'
| 'destructive'
| 'link';
export type ButtonSize = 'small' | 'medium' | 'large';
interface CommonButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
intent?: ButtonIntent;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
leadingIcon?: ApprovedIconName;
trailingIcon?: ApprovedIconName;
children: ReactNode;
}
interface IconOnlyButtonProps extends Omit<
CommonButtonProps,
'children' | 'leadingIcon' | 'trailingIcon'
> {
icon: ApprovedIconName;
'aria-label': string;
children?: never;
}
export type ButtonProps = CommonButtonProps | IconOnlyButtonProps;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
const {
intent = 'primary',
size = 'medium',
loading = false,
fullWidth = false,
disabled = false,
className,
type = 'button',
...rest
} = props;
const iconOnly = 'icon' in props;
const content = iconOnly ? (
<Icon name={props.icon} />
) : (
<>
{props.leadingIcon ? <Icon name={props.leadingIcon} /> : null}
<span className={styles.label}>{props.children}</span>
{props.trailingIcon ? (
<Icon
name={props.trailingIcon}
directionBehavior={
props.trailingIcon === 'arrow-forward' || props.trailingIcon === 'chevron-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
</>
);
const nativeProps = { ...rest } as ButtonHTMLAttributes<HTMLButtonElement>;
delete (nativeProps as Partial<CommonButtonProps>).leadingIcon;
delete (nativeProps as Partial<CommonButtonProps>).trailingIcon;
delete (nativeProps as Partial<IconOnlyButtonProps>).icon;
return (
<button
ref={ref}
type={type}
className={classNames(
styles.button,
styles[intent],
styles[size],
iconOnly && styles.iconOnly,
fullWidth && styles.fullWidth,
loading && styles.loading,
className,
)}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-loading={loading || undefined}
{...nativeProps}
>
{loading ? <Icon name="spinner" className={styles.spinner} /> : null}
<span className={loading ? styles.loadingContent : undefined}>{content}</span>
</button>
);
});
@@ -0,0 +1,28 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import styles from './SiteHeader.module.css';
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
const pathname = usePathname();
return (
<a
className={styles.brand}
href={localizedPath('home', locale)}
aria-label={label}
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
>
<Image
src="/rentaldrivego.jpeg"
alt=""
width={36}
height={36}
className={styles.mark}
priority
/>
<span className={styles.brandName}>RentalDriveGo</span>
</a>
);
}
@@ -0,0 +1,38 @@
.field {
display: grid;
gap: var(--space-1);
}
.label {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 700;
}
.select {
min-block-size: var(--size-touch-min);
min-inline-size: 7.25rem;
max-inline-size: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3) var(--space-8);
background: var(--surface-elevated);
color: var(--text-primary);
}
.select:hover {
border-color: var(--interactive-primary);
}
.compactLabel {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,29 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import styles from './SiteHeader.module.css';
interface DisabledActionProps {
label: string;
reason: string;
pendingLabel?: string;
conversion?: boolean;
}
export function DisabledAction({
label,
reason,
pendingLabel,
conversion = false,
}: DisabledActionProps) {
return (
<span
className={conversion ? styles.conversionDisabled : styles.actionDisabled}
role="link"
aria-disabled="true"
aria-label={`${label}. ${reason}`}
title={reason}
>
<span>{label}</span>
{pendingLabel ? <StatusBadge>{pendingLabel}</StatusBadge> : null}
</span>
);
}
@@ -0,0 +1,57 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import styles from './SiteHeader.module.css';
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
interface HeaderNavigationProps {
locale: Locale;
label: string;
labels: Record<HeaderNavId, string>;
onNavigate?: () => void;
mobile?: boolean;
}
const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing', 'faq'];
export function HeaderNavigation({
locale,
label,
labels,
onNavigate,
mobile = false,
}: HeaderNavigationProps) {
const pathname = usePathname();
const currentRoute = routeIdFromPathname(pathname);
const [currentHash, setCurrentHash] = useState('');
useEffect(() => {
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
updateHash();
window.addEventListener('hashchange', updateHash);
return () => window.removeEventListener('hashchange', updateHash);
}, []);
const home = localizedPath('home', locale);
return (
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
<ul>
{navigationIds.map((id) => (
<li key={id}>
<a
href={`${home}#${id}`}
aria-current={currentRoute === 'home' && currentHash === id ? 'location' : undefined}
onClick={onNavigate}
>
{labels[id]}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,52 @@
'use client';
import { persistLocale } from '@/lib/localization/client';
import {
localeSwitchUrl,
locales,
routeIdFromPathname,
type Locale,
} from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import styles from './Controls.module.css';
interface LocaleSelectorProps {
currentLocale: Locale;
label: string;
localeNames: Record<Locale, string>;
compact?: boolean;
}
export function LocaleSelector({
currentLocale,
label,
localeNames,
compact = false,
}: LocaleSelectorProps) {
const pathname = usePathname();
const routeId = routeIdFromPathname(pathname) ?? 'home';
return (
<label className={styles.field}>
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
<select
className={styles.select}
aria-label={label}
value={currentLocale}
onChange={(event) => {
const targetLocale = event.target.value as Locale;
persistLocale(targetLocale);
window.location.assign(
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
);
}}
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</label>
);
}
@@ -0,0 +1,15 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { useParams } from 'next/navigation';
import { LoadingSkeleton, StateShell } from './StateShell';
export function LocalizedLoading() {
const params = useParams<{ locale?: string }>();
const messages = getClientShellMessages(params?.locale);
return (
<StateShell title={messages.states.loadingTitle} body={messages.states.loadingBody} status>
<LoadingSkeleton />
</StateShell>
);
}
@@ -0,0 +1,20 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { isLocale, localizedPath } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { StateAction, StateActions, StateShell } from './StateShell';
export function LocalizedNotFound() {
const pathname = usePathname();
const localeValue = pathname.split('/').filter(Boolean)[0];
const locale = isLocale(localeValue) ? localeValue : 'en';
const messages = getClientShellMessages(locale);
return (
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
<StateActions>
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
</StateActions>
</StateShell>
);
}
@@ -0,0 +1,146 @@
'use client';
import { ActionLink } from '@/components/actions/ActionLink';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import type { ShellMessages } from '@/lib/localization/messages';
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config';
import { useEffect, useRef, useState } from 'react';
import { HeaderNavigation } from './HeaderNavigation';
import { LocaleSelector } from './LocaleSelector';
import styles from './SiteHeader.module.css';
import { ThemeSelector } from './ThemeSelector';
function signInUrl(locale: Locale): string {
return localizedPath('sign-in', locale);
}
interface MobileNavigationProps {
locale: Locale;
messages: ShellMessages;
themePreference: ThemePreference;
demoEnabled: boolean;
}
export function MobileNavigation({
locale,
messages,
themePreference,
demoEnabled,
}: MobileNavigationProps) {
const [open, setOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
document.body.dataset.navigationOpen = 'true';
closeRef.current?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
return () => {
delete document.body.dataset.navigationOpen;
};
}, [open]);
const close = () => {
dialogRef.current?.close();
setOpen(false);
delete document.body.dataset.navigationOpen;
window.requestAnimationFrame(() => triggerRef.current?.focus());
};
return (
<div className={styles.mobileNavigation}>
<button
ref={triggerRef}
type="button"
className={styles.menuButton}
aria-label={messages.header.menuOpen}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span aria-hidden="true" className={styles.menuIcon}>
<span />
<span />
<span />
</span>
</button>
<dialog
ref={dialogRef}
className={styles.drawer}
aria-label={messages.header.navigationLabel}
onClose={() => {
setOpen(false);
delete document.body.dataset.navigationOpen;
}}
onCancel={(event) => {
event.preventDefault();
close();
}}
>
<div className={styles.drawerPanel}>
<div className={styles.drawerHeader}>
<strong>RentalDriveGo</strong>
<button
ref={closeRef}
type="button"
className={styles.closeButton}
aria-label={messages.header.menuClose}
onClick={close}
>
<span aria-hidden="true">×</span>
</button>
</div>
<HeaderNavigation
locale={locale}
label={messages.header.navigationLabel}
labels={messages.header.nav}
mobile
onNavigate={close}
/>
<div
className={styles.drawerControls}
role="group"
aria-label={messages.header.controlsLabel}
>
<LocaleSelector
currentLocale={locale}
label={messages.controls.language}
localeNames={messages.controls.localeNames}
/>
<ThemeSelector
initialPreference={themePreference}
label={messages.controls.theme}
statusTemplate={messages.controls.themeStatus}
optionLabels={messages.controls.themes}
/>
</div>
<div className={styles.drawerActions}>
<ActionLink href={signInUrl(locale)} variant="button-primary">
{messages.header.login}
</ActionLink>
<DemoTrigger
label={messages.header.demo}
source="mobile-header"
fullWidth
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
</div>
</div>
</dialog>
</div>
);
}
@@ -0,0 +1,118 @@
.footer {
border-block-start: 1px solid var(--border-standard);
background: var(--surface-primary);
}
.inner {
display: grid;
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-block: var(--space-12) var(--space-6);
padding-inline: var(--layout-gutter);
gap: var(--space-10);
}
.brandColumn {
display: grid;
align-content: start;
gap: var(--space-4);
max-inline-size: 32rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
}
.mark {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
padding-inline: var(--space-2);
place-items: center;
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
font-size: var(--type-caption-size);
}
.tagline,
.releaseNotice {
margin: 0;
color: var(--text-secondary);
}
.groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: var(--space-8);
}
.group h2 {
margin-block-end: var(--space-3);
font-size: var(--type-label-size);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
html[dir='rtl'] .group h2 {
letter-spacing: normal;
text-transform: none;
}
.group ul {
display: grid;
margin: 0;
padding: 0;
gap: var(--space-2);
list-style: none;
}
.group a,
.pendingItem {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
text-decoration: none;
}
.group a:hover {
color: var(--interactive-primary);
text-decoration: underline;
}
.pendingItem {
flex-wrap: wrap;
opacity: var(--opacity-disabled);
}
.legalLink {
flex-wrap: wrap;
}
.bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block-start: var(--space-6);
border-block-start: 1px solid var(--border-standard);
color: var(--text-subdued);
font-size: var(--type-caption-size);
}
@media (min-width: 768px) {
.inner {
grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 2fr);
}
.bottom {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,161 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ShellMessages } from '@/lib/localization/messages';
import styles from './SiteFooter.module.css';
function signInUrl(locale: Locale): string {
return localizedPath('sign-in', locale);
}
interface SiteFooterProps {
locale: Locale;
messages: ShellMessages;
demoEnabled: boolean;
}
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
}
function PendingRouteLink({
locale,
routeId,
label,
pending,
}: {
locale: Locale;
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
label: string;
pending: string;
}) {
return (
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</a>
);
}
function PendingItem({ label, pending }: { label: string; pending: string }) {
return (
<span className={styles.pendingItem} aria-disabled="true">
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</span>
);
}
export function SiteFooter({ locale, messages, demoEnabled }: SiteFooterProps) {
const footer = messages.footer;
const year = new Date().getUTCFullYear();
return (
<footer className={styles.footer} aria-label={footer.landmarkLabel}>
<div className={styles.inner}>
<div className={styles.brandColumn}>
<div className={styles.brand}>
<span aria-hidden="true" className={styles.mark}>
RDG
</span>
<span>RentalDriveGo</span>
</div>
<p className={styles.tagline}>{footer.tagline}</p>
<p className={styles.releaseNotice}>{footer.releaseNotice}</p>
</div>
<div className={styles.groups}>
<section className={styles.group} aria-labelledby="footer-product">
<h2 id="footer-product">{footer.groups.product}</h2>
<ul>
<li>
<HashLink locale={locale} hash="workflow">
{footer.links.workflow}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="modules">
{footer.links.modules}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="pricing">
{footer.links.pricing}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="faq">
{footer.links.faq}
</HashLink>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-company">
<h2 id="footer-company">{footer.groups.company}</h2>
<ul>
<li>
<PendingItem label={footer.links.contact} pending={footer.pending} />
</li>
<li>
<DemoTrigger
label={footer.links.demo}
source="footer"
size="small"
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-account">
<h2 id="footer-account">{footer.groups.account}</h2>
<ul>
<li>
<a href={signInUrl(locale)}>
{footer.links.login}
</a>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-legal">
<h2 id="footer-legal">{footer.groups.legal}</h2>
<ul>
<li>
<PendingRouteLink
locale={locale}
routeId="privacy"
label={footer.links.privacy}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="terms"
label={footer.links.terms}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="accessibility"
label={footer.links.accessibility}
pending={footer.pending}
/>
</li>
</ul>
</section>
</div>
<div className={styles.bottom}>
<span>
{footer.copyrightPrefix} {year} RentalDriveGo
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,303 @@
.header {
position: sticky;
z-index: var(--layer-sticky);
inset-block-start: 0;
border-block-end: 1px solid var(--border-standard);
background: color-mix(in srgb, var(--surface-primary) 94%, transparent);
box-shadow: var(--shadow-subtle);
backdrop-filter: blur(var(--space-3));
}
.inner {
display: grid;
min-block-size: var(--header-min-block-size);
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-inline: var(--layout-gutter);
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5);
}
.brand {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
text-decoration: none;
white-space: nowrap;
}
.mark {
inline-size: 36px;
block-size: 36px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.navigation ul,
.drawerNavigation ul {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.navigation ul {
justify-content: center;
gap: var(--space-1);
}
.navigation a,
.drawerNavigation a {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-weight: 650;
text-decoration: none;
}
.navigation a {
padding-inline: var(--space-3);
}
.navigation a:hover,
.navigation a[aria-current],
.drawerNavigation a:hover,
.drawerNavigation a[aria-current] {
background: var(--surface-muted);
color: var(--interactive-primary);
}
.desktopControls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.actionDisabled,
.conversionDisabled {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3);
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
font-weight: 700;
opacity: var(--opacity-disabled);
white-space: nowrap;
}
.actionLink {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3);
color: var(--text-primary);
font-weight: 700;
white-space: nowrap;
text-decoration: none;
}
.actionLink:hover {
background: var(--interactive-primary);
color: var(--text-inverse);
border-color: var(--interactive-primary);
}
.conversionDisabled {
border-color: var(--interactive-conversion);
}
.mobileActions,
.mobileNavigation {
display: none;
}
.menuButton,
.closeButton {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
place-items: center;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
}
.menuIcon {
display: grid;
inline-size: var(--space-5);
gap: var(--space-1);
}
.menuIcon span {
display: block;
block-size: 2px;
border-radius: var(--radius-pill);
background: currentColor;
}
.drawer {
inline-size: min(88vw, 384px);
max-inline-size: none;
block-size: 100dvh;
max-block-size: none;
margin-block: 0;
margin-inline: 0 auto;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
}
html[dir='rtl'] .drawer {
margin-inline: auto 0;
}
.drawer::backdrop {
background: var(--overlay-scrim);
}
.drawerPanel {
display: flex;
block-size: 100%;
flex-direction: column;
gap: var(--space-6);
overflow-y: auto;
padding: var(--space-5);
background: var(--surface-elevated);
box-shadow: var(--shadow-overlay);
}
.drawerHeader {
display: flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.closeButton {
font-size: var(--type-heading-3-size);
line-height: 1;
}
.drawerNavigation ul {
flex-direction: column;
gap: var(--space-1);
}
.drawerNavigation a {
inline-size: 100%;
padding-inline: var(--space-3);
}
.drawerControls,
.drawerActions {
display: grid;
gap: var(--space-4);
}
.drawerActions {
margin-block-start: auto;
}
/*
* Preserve the full navigation on laptop and tablet landscape widths.
* Only the secondary controls collapse into the menu until the viewport
* becomes too narrow for the five primary destinations.
*/
@media (min-width: 900px) and (max-width: 1279px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 899px) {
.inner {
grid-template-columns: minmax(0, 1fr) auto;
}
.desktopNavigation,
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 559px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.mobileActions > .conversionDisabled {
display: none;
}
}
@media (forced-colors: active) {
.header {
backdrop-filter: none;
}
.mark,
.actionDisabled,
.conversionDisabled,
.menuButton,
.closeButton {
border: 1px solid CanvasText;
}
}
@media (max-width: 559px) {
.mobileDemoTrigger {
display: none;
}
}
@@ -0,0 +1,85 @@
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ShellMessages } from '@/lib/localization/messages';
import type { ThemePreference } from '@/lib/theme/config';
import { ActionLink } from '@/components/actions/ActionLink';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { BrandLink } from './BrandLink';
import { HeaderNavigation } from './HeaderNavigation';
import { LocaleSelector } from './LocaleSelector';
import { MobileNavigation } from './MobileNavigation';
import styles from './SiteHeader.module.css';
import { ThemeSelector } from './ThemeSelector';
function signInUrl(locale: Locale): string {
return localizedPath('sign-in', locale);
}
interface SiteHeaderProps {
locale: Locale;
messages: ShellMessages;
themePreference: ThemePreference;
demoEnabled: boolean;
}
export function SiteHeader({ locale, messages, themePreference, demoEnabled }: SiteHeaderProps) {
return (
<header className={styles.header}>
<div className={styles.inner}>
<BrandLink locale={locale} label={messages.brandLabel} />
<div className={styles.desktopNavigation}>
<HeaderNavigation
locale={locale}
label={messages.header.navigationLabel}
labels={messages.header.nav}
/>
</div>
<div
className={styles.desktopControls}
role="group"
aria-label={messages.header.controlsLabel}
>
<LocaleSelector
currentLocale={locale}
label={messages.controls.language}
localeNames={messages.controls.localeNames}
compact
/>
<ThemeSelector
initialPreference={themePreference}
label={messages.controls.theme}
statusTemplate={messages.controls.themeStatus}
optionLabels={messages.controls.themes}
compact
/>
<ActionLink href={signInUrl(locale)} variant="button-primary">
{messages.header.login}
</ActionLink>
<DemoTrigger
label={messages.header.demo}
source="header"
size="small"
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
</div>
<div className={styles.mobileActions}>
<DemoTrigger
label={messages.header.demo}
source="mobile-header"
size="small"
className={styles.mobileDemoTrigger}
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
<MobileNavigation
locale={locale}
messages={messages}
themePreference={themePreference}
demoEnabled={demoEnabled}
/>
</div>
</div>
</header>
);
}
@@ -0,0 +1,104 @@
.main {
display: grid;
min-block-size: calc(100dvh - var(--header-min-block-size));
place-items: center;
padding-block: var(--section-space);
padding-inline: var(--layout-gutter);
}
.panel {
display: grid;
inline-size: min(100%, var(--container-reading));
gap: var(--space-5);
padding: clamp(var(--space-6), 6vw, var(--space-12));
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
background: var(--surface-elevated);
box-shadow: var(--shadow-card);
text-align: start;
}
.panel h1 {
margin: 0;
font-size: var(--type-heading-1-size);
}
.panel p {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-body-large-size);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.action {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
border: 1px solid var(--interactive-primary);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-4);
background: var(--interactive-primary);
color: var(--text-inverse);
font-weight: 750;
text-decoration: none;
}
.action:hover {
background: var(--interactive-primary-hover);
color: var(--text-inverse);
}
.skeleton {
display: grid;
gap: var(--space-3);
}
.skeleton span {
display: block;
block-size: var(--space-4);
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--skeleton-base),
var(--skeleton-highlight),
var(--skeleton-base)
);
background-size: 200% 100%;
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
}
.skeleton span:first-child {
inline-size: 64%;
}
.skeleton span:last-child {
inline-size: 82%;
}
@keyframes skeleton-shift {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton span {
animation: none;
}
}
.content {
display: grid;
gap: var(--space-4);
}
@@ -0,0 +1,55 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import styles from './StateShell.module.css';
interface StateShellProps {
title: string;
body: string;
children?: ReactNode;
status?: boolean;
}
export function StateShell({ title, body, children, status = false }: StateShellProps) {
return (
<main id="main-content" className={styles.main} tabIndex={-1}>
<section
className={styles.panel}
role={status ? 'status' : undefined}
aria-live={status ? 'polite' : undefined}
>
<h1>{title}</h1>
<p>{body}</p>
{children ? <div className={styles.content}>{children}</div> : null}
</section>
</main>
);
}
export function StateActions({ children }: { children: ReactNode }) {
return <div className={styles.actions}>{children}</div>;
}
export function StateAction({ children, ...props }: ComponentPropsWithoutRef<'a'>) {
return (
<a className={styles.action} {...props}>
{children}
</a>
);
}
export function StateButton({ children, ...props }: ComponentPropsWithoutRef<'button'>) {
return (
<button className={styles.action} type="button" {...props}>
{children}
</button>
);
}
export function LoadingSkeleton() {
return (
<div className={styles.skeleton} aria-hidden="true">
<span />
<span />
<span />
</div>
);
}
@@ -0,0 +1,31 @@
'use client';
import { applyTheme, currentThemePreference } from '@/lib/theme/client';
import { themeStorageKey } from '@/lib/theme/config';
import { useEffect } from 'react';
export function ThemeController() {
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = () => {
if (currentThemePreference() === 'system') applyTheme('system');
};
const handleStorage = (event: StorageEvent) => {
if (event.key === themeStorageKey && event.newValue) {
const preference = event.newValue;
if (preference === 'light' || preference === 'dark' || preference === 'system') {
applyTheme(preference);
}
}
};
media.addEventListener('change', handleSystemChange);
window.addEventListener('storage', handleStorage);
return () => {
media.removeEventListener('change', handleSystemChange);
window.removeEventListener('storage', handleStorage);
};
}, []);
return null;
}
@@ -0,0 +1,74 @@
'use client';
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
import { useEffect, useId, useState } from 'react';
import styles from './Controls.module.css';
interface ThemeSelectorProps {
initialPreference: ThemePreference;
label: string;
statusTemplate: string;
optionLabels: Record<ThemePreference, string>;
compact?: boolean;
}
export function ThemeSelector({
initialPreference,
label,
statusTemplate,
optionLabels,
compact = false,
}: ThemeSelectorProps) {
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
const statusId = useId();
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (
event.key === themeStorageKey &&
(event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system')
) {
setPreference(event.newValue);
}
};
const handlePreference = (event: Event) => {
const preferenceEvent = event as CustomEvent<ThemePreference>;
setPreference(preferenceEvent.detail);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(themePreferenceEvent, handlePreference);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(themePreferenceEvent, handlePreference);
};
}, []);
const status = statusTemplate.replace('{value}', optionLabels[preference]);
return (
<label className={styles.field}>
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
<select
className={styles.select}
aria-label={label}
aria-describedby={statusId}
value={preference}
onChange={(event) => {
const nextPreference = event.target.value as ThemePreference;
setPreference(nextPreference);
persistTheme(nextPreference);
}}
>
{themePreferences.map((option) => (
<option key={option} value={option}>
{optionLabels[option]}
</option>
))}
</select>
<span id={statusId} className="visually-hidden" aria-live="polite">
{status}
</span>
</label>
);
}
@@ -0,0 +1,96 @@
'use client'
import { localizedPath, type Locale } from '@/lib/localization/config'
import Image from 'next/image'
import type { ReactNode } from 'react'
interface AuthShellProps {
locale: Locale
title: string
subtitle?: string
children: ReactNode
}
export function AuthShell({ locale, title, subtitle, children }: AuthShellProps) {
return (
<main
id="main-content"
tabIndex={-1}
style={{
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
minHeight: 'calc(100vh - 80px)',
}}
>
<div style={{ width: '100%', maxWidth: '28rem' }}>
<div style={{ marginBottom: '2rem', textAlign: 'center' }}>
<a href={localizedPath('home', locale)}>
<Image
src="/rentaldrivego.jpeg"
alt="RentalDriveGo"
width={96}
height={96}
priority
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
padding: '0.375rem',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
}}
/>
</a>
<p
style={{
marginTop: '1rem',
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.28em',
color: 'var(--color-accent)',
}}
>
RentalDriveGo
</p>
<h1
style={{
marginTop: '0.5rem',
fontSize: '1.75rem',
fontWeight: 800,
letterSpacing: '-0.03em',
color: 'var(--color-heading)',
}}
>
{title}
</h1>
{subtitle ? (
<p
style={{
marginTop: '0.5rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{subtitle}
</p>
) : null}
</div>
<section
style={{
borderRadius: '2rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
padding: '2rem',
boxShadow: '0 30px 80px rgba(28,25,23,0.06)',
}}
>
{children}
</section>
</div>
</main>
)
}
@@ -0,0 +1,189 @@
'use client'
import { Button } from '@/components/actions/Button'
import { TextInput } from '@/components/forms/TextInput'
import { FormField } from '@/components/forms/FormField'
import { AuthShell } from '@/components/auth/AuthShell'
import { API_BASE } from '@/lib/api'
import { localizedPath, type Locale } from '@/lib/localization/config'
import { useState } from 'react'
interface Dict {
title: string
subtitle: string
email: string
emailPlaceholder: string
submit: string
submitting: string
backToLogin: string
successTitle: string
successBody: string
error: string
}
const dicts: Record<string, Dict> = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Send reset link',
submitting: 'Sending…',
backToLogin: 'Back to sign in',
successTitle: 'Check your email',
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
error: 'Something went wrong. Please try again.',
},
fr: {
title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Envoyer le lien',
submitting: 'Envoi…',
backToLogin: 'Retour à la connexion',
successTitle: 'Vérifiez votre email',
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
error: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
submit: 'إرسال رابط إعادة التعيين',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}
export function ForgotPasswordForm({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const [empRes, adminRes] = await Promise.all([
fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
fetch(`${API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
])
if (!empRes.ok && !adminRes.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
const signInHref = localizedPath('sign-in', locale)
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
{sent ? (
<div style={{ textAlign: 'center' }}>
<div
style={{
margin: '0 auto 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
background: 'var(--color-success-bg, #ecfdf5)',
}}
>
<svg
style={{ width: '1.75rem', height: '1.75rem', color: 'var(--color-success-text, #059669)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
{dict.successTitle}
</h2>
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
{dict.successBody}
</p>
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{error ? (
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
<FormField id="forgot-email" label={dict.email} required>
<TextInput
id="forgot-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoComplete="email"
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.submitting : dict.submit}
</Button>
</form>
)}
<div
style={{
marginTop: '1.5rem',
borderTop: '1px solid var(--color-border)',
paddingTop: '1.5rem',
textAlign: 'center',
fontSize: '0.875rem',
}}
>
<a
href={signInHref}
style={{
fontWeight: 600,
color: 'var(--color-heading)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.backToLogin}
</a>
</div>
</AuthShell>
)
}
@@ -0,0 +1,320 @@
'use client';
import { Button } from '@/components/actions/Button';
import { FormField } from '@/components/forms/FormField';
import { AuthShell } from '@/components/auth/AuthShell';
import { API_BASE } from '@/lib/api';
import { localizedPath, type Locale } from '@/lib/localization/config';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState } from 'react';
interface Dict {
title: string;
subtitle: string;
newPassword: string;
confirmPassword: string;
submit: string;
submitting: string;
backToLogin: string;
successTitle: string;
successBody: string;
signIn: string;
errorMismatch: string;
errorShort: string;
errorInvalidToken: string;
errorGeneric: string;
noToken: string;
requestNew: string;
}
const dicts: Record<string, Dict> = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: 'Choisissez un mot de passe fort pour votre compte.',
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحسابك.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
requestNew: 'طلب رابط جديد',
},
};
function ResetPasswordContent({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict;
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (password.length < 8) {
setError(dict.errorShort);
return;
}
if (password !== confirm) {
setError(dict.errorMismatch);
return;
}
setLoading(true);
setError(null);
try {
// Try employee endpoint first, fall back to admin
let res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
if (!res.ok) {
res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
}
const json = await res.json();
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken);
throw new Error(dict.errorGeneric);
}
setDone(true);
} catch (error: unknown) {
setError(error instanceof Error ? error.message : dict.errorGeneric);
} finally {
setLoading(false);
}
}
const signInHref = localizedPath('sign-in', locale);
const forgotHref = localizedPath('forgot-password', locale);
if (!token) {
return (
<AuthShell locale={locale} title={dict.title}>
<p
style={{
textAlign: 'center',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.noToken}
</p>
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
<a href={forgotHref}>
<Button intent="primary">{dict.requestNew}</Button>
</a>
</div>
</AuthShell>
);
}
if (done) {
return (
<AuthShell locale={locale} title={dict.title}>
<div style={{ textAlign: 'center' }}>
<div
style={{
margin: '0 auto 1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
background: 'var(--color-success-bg, #ecfdf5)',
}}
>
<svg
style={{
width: '1.75rem',
height: '1.75rem',
color: 'var(--color-success-text, #059669)',
}}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-heading)' }}>
{dict.successTitle}
</h2>
<p
style={{
marginTop: '0.5rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.successBody}
</p>
<div style={{ marginTop: '1rem' }}>
<a href={signInHref}>
<Button intent="primary" fullWidth>
{dict.signIn}
</Button>
</a>
</div>
</div>
</AuthShell>
);
}
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
{error ? (
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
<FormField id="reset-password" label={dict.newPassword} required>
<input
id="reset-password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<FormField id="reset-confirm" label={dict.confirmPassword} required>
<input
id="reset-confirm"
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.submitting : dict.submit}
</Button>
</form>
<div
style={{
marginTop: '1.5rem',
borderTop: '1px solid var(--color-border)',
paddingTop: '1.5rem',
textAlign: 'center',
fontSize: '0.875rem',
}}
>
<a
href={signInHref}
style={{
fontWeight: 600,
color: 'var(--color-heading)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.backToLogin}
</a>
</div>
</AuthShell>
);
}
export function ResetPasswordForm({ locale }: { locale: Locale }) {
return (
<Suspense>
<ResetPasswordContent locale={locale} />
</Suspense>
);
}
@@ -0,0 +1,374 @@
'use client';
import { Button } from '@/components/actions/Button';
import { TextInput } from '@/components/forms/TextInput';
import { FormField } from '@/components/forms/FormField';
import { AuthShell } from '@/components/auth/AuthShell';
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api';
import { localizedPath, type Locale } from '@/lib/localization/config';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
interface Dict {
title: string;
subtitle: string;
email: string;
emailPlaceholder: string;
password: string;
signIn: string;
signingIn: string;
verify: string;
verifying: string;
authCode: string;
enterCode: string;
totpPlaceholder: string;
back: string;
forgotPassword: string;
invalidCredentials: string;
passwordNotSet: string;
tooManyRequests: string;
emailNotVerified: string;
unexpectedError: string;
}
const dicts: Record<string, Dict> = {
en: {
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.',
passwordNotSet:
'No password set yet. Check your invitation email or use "Forgot your password?"',
tooManyRequests: 'Too many attempts. Please try again later.',
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
passwordNotSet:
"Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »",
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
};
export function SignInForm({ locale }: { locale: Locale }) {
const dict = (dicts[locale] ?? dicts.en) as Dict;
const searchParams = useSearchParams();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [step, setStep] = useState<'credentials' | 'totp'>('credentials');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestedPortal = searchParams.get('portal');
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
const preferAdminAuth = requestedPortal === 'admin';
async function handleCredentials(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const tryAdminLogin = async () => {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${window.location.origin}/admin/dashboard`;
return true;
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp');
return true;
}
if (adminRes.status === 429) {
setError(dict.tooManyRequests);
return true;
}
return false;
};
const tryEmployeeLogin = async () => {
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
const empJson = await empRes.json();
if (empRes.ok && empJson?.data?.employee) {
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee));
const prefLang = empJson.data.employee?.preferredLanguage;
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`;
}
}
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'));
window.location.replace(employeeRedirect);
return true;
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet);
return true;
}
if (empJson?.error === 'email_not_verified') {
setError(dict.emailNotVerified);
return true;
}
if (empRes.status === 429) {
setError(dict.tooManyRequests);
return true;
}
return false;
};
if (preferAdminAuth) {
if (await tryAdminLogin()) return;
setError(dict.invalidCredentials);
return;
}
if (await tryEmployeeLogin()) return;
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError);
} finally {
setLoading(false);
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const normalizedCode = totpCode.trim().toUpperCase();
const secondFactor = /^\d{6}$/.test(normalizedCode)
? { totpCode: normalizedCode }
: { recoveryCode: normalizedCode };
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password, ...secondFactor }),
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${window.location.origin}/admin/dashboard`;
return;
}
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError);
} finally {
setLoading(false);
}
}
const hrefBase = localizedPath('forgot-password', locale);
return (
<AuthShell locale={locale} title={dict.title} subtitle={dict.subtitle}>
{error ? (
<div
style={{
marginBottom: '1.25rem',
borderRadius: '1.5rem',
border: '1px solid var(--color-error-border, #fecaca)',
background: 'var(--color-error-bg, #fef2f2)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-error-text, #b91c1c)',
}}
>
{error}
</div>
) : null}
{step === 'credentials' ? (
<form
onSubmit={handleCredentials}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
<FormField id="signin-email" label={dict.email} required>
<TextInput
id="signin-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoComplete="email"
/>
</FormField>
<FormField id="signin-password" label={dict.password} required>
<input
id="signin-password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
style={{
width: '100%',
minHeight: '2.875rem',
padding: 'var(--space-3) var(--space-4)',
border: '1px solid var(--border-strong)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-primary)',
color: 'var(--text-primary)',
fontSize: '0.875rem',
}}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.signingIn : dict.signIn}
</Button>
<div style={{ textAlign: 'center' }}>
<a
href={hrefBase}
style={{
fontSize: '0.875rem',
color: 'var(--color-text-tertiary)',
textDecoration: 'underline',
textUnderlineOffset: '4px',
}}
>
{dict.forgotPassword}
</a>
</div>
</form>
) : (
<form
onSubmit={handleTotp}
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
<div
style={{
borderRadius: '1.5rem',
border: '1px solid var(--color-border)',
background: 'var(--color-surface-secondary)',
padding: '1rem',
textAlign: 'center',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
}}
>
{dict.enterCode}
</div>
<FormField id="signin-totp" label={dict.authCode} required>
<TextInput
id="signin-totp"
type="text"
inputMode="text"
required
maxLength={14}
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())
}
placeholder={dict.totpPlaceholder}
style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.45em' }}
/>
</FormField>
<Button type="submit" intent="primary" fullWidth loading={loading} disabled={loading}>
{loading ? dict.verifying : dict.verify}
</Button>
<button
type="button"
onClick={() => {
setStep('credentials');
setTotpCode('');
setError(null);
}}
style={{
width: '100%',
fontSize: '0.875rem',
color: 'var(--color-text-tertiary)',
background: 'none',
border: 'none',
cursor: 'pointer',
}}
>
{dict.back}
</button>
</form>
)}
</AuthShell>
);
}
@@ -0,0 +1,41 @@
.accordion {
border-block-start: 1px solid var(--border-standard);
}
.item {
border-block-end: 1px solid var(--border-standard);
}
.summary {
display: flex;
min-block-size: 3.5rem;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-4);
cursor: pointer;
list-style: none;
}
.summary::-webkit-details-marker {
display: none;
}
.heading {
margin: 0;
font-size: var(--type-body-large-size);
}
.icon {
transition: transform var(--duration-fast) var(--easing-productive);
}
.item[open] .icon {
transform: rotate(180deg);
}
.content {
padding-block-end: var(--space-5);
color: var(--text-secondary);
}
.content > :last-child {
margin-block-end: 0;
}
@media (prefers-reduced-motion: reduce) {
.icon {
transition: none;
}
}
@@ -0,0 +1,33 @@
import { Icon } from '@/components/foundation/Icon';
import type { ReactNode } from 'react';
import styles from './Accordion.module.css';
export interface AccordionItem {
id: string;
title: string;
content: ReactNode;
open?: boolean;
}
export function Accordion({
items,
headingLevel = 3,
}: {
items: AccordionItem[];
headingLevel?: 2 | 3 | 4;
}) {
const Heading = `h${headingLevel}` as const;
return (
<div className={styles.accordion}>
{items.map((item) => (
<details key={item.id} className={styles.item} open={item.open}>
<summary className={styles.summary}>
<Heading className={styles.heading}>{item.title}</Heading>
<Icon name="chevron-down" className={styles.icon} />
</summary>
<div className={styles.content}>{item.content}</div>
</details>
))}
</div>
);
}
@@ -0,0 +1,33 @@
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-4);
}
.pagination > :last-child {
justify-self: end;
}
.pages {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.page {
min-inline-size: 2.75rem;
justify-content: center;
text-decoration: none;
}
.page[aria-current='page'] {
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
}
@media (max-width: 479px) {
.pages {
display: none;
}
}
@@ -0,0 +1,51 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon } from '@/components/foundation/Icon';
import styles from './Pagination.module.css';
export interface PaginationPage {
number: number;
href: string;
current?: boolean;
}
interface PaginationProps {
label: string;
pages: PaginationPage[];
previous?: { href: string; label: string };
next?: { href: string; label: string };
}
export function Pagination({ label, pages, previous, next }: PaginationProps) {
return (
<nav aria-label={label} className={styles.pagination}>
{previous ? (
<ActionLink href={previous.href} variant="navigation">
<Icon name="arrow-back" directionBehavior="mirror" /> {previous.label}
</ActionLink>
) : (
<span />
)}
<ol className={styles.pages}>
{pages.map((page) => (
<li key={page.number}>
<ActionLink
href={page.href}
variant="navigation"
aria-current={page.current ? 'page' : undefined}
className={styles.page}
>
{page.number}
</ActionLink>
</li>
))}
</ol>
{next ? (
<ActionLink href={next.href} variant="navigation">
{next.label} <Icon name="arrow-forward" directionBehavior="mirror" />
</ActionLink>
) : (
<span />
)}
</nav>
);
}
@@ -0,0 +1,31 @@
.control {
display: inline-flex;
max-inline-size: 100%;
gap: var(--space-1);
padding: var(--space-1);
border: 1px solid var(--border-standard);
border-radius: var(--radius-sm);
background: var(--surface-muted);
overflow-x: auto;
}
.option {
flex: none;
min-block-size: 2.5rem;
padding-block: var(--space-2);
padding-inline: var(--space-4);
border: 0;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-weight: 700;
}
.option[aria-checked='true'] {
background: var(--surface-elevated);
color: var(--text-primary);
box-shadow: var(--shadow-subtle);
}
.option:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
@@ -0,0 +1,44 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import styles from './SegmentedControl.module.css';
export interface SegmentOption {
value: string;
label: string;
disabled?: boolean;
}
interface SegmentedControlProps {
label: string;
value: string;
options: SegmentOption[];
onChange: (value: string) => void;
className?: string;
}
export function SegmentedControl({
label,
value,
options,
onChange,
className,
}: SegmentedControlProps) {
return (
<div className={classNames(styles.control, className)} role="radiogroup" aria-label={label}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={option.value === value}
disabled={option.disabled}
className={styles.option}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,33 @@
.tabs {
min-inline-size: 0;
}
.list {
display: flex;
gap: var(--space-1);
overflow-x: auto;
border-block-end: 1px solid var(--border-standard);
scrollbar-width: thin;
}
.tab {
flex: none;
min-block-size: 2.75rem;
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: 0;
border-block-end: 3px solid transparent;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-weight: 700;
}
.tab[aria-selected='true'] {
border-block-end-color: var(--interactive-primary);
color: var(--text-primary);
}
.tab:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.panel {
padding-block: var(--space-5);
}
@@ -0,0 +1,116 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import styles from './Tabs.module.css';
export interface TabItem {
id: string;
label: string;
content: ReactNode;
disabled?: boolean;
}
interface TabsProps {
items: TabItem[];
label: string;
defaultActiveId?: string;
activation?: 'automatic' | 'manual';
className?: string;
}
export function Tabs({
items,
label,
defaultActiveId,
activation = 'automatic',
className,
}: TabsProps) {
const available = items.filter((item) => !item.disabled);
const initial =
defaultActiveId && available.some((item) => item.id === defaultActiveId)
? defaultActiveId
: (available[0]?.id ?? '');
const [activeId, setActiveId] = useState(initial);
const [focusedId, setFocusedId] = useState(initial);
const refs = useRef(new Map<string, HTMLButtonElement>());
const prefix = useId().replaceAll(':', '');
const focusByOffset = (currentId: string, offset: number) => {
const currentIndex = available.findIndex((item) => item.id === currentId);
if (currentIndex < 0 || available.length === 0) return;
const next = available[(currentIndex + offset + available.length) % available.length];
if (!next) return;
setFocusedId(next.id);
if (activation === 'automatic') setActiveId(next.id);
refs.current.get(next.id)?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
focusByOffset(id, document.documentElement.dir === 'rtl' ? -1 : 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusByOffset(id, document.documentElement.dir === 'rtl' ? 1 : -1);
} else if (event.key === 'Home') {
event.preventDefault();
const first = available[0];
if (first) focusByOffset(first.id, 0);
} else if (event.key === 'End') {
event.preventDefault();
const last = available.at(-1);
if (last) focusByOffset(last.id, 0);
} else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
event.preventDefault();
setActiveId(id);
}
};
return (
<div className={classNames(styles.tabs, className)}>
<div className={styles.list} role="tablist" aria-label={label}>
{items.map((item) => {
const selected = item.id === activeId;
return (
<button
key={item.id}
ref={(node) => {
if (node) refs.current.set(item.id, node);
else refs.current.delete(item.id);
}}
type="button"
role="tab"
id={`${prefix}-tab-${item.id}`}
aria-controls={`${prefix}-panel-${item.id}`}
aria-selected={selected}
tabIndex={item.id === focusedId ? 0 : -1}
disabled={item.disabled}
className={styles.tab}
onClick={() => {
setFocusedId(item.id);
setActiveId(item.id);
}}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.label}
</button>
);
})}
</div>
{items.map((item) => (
<div
key={item.id}
role="tabpanel"
id={`${prefix}-panel-${item.id}`}
aria-labelledby={`${prefix}-tab-${item.id}`}
hidden={item.id !== activeId}
tabIndex={0}
className={styles.panel}
>
{item.content}
</div>
))}
</div>
);
}
@@ -0,0 +1,34 @@
.alert {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid currentColor;
border-radius: var(--radius-sm);
}
.inline {
padding: var(--space-3);
}
.info {
background: var(--status-info-surface);
color: var(--status-info-text);
}
.success {
background: var(--status-success-surface);
color: var(--status-success-text);
}
.warning {
background: var(--status-warning-surface);
color: var(--status-warning-text);
}
.error {
background: var(--status-error-surface);
color: var(--status-error-text);
}
.title {
display: block;
margin-block-end: var(--space-1);
}
.content > :last-child {
margin-block-end: 0;
}
@@ -0,0 +1,45 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './Alert.module.css';
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
const toneIcon: Record<AlertTone, ApprovedIconName> = {
info: 'info',
success: 'check',
warning: 'warning',
error: 'error',
};
interface AlertProps {
tone?: AlertTone;
title?: string;
children: ReactNode;
live?: 'off' | 'polite' | 'assertive';
inline?: boolean;
className?: string;
}
export function Alert({
tone = 'info',
title,
children,
live = 'off',
inline = false,
className,
}: AlertProps) {
return (
<div
className={classNames(styles.alert, styles[tone], inline && styles.inline, className)}
role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
aria-live={live === 'off' ? undefined : live}
>
<Icon name={toneIcon[tone]} />
<div>
{title ? <strong className={styles.title}>{title}</strong> : null}
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
.spinnerGroup {
display: inline-flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 0.9s linear infinite;
}
.skeleton {
display: grid;
gap: var(--space-3);
}
.skeleton span {
block-size: 0.9rem;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--skeleton-base),
var(--skeleton-highlight),
var(--skeleton-base)
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 1.8s;
}
.skeleton span {
animation: none;
background: var(--skeleton-base);
}
}
@@ -0,0 +1,38 @@
import { Icon } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { HTMLAttributes } from 'react';
import styles from './Loading.module.css';
export function Spinner({ label, className }: { label: string; className?: string }) {
return (
<span className={classNames(styles.spinnerGroup, className)} role="status">
<Icon name="spinner" className={styles.spinner} />
<span className="visually-hidden">{label}</span>
</span>
);
}
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
lines?: number;
label?: string;
}
export function Skeleton({
lines = 3,
label = 'Loading content',
className,
...props
}: SkeletonProps) {
return (
<div
className={classNames(styles.skeleton, className)}
aria-label={label}
aria-busy="true"
{...props}
>
{Array.from({ length: lines }, (_, index) => (
<span key={index} style={{ inlineSize: index === lines - 1 ? '68%' : '100%' }} />
))}
</div>
);
}
@@ -0,0 +1,16 @@
.group {
display: grid;
gap: var(--space-2);
}
.labelRow {
display: flex;
justify-content: space-between;
gap: var(--space-4);
font-size: var(--type-label-size);
font-weight: 700;
}
.progress {
inline-size: 100%;
block-size: 0.75rem;
accent-color: var(--interactive-primary);
}
@@ -0,0 +1,31 @@
import { BidiText } from '@/components/foundation/BidiText';
import { classNames } from '@/lib/components/classNames';
import styles from './Progress.module.css';
interface ProgressProps {
value?: number;
max?: number;
label: string;
showValue?: boolean;
className?: string;
}
export function Progress({ value, max = 100, label, showValue = false, className }: ProgressProps) {
const normalized = value === undefined ? undefined : Math.min(max, Math.max(0, value));
return (
<div className={classNames(styles.group, className)}>
<div className={styles.labelRow}>
<span>{label}</span>
{showValue && normalized !== undefined ? (
<BidiText>{`${Math.round((normalized / max) * 100)}%`}</BidiText>
) : null}
</div>
<progress
className={styles.progress}
max={max}
aria-label={label}
{...(normalized === undefined ? {} : { value: normalized })}
/>
</div>
);
}
@@ -0,0 +1,17 @@
.state {
display: grid;
justify-items: start;
max-inline-size: var(--container-reading);
gap: var(--space-3);
padding: var(--space-8);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-muted);
}
.state h2,
.state p {
margin: 0;
}
.state p {
color: var(--text-secondary);
}
@@ -0,0 +1,25 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import styles from './StateMessage.module.css';
interface StateMessageProps {
icon?: ApprovedIconName;
title: string;
body: string;
action?: { label: string; href: string };
}
export function StateMessage({ icon = 'info', title, body, action }: StateMessageProps) {
return (
<section className={styles.state}>
<Icon name={icon} size="lg" />
<h2>{title}</h2>
<p>{body}</p>
{action ? (
<ActionLink href={action.href} variant="standalone" icon="arrow-forward">
{action.label}
</ActionLink>
) : null}
</section>
);
}
@@ -0,0 +1,77 @@
.choice {
display: flex;
min-block-size: 2.75rem;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
}
.choice > input:not(.switchInput) {
inline-size: 1.25rem;
block-size: 1.25rem;
margin-block-start: 0.2rem;
accent-color: var(--interactive-primary);
}
.content {
display: grid;
gap: var(--space-1);
}
.label {
font-weight: 700;
}
.description {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.invalid .label {
color: var(--status-error-text);
}
.switchInput {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
}
.switchTrack {
position: relative;
flex: none;
inline-size: 2.75rem;
block-size: 1.5rem;
margin-block-start: 0.1rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
background: var(--surface-strong);
transition: background var(--duration-fast) var(--easing-productive);
}
.switchTrack span {
position: absolute;
inset-block-start: 0.18rem;
inset-inline-start: 0.2rem;
inline-size: 1rem;
block-size: 1rem;
border-radius: 50%;
background: var(--surface-elevated);
box-shadow: var(--shadow-subtle);
transition: transform var(--duration-fast) var(--easing-productive);
}
.switchInput:checked + .switchTrack {
background: var(--interactive-primary);
}
.switchInput:checked + .switchTrack span {
transform: translateX(1.2rem);
}
html[dir='rtl'] .switchInput:checked + .switchTrack span {
transform: translateX(-1.2rem);
}
.switchInput:focus-visible + .switchTrack {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.switchInput:disabled + .switchTrack {
opacity: var(--opacity-disabled);
}
@media (prefers-reduced-motion: reduce) {
.switchTrack,
.switchTrack span {
transition: none;
}
}
@@ -0,0 +1,58 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import type { InputHTMLAttributes, ReactNode } from 'react';
import styles from './ChoiceControls.module.css';
interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label: ReactNode;
description?: ReactNode;
invalid?: boolean;
}
function Choice({
label,
description,
invalid = false,
className,
type,
...props
}: ChoiceProps & { type: 'checkbox' | 'radio' }) {
return (
<label className={classNames(styles.choice, invalid && styles.invalid, className)}>
<input type={type} aria-invalid={invalid || undefined} {...props} />
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
export function Checkbox(props: ChoiceProps) {
return <Choice type="checkbox" {...props} />;
}
export function Radio(props: ChoiceProps) {
return <Choice type="radio" {...props} />;
}
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'role'> {
label: ReactNode;
description?: ReactNode;
}
export function Switch({ label, description, className, ...props }: SwitchProps) {
return (
<label className={classNames(styles.choice, className)}>
<input type="checkbox" role="switch" className={styles.switchInput} {...props} />
<span aria-hidden="true" className={styles.switchTrack}>
<span />
</span>
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
@@ -0,0 +1,24 @@
.summary {
padding: var(--space-5);
border: 2px solid var(--status-error-text);
border-radius: var(--radius-sm);
background: var(--status-error-surface);
color: var(--status-error-text);
}
.heading {
display: flex;
align-items: center;
gap: var(--space-2);
}
.heading h2 {
margin: 0;
font-size: var(--type-heading-3-size);
}
.summary ul {
margin-block-end: 0;
padding-inline-start: var(--space-6);
}
.summary a {
color: currentColor;
font-weight: 700;
}
@@ -0,0 +1,31 @@
import { Icon } from '@/components/foundation/Icon';
import styles from './ErrorSummary.module.css';
export interface FormErrorItem {
fieldId: string;
message: string;
}
export function ErrorSummary({ title, errors }: { title: string; errors: FormErrorItem[] }) {
if (errors.length === 0) return null;
return (
<section
className={styles.summary}
role="alert"
aria-labelledby="form-error-summary-title"
tabIndex={-1}
>
<div className={styles.heading}>
<Icon name="error" />
<h2 id="form-error-summary-title">{title}</h2>
</div>
<ul>
{errors.map((error) => (
<li key={error.fieldId}>
<a href={`#${error.fieldId}`}>{error.message}</a>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,47 @@
.field {
display: grid;
gap: var(--space-2);
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
font-weight: 700;
}
.required {
color: var(--status-error-text);
}
.optional {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 500;
}
.help,
.error {
margin: 0;
font-size: var(--type-label-size);
}
.help {
color: var(--text-secondary);
}
.error {
color: var(--status-error-text);
font-weight: 650;
}
.invalid .label {
color: var(--status-error-text);
}
.fieldset {
display: grid;
gap: var(--space-4);
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-block-end: var(--space-3);
padding: 0;
font-weight: 750;
}
@@ -0,0 +1,69 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './FormField.module.css';
interface FormFieldProps {
id: string;
label: string;
required?: boolean;
optionalLabel?: string | undefined;
supportingText?: string | undefined;
error?: string | undefined;
children: ReactNode;
className?: string | undefined;
}
export function FormField({
id,
label,
required = false,
optionalLabel,
supportingText,
error,
children,
className,
}: FormFieldProps) {
return (
<div className={classNames(styles.field, error && styles.invalid, className)}>
<label className={styles.label} htmlFor={id}>
<span>{label}</span>
{required ? (
<span aria-hidden="true" className={styles.required}>
*
</span>
) : null}
{!required && optionalLabel ? (
<span className={styles.optional}>{optionalLabel}</span>
) : null}
</label>
{children}
{supportingText ? (
<p id={`${id}-help`} className={styles.help}>
{supportingText}
</p>
) : null}
{error ? (
<p id={`${id}-error`} className={styles.error}>
<span aria-hidden="true">!</span> {error}
</p>
) : null}
</div>
);
}
export function Fieldset({
legend,
children,
className,
}: {
legend: string;
children: ReactNode;
className?: string | undefined;
}) {
return (
<fieldset className={classNames(styles.fieldset, className)}>
<legend className={styles.legend}>{legend}</legend>
{children}
</fieldset>
);
}
@@ -0,0 +1,43 @@
.control {
inline-size: 100%;
min-block-size: 2.875rem;
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-primary);
color: var(--text-primary);
}
.control::placeholder {
color: var(--text-subdued);
opacity: 1;
}
.control:hover:not(:disabled) {
border-color: var(--interactive-primary);
}
.control[aria-invalid='true'] {
border-color: var(--status-error-text);
box-shadow: 0 0 0 1px var(--status-error-text);
}
.control:disabled {
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.textarea {
min-block-size: 8rem;
resize: vertical;
}
.textareaGroup {
display: grid;
gap: var(--space-1);
}
.count {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-caption-size);
text-align: end;
}
.select {
appearance: auto;
}
@@ -0,0 +1,93 @@
import { BidiText } from '@/components/foundation/BidiText';
import { classNames } from '@/lib/components/classNames';
import {
forwardRef,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
} from 'react';
import styles from './TextInput.module.css';
export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
type?: TextInputType;
invalid?: boolean;
describedBy?: string | undefined;
bidiValue?: boolean;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ type = 'text', invalid = false, describedBy, bidiValue = false, className, ...props },
ref,
) {
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
return (
<input
ref={ref}
type={type}
dir={dir}
className={classNames(styles.control, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
/>
);
});
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
invalid?: boolean;
describedBy?: string | undefined;
characterCount?: { current: number; max: number; label: string };
}
export function TextArea({
invalid = false,
describedBy,
characterCount,
className,
...props
}: TextAreaProps) {
const countId = characterCount && props.id ? `${props.id}-count` : undefined;
const ids = [describedBy, countId].filter(Boolean).join(' ') || undefined;
return (
<div className={styles.textareaGroup}>
<textarea
className={classNames(styles.control, styles.textarea, className)}
aria-invalid={invalid || undefined}
aria-describedby={ids}
{...props}
/>
{characterCount ? (
<p id={countId} className={styles.count} aria-live="polite">
<BidiText>{`${characterCount.current}/${characterCount.max}`}</BidiText>{' '}
{characterCount.label}
</p>
) : null}
</div>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
invalid?: boolean;
describedBy?: string | undefined;
}
export function Select({
invalid = false,
describedBy,
className,
children,
...props
}: SelectProps) {
return (
<select
className={classNames(styles.control, styles.select, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
>
{children}
</select>
);
}
@@ -0,0 +1,17 @@
import type { ReactElement } from 'react';
interface AccessibleIconProps {
icon: ReactElement;
label?: string;
}
export function AccessibleIcon({ icon, label }: AccessibleIconProps) {
if (label) {
return (
<span role="img" aria-label={label}>
{icon}
</span>
);
}
return <span aria-hidden="true">{icon}</span>;
}
@@ -0,0 +1,3 @@
.noWrap {
white-space: nowrap;
}
@@ -0,0 +1,23 @@
import { classNames } from '@/lib/components/classNames';
import type { HTMLAttributes, ReactNode } from 'react';
import styles from './BidiText.module.css';
interface BidiTextProps extends Omit<HTMLAttributes<HTMLElement>, 'dir'> {
children: ReactNode;
direction?: 'ltr' | 'rtl' | 'auto';
noWrap?: boolean;
}
export function BidiText({
children,
direction = 'ltr',
noWrap = true,
className,
...props
}: BidiTextProps) {
return (
<bdi dir={direction} className={classNames(noWrap && styles.noWrap, className)} {...props}>
{children}
</bdi>
);
}
@@ -0,0 +1,25 @@
.icon {
flex: none;
inline-size: 1.25rem;
block-size: 1.25rem;
}
.sm {
inline-size: 1rem;
block-size: 1rem;
}
.md {
inline-size: 1.25rem;
block-size: 1.25rem;
}
.lg {
inline-size: 1.5rem;
block-size: 1.5rem;
}
html[dir='rtl'] .mirror {
transform: scaleX(-1);
}
@media (prefers-reduced-motion: reduce) {
.icon {
animation: none !important;
}
}
@@ -0,0 +1,139 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode, SVGProps } from 'react';
import styles from './Icon.module.css';
export const approvedIconNames = [
'arrow-back',
'arrow-forward',
'calendar',
'car',
'check',
'chevron-down',
'chevron-forward',
'close',
'error',
'external',
'globe',
'info',
'menu',
'minus',
'plus',
'search',
'settings',
'spinner',
'warning',
] as const;
export type ApprovedIconName = (typeof approvedIconNames)[number];
export type IconSize = 'sm' | 'md' | 'lg';
export type IconDirectionBehavior = 'fixed' | 'mirror';
interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
name: ApprovedIconName;
size?: IconSize;
decorative?: boolean;
label?: string;
directionBehavior?: IconDirectionBehavior;
}
const paths: Record<ApprovedIconName, ReactNode> = {
'arrow-back': <path d="m15 18-6-6 6-6M9 12h10" />,
'arrow-forward': <path d="m9 18 6-6-6-6m6 6H5" />,
calendar: (
<>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" />
</>
),
car: (
<>
<path d="m5 11 2-5h10l2 5" />
<path d="M3 11h18v7H3zM6 18v2m12-2v2M7 14h.01M17 14h.01" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
'chevron-down': <path d="m6 9 6 6 6-6" />,
'chevron-forward': <path d="m9 18 6-6-6-6" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
error: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6m0 4h.01" />
</>
),
external: (
<>
<path d="M14 4h6v6M20 4l-9 9" />
<path d="M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6m0-10h.01" />
</>
),
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
minus: <path d="M5 12h14" />,
plus: <path d="M12 5v14M5 12h14" />,
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m16 16 5 5" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
spinner: <path d="M21 12a9 9 0 1 1-9-9" />,
warning: (
<>
<path d="M12 3 2.5 20h19L12 3Z" />
<path d="M12 9v5m0 3h.01" />
</>
),
};
export function Icon({
name,
size = 'md',
decorative = true,
label,
directionBehavior = 'fixed',
className,
...props
}: IconProps) {
const accessibleProps = decorative
? ({ 'aria-hidden': true } as const)
: ({ role: 'img', 'aria-label': label ?? name } as const);
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
className={classNames(
styles.icon,
styles[size],
directionBehavior === 'mirror' && styles.mirror,
className,
)}
{...accessibleProps}
{...props}
>
{paths[name]}
</svg>
);
}
@@ -0,0 +1,11 @@
.region {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import styles from './LiveRegion.module.css';
interface LiveRegionProps {
children: ReactNode;
politeness?: 'polite' | 'assertive';
}
export function LiveRegion({ children, politeness = 'polite' }: LiveRegionProps) {
return (
<span className={styles.region} role="status" aria-live={politeness} aria-atomic="true">
{children}
</span>
);
}
@@ -0,0 +1,34 @@
.badge {
display: inline-flex;
min-block-size: 1.75rem;
align-items: center;
gap: var(--space-1);
inline-size: fit-content;
padding-block: var(--space-1);
padding-inline: var(--space-3);
border: 1px solid currentColor;
border-radius: var(--radius-pill);
font-size: var(--type-caption-size);
font-weight: 800;
line-height: 1.2;
}
.neutral {
background: var(--surface-muted);
color: var(--text-secondary);
}
.info {
background: var(--status-info-surface);
color: var(--status-info-text);
}
.success {
background: var(--status-success-surface);
color: var(--status-success-text);
}
.warning {
background: var(--status-warning-surface);
color: var(--status-warning-text);
}
.error {
background: var(--status-error-surface);
color: var(--status-error-text);
}
@@ -0,0 +1,29 @@
import { Icon, type ApprovedIconName } from './Icon';
import styles from './StatusBadge.module.css';
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
const icons: Partial<Record<StatusTone, ApprovedIconName>> = {
info: 'info',
success: 'check',
warning: 'warning',
error: 'error',
};
export function StatusBadge({
children,
tone = 'neutral',
showIcon = false,
}: {
children: string;
tone?: StatusTone;
showIcon?: boolean;
}) {
const icon = icons[tone];
return (
<span className={`${styles.badge} ${styles[tone]}`}>
{showIcon && icon ? <Icon name={icon} size="sm" /> : null}
{children}
</span>
);
}
@@ -0,0 +1,14 @@
import type { HTMLAttributes, ReactNode } from 'react';
interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
}
export function VisuallyHidden({ children, className, ...props }: VisuallyHiddenProps) {
const classes = ['visually-hidden', className].filter(Boolean).join(' ');
return (
<span className={classes} {...props}>
{children}
</span>
);
}
@@ -0,0 +1,74 @@
'use client';
import { useEffect, useId, useState, useSyncExternalStore, type RefObject } from 'react';
export function useAccessibleId(prefix = 'rdg'): string {
return `${prefix}-${useId().replaceAll(':', '')}`;
}
const subscribeHydration = () => () => undefined;
export function useHydrated(): boolean {
return useSyncExternalStore(
subscribeHydration,
() => true,
() => false,
);
}
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [query]);
return matches;
}
export function useReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}
export function useEscapeKey(onEscape: () => void, enabled = true): void {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [enabled, onEscape]);
}
export function useOutsideClick<T extends HTMLElement>(
ref: RefObject<T | null>,
onOutside: () => void,
enabled = true,
): void {
useEffect(() => {
if (!enabled) return;
const handlePointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [enabled, onOutside, ref]);
}
export function useScrollLock(locked: boolean): void {
useEffect(() => {
if (!locked) return;
const previousOverflow = document.body.style.overflow;
const previousPadding = document.body.style.paddingInlineEnd;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbar > 0) document.body.style.paddingInlineEnd = `${scrollbar}px`;
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.paddingInlineEnd = previousPadding;
};
}, [locked]);
}
@@ -0,0 +1,4 @@
.main {
min-block-size: calc(100dvh - var(--header-min-block-size));
overflow: clip;
}
@@ -0,0 +1,25 @@
# Homepage section architecture
The localized homepage is assembled in `src/app/[locale]/page.tsx`, but each visible section owns its component and, when needed, its CSS module under `src/components/homepage/sections`.
## Update one section
1. Edit only the matching component, for example `sections/WorkflowSection.tsx`.
2. Edit only its localized copy in `src/content/locales/en/homepage.json`, `fr/homepage.json`, and `ar/homepage.json`.
3. Keep reusable content transformation in `src/content/homepage-model.ts`.
4. Add or update the section-specific test.
Do not move section-specific styling into a global stylesheet. Shared design tokens and primitives remain global; layout and presentation unique to a section stay beside that section.
## Pricing
- UI and interaction: `sections/PricingSection.tsx`
- Section-only styles: `sections/PricingSection.module.css`
- Localized copy: `src/content/locales/{en,fr,ar}/homepage.json`
- Commercial values: `src/content/pricing-config.ts`
Public prices remain unavailable until `publicPricesApproved` is set to `true` and approved monthly prices are supplied for the supported plan and fleet-band combinations.
## Application shell preservation
The modular homepage sections do not own or replace the application shell. The existing `SiteHeader`, complete primary navigation, mobile navigation drawer, locale selector, theme selector, account action, and footer remain independent under `src/components/app-shell/`. The full Product, Workflow, Modules, Pricing, and FAQ navigation remains visible at laptop widths and is preserved in the mobile drawer at narrower widths.
@@ -0,0 +1,22 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { Comparison } from '@/components/marketing/Comparison';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import type { HomepageContent } from '@/content/homepage-model';
export function ComparisonSection({ content }: { content: HomepageContent['comparison'] }) {
return (
<Section tone="muted" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<Comparison
columns={[
{ title: content.beforeTitle, items: content.beforeItems, tone: 'problem' },
{ title: content.afterTitle, items: content.afterItems, tone: 'solution' },
]}
/>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,25 @@
import { Accordion } from '@/components/controls/Accordion';
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { Text } from '@/components/typography/Typography';
import type { HomepageContent } from '@/content/homepage-model';
export function FaqSection({ content }: { content: HomepageContent['faq'] }) {
return (
<Section id="faq" tone="muted" aria-label={content.title}>
<Container width="content">
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} alignment="center" />
<Accordion
items={content.items.map((item, index) => ({
id: item.id,
title: item.title,
content: <Text variant="supporting">{item.body}</Text>,
open: index === 0,
}))}
/>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,3 @@
.finalSection {
padding-block-start: clamp(var(--space-8), 6vw, var(--space-16));
}
@@ -0,0 +1,34 @@
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { Container, Section } from '@/components/layout/LayoutPrimitives';
import { CTA } from '@/components/marketing/CTA';
import type { HomepageContent } from '@/content/homepage-model';
import styles from './FinalCtaSection.module.css';
interface FinalCtaSectionProps {
content: HomepageContent['final'];
demoSubmissionEnabled: boolean;
}
export function FinalCtaSection({ content, demoSubmissionEnabled }: FinalCtaSectionProps) {
return (
<Section className={styles.finalSection} aria-label={content.title}>
<Container>
<CTA
eyebrow={content.eyebrow}
title={content.title}
body={content.body}
primary={content.primary}
secondary={content.secondary}
primaryNode={
<DemoTrigger
label={content.primary.label}
source="final-cta"
size="large"
disabledReason={demoSubmissionEnabled ? undefined : content.primary.disabledReason}
/>
}
/>
</Container>
</Section>
);
}
@@ -0,0 +1,130 @@
.hero {
position: relative;
isolation: isolate;
padding-block: clamp(var(--space-12), 9vw, var(--space-24));
background:
radial-gradient(
circle at 12% 18%,
color-mix(in srgb, var(--status-info-surface) 82%, transparent),
transparent 40%
),
radial-gradient(
circle at 88% 72%,
color-mix(in srgb, var(--surface-strong) 78%, transparent),
transparent 42%
),
var(--surface-page);
}
.hero::after {
position: absolute;
z-index: -1;
inset-block-end: 0;
inset-inline: 0;
block-size: 1px;
background: var(--border-standard);
content: '';
}
.heroGrid {
display: grid;
grid-template-columns: minmax(0, 1.02fr) minmax(22rem, 0.98fr);
align-items: center;
gap: clamp(var(--space-10), 7vw, var(--space-20));
}
.heroCopy {
display: grid;
gap: var(--space-5);
min-inline-size: 0;
}
.heroCopy > p:last-of-type {
max-inline-size: 58ch;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding-block-start: var(--space-2);
}
.previewStage {
position: relative;
min-inline-size: 0;
padding: clamp(var(--space-3), 2.5vw, var(--space-6));
}
.previewStage::before,
.previewStage::after {
position: absolute;
z-index: -1;
border-radius: var(--radius-md);
content: '';
}
.previewStage::before {
inset: 0;
background: var(--surface-strong);
transform: rotate(-2deg);
}
.previewStage::after {
inset-block-start: 8%;
inset-inline: 8%;
block-size: 88%;
border: 1px solid var(--border-strong);
transform: rotate(2deg);
}
@media (max-width: 1023px) {
.heroGrid {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
gap: var(--space-10);
}
}
@media (max-width: 767px) {
.heroGrid {
grid-template-columns: 1fr;
}
.heroCopy {
justify-items: start;
}
.heroActions,
.heroActions > * {
inline-size: 100%;
}
.previewStage {
padding-inline: 0;
}
.previewStage::before,
.previewStage::after {
display: none;
}
}
@media (max-width: 359px) {
.hero {
padding-block: var(--space-10);
}
}
@media (forced-colors: active) {
.previewStage::before,
.previewStage::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.previewStage::before,
.previewStage::after {
transform: none;
}
}
@@ -0,0 +1,49 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { Container, Section } from '@/components/layout/LayoutPrimitives';
import { ProductPreview } from '@/components/marketing/ProductPreview';
import { Eyebrow, Heading, Text } from '@/components/typography/Typography';
import type { HomepageContent } from '@/content/homepage-model';
import styles from './HeroSection.module.css';
interface HeroSectionProps {
content: HomepageContent['hero'];
homePath: string;
demoSubmissionEnabled: boolean;
}
export function HeroSection({ content, homePath, demoSubmissionEnabled }: HeroSectionProps) {
return (
<Section id="product" className={styles.hero} aria-label={content.title}>
<Container>
<div className={styles.heroGrid}>
<div className={styles.heroCopy}>
<Eyebrow>{content.eyebrow}</Eyebrow>
<Heading level={1} appearance="display">
{content.title}
</Heading>
<Text variant="lead">{content.body}</Text>
<div className={styles.heroActions} role="group" aria-label={content.eyebrow}>
<DemoTrigger
label={content.primary.label}
source="hero"
size="large"
disabledReason={demoSubmissionEnabled ? undefined : content.primary.disabledReason}
/>
<ActionLink
href={homePath}
variant="button-primary"
disabledReason={content.secondary.disabledReason}
>
{content.secondary.label}
</ActionLink>
</div>
</div>
<div className={styles.previewStage}>
<ProductPreview {...content.preview} headingLevel={2} />
</div>
</div>
</Container>
</Section>
);
}
@@ -0,0 +1,40 @@
.splitSection {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(22rem, 1.1fr);
align-items: start;
gap: clamp(var(--space-8), 7vw, var(--space-16));
}
.capabilityList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
}
.capabilityList li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: var(--space-3);
}
.capabilityList svg {
margin-block-start: 0.28rem;
color: var(--interactive-primary);
}
@media (max-width: 1023px) {
.splitSection {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 1fr);
}
}
@media (max-width: 767px) {
.splitSection,
.capabilityList {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,28 @@
import { Icon } from '@/components/foundation/Icon';
import { Container, Section } from '@/components/layout/LayoutPrimitives';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { Card } from '@/components/surfaces/Card';
import type { HomepageContent } from '@/content/homepage-model';
import styles from './IntegrationsSection.module.css';
export function IntegrationsSection({ content }: { content: HomepageContent['integrations'] }) {
return (
<Section aria-label={content.title}>
<Container>
<div className={styles.splitSection}>
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<Card as="div" tone="elevated" padding="spacious">
<ul className={styles.capabilityList}>
{content.items.map((item) => (
<li key={item}>
<Icon name="plus" size="sm" />
<span>{item}</span>
</li>
))}
</ul>
</Card>
</div>
</Container>
</Section>
);
}
@@ -0,0 +1,27 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import type { HomepageContent } from '@/content/homepage-model';
export function ModulesSection({ content }: { content: HomepageContent['modules'] }) {
return (
<Section id="modules" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<FeatureGrid>
{content.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,388 @@
.section {
border-block: 1px solid var(--border-standard);
background:
radial-gradient(
circle at 50% 0%,
color-mix(in srgb, var(--status-info-surface) 74%, transparent),
transparent 42%
),
var(--surface-page);
}
.header {
max-inline-size: 54rem;
margin-inline: auto;
}
.controls {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr);
gap: var(--space-5);
padding: var(--space-5);
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
background: var(--surface-elevated);
box-shadow: var(--shadow-subtle);
}
.controlGroup {
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.controlGroup legend {
margin-block-end: var(--space-3);
color: var(--text-primary);
font-size: var(--type-label-size);
font-weight: 800;
}
.optionGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
.billingOptions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.option {
position: relative;
min-inline-size: 0;
}
.option input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.option label {
display: flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-standard);
border-radius: var(--radius-sm);
background: var(--surface-primary);
color: var(--text-secondary);
font-size: var(--type-label-size);
font-weight: 700;
text-align: center;
cursor: pointer;
transition:
border-color var(--duration-fast) var(--easing-productive),
background var(--duration-fast) var(--easing-productive),
color var(--duration-fast) var(--easing-productive);
}
.option input:checked + label {
border-color: var(--interactive-primary);
background: var(--status-info-surface);
color: var(--text-primary);
}
.option input:focus-visible + label {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.savings {
display: inline-flex;
padding: 0.12rem 0.42rem;
border-radius: var(--radius-pill);
background: var(--status-success-surface);
color: var(--status-success-text);
font-size: 0.72rem;
font-weight: 800;
white-space: nowrap;
}
.planGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: var(--space-5);
}
.planCard {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-5);
min-inline-size: 0;
padding: clamp(var(--space-5), 3vw, var(--space-8));
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
background: var(--surface-elevated);
box-shadow: var(--shadow-subtle);
}
.recommendedCard {
border-color: var(--interactive-primary);
box-shadow: var(--shadow-card);
transform: translateY(-0.35rem);
}
.recommendedBadge {
position: absolute;
inset-block-start: 0;
inset-inline-end: var(--space-5);
padding: var(--space-2) var(--space-3);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
font-size: var(--type-caption-size);
font-weight: 800;
}
.planHeader {
display: grid;
gap: var(--space-3);
padding-block-start: var(--space-2);
}
.planName {
margin: 0;
color: var(--text-primary);
font-size: var(--type-heading-3-size);
line-height: var(--line-heading);
}
.audience {
min-block-size: 5.2rem;
margin: 0;
color: var(--text-secondary);
}
.priceBlock {
display: grid;
align-content: start;
gap: var(--space-1);
min-block-size: 5.75rem;
}
.priceLabel,
.priceMeta {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-caption-size);
}
.priceLine {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
color: var(--text-primary);
}
.price {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 850;
line-height: 1;
letter-spacing: -0.035em;
}
.pendingPrice,
.customPrice {
max-inline-size: 16ch;
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 800;
line-height: 1.25;
}
.featureList,
.basisList,
.moduleList,
.assuranceList {
margin: 0;
padding: 0;
list-style: none;
}
.featureList {
display: grid;
gap: var(--space-3);
flex: 1;
}
.featureList li,
.basisList li,
.assuranceList li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: var(--space-3);
}
.featureList svg,
.basisList svg,
.assuranceList svg {
margin-block-start: 0.22rem;
color: var(--interactive-primary);
}
.planAction {
inline-size: 100%;
margin-block-start: auto;
}
.detailsGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-5);
}
.detailCard {
display: grid;
gap: var(--space-4);
padding: var(--space-6);
border: 1px solid var(--border-standard);
border-radius: var(--radius-md);
background: var(--surface-primary);
}
.detailCard h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.125rem;
}
.basisList {
display: grid;
gap: var(--space-3);
}
.moduleList {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.moduleList li {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-standard);
border-radius: var(--radius-pill);
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.assuranceRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-6);
padding: var(--space-5) var(--space-6);
border: 1px solid var(--border-standard);
border-radius: var(--radius-md);
background: var(--surface-strong);
}
.assuranceList {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-6);
}
.assuranceList li {
color: var(--text-secondary);
font-size: var(--type-label-size);
font-weight: 700;
}
.compareBlock {
display: grid;
justify-items: end;
gap: var(--space-2);
text-align: end;
}
.compareBlock p,
.configurationNote {
margin: 0;
color: var(--text-secondary);
}
.configurationNote {
max-inline-size: 80ch;
margin-inline: auto;
font-size: var(--type-caption-size);
text-align: center;
}
@media (max-width: 1023px) {
.controls,
.assuranceRow {
grid-template-columns: 1fr;
}
.planGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.planCard:last-child {
grid-column: 1 / -1;
}
.compareBlock {
justify-items: start;
text-align: start;
}
}
@media (max-width: 767px) {
.optionGrid,
.planGrid,
.detailsGrid {
grid-template-columns: 1fr;
}
.billingOptions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.recommendedCard {
order: -1;
transform: none;
}
.planCard:last-child {
grid-column: auto;
}
.audience,
.priceBlock {
min-block-size: auto;
}
.assuranceList {
display: grid;
}
}
@media (prefers-reduced-motion: reduce) {
.option label {
transition: none;
}
.recommendedCard {
transform: none;
}
}
@media (forced-colors: active) {
.recommendedCard,
.option input:checked + label {
border-width: 2px;
}
}
@@ -0,0 +1,287 @@
'use client';
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon } from '@/components/foundation/Icon';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import type { HomepageContent } from '@/content/homepage-model';
import {
pricingCommercialConfig,
type BillingPeriod,
type FleetBandId,
type PricingPlanId,
} from '@/content/pricing-config';
import { trackAnalytics } from '@/lib/analytics/client';
import type { AnalyticsContext } from '@/lib/analytics/events';
import { classNames } from '@/lib/components/classNames';
import { getDirection, type Locale } from '@/lib/localization/config';
import { useMemo, useState } from 'react';
import styles from './PricingSection.module.css';
interface PricingSectionProps {
content: HomepageContent['pricing'];
locale: Locale;
homePath: string;
demoSubmissionEnabled: boolean;
}
function analyticsContext(
locale: Locale,
fleetBand: FleetBandId,
billingPeriod: BillingPeriod,
plan?: PricingPlanId,
): AnalyticsContext {
return {
routeId: 'home',
locale,
direction: getDirection(locale),
resolvedTheme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light',
source: 'pricing',
fleetBand,
billingPeriod,
...(plan ? { plan } : {}),
};
}
function monthlyEquivalent(monthlyPrice: number, billingPeriod: BillingPeriod): number {
if (billingPeriod === 'monthly') return monthlyPrice;
const discountPercent = pricingCommercialConfig.annualDiscountPercent ?? 0;
return monthlyPrice * (1 - discountPercent / 100);
}
function formatPrice(value: number, locale: Locale): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: pricingCommercialConfig.currency,
maximumFractionDigits: 0,
}).format(value);
}
function formatDiscount(locale: Locale): string {
const discountPercent = pricingCommercialConfig.annualDiscountPercent ?? 0;
return new Intl.NumberFormat(locale, {
style: 'percent',
maximumFractionDigits: 0,
}).format(discountPercent / 100);
}
export function PricingSection({
content,
locale,
homePath,
demoSubmissionEnabled,
}: PricingSectionProps) {
const [fleetBand, setFleetBand] = useState<FleetBandId>('small');
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('monthly');
const recommendedPlan = pricingCommercialConfig.recommendedPlanByFleetBand[fleetBand];
const prices = useMemo(
() =>
Object.fromEntries(
content.plans.map((plan) => {
const configuredPrice = pricingCommercialConfig.monthlyPrices[plan.id][fleetBand];
const publicPrice = pricingCommercialConfig.publicPricesApproved ? configuredPrice : null;
return [
plan.id,
publicPrice === null ? null : monthlyEquivalent(publicPrice, billingPeriod),
];
}),
) as Record<PricingPlanId, number | null>,
[billingPeriod, content.plans, fleetBand],
);
return (
<Section id="pricing" className={styles.section} aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader
className={styles.header ?? ''}
eyebrow={content.eyebrow}
title={content.title}
body={content.body}
alignment="center"
/>
<div className={styles.controls}>
<fieldset className={styles.controlGroup}>
<legend>{content.fleetSelectorLabel}</legend>
<div className={styles.optionGrid}>
{content.fleetBands.map((band) => (
<div key={band.id} className={styles.option}>
<input
id={`pricing-fleet-${band.id}`}
type="radio"
name="pricing-fleet-band"
value={band.id}
checked={fleetBand === band.id}
onChange={() => {
setFleetBand(band.id);
trackAnalytics(
'pricing_fleet_band_selected',
analyticsContext(locale, band.id, billingPeriod),
);
}}
/>
<label htmlFor={`pricing-fleet-${band.id}`}>{band.label}</label>
</div>
))}
</div>
</fieldset>
<fieldset className={styles.controlGroup}>
<legend>{content.billingLegend}</legend>
<div className={classNames(styles.optionGrid, styles.billingOptions)}>
{(['monthly', 'annual'] as const).map((period) => (
<div key={period} className={styles.option}>
<input
id={`pricing-billing-${period}`}
type="radio"
name="pricing-billing-period"
value={period}
checked={billingPeriod === period}
onChange={() => {
setBillingPeriod(period);
trackAnalytics(
'pricing_billing_period_changed',
analyticsContext(locale, fleetBand, period),
);
}}
/>
<label htmlFor={`pricing-billing-${period}`}>
{period === 'monthly' ? content.billingMonthly : content.billingAnnual}
{period === 'annual' &&
pricingCommercialConfig.publicPricesApproved &&
pricingCommercialConfig.annualDiscountPercent ? (
<span className={styles.savings}>
{content.billingSavings} {formatDiscount(locale)}
</span>
) : null}
</label>
</div>
))}
</div>
</fieldset>
</div>
<div className={styles.planGrid} aria-live="polite">
{content.plans.map((plan) => {
const recommended = plan.id === recommendedPlan;
const price = prices[plan.id];
const custom = plan.id === 'enterprise';
return (
<article
key={plan.id}
className={classNames(styles.planCard, recommended && styles.recommendedCard)}
data-plan={plan.id}
data-recommended={recommended || undefined}
>
{recommended ? (
<span className={styles.recommendedBadge}>{content.recommended}</span>
) : null}
<header className={styles.planHeader}>
<h3 className={styles.planName}>{plan.name}</h3>
<p className={styles.audience}>{plan.audience}</p>
</header>
<div className={styles.priceBlock}>
{custom ? (
<span className={styles.customPrice}>{content.customPrice}</span>
) : price === null ? (
<span className={styles.pendingPrice}>{content.pricePending}</span>
) : (
<>
<p className={styles.priceLabel}>{content.startingAt}</p>
<div className={styles.priceLine}>
<bdi className={styles.price} dir="ltr">
{formatPrice(price, locale)}
</bdi>
<span>{content.perMonth}</span>
</div>
{billingPeriod === 'annual' ? (
<p className={styles.priceMeta}>{content.annualEquivalent}</p>
) : null}
</>
)}
</div>
<ul className={styles.featureList}>
{plan.features.map((feature) => (
<li key={feature}>
<Icon name="check" size="sm" />
<span>{feature}</span>
</li>
))}
</ul>
<DemoTrigger
label={plan.cta}
source="pricing"
size="large"
fullWidth
className={styles.planAction}
disabledReason={
demoSubmissionEnabled ? undefined : content.actionDisabledReason
}
onActivate={() =>
trackAnalytics(
'pricing_plan_cta_clicked',
analyticsContext(locale, fleetBand, billingPeriod, plan.id),
)
}
/>
</article>
);
})}
</div>
<div className={styles.detailsGrid}>
<section className={styles.detailCard} aria-labelledby="pricing-basis-title">
<h3 id="pricing-basis-title">{content.basisTitle}</h3>
<ul className={styles.basisList}>
{content.basis.map((item) => (
<li key={item}>
<Icon name="check" size="sm" />
<span>{item}</span>
</li>
))}
</ul>
</section>
<section className={styles.detailCard} aria-labelledby="pricing-modules-title">
<h3 id="pricing-modules-title">{content.modulesTitle}</h3>
<ul className={styles.moduleList}>
{content.modules.map((module) => (
<li key={module}>{module}</li>
))}
</ul>
</section>
</div>
<div className={styles.assuranceRow}>
<ul className={styles.assuranceList}>
{content.assurances.map((assurance) => (
<li key={assurance}>
<Icon name="check" size="sm" />
<span>{assurance}</span>
</li>
))}
</ul>
<div className={styles.compareBlock}>
<p>{content.notSure}</p>
<ActionLink href={`${homePath}#modules`} variant="standalone" icon="arrow-forward">
{content.compare}
</ActionLink>
</div>
</div>
{!pricingCommercialConfig.publicPricesApproved ? (
<p className={styles.configurationNote}>{content.note}</p>
) : null}
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,6 @@
.methodList {
display: grid;
gap: var(--space-2);
margin: 0;
padding-inline-start: var(--space-5);
}
@@ -0,0 +1,37 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import { Callout } from '@/components/surfaces/Callout';
import type { HomepageContent } from '@/content/homepage-model';
import styles from './ResultsSection.module.css';
export function ResultsSection({ content }: { content: HomepageContent['results'] }) {
return (
<Section tone="muted" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<FeatureGrid>
{content.metrics.map((metric) => (
<FeatureCard
key={metric.id}
title={metric.title}
description={metric.description}
{...(metric.icon ? { icon: metric.icon } : {})}
emphasis="muted"
/>
))}
</FeatureGrid>
<Callout icon="info" title={content.methodTitle}>
<ol className={styles.methodList}>
{content.method.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</Callout>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,28 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import type { HomepageContent } from '@/content/homepage-model';
export function RolesSection({ content }: { content: HomepageContent['roles'] }) {
return (
<Section tone="strong" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} alignment="center" />
<FeatureGrid>
{content.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
emphasis="primary"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,28 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import type { HomepageContent } from '@/content/homepage-model';
export function SecuritySection({ content }: { content: HomepageContent['security'] }) {
return (
<Section tone="strong" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<FeatureGrid>
{content.checks.map((check) => (
<FeatureCard
key={check.id}
title={check.title}
description={check.description}
{...(check.icon ? { icon: check.icon } : {})}
emphasis="primary"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,3 @@
.sectionBorder {
border-block-end: 1px solid var(--border-standard);
}
@@ -0,0 +1,34 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import type { HomepageContent } from '@/content/homepage-model';
import styles from './TrustSection.module.css';
export function TrustSection({ content }: { content: HomepageContent['trust'] }) {
return (
<Section className={styles.sectionBorder} aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.eyebrow}
title={content.title}
body={content.body}
alignment="center"
/>
<FeatureGrid>
{content.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
emphasis="muted"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,17 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { Workflow } from '@/components/marketing/Workflow';
import type { HomepageContent } from '@/content/homepage-model';
export function WorkflowSection({ content }: { content: HomepageContent['workflow'] }) {
return (
<Section id="workflow" aria-label={content.title}>
<Container>
<Stack gap="large">
<SectionHeader eyebrow={content.eyebrow} title={content.title} body={content.body} />
<Workflow items={content.steps} label={content.title} />
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,12 @@
export { ComparisonSection } from './ComparisonSection';
export { FaqSection } from './FaqSection';
export { FinalCtaSection } from './FinalCtaSection';
export { HeroSection } from './HeroSection';
export { IntegrationsSection } from './IntegrationsSection';
export { ModulesSection } from './ModulesSection';
export { PricingSection } from './PricingSection';
export { ResultsSection } from './ResultsSection';
export { RolesSection } from './RolesSection';
export { SecuritySection } from './SecuritySection';
export { TrustSection } from './TrustSection';
export { WorkflowSection } from './WorkflowSection';
@@ -0,0 +1,76 @@
.form {
display: grid;
gap: var(--space-5);
}
.notice {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--border-standard);
border-radius: var(--radius-sm);
background: var(--surface-muted);
}
.notice p,
.statusText {
margin: 0;
color: var(--text-secondary);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.spanAll {
grid-column: 1 / -1;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.success {
display: grid;
gap: var(--space-4);
text-align: start;
}
.success h3,
.success p {
margin: 0;
}
.honeypot {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 639px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
.spanAll {
grid-column: auto;
}
.actions > * {
inline-size: 100%;
}
}
@media (forced-colors: active) {
.notice {
border: 1px solid CanvasText;
}
}
@@ -0,0 +1,449 @@
'use client';
import { Button } from '@/components/actions/Button';
import { ErrorSummary, type FormErrorItem } from '@/components/forms/ErrorSummary';
import { FormField } from '@/components/forms/FormField';
import { Select, TextArea, TextInput } from '@/components/forms/TextInput';
import { LiveRegion } from '@/components/foundation/LiveRegion';
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { Dialog } from '@/components/overlays/Dialog';
import type { HomepageMessages } from '@/lib/localization/messages';
import { trackAnalytics } from '@/lib/analytics/client';
import type { AnalyticsContext } from '@/lib/analytics/events';
import {
demoLeadSchema,
fleetSizeValues,
type DemoSource,
type DemoSubmissionResult,
} from '@/lib/demo/schema';
import type { Locale } from '@/lib/localization/config';
import { getDirection } from '@/lib/localization/config';
import { useEffect, useMemo, useRef, useState } from 'react';
import { demoOpenEventName, type DemoOpenEventDetail } from './DemoTrigger';
import styles from './DemoDialogHost.module.css';
interface DemoDialogHostProps {
locale: Locale;
messages: HomepageMessages['form'];
enabled: boolean;
}
type FormStatus = 'idle' | 'submitting' | 'success' | 'error';
type FieldErrors = Record<string, string>;
function messageForCode(code: string, messages: DemoDialogHostProps['messages']): string {
switch (code) {
case 'required':
return messages.requiredError;
case 'invalid_email':
return messages.emailError;
case 'invalid_fleet_size':
case 'invalid_source':
case 'invalid_idempotency_key':
return messages.invalidOptionError;
case 'too_long':
return messages.tooLongError;
default:
return messages.genericError;
}
}
function formMessage(
result: Extract<DemoSubmissionResult, { ok: false }>,
messages: DemoDialogHostProps['messages'],
) {
switch (result.category) {
case 'configuration':
case 'consent':
return messages.configurationError;
case 'timeout':
return messages.timeoutError;
case 'duplicate':
return messages.duplicateError;
case 'rate-limit':
return messages.rateLimitError;
case 'validation':
return messages.errorSummary;
case 'integration':
case 'abuse':
return messages.genericError;
}
}
function analyticsContext(
locale: Locale,
source: DemoSource,
extra: Partial<Pick<AnalyticsContext, 'errorCode' | 'errorCount' | 'reason'>> = {},
): AnalyticsContext {
const theme = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
return {
routeId: 'home',
locale,
direction: getDirection(locale),
resolvedTheme: theme,
source,
...extra,
};
}
export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProps) {
const [open, setOpen] = useState(false);
const [source, setSource] = useState<DemoSource>('hero');
const [status, setStatus] = useState<FormStatus>('idle');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState('');
const [messageLength, setMessageLength] = useState(0);
const formRef = useRef<HTMLFormElement>(null);
const firstFieldRef = useRef<HTMLInputElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const idempotencyKeyRef = useRef('');
const startedAtRef = useRef(0);
const closeTrackedRef = useRef(false);
const summaryErrors = useMemo<FormErrorItem[]>(
() =>
Object.entries(fieldErrors).map(([fieldId, code]) => ({
fieldId,
message: messageForCode(code, messages),
})),
[fieldErrors, messages],
);
useEffect(() => {
const handleOpen = (event: Event) => {
const detail = (event as CustomEvent<DemoOpenEventDetail>).detail;
if (!detail || !enabled) return;
returnFocusRef.current = detail.trigger;
setSource(detail.source);
closeTrackedRef.current = false;
setOpen(true);
setStatus('idle');
setFieldErrors({});
setFormError('');
idempotencyKeyRef.current = crypto.randomUUID();
startedAtRef.current = Date.now();
trackAnalytics('demo_open', analyticsContext(locale, detail.source));
};
window.addEventListener(demoOpenEventName, handleOpen);
return () => window.removeEventListener(demoOpenEventName, handleOpen);
}, [enabled, locale]);
const reset = () => {
formRef.current?.reset();
setMessageLength(0);
setStatus('idle');
setFieldErrors({});
setFormError('');
idempotencyKeyRef.current = crypto.randomUUID();
startedAtRef.current = Date.now();
window.requestAnimationFrame(() => firstFieldRef.current?.focus());
};
const close = () => {
if (status === 'submitting' || closeTrackedRef.current) return;
closeTrackedRef.current = true;
setOpen(false);
trackAnalytics('demo_close', analyticsContext(locale, source, { reason: 'user' }));
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) close();
else {
closeTrackedRef.current = false;
setOpen(true);
}
}}
title={messages.title}
description={messages.body}
closeLabel={messages.close}
initialFocusRef={firstFieldRef}
returnFocusRef={returnFocusRef}
dismissible={status !== 'submitting'}
>
{status === 'success' ? (
<section className={styles.success} aria-live="polite">
<StatusBadge tone="success">{messages.localModeLabel}</StatusBadge>
<h3>{messages.successTitle}</h3>
<p>{messages.successBody}</p>
<div className={styles.actions}>
<Button intent="primary" onClick={reset}>
{messages.submitAnother}
</Button>
<Button intent="secondary" onClick={close}>
{messages.close}
</Button>
</div>
</section>
) : (
<form
ref={formRef}
className={styles.form}
noValidate
onSubmit={async (event) => {
event.preventDefault();
if (status === 'submitting') return;
const form = event.currentTarget;
const values = new FormData(form);
const leadInput = {
fullName: String(values.get('fullName') ?? ''),
workEmail: String(values.get('workEmail') ?? ''),
company: String(values.get('company') ?? ''),
fleetSize: String(values.get('fleetSize') ?? ''),
market: String(values.get('market') ?? ''),
preferredLanguage: String(values.get('preferredLanguage') ?? locale),
message: String(values.get('message') ?? ''),
idempotencyKey: idempotencyKeyRef.current || crypto.randomUUID(),
source,
};
const clientResult = demoLeadSchema.safeParse(leadInput);
if (!clientResult.success) {
const nextErrors: FieldErrors = {};
for (const issue of clientResult.error.issues) {
const field = issue.path.at(-1);
if (typeof field === 'string' && !nextErrors[field])
nextErrors[field] = issue.message;
}
setFieldErrors(nextErrors);
setFormError(messages.errorSummary);
trackAnalytics(
'demo_validation_error',
analyticsContext(locale, source, { errorCount: Object.keys(nextErrors).length }),
);
window.requestAnimationFrame(() => {
const firstInvalid = form.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
});
return;
}
setStatus('submitting');
setFieldErrors({});
setFormError('');
trackAnalytics('demo_submit_attempt', analyticsContext(locale, source));
try {
const response = await fetch('/api/demo', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-RDG-Form': 'demo-v1' },
body: JSON.stringify({
lead: clientResult.data,
guard: {
honeypot: String(values.get('website') ?? ''),
startedAtMs: startedAtRef.current,
},
}),
});
const result = (await response.json()) as DemoSubmissionResult;
if (result.ok) {
setStatus('success');
trackAnalytics('demo_submit_success', analyticsContext(locale, source));
return;
}
const serverErrors = Object.fromEntries(
Object.entries(result.fieldErrors ?? {}).map(([field, code]) => [field, code]),
);
setFieldErrors(serverErrors);
setFormError(formMessage(result, messages));
setStatus('error');
trackAnalytics(
'demo_submit_failure',
analyticsContext(locale, source, { errorCode: result.category }),
);
window.requestAnimationFrame(() => {
const firstInvalid = form.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
});
} catch {
setFormError(messages.genericError);
setStatus('error');
trackAnalytics(
'demo_submit_failure',
analyticsContext(locale, source, { errorCode: 'network' }),
);
}
}}
>
<div className={styles.notice}>
<StatusBadge tone="info">{messages.localModeLabel}</StatusBadge>
<p>{messages.privacy}</p>
</div>
{formError ? (
<ErrorSummary
title={formError}
errors={
summaryErrors.length > 0
? summaryErrors
: [{ fieldId: 'demo-submit', message: formError }]
}
/>
) : null}
<div className={styles.grid}>
<FormField
id="fullName"
label={messages.name}
required
error={
fieldErrors.fullName ? messageForCode(fieldErrors.fullName, messages) : undefined
}
>
<TextInput
ref={firstFieldRef}
id="fullName"
name="fullName"
autoComplete="name"
required
maxLength={120}
invalid={Boolean(fieldErrors.fullName)}
describedBy={fieldErrors.fullName ? 'fullName-error' : undefined}
/>
</FormField>
<FormField
id="workEmail"
label={messages.email}
required
error={
fieldErrors.workEmail ? messageForCode(fieldErrors.workEmail, messages) : undefined
}
>
<TextInput
id="workEmail"
name="workEmail"
type="email"
inputMode="email"
autoComplete="email"
required
maxLength={254}
invalid={Boolean(fieldErrors.workEmail)}
describedBy={fieldErrors.workEmail ? 'workEmail-error' : undefined}
/>
</FormField>
<FormField
id="company"
label={messages.company}
required
error={
fieldErrors.company ? messageForCode(fieldErrors.company, messages) : undefined
}
>
<TextInput
id="company"
name="company"
autoComplete="organization"
required
maxLength={160}
invalid={Boolean(fieldErrors.company)}
describedBy={fieldErrors.company ? 'company-error' : undefined}
/>
</FormField>
<FormField
id="fleetSize"
label={messages.fleet}
required
error={
fieldErrors.fleetSize ? messageForCode(fieldErrors.fleetSize, messages) : undefined
}
>
<Select
id="fleetSize"
name="fleetSize"
required
defaultValue=""
invalid={Boolean(fieldErrors.fleetSize)}
describedBy={fieldErrors.fleetSize ? 'fleetSize-error' : undefined}
>
<option value="">{messages.fleetOptions[0]}</option>
{fleetSizeValues.map((value, index) => (
<option key={value} value={value}>
{messages.fleetOptions[index + 1]}
</option>
))}
</Select>
</FormField>
<FormField id="market" label={messages.market} optionalLabel={messages.optional}>
<TextInput id="market" name="market" defaultValue={messages.marketDefault} autoComplete="country-name" maxLength={100} />
</FormField>
<FormField
id="preferredLanguage"
label={messages.language}
optionalLabel={messages.optional}
>
<Select id="preferredLanguage" name="preferredLanguage" defaultValue={locale}>
{(['en', 'fr', 'ar'] as const).map((value, index) => (
<option key={value} value={value}>
{messages.langOptions[index]}
</option>
))}
</Select>
</FormField>
<FormField
id="message"
label={messages.message}
optionalLabel={messages.optional}
supportingText={messages.messageHelp}
className={styles.spanAll}
error={
fieldErrors.message ? messageForCode(fieldErrors.message, messages) : undefined
}
>
<TextArea
id="message"
name="message"
rows={5}
maxLength={1000}
invalid={Boolean(fieldErrors.message)}
describedBy={fieldErrors.message ? 'message-help message-error' : 'message-help'}
characterCount={{ current: messageLength, max: 1000, label: messages.messageCount }}
onInput={(event) => setMessageLength(event.currentTarget.value.length)}
/>
</FormField>
</div>
<div className={styles.honeypot} aria-hidden="true">
<label htmlFor="website">Website</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" />
</div>
<div className={styles.actions}>
<Button
id="demo-submit"
type="submit"
intent="conversion"
loading={status === 'submitting'}
>
{status === 'submitting'
? messages.submitting
: status === 'error'
? messages.retry
: messages.submit}
</Button>
<Button
type="button"
intent="tertiary"
disabled={status === 'submitting'}
onClick={close}
>
{messages.close}
</Button>
</div>
<LiveRegion politeness="polite">
{status === 'submitting' ? messages.submitting : ''}
</LiveRegion>
</form>
)}
</Dialog>
);
}
@@ -0,0 +1,65 @@
'use client';
import { Button } from '@/components/actions/Button';
import { ActionLink } from '@/components/actions/ActionLink';
import type { DemoSource } from '@/lib/demo/schema';
export const demoOpenEventName = 'rdg:demo-open';
export interface DemoOpenEventDetail {
source: DemoSource;
trigger: HTMLElement;
}
interface DemoTriggerProps {
label: string;
source: DemoSource;
disabledReason?: string | undefined;
size?: 'small' | 'medium' | 'large';
fullWidth?: boolean;
className?: string | undefined;
onActivate?: (() => void) | undefined;
}
export function DemoTrigger({
label,
source,
disabledReason,
size = 'medium',
fullWidth = false,
className,
onActivate,
}: DemoTriggerProps) {
if (disabledReason) {
return (
<ActionLink
href="/"
variant="button-conversion"
disabledReason={disabledReason}
className={className}
>
{label}
</ActionLink>
);
}
return (
<Button
intent="conversion"
size={size}
fullWidth={fullWidth}
className={className}
data-demo-source={source}
onClick={(event) => {
onActivate?.();
window.dispatchEvent(
new CustomEvent<DemoOpenEventDetail>(demoOpenEventName, {
detail: { source, trigger: event.currentTarget },
}),
);
}}
>
{label}
</Button>
);
}
@@ -0,0 +1,81 @@
.container {
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-inline: var(--layout-gutter);
}
.content {
max-inline-size: var(--container-content);
}
.reading {
max-inline-size: var(--container-reading);
}
.full {
max-inline-size: none;
}
.section {
padding-block: var(--section-space);
}
.sectionMuted {
background: var(--surface-muted);
}
.sectionStrong {
background: var(--surface-strong);
}
.stack {
display: flex;
flex-direction: column;
}
.stackSmall {
gap: var(--space-3);
}
.stackMedium {
gap: var(--space-6);
}
.stackLarge {
gap: var(--space-12);
}
.cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.clusterSmall {
gap: var(--space-2);
}
.clusterMedium {
gap: var(--space-4);
}
.clusterLarge {
gap: var(--space-8);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: var(--space-6);
}
.bleed {
inline-size: 100vw;
margin-inline: calc(50% - 50vw);
}
.sticky {
position: sticky;
z-index: var(--layer-sticky);
inset-block-start: 0;
}
@@ -0,0 +1,92 @@
import type { HTMLAttributes, ReactNode } from 'react';
import styles from './LayoutPrimitives.module.css';
type ContainerWidth = 'wide' | 'content' | 'reading' | 'full';
type Gap = 'small' | 'medium' | 'large';
type SectionTone = 'default' | 'muted' | 'strong';
function classes(...values: Array<string | false | undefined>): string {
return values.filter(Boolean).join(' ');
}
export function Container({
children,
width = 'wide',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; width?: ContainerWidth }) {
return (
<div className={classes(styles.container, styles[width], className)} {...props}>
{children}
</div>
);
}
export function Section({
children,
tone = 'default',
className,
...props
}: HTMLAttributes<HTMLElement> & { children: ReactNode; tone?: SectionTone }) {
return (
<section
className={classes(
styles.section,
tone === 'muted' && styles.sectionMuted,
tone === 'strong' && styles.sectionStrong,
className,
)}
{...props}
>
{children}
</section>
);
}
export function Stack({
children,
gap = 'medium',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; gap?: Gap }) {
const gapClass = {
small: styles.stackSmall,
medium: styles.stackMedium,
large: styles.stackLarge,
}[gap];
return (
<div className={classes(styles.stack, gapClass, className)} {...props}>
{children}
</div>
);
}
export function Cluster({
children,
gap = 'medium',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; gap?: Gap }) {
const gapClass = {
small: styles.clusterSmall,
medium: styles.clusterMedium,
large: styles.clusterLarge,
}[gap];
return (
<div className={classes(styles.cluster, gapClass, className)} {...props}>
{children}
</div>
);
}
export function ResponsiveGrid({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={classes(styles.grid, className)} {...props}>
{children}
</div>
);
}
@@ -0,0 +1,42 @@
.cta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-8);
}
.copy {
display: grid;
gap: var(--space-3);
}
.eyebrow {
margin: 0;
color: var(--interactive-primary);
font-size: var(--type-label-size);
font-weight: 800;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
.inline {
border-radius: var(--radius-sm);
}
.card {
grid-template-columns: 1fr;
}
.card .actions {
justify-content: flex-start;
}
@media (max-width: 767px) {
.cta {
grid-template-columns: 1fr;
}
.actions {
justify-content: flex-start;
}
.actions > * {
inline-size: 100%;
}
}

Some files were not shown because too many files have changed in this diff Show More