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
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:
@@ -1,274 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { StatsStrip } from '@/components/StatsStrip'
|
||||
import { HowItWorks } from '@/components/HowItWorks'
|
||||
import { TestimonialsSection } from '@/components/TestimonialsSection'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageSectionType,
|
||||
} from '@rentaldrivego/types'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function HomeContent() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const dict = content[language]
|
||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadHomepageContent() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/site/platform/homepage`, { cache: 'no-store' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok || !json?.data || cancelled) return
|
||||
setContent(json.data as MarketplaceHomepageConfig)
|
||||
} catch {
|
||||
// Fall back to the shared defaults when the API is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
loadHomepageContent()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
function hasSection(section: MarketplaceHomepageSectionType) {
|
||||
return sections.includes(section)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-transparent">
|
||||
<section className="relative">
|
||||
<div className="site-glow absolute inset-x-0 top-0 h-[38rem]" />
|
||||
<div className="shell relative py-10 sm:py-14 lg:py-20">
|
||||
<div className={`grid gap-8 ${hasSection('hero') && hasSection('surface') ? 'lg:grid-cols-[minmax(0,1.15fr)_24rem] lg:items-stretch xl:grid-cols-[minmax(0,1.2fr)_28rem]' : ''}`}>
|
||||
{hasSection('hero') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/70">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700 dark:text-orange-300">{dict.heroKicker}</p>
|
||||
<h1 className="mt-5 max-w-4xl text-5xl font-black leading-none tracking-[-0.04em] text-blue-950 dark:text-stone-50 sm:text-6xl lg:text-7xl">
|
||||
{dict.heroTitle}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-stone-600 dark:text-stone-300 sm:text-lg">
|
||||
{dict.heroBody}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.startTrial}
|
||||
</a>
|
||||
<a
|
||||
href="/features"
|
||||
className="rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:border-stone-200 dark:hover:text-white"
|
||||
>
|
||||
{dict.exploreVehicles}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-3 sm:grid-cols-3">
|
||||
{dict.metrics.map(({ value, label }) => (
|
||||
<div key={value} className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 p-4 dark:border-blue-900 dark:bg-blue-950/50">
|
||||
<p className="text-xs font-bold tracking-[0.28em] text-stone-400 dark:text-stone-500">{value}</p>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-stone-900 dark:text-stone-100">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasSection('surface') ? (
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] p-6 text-white shadow-[0_30px_80px_rgba(6,19,46,0.40)] dark:border-blue-800">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(249,115,22,0.30),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(96,165,250,0.22),transparent_32%)]" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{dict.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{dict.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 text-3xl font-black leading-tight tracking-[-0.03em]">{dict.surfaceTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{dict.surfaceBody}</p>
|
||||
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
['01', dict.trustedFleets],
|
||||
['02', dict.brandedFlows],
|
||||
['03', dict.multiTenant],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="flex items-center justify-between rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-xs font-bold text-blue-950">
|
||||
{step}
|
||||
</span>
|
||||
<p className="text-sm font-semibold text-white">{label}</p>
|
||||
</div>
|
||||
<span className="text-lg text-orange-300">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSection('audiences') ? <div className="mt-8 grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-white/80 p-7 backdrop-blur dark:border-blue-900 dark:bg-blue-950/60">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.companyKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.companyTitle}</h2>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-7 text-stone-600 dark:text-stone-300">{dict.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-stone-200/80 bg-[#f7efe2] p-7 dark:border-blue-900 dark:bg-[#1d1712]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.renterKicker}</p>
|
||||
<h2 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50 sm:text-3xl">{dict.renterTitle}</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-700 dark:text-stone-300">{dict.renterBody}</p>
|
||||
</article>
|
||||
</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasSection('pillars') ? <section className="shell pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{dict.pillars.map(({ title, body }, index) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`rounded-[2rem] border p-7 shadow-sm ${
|
||||
index === 1
|
||||
? 'border-orange-700 bg-orange-600 text-white dark:border-orange-500 dark:bg-orange-500 dark:text-white'
|
||||
: 'border-stone-200 bg-white dark:border-blue-900 dark:bg-blue-950'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-[0.26em] ${
|
||||
index === 1 ? 'text-stone-300 dark:text-stone-700' : 'text-stone-400 dark:text-stone-500'
|
||||
}`}
|
||||
>
|
||||
0{index + 1}
|
||||
</p>
|
||||
<h3 className="mt-4 text-2xl font-black tracking-[-0.03em]">{title}</h3>
|
||||
<p
|
||||
className={`mt-4 text-sm leading-7 ${
|
||||
index === 1 ? 'text-stone-200 dark:text-stone-800' : 'text-stone-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('pillars') ? (
|
||||
<StatsStrip
|
||||
stats={dict.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: parseInt(m.value) || 0,
|
||||
}))}
|
||||
kicker="BY THE NUMBERS"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{(hasSection('features') || hasSection('steps')) ? <section className="shell py-10">
|
||||
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{hasSection('features') ? (
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-[linear-gradient(160deg,#f5f8ff_0%,#edf2ff_100%)] p-8 dark:border-blue-900 dark:bg-[linear-gradient(160deg,#0c1830_0%,#10203e_100%)]">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">{dict.featureLabel}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{dict.features.map((item, index) => (
|
||||
<div key={item} className="flex items-start gap-4 rounded-[1.25rem] border border-stone-200/80 bg-white/85 px-4 py-4 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white dark:bg-orange-500 dark:text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800 dark:text-stone-100">{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div />}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('howitworks') ? (
|
||||
<HowItWorks
|
||||
steps={dict.howitworksSteps}
|
||||
kicker={dict.howitworksKicker}
|
||||
title={dict.howitworksTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('steps') ? <section className="shell py-10">
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm dark:border-blue-900 dark:bg-blue-950">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">{dict.stepsTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{dict.steps.map(({ step, title, body }) => (
|
||||
<article key={step} className="rounded-[1.5rem] border border-stone-200 bg-stone-50 p-5 dark:border-blue-900 dark:bg-blue-950">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">
|
||||
{dict.stepLabel} {step}
|
||||
</p>
|
||||
<h3 className="mt-3 text-lg font-black text-blue-950 dark:text-stone-50">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600 dark:text-stone-300">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{hasSection('testimonials') && dict.testimonials && dict.testimonials.length > 0 ? (
|
||||
<TestimonialsSection
|
||||
testimonials={dict.testimonials}
|
||||
kicker={dict.testimonialsKicker}
|
||||
title={dict.testimonialsTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasSection('closing') ? <section className="shell pb-16 pt-6 sm:pb-20">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-blue-900/60 bg-[#06132e] px-8 py-10 text-white dark:border-blue-800">
|
||||
<div className="grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{dict.readyKicker}</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight tracking-[-0.04em] sm:text-5xl">{dict.readyTitle}</h2>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-8 text-stone-300 sm:text-base">{dict.readyBody}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<a
|
||||
href="/pricing"
|
||||
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white hover:text-blue-900"
|
||||
>
|
||||
{dict.viewPricing}
|
||||
</a>
|
||||
<a
|
||||
href={starterSignupHref}
|
||||
className="rounded-full bg-orange-300 px-6 py-3 text-sm font-semibold text-blue-950 transition hover:bg-orange-200"
|
||||
>
|
||||
{dict.createWorkspace}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import AppPrivacyEnPage from './app-privacy-en/page'
|
||||
import AppPrivacyFrPage from './app-privacy-fr/page'
|
||||
import AppPrivacyArPage from './app-privacy-ar/page'
|
||||
import AppTermsEnPage from './app-tc-en/page'
|
||||
import AppTermsFrPage from './app-tc-fr/page'
|
||||
import AppTermsArPage from './app-tc-ar/page'
|
||||
|
||||
function expectPolicyPage(element: unknown, slug: string, forcedLanguage: string) {
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
const page = element as React.ReactElement
|
||||
expect(page.type).toBe(FooterContentPage)
|
||||
expect(page.props.slug).toBe(slug)
|
||||
expect(page.props.forcedLanguage).toBe(forcedLanguage)
|
||||
}
|
||||
|
||||
describe('marketplace static app policy pages', () => {
|
||||
it('binds app privacy pages to explicit locales', () => {
|
||||
expectPolicyPage(AppPrivacyEnPage(), 'privacy-policy', 'en')
|
||||
expectPolicyPage(AppPrivacyFrPage(), 'privacy-policy', 'fr')
|
||||
expectPolicyPage(AppPrivacyArPage(), 'privacy-policy', 'ar')
|
||||
})
|
||||
|
||||
it('binds app terms pages to explicit locales and the app terms content slug', () => {
|
||||
expectPolicyPage(AppTermsEnPage(), 'general-conditions', 'en')
|
||||
expectPolicyPage(AppTermsFrPage(), 'general-conditions', 'fr')
|
||||
expectPolicyPage(AppTermsArPage(), 'general-conditions', 'ar')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyArPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyEnPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppPrivacyFrPage() {
|
||||
return <FooterContentPage slug="privacy-policy" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsArPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="ar" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsEnPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="en" />
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
|
||||
export default function AppTermsFrPage() {
|
||||
return <FooterContentPage slug="general-conditions" forcedLanguage="fr" />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CompanyWorkspacePage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { FeatureAlternating } from '@/components/FeatureAlternating'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Features',
|
||||
title: 'Operations, marketplace discovery, and branded booking.',
|
||||
body: "RentalDriveGo combines company-only operations with a public marketplace that sends renters to each company's own payment flow.",
|
||||
features: [
|
||||
{
|
||||
title: 'Private dashboard',
|
||||
body: 'Fleet, reservations, CRM, team permissions, billing, and reports stay isolated per company.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace discovery',
|
||||
body: 'Published vehicles and public offers appear on `/explore` for cross-company browsing.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Branded booking site',
|
||||
body: 'Each company gets its own subdomain where renters complete booking and payment directly.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Payment flexibility',
|
||||
body: 'Use AmanPay or PayPal for subscriptions and for company-side renter payments.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Advanced rental ops',
|
||||
body: 'Insurance, additional drivers, pricing rules, and structured fuel policies live in dashboard settings.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Notification controls',
|
||||
body: 'Both company staff and renters can manage event-by-channel notification preferences.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Fonctionnalités',
|
||||
title: 'Opérations, découverte marketplace et réservation de marque.',
|
||||
body: "RentalDriveGo combine des opérations privées pour l'entreprise avec une marketplace publique qui redirige les clients vers le parcours de paiement propre à chaque société.",
|
||||
features: [
|
||||
{
|
||||
title: 'Tableau de bord privé',
|
||||
body: "Flotte, réservations, CRM, permissions d'équipe, facturation et rapports restent isolés par entreprise.",
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'Découverte marketplace',
|
||||
body: 'Les véhicules publiés et les offres publiques apparaissent sur `/explore` pour une navigation multi-entreprises.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'Site de réservation de marque',
|
||||
body: 'Chaque entreprise dispose de son propre sous-domaine où les clients finalisent la réservation et le paiement.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'Flexibilité de paiement',
|
||||
body: 'Utilisez AmanPay ou PayPal pour les abonnements et pour les paiements côté entreprise.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'Opérations avancées',
|
||||
body: 'Assurance, conducteurs supplémentaires, règles tarifaires et politiques carburant structurées sont gérés dans les paramètres du tableau de bord.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Contrôle des notifications',
|
||||
body: 'Le personnel comme les clients peuvent gérer leurs préférences de notification par événement et canal.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'المزايا',
|
||||
title: 'العمليات، اكتشاف السوق، والحجز المخصص.',
|
||||
body: 'يجمع RentalDriveGo بين عمليات الشركة الخاصة وسوق عام يوجّه المستأجرين إلى مسار الدفع الخاص بكل شركة.',
|
||||
features: [
|
||||
{
|
||||
title: 'لوحة تحكم خاصة',
|
||||
body: 'الأسطول والحجوزات وCRM وصلاحيات الفريق والفوترة والتقارير تبقى معزولة لكل شركة.',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
{
|
||||
title: 'اكتشاف عبر السوق',
|
||||
body: 'تظهر السيارات المنشورة والعروض العامة في `/explore` للتصفح بين الشركات.',
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
title: 'موقع حجز مخصص',
|
||||
body: 'تحصل كل شركة على نطاق فرعي خاص بها لإتمام الحجز والدفع مباشرة.',
|
||||
icon: 'Lock',
|
||||
},
|
||||
{
|
||||
title: 'مرونة الدفع',
|
||||
body: 'استخدم AmanPay أو PayPal للاشتراكات ومدفوعات المستأجرين من جهة الشركة.',
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
title: 'عمليات متقدمة',
|
||||
body: 'التأمين والسائقون الإضافيون وقواعد التسعير وسياسات الوقود المنظمة موجودة في إعدادات اللوحة.',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'التحكم في الإشعارات',
|
||||
body: 'يمكن للموظفين والمستأجرين إدارة تفضيلات الإشعارات حسب الحدث والقناة.',
|
||||
icon: 'Bell',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
const iconMap = {
|
||||
BarChart3,
|
||||
Zap,
|
||||
Users,
|
||||
CreditCard,
|
||||
Lock,
|
||||
Bell,
|
||||
}
|
||||
|
||||
export default function FeaturesPage() {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
const features = dict.features.map((f) => ({
|
||||
...f,
|
||||
icon: iconMap[f.icon as keyof typeof iconMap],
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section space-y-16">
|
||||
<div className="max-w-3xl">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<FeatureAlternating features={features} />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error('NEXT_NOT_FOUND')
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import FooterPage, { generateStaticParams } from './page'
|
||||
import { footerPageSlugs } from '@/lib/footerContent'
|
||||
|
||||
describe('marketplace footer route', () => {
|
||||
it('generates one static route per registered footer slug', () => {
|
||||
expect(generateStaticParams()).toEqual(footerPageSlugs.map((slug) => ({ slug })))
|
||||
})
|
||||
|
||||
it('passes valid slugs through to FooterContentPage', () => {
|
||||
const element = FooterPage({ params: { slug: 'privacy-policy' } })
|
||||
|
||||
expect(isValidElement(element)).toBe(true)
|
||||
expect(element.type).toBe(FooterContentPage)
|
||||
expect(element.props.slug).toBe('privacy-policy')
|
||||
expect(element.props.forcedLanguage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unknown slugs instead of rendering arbitrary policy pages', () => {
|
||||
expect(() => FooterPage({ params: { slug: 'definitely-not-a-policy' } })).toThrow('NEXT_NOT_FOUND')
|
||||
expect(navigation.notFound).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
import FooterContentPage from '@/components/FooterContentPage'
|
||||
import { footerPageSlugs, isFooterPageSlug } from '@/lib/footerContent'
|
||||
|
||||
export function generateStaticParams() {
|
||||
return footerPageSlugs.map((slug) => ({ slug }))
|
||||
}
|
||||
|
||||
export default function FooterPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const { slug } = params
|
||||
|
||||
if (!isFooterPageSlug(slug)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <FooterContentPage slug={slug} />
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import MarketplaceHeader from '@/components/MarketplaceHeader'
|
||||
import MarketplaceFooter from '@/components/MarketplaceFooter'
|
||||
import { useMarketplacePreferences, getFooterContent, localeOptions } from '@/components/MarketplaceShell'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const { language, theme, dict, companyName, setLanguage, setTheme } = useMarketplacePreferences()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const footerContent = getFooterContent(language)
|
||||
const available = localeOptions.filter((o) => o.value !== language)
|
||||
const current = localeOptions.find((o) => o.value === language) ?? localeOptions[0]
|
||||
|
||||
return (
|
||||
<div className="site-page flex min-h-screen flex-col">
|
||||
<MarketplaceHeader
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
companyName={companyName}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentLanguage={{ value: current.value, flag: current.flag, shortLabel: current.value.toUpperCase() }}
|
||||
localeOptions={available.map((o) => ({ value: o.value, flag: o.flag, shortLabel: o.value.toUpperCase() }))}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<MarketplaceFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
rightsLabel={footerContent.rightsLabel}
|
||||
localeOptions={available}
|
||||
currentLocale={current}
|
||||
onSelectLanguage={setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import HomeContent from './HomeContent'
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeContent />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function PlatformOperationsPage() {
|
||||
redirect('/')
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCurrencyLabel, PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
|
||||
type Billing = 'monthly' | 'annual'
|
||||
|
||||
type PricingMatrix = Record<string, Record<string, Record<string, number>>>
|
||||
type PlanFeatureMap = Record<string, string[]>
|
||||
|
||||
type PlatformPricing = {
|
||||
prices: PricingMatrix
|
||||
planFeatures: PlanFeatureMap
|
||||
}
|
||||
|
||||
export const DEFAULT_PRICING: PlatformPricing = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: 'STARTER',
|
||||
name: 'Starter',
|
||||
tagline: 'Launch your fleet online',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'GROWTH',
|
||||
name: 'Growth',
|
||||
tagline: 'Scale with confidence',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'PRO',
|
||||
name: 'Pro',
|
||||
tagline: 'Enterprise-grade power',
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual',
|
||||
yearly: '/ year',
|
||||
youSave: 'You save',
|
||||
withAnnual: 'with annual billing — billed as one payment per year.',
|
||||
mostPopular: 'Most popular',
|
||||
perMonth: '/mo',
|
||||
billedAs: 'Billed as',
|
||||
getStarted: 'Get started',
|
||||
footer: 'All plans include a 90-day free trial. No credit card required.',
|
||||
},
|
||||
fr: {
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel',
|
||||
yearly: '/ an',
|
||||
youSave: 'Vous économisez',
|
||||
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
|
||||
mostPopular: 'Le plus populaire',
|
||||
perMonth: '/mois',
|
||||
billedAs: 'Facturé à',
|
||||
getStarted: 'Commencer',
|
||||
footer: "Toutes les formules incluent un essai gratuit de 90 jours. Aucune carte bancaire n'est requise.",
|
||||
},
|
||||
ar: {
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي',
|
||||
yearly: '/ سنة',
|
||||
youSave: 'توفر',
|
||||
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
||||
mostPopular: 'الأكثر شيوعاً',
|
||||
perMonth: '/شهر',
|
||||
billedAs: 'يتم الفوترة بمبلغ',
|
||||
getStarted: 'ابدأ الآن',
|
||||
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||
},
|
||||
} as const
|
||||
|
||||
function getPlanPrice(prices: PricingMatrix, plan: string, billingPeriod: 'MONTHLY' | 'ANNUAL') {
|
||||
return (prices[plan]?.[billingPeriod]?.MAD ?? PLAN_PRICES[plan]?.[billingPeriod]?.MAD ?? 0) / 100
|
||||
}
|
||||
|
||||
function annualSavingsPct(prices: PricingMatrix, plan: string): number {
|
||||
const monthly = getPlanPrice(prices, plan, 'MONTHLY')
|
||||
const annual = getPlanPrice(prices, plan, 'ANNUAL')
|
||||
const wouldPay = monthly * 12
|
||||
if (wouldPay <= 0) return 0
|
||||
return Math.max(0, Math.round(((wouldPay - annual) / wouldPay) * 100))
|
||||
}
|
||||
|
||||
export default function PricingClient({ initialPricing = DEFAULT_PRICING }: { initialPricing?: PlatformPricing }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const currencyLabel = getCurrencyLabel(language)
|
||||
const dict = copy[language]
|
||||
const [billing, setBilling] = useState<Billing>('monthly')
|
||||
const [pricing, setPricing] = useState<PlatformPricing>(initialPricing)
|
||||
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
marketplaceFetchOrDefault<PlatformPricing>('/site/platform/pricing', DEFAULT_PRICING)
|
||||
.then(setPricing)
|
||||
}, [])
|
||||
|
||||
const savings = annualSavingsPct(pricing.prices, 'STARTER')
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{/* Billing toggle */}
|
||||
<div className="flex items-center justify-center gap-1 rounded-full border border-stone-200 bg-white p-1 shadow-sm dark:border-blue-800 dark:bg-blue-950">
|
||||
{(['monthly', 'annual'] as Billing[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBilling(b)}
|
||||
className={`relative rounded-full px-5 py-2 text-sm font-semibold transition-colors ${
|
||||
billing === b
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100'
|
||||
}`}
|
||||
>
|
||||
{b === 'monthly' ? dict.monthly : dict.annual}
|
||||
{b === 'annual' && billing !== 'annual' && (
|
||||
<span className="ml-2 rounded-full bg-orange-100 px-2 py-0.5 text-[10px] font-bold text-orange-700 dark:bg-orange-950/50 dark:text-orange-400">
|
||||
-{savings}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{billing === 'annual' && (
|
||||
<p className="text-center text-sm font-medium text-orange-700 dark:text-orange-400">
|
||||
{dict.youSave} {savings}% {dict.withAnnual}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => {
|
||||
const monthlyPrice = getPlanPrice(pricing.prices, plan.key, 'MONTHLY')
|
||||
const annualPrice = getPlanPrice(pricing.prices, plan.key, 'ANNUAL')
|
||||
const displayPrice = billing === 'annual'
|
||||
? Math.round(annualPrice / 12)
|
||||
: monthlyPrice
|
||||
const features = pricing.planFeatures[plan.key] ?? PLAN_FEATURES[plan.key] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`card relative flex flex-col overflow-hidden ${
|
||||
plan.highlight
|
||||
? 'border-orange-400 ring-2 ring-orange-400 ring-offset-2 dark:ring-offset-blue-950'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="bg-orange-500 dark:bg-orange-400 py-1.5 text-center text-xs font-bold uppercase tracking-widest text-white dark:text-stone-900">
|
||||
{dict.mostPopular}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-400">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-4xl font-black text-stone-900 dark:text-stone-100">
|
||||
{displayPrice}
|
||||
</span>
|
||||
<span className="mb-1 text-lg font-semibold text-stone-500 dark:text-stone-400">{currencyLabel}</span>
|
||||
<span className="mb-1 text-sm text-stone-400 dark:text-stone-500">{dict.perMonth}</span>
|
||||
</div>
|
||||
{billing === 'annual' && (
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
|
||||
{dict.billedAs} {annualPrice} {currencyLabel} {dict.yearly}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-3">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2.5 text-sm text-stone-700 dark:text-stone-300">
|
||||
<svg
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-orange-500 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={`${dashboardUrl}/sign-up?plan=${plan.key}&billing=${billing.toUpperCase()}¤cy=MAD`}
|
||||
className={`mt-10 block rounded-full px-6 py-3 text-center text-sm font-semibold transition-colors ${
|
||||
plan.highlight
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-400 dark:text-stone-900 dark:hover:bg-orange-300'
|
||||
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400'
|
||||
}`}
|
||||
>
|
||||
{dict.getStarted}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-stone-400 dark:text-stone-500">{dict.footer}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||
import PricingClient from './PricingClient'
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
kicker: 'Pricing',
|
||||
title: 'One platform, every fleet size.',
|
||||
body: 'Start free for 90 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
|
||||
faq: 'Frequently asked',
|
||||
items: [
|
||||
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
|
||||
['What counts as a vehicle?', 'Any vehicle record added to your fleet dashboard, active or not.'],
|
||||
['Is there a setup fee?', 'No setup fee, ever. You only pay the monthly or annual subscription.'],
|
||||
['How does the marketplace listing work?', 'All paid plans include a public marketplace listing on RentalDriveGo. Renters can discover your fleet and are redirected to your branded booking site.'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Tarifs',
|
||||
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
||||
body: 'Commencez gratuitement pendant 90 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
|
||||
faq: 'Questions fréquentes',
|
||||
items: [
|
||||
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
|
||||
["Qu'est-ce qui compte comme véhicule ?", "Tout véhicule ajouté à votre tableau de bord de flotte, qu'il soit actif ou non."],
|
||||
["Y a-t-il des frais d'installation ?", "Aucun frais d'installation. Vous payez uniquement l'abonnement mensuel ou annuel."],
|
||||
['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'الأسعار',
|
||||
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
||||
body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
|
||||
faq: 'الأسئلة الشائعة',
|
||||
items: [
|
||||
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
||||
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
||||
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
|
||||
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
||||
],
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function PricingPageContent({ initialPricing }: { initialPricing?: React.ComponentProps<typeof PricingClient>['initialPricing'] }) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
const dict = copy[language]
|
||||
|
||||
return (
|
||||
<main className="site-page">
|
||||
<div className="site-section">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="site-kicker">{dict.kicker}</p>
|
||||
<h1 className="site-title">{dict.title}</h1>
|
||||
<p className="site-lead">{dict.body}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<PricingClient initialPricing={initialPricing} />
|
||||
</div>
|
||||
|
||||
<div className="site-panel mx-auto mt-24 max-w-3xl divide-y divide-stone-200/80 dark:divide-stone-800">
|
||||
<h2 className="pb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">{dict.faq}</h2>
|
||||
{dict.items.map(([q, a]) => (
|
||||
<div key={q} className="py-6">
|
||||
<p className="font-semibold text-stone-900 dark:text-stone-100">{q}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-stone-400">{a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import PricingPageContent from './PricingPageContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — RentalDriveGo',
|
||||
description: 'Simple, transparent pricing for every fleet size.',
|
||||
}
|
||||
|
||||
const DEFAULT_PRICING = {
|
||||
prices: PLAN_PRICES,
|
||||
planFeatures: PLAN_FEATURES,
|
||||
}
|
||||
|
||||
export default async function PricingPage() {
|
||||
const initialPricing = await marketplaceFetchOrDefault('/site/platform/pricing', DEFAULT_PRICING)
|
||||
return <PricingPageContent initialPricing={initialPricing} />
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Star } from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface ReviewInfo {
|
||||
reservationId: string
|
||||
companyName: string
|
||||
companyLogoUrl: string | null
|
||||
vehicle: string
|
||||
vehiclePhoto: string | null
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
|
||||
const [hovered, setHovered] = useState(0)
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">{label}</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
onMouseEnter={() => setHovered(n)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
n <= (hovered || value)
|
||||
? 'fill-orange-400 text-orange-400'
|
||||
: 'fill-stone-200 text-stone-200 dark:fill-stone-700 dark:text-stone-700'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPageContent() {
|
||||
const params = useSearchParams()
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<ReviewInfo | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
|
||||
|
||||
const [overall, setOverall] = useState(0)
|
||||
const [vehicle, setVehicle] = useState(0)
|
||||
const [service, setService] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoadState('invalid'); return }
|
||||
fetch(`${API_BASE}/marketplace/review/${token}`)
|
||||
.then(async (r) => {
|
||||
if (r.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!r.ok) { setLoadState('invalid'); return }
|
||||
const json = await r.json()
|
||||
setInfo(json.data)
|
||||
setLoadState('ready')
|
||||
})
|
||||
.catch(() => setLoadState('invalid'))
|
||||
}, [token])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
|
||||
setSubmitState('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
overallRating: overall,
|
||||
vehicleRating: vehicle || undefined,
|
||||
serviceRating: service || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.message ?? 'Something went wrong.')
|
||||
}
|
||||
setSubmitState('done')
|
||||
} catch (err: any) {
|
||||
setSubmitState('error')
|
||||
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'invalid') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<p className="text-2xl font-black text-stone-900 dark:text-stone-100">Link not found</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">This review link is invalid or has already been used.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'already_reviewed') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-3 p-8 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-900 dark:text-stone-100">Already reviewed</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">You have already submitted a review for this rental. Thank you!</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitState === 'done') {
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-md space-y-4 p-8 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-8 w-8 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-stone-900 dark:text-stone-100">Thank you!</h1>
|
||||
<p className="text-stone-500 dark:text-stone-400">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<div className="card w-full max-w-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 border-b border-stone-200 dark:border-blue-900 p-6">
|
||||
{info?.vehiclePhoto ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 flex-shrink-0 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-24 flex-shrink-0 rounded-xl bg-stone-100 dark:bg-blue-900/30" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs uppercase tracking-widest text-stone-400 dark:text-stone-500">{info?.companyName}</p>
|
||||
<h1 className="mt-0.5 truncate text-lg font-bold text-stone-900 dark:text-stone-100">{info?.vehicle}</h1>
|
||||
<p className="mt-0.5 text-sm text-stone-500 dark:text-stone-400">How was your experience?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
||||
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
|
||||
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
|
||||
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">Comments (optional)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us about your rental experience…"
|
||||
className="w-full resize-none rounded-xl border border-stone-200 dark:border-blue-800 bg-white dark:bg-blue-950 px-4 py-3 text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 dark:placeholder:text-stone-500 focus:outline-none focus:ring-2 focus:ring-stone-900 dark:focus:ring-stone-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<p className="rounded-xl bg-rose-50 dark:bg-rose-950/40 px-4 py-3 text-sm text-rose-700 dark:text-rose-400">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState === 'submitting'}
|
||||
className="w-full rounded-full bg-orange-600 py-3 text-sm font-semibold text-white transition-colors hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{submitState === 'submitting' ? 'Submitting…' : 'Submit review'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="site-page flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">Loading…</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<ReviewPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { StateAction, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { SignInForm } from '@/components/auth/SignInForm';
|
||||
import { ForgotPasswordForm } from '@/components/auth/ForgotPasswordForm';
|
||||
import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm';
|
||||
import {
|
||||
isLocale,
|
||||
localizedPath,
|
||||
routeIdFromSlug,
|
||||
type Locale,
|
||||
type RouteId,
|
||||
} from '@/lib/localization/config';
|
||||
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface PendingRouteProps {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
|
||||
const labels = messages.footer.links;
|
||||
switch (routeId) {
|
||||
case 'sign-in':
|
||||
return labels.login;
|
||||
case 'forgot-password':
|
||||
return 'Forgot password';
|
||||
case 'reset-password':
|
||||
return 'Reset password';
|
||||
case 'privacy':
|
||||
return labels.privacy;
|
||||
case 'terms':
|
||||
return labels.terms;
|
||||
case 'accessibility':
|
||||
return labels.accessibility;
|
||||
case 'home':
|
||||
return 'RentalDriveGo';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveParams(params: PendingRouteProps['params']) {
|
||||
const { locale: localeValue, slug } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const routeId = routeIdFromSlug(localeValue, slug);
|
||||
if (!routeId || routeId === 'home') notFound();
|
||||
return { locale: localeValue as Locale, routeId };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
return buildLocalizedMetadata({
|
||||
locale,
|
||||
routeId,
|
||||
title: `${label} | ${messages.metadata.pendingTitle}`,
|
||||
description: messages.metadata.pendingDescription,
|
||||
indexable: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PendingRoute({ params }: PendingRouteProps) {
|
||||
const { locale, routeId } = await resolveParams(params);
|
||||
const messages = getMessages(locale).shell;
|
||||
const label = routeLabel(messages, routeId);
|
||||
|
||||
if (routeId === 'sign-in') {
|
||||
return <SignInForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'forgot-password') {
|
||||
return <ForgotPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
if (routeId === 'reset-password') {
|
||||
return <ResetPasswordForm locale={locale} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StateShell
|
||||
title={`${label}: ${messages.states.pendingTitle}`}
|
||||
body={messages.states.pendingBody}
|
||||
>
|
||||
<StateActions>
|
||||
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { Accordion } from '@/components/controls/Accordion';
|
||||
import { SegmentedControl } from '@/components/controls/SegmentedControl';
|
||||
import { Tabs } from '@/components/controls/Tabs';
|
||||
import { Alert } from '@/components/feedback/Alert';
|
||||
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { TextInput } from '@/components/forms/TextInput';
|
||||
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
|
||||
import { Dialog } from '@/components/overlays/Dialog';
|
||||
import type { ComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { useRef, useState } from 'react';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [segment, setSegment] = useState('comfortable');
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<div className={styles.interactiveGrid}>
|
||||
<section className={styles.group} aria-labelledby="lab-actions">
|
||||
<h2 id="lab-actions">Actions</h2>
|
||||
<div className={styles.cluster}>
|
||||
<Button>{copy.actions.primary}</Button>
|
||||
<Button intent="conversion">{copy.actions.conversion}</Button>
|
||||
<Button loading>{copy.actions.loading}</Button>
|
||||
<Button disabled>{copy.actions.disabled}</Button>
|
||||
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
|
||||
{copy.dialog.open}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
label={copy.menu.label}
|
||||
items={[
|
||||
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
|
||||
{
|
||||
id: 'archive',
|
||||
label: copy.menu.second,
|
||||
onSelect: () => undefined,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-form">
|
||||
<h2 id="lab-form">Form controls</h2>
|
||||
<FormField
|
||||
id="fixture-email"
|
||||
label={copy.form.label}
|
||||
supportingText={copy.form.supporting}
|
||||
error={copy.form.error}
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
id="fixture-email"
|
||||
type="email"
|
||||
placeholder={copy.form.placeholder}
|
||||
invalid
|
||||
describedBy="fixture-email-help fixture-email-error"
|
||||
/>
|
||||
</FormField>
|
||||
<Checkbox label={copy.form.checkbox} />
|
||||
<Switch label={copy.form.switch} />
|
||||
<SegmentedControl
|
||||
label="Density"
|
||||
value={segment}
|
||||
onChange={setSegment}
|
||||
options={[
|
||||
{ value: 'comfortable', label: 'Comfortable' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<section className={styles.group} aria-labelledby="lab-controls">
|
||||
<h2 id="lab-controls">Selection and disclosure</h2>
|
||||
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
|
||||
<Accordion items={copy.accordion.items} />
|
||||
</section>
|
||||
<Alert tone="warning" title={copy.alert.title}>
|
||||
{copy.alert.body}
|
||||
</Alert>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={copy.dialog.title}
|
||||
description={copy.dialog.description}
|
||||
closeLabel={copy.dialog.close}
|
||||
returnFocusRef={triggerRef}
|
||||
>
|
||||
<p>{copy.dialog.content}</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.main {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
.interactiveGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
min-inline-size: 0;
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--border-standard);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.group h2 {
|
||||
margin: 0;
|
||||
font-size: var(--type-heading-3-size);
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.interactiveGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
|
||||
import { Comparison } from '@/components/marketing/Comparison';
|
||||
import { CTA } from '@/components/marketing/CTA';
|
||||
import { Metric } from '@/components/marketing/Evidence';
|
||||
import { ProductPreview } from '@/components/marketing/ProductPreview';
|
||||
import { SectionHeader } from '@/components/marketing/SectionHeader';
|
||||
import { Workflow } from '@/components/marketing/Workflow';
|
||||
import { getComponentLabCopy } from '@/content/development/component-lab';
|
||||
import { isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ComponentLabInteractive } from './ComponentLabInteractive';
|
||||
import styles from './page.module.css';
|
||||
|
||||
export const metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
export default async function ComponentLabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const resolvedLocale: Locale = locale;
|
||||
const copy = getComponentLabCopy(resolvedLocale);
|
||||
return (
|
||||
<main id="main-content" className={styles.main}>
|
||||
<Section>
|
||||
<Container>
|
||||
<Stack gap="large">
|
||||
<SectionHeader
|
||||
eyebrow={copy.developmentLabel}
|
||||
title={copy.title}
|
||||
body={copy.body}
|
||||
headingLevel={1}
|
||||
/>
|
||||
<ComponentLabInteractive copy={copy} />
|
||||
<Workflow label={copy.title} items={copy.workflow} />
|
||||
<Comparison
|
||||
columns={[
|
||||
{
|
||||
title: copy.comparison.beforeTitle,
|
||||
items: copy.comparison.before,
|
||||
tone: 'problem',
|
||||
},
|
||||
{
|
||||
title: copy.comparison.afterTitle,
|
||||
items: copy.comparison.after,
|
||||
tone: 'solution',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProductPreview
|
||||
title={copy.preview.title}
|
||||
caption={copy.preview.caption}
|
||||
illustrativeLabel={copy.preview.illustrative}
|
||||
rows={copy.preview.rows.map((row, index) => ({
|
||||
...row,
|
||||
statusTone: index === 0 ? 'success' : 'warning',
|
||||
}))}
|
||||
/>
|
||||
<Metric
|
||||
content={{
|
||||
value: copy.metric.value,
|
||||
label: copy.metric.label,
|
||||
qualification: copy.metric.qualification,
|
||||
status: 'research-only',
|
||||
}}
|
||||
statusLabel={copy.metric.status}
|
||||
/>
|
||||
<CTA
|
||||
title={copy.cta.title}
|
||||
body={copy.cta.body}
|
||||
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { StateButton, StateActions, StateShell } from '@/components/app-shell/StateShell';
|
||||
import { getClientShellMessages } from '@/lib/localization/client-messages';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function ErrorPage({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const params = useParams<{ locale?: string }>();
|
||||
const messages = getClientShellMessages(params?.locale);
|
||||
|
||||
return (
|
||||
<StateShell title={messages.states.errorTitle} body={messages.states.errorBody}>
|
||||
<StateActions>
|
||||
<StateButton onClick={reset}>{messages.states.retry}</StateButton>
|
||||
</StateActions>
|
||||
</StateShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '@fontsource-variable/noto-sans-arabic/index.css';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/component-tokens.css';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import { SiteFooter } from '@/components/app-shell/SiteFooter';
|
||||
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
|
||||
import { SiteHeader } from '@/components/app-shell/SiteHeader';
|
||||
import { ThemeController } from '@/components/app-shell/ThemeController';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
|
||||
import {
|
||||
isThemePreference,
|
||||
serverResolvedTheme,
|
||||
themeCookie,
|
||||
type ThemePreference,
|
||||
} from '@/lib/theme/config';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
icons: {
|
||||
icon: '/rentaldrivego.jpeg',
|
||||
apple: '/rentaldrivego.jpeg',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const requestHeaders = await headers();
|
||||
const nonce = requestHeaders.get('x-nonce') ?? undefined;
|
||||
const cookieStore = await cookies();
|
||||
const cookiePreference = cookieStore.get(themeCookie)?.value;
|
||||
const preference: ThemePreference = isThemePreference(cookiePreference)
|
||||
? cookiePreference
|
||||
: 'system';
|
||||
const resolvedTheme = serverResolvedTheme(preference);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={getDirection(locale)}
|
||||
data-theme-preference={preference}
|
||||
data-theme={resolvedTheme}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
id="theme-bootstrap"
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<ThemeController />
|
||||
<a className="skip-link" href="#main-content">
|
||||
{messages.shell.skipToContent}
|
||||
</a>
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
themePreference={preference}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
{children}
|
||||
<DemoDialogHost
|
||||
locale={locale}
|
||||
messages={messages.homepage.form}
|
||||
enabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<SiteFooter
|
||||
locale={locale}
|
||||
messages={messages.shell}
|
||||
demoEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedLoading } from '@/components/app-shell/LocalizedLoading';
|
||||
|
||||
export default function Loading() {
|
||||
return <LocalizedLoading />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LocalizedNotFound } from '@/components/app-shell/LocalizedNotFound';
|
||||
|
||||
export default function NotFound() {
|
||||
return <LocalizedNotFound />;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import styles from '@/components/homepage/Homepage.module.css';
|
||||
import {
|
||||
ComparisonSection,
|
||||
FaqSection,
|
||||
FinalCtaSection,
|
||||
HeroSection,
|
||||
IntegrationsSection,
|
||||
ModulesSection,
|
||||
PricingSection,
|
||||
ResultsSection,
|
||||
RolesSection,
|
||||
SecuritySection,
|
||||
TrustSection,
|
||||
WorkflowSection,
|
||||
} from '@/components/homepage/sections';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { getPublicIntegrationConfig } from '@/lib/integrations/environment';
|
||||
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
|
||||
import { getMessages } from '@/lib/localization/messages';
|
||||
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface LocalePageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
const messages = getMessages(localeValue);
|
||||
return buildLocalizedMetadata({
|
||||
locale: localeValue,
|
||||
routeId: 'home',
|
||||
title: messages.homepage.meta.title,
|
||||
description: messages.homepage.meta.description,
|
||||
indexable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function LocaleHomePage({ params }: LocalePageProps) {
|
||||
const { locale: localeValue } = await params;
|
||||
if (!isLocale(localeValue)) notFound();
|
||||
|
||||
const locale: Locale = localeValue;
|
||||
const messages = getMessages(locale);
|
||||
const content = buildHomepageContent(messages.homepage, messages.shell);
|
||||
const integrationConfig = getPublicIntegrationConfig();
|
||||
const homePath = localizedPath('home', locale);
|
||||
|
||||
return (
|
||||
<main id="main-content" className={styles.main} tabIndex={-1}>
|
||||
<HeroSection
|
||||
content={content.hero}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<TrustSection content={content.trust} />
|
||||
<ComparisonSection content={content.comparison} />
|
||||
<WorkflowSection content={content.workflow} />
|
||||
<RolesSection content={content.roles} />
|
||||
<ModulesSection content={content.modules} />
|
||||
<ResultsSection content={content.results} />
|
||||
<IntegrationsSection content={content.integrations} />
|
||||
<SecuritySection content={content.security} />
|
||||
<PricingSection
|
||||
content={content.pricing}
|
||||
locale={locale}
|
||||
homePath={homePath}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
<FaqSection content={content.faq} />
|
||||
<FinalCtaSection
|
||||
content={content.final}
|
||||
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
demoSubmissionEnvelopeSchema,
|
||||
zodFieldErrors,
|
||||
type DemoSubmissionResult,
|
||||
} from '@/lib/demo/schema';
|
||||
import { submitDemoLead } from '@/lib/demo/service';
|
||||
import { getIntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { isApplicationJson } from '@/lib/security/content-type';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const maximumBodyBytes = 16_384;
|
||||
const responseHeaders = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
result: Extract<DemoSubmissionResult, { ok: false }>,
|
||||
): NextResponse<DemoSubmissionResult> {
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
|
||||
function isAllowedOrigin(
|
||||
request: Request,
|
||||
approvedOrigin: string | undefined,
|
||||
production: boolean,
|
||||
) {
|
||||
const origin = request.headers.get('origin');
|
||||
if (!origin) return !production;
|
||||
const requestOrigin = new URL(request.url).origin;
|
||||
if (origin === requestOrigin) return true;
|
||||
return approvedOrigin ? origin === new URL(approvedOrigin).origin : false;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse<DemoSubmissionResult>> {
|
||||
const correlationId = randomUUID();
|
||||
let environment;
|
||||
try {
|
||||
environment = getIntegrationEnvironment();
|
||||
} catch {
|
||||
return failure(503, {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
formErrorCode: 'integration_environment_invalid',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.headers.get('x-rdg-form') !== 'demo-v1') {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_request_marker',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(request, environment.SITE_ORIGIN, environment.NODE_ENV === 'production')) {
|
||||
return failure(403, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'invalid_origin',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
if (!isApplicationJson(contentType)) {
|
||||
return failure(415, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'unsupported_content_type',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get('content-length') ?? '0');
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
if (new TextEncoder().encode(raw).byteLength > maximumBodyBytes) {
|
||||
return failure(413, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'request_too_large',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
formErrorCode: 'malformed_json',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = demoSubmissionEnvelopeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'validation',
|
||||
fieldErrors: zodFieldErrors(parsed.error),
|
||||
formErrorCode: 'validation_failed',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.guard.honeypot.length > 0) {
|
||||
return failure(400, {
|
||||
ok: false,
|
||||
category: 'abuse',
|
||||
formErrorCode: 'request_rejected',
|
||||
retryable: false,
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioHeader = request.headers.get('x-rdg-test-scenario');
|
||||
const testScenario =
|
||||
environment.NODE_ENV !== 'production' &&
|
||||
environment.DEMO_TEST_SCENARIOS_ENABLED &&
|
||||
['success', 'duplicate', 'timeout', 'integration-error'].includes(scenarioHeader ?? '')
|
||||
? (scenarioHeader as 'success' | 'duplicate' | 'timeout' | 'integration-error')
|
||||
: undefined;
|
||||
|
||||
const result = await submitDemoLead(parsed.data.lead, {
|
||||
environment,
|
||||
...(testScenario ? { testScenario } : {}),
|
||||
});
|
||||
|
||||
const status = result.ok
|
||||
? 202
|
||||
: result.category === 'timeout'
|
||||
? 504
|
||||
: result.category === 'configuration'
|
||||
? 503
|
||||
: result.category === 'duplicate'
|
||||
? 409
|
||||
: 502;
|
||||
return NextResponse.json(result, { status, headers: responseHeaders });
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-white text-blue-950 antialiased transition-colors dark:text-slate-100;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-[2rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
}
|
||||
|
||||
.site-page {
|
||||
@apply min-h-screen overflow-hidden text-blue-950;
|
||||
background-image:
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-page {
|
||||
@apply text-slate-100;
|
||||
background-image:
|
||||
linear-gradient(180deg, #0a1535 0%, #0d1f52 35%, #091228 100%);
|
||||
}
|
||||
|
||||
.site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(234, 88, 12, 0.24), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
html.dark .site-glow {
|
||||
background-image:
|
||||
radial-gradient(circle at top left, rgba(251, 146, 60, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(96, 165, 250, 0.18), transparent 26%);
|
||||
}
|
||||
|
||||
.site-section {
|
||||
@apply shell py-10 sm:py-14 lg:py-16;
|
||||
}
|
||||
|
||||
.site-panel {
|
||||
@apply rounded-[2rem] border p-7 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-color: rgb(255 255 255 / 0.82);
|
||||
}
|
||||
|
||||
html.dark .site-panel {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.72);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.site-panel-muted {
|
||||
@apply rounded-[2rem] border p-7 transition-colors sm:p-8;
|
||||
border-color: rgb(231 229 228 / 0.8);
|
||||
background-image: linear-gradient(160deg, #f5f8ff 0%, #edf2ff 100%);
|
||||
}
|
||||
|
||||
html.dark .site-panel-muted {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-image: linear-gradient(160deg, #0c1830 0%, #10203e 100%);
|
||||
}
|
||||
|
||||
.site-panel-contrast {
|
||||
@apply rounded-[2rem] border p-7 text-white shadow-[0_30px_80px_rgba(28,25,23,0.18)] transition-colors sm:p-8;
|
||||
border-color: rgb(14 40 90 / 0.8);
|
||||
background-color: rgb(10 25 75);
|
||||
}
|
||||
|
||||
html.dark .site-panel-contrast {
|
||||
border-color: rgb(30 64 175);
|
||||
}
|
||||
|
||||
.site-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.28em] text-orange-600 dark:text-orange-400;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
@apply mt-4 text-4xl font-black tracking-[-0.04em] text-blue-950 dark:text-white sm:text-5xl;
|
||||
}
|
||||
|
||||
.site-lead {
|
||||
@apply mt-5 text-base leading-8 text-stone-600 dark:text-slate-300 sm:text-lg;
|
||||
}
|
||||
|
||||
.site-link-primary {
|
||||
@apply rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
.site-link-secondary {
|
||||
@apply rounded-full border border-stone-300 bg-white/85 px-6 py-3 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FleetOS Marketplace',
|
||||
description: 'Discover vehicles from trusted rental companies.',
|
||||
icons: {
|
||||
icon: '/rentalcardrive.png',
|
||||
shortcut: '/favicon.ico',
|
||||
apple: '/rentalcardrive.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = await getMarketplaceLanguage()
|
||||
const cookieStore = await cookies()
|
||||
const rawTheme =
|
||||
cookieStore.get('rentaldrivego-theme')?.value ??
|
||||
cookieStore.get('marketplace-theme')?.value
|
||||
const theme = rawTheme === 'dark' ? 'dark' : 'light'
|
||||
|
||||
return (
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before hydration to prevent flash of wrong theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('marketplace-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell initialLanguage={language} initialTheme={theme}>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getSiteOrigin } from '@/lib/localization/site-origin';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const origin = getSiteOrigin();
|
||||
const approved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
|
||||
return {
|
||||
rules: approved ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
|
||||
host: origin.href,
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
© {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 d’entrée sur le port 3000 pour la découverte marketplace, les opérations de l’entreprise et l’administration 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 l’identité de l’entreprise 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 d’une entreprise.',
|
||||
exploreVehicles: 'Explorer les véhicules',
|
||||
viewPricing: 'Voir les tarifs',
|
||||
nowServing: 'Disponible',
|
||||
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et l’accès à la réservation restent ancrés dans la marketplace.',
|
||||
promoBody:
|
||||
'L’espace 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 d’un 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 l’analyse.',
|
||||
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 d’un 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user