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

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
<Link href="/" className="block px-5 py-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">FleetOS</p>
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
</Link>
<nav className="flex-1 px-3 space-y-0.5">
{navLinks.map((link) => {
+9 -9
View File
@@ -21,9 +21,9 @@ export default function AdminDashboardPage() {
const copy = {
en: {
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
brand: 'FleetOS',
brand: 'RentalDriveGo',
eyebrow: 'Operations command',
platformOverview: 'FleetOS admin dashboard',
platformOverview: 'RentalDriveGo admin dashboard',
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
liveSignal: 'Live platform signal',
readiness: 'Operational readiness',
@@ -34,15 +34,15 @@ export default function AdminDashboardPage() {
cards: [
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Trace every sensitive admin action across FleetOS.', 'View logs'],
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
],
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
},
fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
brand: 'FleetOS',
brand: 'RentalDriveGo',
eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin FleetOS',
platformOverview: 'Tableau de bord admin RentalDriveGo',
subtitle: 'Suivez la santé de la marketplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle',
@@ -53,15 +53,15 @@ export default function AdminDashboardPage() {
cards: [
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Tracer chaque action admin sensible dans FleetOS.', 'Voir les journaux'],
['Journaux daudit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
],
health: ['Comptes locataires', 'Identité marketplace', 'Piste daudit admin'],
},
ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
brand: 'FleetOS',
brand: 'RentalDriveGo',
eyebrow: 'مركز العمليات',
platformOverview: 'لوحة إدارة FleetOS',
platformOverview: 'لوحة إدارة RentalDriveGo',
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
liveSignal: 'مؤشر المنصة المباشر',
readiness: 'جاهزية التشغيل',
@@ -72,7 +72,7 @@ export default function AdminDashboardPage() {
cards: [
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل FleetOS.', 'عرض السجلات'],
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
],
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
},
+2 -2
View File
@@ -3,8 +3,8 @@ import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css'
export const metadata: Metadata = {
title: 'FleetOS Admin',
description: 'FleetOS platform administration.',
title: 'RentalDriveGo Admin',
description: 'RentalDriveGo platform administration.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -98,19 +98,19 @@ export async function startAccount(body: AccountStartInput) {
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
en: 'Verify your email — RentalDriveGo',
fr: 'Vérifiez votre email — RentalDriveGo',
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
}
const emailBodies: Record<string, string> = {
en: `<p>Welcome to FleetOS!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
fr: `<p>Bienvenue sur FleetOS !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
ar: `<p>مرحباً بك في FleetOS!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
en: `<p>Welcome to RentalDriveGo!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
fr: `<p>Bienvenue sur RentalDriveGo !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
ar: `<p>مرحباً بك في RentalDriveGo!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
}
const emailTexts: Record<string, string> = {
en: `Welcome to FleetOS!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
fr: `Bienvenue sur FleetOS !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
ar: `مرحباً بك في FleetOS!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
en: `Welcome to RentalDriveGo!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
fr: `Bienvenue sur RentalDriveGo !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
ar: `مرحباً بك في RentalDriveGo!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
}
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
@@ -213,9 +213,9 @@ export async function resendVerification(email: string) {
const lang = (employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr') ? employee.preferredLanguage : 'en'
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
en: 'Verify your email — RentalDriveGo',
fr: 'Vérifiez votre email — RentalDriveGo',
ar: 'تحقق من بريدك الإلكتروني — RentalDriveGo',
}
await sendTransactionalEmail({
+2
View File
@@ -15,6 +15,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
"@rentaldrivego/types": "*",
"autoprefixer": "^10.4.19",
"dayjs": "^1.11.11",
Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

@@ -657,7 +657,7 @@ export default function SettingsPage() {
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.FleetOS.com</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
@@ -1,90 +1,98 @@
'use client'
"use client";
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { API_BASE } from '@/lib/api'
import { marketplaceUrl } from '@/lib/urls'
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useDashboardI18n } from "@/components/I18nProvider";
import PublicShell from "@/components/layout/PublicShell";
import { API_BASE } from "@/lib/api";
import { marketplaceUrl } from "@/lib/urls";
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const DASHBOARD_LOGO_SRC = "/dashboard/rentalcardrive.png";
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
const { language } = useDashboardI18n()
export default function ForgotPasswordPageClient({
embedded = false,
}: {
embedded?: boolean;
}) {
const { language } = useDashboardI18n();
const dict = {
en: {
title: 'Forgot your password?',
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.',
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.',
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: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
title: "نسيت كلمة المرور؟",
subtitle: "أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.",
email: "البريد الإلكتروني",
emailPlaceholder: "owner@company.com",
submit: "إرسال رابط إعادة التعيين",
submitting: "جارٍ الإرسال…",
backToLogin: "العودة إلى تسجيل الدخول",
successTitle: "تحقق من بريدك الإلكتروني",
successBody:
"إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.",
error: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
},
}[language]
}[language];
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
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)
e.preventDefault();
setLoading(true);
setError(null);
try {
// Try employee first, then admin — mirrors the sign-in page which handles both
const [empRes, adminRes] = await Promise.all([
fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}),
])
if (!empRes.ok && !adminRes.ok) throw new Error()
setSent(true)
]);
if (!empRes.ok && !adminRes.ok) throw new Error();
setSent(true);
} catch {
setError(dict.error)
setError(dict.error);
} finally {
setLoading(false)
setLoading(false);
}
}
return (
<PublicShell embedded={embedded}>
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
@@ -92,7 +100,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="FleetOS"
alt="RentalDriveGo"
width={104}
height={104}
priority
@@ -100,28 +108,50 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
/>
</a>
</div>
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.title}</h1>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.subtitle}</p>
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">
{dict.title}
</h1>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
{sent ? (
<div className="space-y-4 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
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>
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">
{dict.successTitle}
</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">
{dict.successBody}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">
{error}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.email}</label>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">
{dict.email}
</label>
<input
type="email"
required
@@ -142,7 +172,10 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
)}
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
<Link
href="/sign-in"
className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700"
>
{dict.backToLogin}
</Link>
</div>
@@ -150,5 +183,5 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
</div>
</main>
</PublicShell>
)
);
}
@@ -1,6 +1,13 @@
import * as React from "react";
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
import ForgotPasswordPageClient from "./ForgotPasswordPageClient";
export default function ForgotPasswordPage() {
return <ForgotPasswordPageClient embedded={true} />
export default async function ForgotPasswordPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const embedded = params.embedded === "1";
return <ForgotPasswordPageClient embedded={embedded} />;
}
+3 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { JetBrains_Mono } from 'next/font/google'
import '@fontsource-variable/inter/index.css'
import '@fontsource-variable/noto-sans-arabic/index.css'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
import './globals.css'
@@ -12,7 +14,7 @@ const jetbrainsMono = JetBrains_Mono({
})
export const metadata: Metadata = {
title: 'FleetOS Dashboard',
title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business',
icons: {
icon: '/dashboard/icon.svg',
+2 -2
View File
@@ -96,7 +96,7 @@ export default function OnboardingPage() {
<main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">FleetOS</a>
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
@@ -251,7 +251,7 @@ export default function OnboardingPage() {
checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/>
<span className="text-sm text-slate-700">List my company on the FleetOS marketplace</span>
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
</label>
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
@@ -1,57 +1,79 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import ForgotPasswordPageClient from './forgot-password/ForgotPasswordPageClient'
import ResetPasswordPageClient from './reset-password/ResetPasswordPageClient'
import ForgotPasswordPage from './forgot-password/page'
import ResetPasswordPage from './reset-password/page'
import AcceptInvitePage from './onboarding/accept-invite/page'
import React, { isValidElement } from "react";
import { describe, expect, it } from "vitest";
import ForgotPasswordPageClient from "./forgot-password/ForgotPasswordPageClient";
import ResetPasswordPageClient from "./reset-password/ResetPasswordPageClient";
import ForgotPasswordPage from "./forgot-password/page";
import ResetPasswordPage from "./reset-password/page";
import AcceptInvitePage from "./onboarding/accept-invite/page";
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 []
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 findElement(node: unknown, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null {
if (node === null || node === undefined || typeof node === 'boolean') return null
function findElement(
node: unknown,
predicate: (element: React.ReactElement) => boolean,
): React.ReactElement | null {
if (node === null || node === undefined || typeof node === "boolean")
return null;
if (Array.isArray(node)) {
for (const child of node) {
const found = findElement(child, predicate)
if (found) return found
const found = findElement(child, predicate);
if (found) return found;
}
return null
return null;
}
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null
if (predicate(node)) return node
return findElement(node.props.children, predicate)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null;
if (predicate(node)) return node;
return findElement(node.props.children, predicate);
}
describe('dashboard public auth pages', () => {
it('renders forgot-password in embedded mode for the public shell route', () => {
const page = ForgotPasswordPage()
describe("dashboard public auth pages", () => {
it("renders forgot-password with public chrome by default", async () => {
const page = await ForgotPasswordPage({
searchParams: Promise.resolve({}),
});
expect(isValidElement(page)).toBe(true)
expect(page.type).toBe(ForgotPasswordPageClient)
expect(page.props.embedded).toBe(true)
})
expect(isValidElement(page)).toBe(true);
expect(page.type).toBe(ForgotPasswordPageClient);
expect(page.props.embedded).toBe(false);
});
it('renders reset-password in embedded mode for the public shell route', () => {
const page = ResetPasswordPage()
it("renders forgot-password in embedded mode when requested", async () => {
const page = await ForgotPasswordPage({
searchParams: Promise.resolve({ embedded: "1" }),
});
expect(isValidElement(page)).toBe(true)
expect(page.type).toBe(ResetPasswordPageClient)
expect(page.props.embedded).toBe(true)
})
expect(isValidElement(page)).toBe(true);
expect(page.type).toBe(ForgotPasswordPageClient);
expect(page.props.embedded).toBe(true);
});
it('keeps accept-invite copy and sign-in handoff visible', () => {
const page = AcceptInvitePage()
const text = collectText(page).join(' ')
const signInLink = findElement(page, (element) => element.props.href === '/sign-in')
it("renders reset-password in embedded mode for the public shell route", () => {
const page = ResetPasswordPage();
expect(text).toContain('Invitation accepted')
expect(text).toContain('Sign in to dashboard')
expect(signInLink).not.toBeNull()
})
})
expect(isValidElement(page)).toBe(true);
expect(page.type).toBe(ResetPasswordPageClient);
expect(page.props.embedded).toBe(true);
});
it("keeps accept-invite copy and sign-in handoff visible", () => {
const page = AcceptInvitePage();
const text = collectText(page).join(" ");
const signInLink = findElement(
page,
(element) => element.props.href === "/sign-in",
);
expect(text).toContain("Invitation accepted");
expect(text).toContain("Sign in to dashboard");
expect(signInLink).not.toBeNull();
});
});
@@ -240,7 +240,7 @@ function ResetShell({
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">FleetOS</span>
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">RentalDriveGo</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
</div>
@@ -1,156 +1,178 @@
'use client'
"use client";
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDashboardI18n } from "@/components/I18nProvider";
import PublicShell from "@/components/layout/PublicShell";
import { adminUrl, marketplaceUrl } from "@/lib/urls";
import { API_BASE, EMPLOYEE_PROFILE_KEY } from "@/lib/api";
import { toPublicDashboardPath } from "@/lib/dashboardPaths";
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const DASHBOARD_LOGO_SRC = "/dashboard/rentalcardrive.png";
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
if (typeof window === "undefined" || window.parent === window) return;
window.parent.postMessage(message, "*");
}
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const initializedFromQuery = useRef(false)
export default function SignInPageClient({
embedded = false,
}: {
embedded?: boolean;
}) {
const { language, theme, setLanguage, setTheme } = useDashboardI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const requestedLanguage = searchParams.get("lang");
const requestedTheme = searchParams.get("theme");
const initializedFromQuery = useRef(false);
const dict = {
en: {
brandName: 'FleetOS',
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.',
brandName: "RentalDriveGo",
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: {
brandName: 'FleetOS',
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…',
brandName: "RentalDriveGo",
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.',
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.',
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: {
brandName: 'FleetOS',
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: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
brandName: "RentalDriveGo",
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: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
},
}[language]
}[language];
useEffect(() => {
if (initializedFromQuery.current) return
if (initializedFromQuery.current) return;
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
(requestedLanguage === "en" ||
requestedLanguage === "fr" ||
requestedLanguage === "ar") &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
setLanguage(requestedLanguage);
}
if (
(requestedTheme === 'light' || requestedTheme === 'dark') &&
(requestedTheme === "light" || requestedTheme === "dark") &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
setTheme(requestedTheme);
}
initializedFromQuery.current = true
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
initializedFromQuery.current = true;
}, [
language,
requestedLanguage,
requestedTheme,
setLanguage,
setTheme,
theme,
]);
useEffect(() => {
if (!initializedFromQuery.current) return
if (!initializedFromQuery.current) return;
const params = new URLSearchParams(searchParams.toString())
let changed = false
const params = new URLSearchParams(searchParams.toString());
let changed = false;
if (params.get('lang') !== language) {
params.set('lang', language)
changed = true
if (params.get("lang") !== language) {
params.set("lang", language);
changed = true;
}
if (params.get('theme') !== theme) {
params.set('theme', theme)
changed = true
if (params.get("theme") !== theme) {
params.set("theme", theme);
changed = true;
}
// useSearchParams() can return empty params during hydration without a Suspense boundary.
// Always preserve embedded=1 when the component was server-rendered as embedded so
// the router.replace doesn't strip it and trigger an unintended redirect.
if (embedded && params.get('embedded') !== '1') {
params.set('embedded', '1')
changed = true
if (embedded && params.get("embedded") !== "1") {
params.set("embedded", "1");
changed = true;
}
if (!changed) return
if (!changed) return;
const nextQuery = params.toString()
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
}, [embedded, language, pathname, router, searchParams, theme])
const nextQuery = params.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [embedded, language, pathname, router, searchParams, theme]);
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
const currentPath =
window.location.pathname + (window.location.search || "");
notifyParent({ type: "rentaldrivego:embedded-path", path: currentPath });
}, [pathname, searchParams]);
return (
<PublicShell embedded={embedded}>
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
@@ -158,7 +180,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="FleetOS"
alt="RentalDriveGo"
width={104}
height={104}
priority
@@ -167,24 +189,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
</a>
</div>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
FleetOS
RentalDriveGo
</p>
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.brandName}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
{(["en", "fr", "ar"] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setLanguage(lang)}
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-orange-700 dark:text-orange-300 ${
language === lang
? 'ring-1 ring-current opacity-100'
: 'opacity-45 hover:opacity-80'
? "ring-1 ring-current opacity-100"
: "opacity-45 hover:opacity-80"
}`}
>
{lang}
@@ -199,168 +218,174 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
</div>
</main>
</PublicShell>
)
);
}
function LocalSignInForm({
dict,
}: {
dict: {
email: string
password: string
signIn: string
signingIn: string
verify: string
verifying: string
authCode: string
enterCode: string
back: string
forgotPassword: string
invalidCredentials: string
passwordNotSet: string
tooManyRequests: string
emailPlaceholder: string
totpPlaceholder: string
emailNotVerified: string
unexpectedError: string
}
email: string;
password: string;
signIn: string;
signingIn: string;
verify: string;
verifying: string;
authCode: string;
enterCode: string;
back: string;
forgotPassword: string;
invalidCredentials: string;
passwordNotSet: string;
tooManyRequests: string;
emailPlaceholder: string;
totpPlaceholder: string;
emailNotVerified: string;
unexpectedError: string;
};
}) {
const searchParams = useSearchParams()
const { setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [showPassword, setShowPassword] = useState(false)
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'
const searchParams = useSearchParams();
const { setLanguage } = useDashboardI18n();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [step, setStep] = useState<"credentials" | "totp">("credentials");
const [showPassword, setShowPassword] = useState(false);
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)
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',
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return true
window.location.href = `${adminUrl}/dashboard`;
return true;
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return true
if (adminRes.status === 401 && adminJson?.error === "totp_required") {
setStep("totp");
return true;
}
if (adminRes.status === 429) {
setError(dict.tooManyRequests)
return true
setError(dict.tooManyRequests);
return true;
}
return false
}
return false;
};
const tryEmployeeLogin = async () => {
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
});
const empJson = await empRes.json();
if (empRes.ok && empJson?.data?.employee) {
const targetPath = toPublicDashboardPath(employeeRedirect)
const targetPath = toPublicDashboardPath(employeeRedirect);
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') {
setLanguage(prefLang)
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
localStorage.setItem(
EMPLOYEE_PROFILE_KEY,
JSON.stringify(empJson.data.employee),
);
const prefLang = empJson.data.employee?.preferredLanguage;
if (prefLang === "en" || prefLang === "fr" || prefLang === "ar") {
setLanguage(prefLang);
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`;
}
}
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
window.dispatchEvent(new CustomEvent("rentaldrivego:auth-changed"));
notifyParent({
type: "rentaldrivego:employee-login",
path: targetPath,
});
// Use a document navigation here so the authenticated dashboard bootstraps
// from a fresh request after the token cookie and localStorage are set.
window.location.replace(targetPath)
return true
window.location.replace(targetPath);
return true;
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
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 (empJson?.error === "email_not_verified") {
setError(dict.emailNotVerified);
return true;
}
if (empRes.status === 429) {
setError(dict.tooManyRequests)
return true
setError(dict.tooManyRequests);
return true;
}
return false
}
return false;
};
if (preferAdminAuth) {
if (await tryAdminLogin()) return
setError(dict.invalidCredentials)
return
if (await tryAdminLogin()) return;
setError(dict.invalidCredentials);
return;
}
if (await tryEmployeeLogin()) return
if (await tryEmployeeLogin()) return;
setError(dict.invalidCredentials)
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError)
setError(dict.unexpectedError);
} finally {
setLoading(false)
setLoading(false);
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
e.preventDefault();
setLoading(true);
setError(null);
try {
const normalizedCode = totpCode.trim().toUpperCase()
const normalizedCode = totpCode.trim().toUpperCase();
const secondFactor = /^\d{6}$/.test(normalizedCode)
? { totpCode: normalizedCode }
: { recoveryCode: normalizedCode }
: { recoveryCode: normalizedCode };
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password, ...secondFactor }),
})
const adminJson = await adminRes.json()
});
const adminJson = await adminRes.json();
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return
window.location.href = `${adminUrl}/dashboard`;
return;
}
setError(dict.invalidCredentials)
setError(dict.invalidCredentials);
} catch {
setError(dict.unexpectedError)
setError(dict.unexpectedError);
} finally {
setLoading(false)
setLoading(false);
}
}
@@ -372,10 +397,12 @@ function LocalSignInForm({
</div>
) : null}
{step === 'credentials' ? (
{step === "credentials" ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.email}</label>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email}
</label>
<input
type="email"
required
@@ -387,10 +414,12 @@ function LocalSignInForm({
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.password}</label>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.password}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -404,13 +433,39 @@ function LocalSignInForm({
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
@@ -426,7 +481,10 @@ function LocalSignInForm({
</button>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100">
<Link
href="/forgot-password"
className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100"
>
{dict.forgotPassword}
</Link>
</div>
@@ -438,7 +496,9 @@ function LocalSignInForm({
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.authCode}
</label>
<input
type="text"
inputMode="text"
@@ -446,7 +506,11 @@ function LocalSignInForm({
maxLength={14}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
onChange={(e) =>
setTotpCode(
e.target.value.replace(/[^0-9A-Za-z-]/g, "").toUpperCase(),
)
}
placeholder={dict.totpPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
@@ -463,9 +527,9 @@ function LocalSignInForm({
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
setStep("credentials");
setTotpCode("");
setError(null);
}}
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
>
@@ -474,5 +538,5 @@ function LocalSignInForm({
</form>
)}
</>
)
);
}
@@ -1,117 +1,147 @@
'use client'
"use client";
import Image from 'next/image'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import Image from "next/image";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import PublicShell from "@/components/layout/PublicShell";
import { useDashboardI18n } from "@/components/I18nProvider";
import { marketplaceUrl } from "@/lib/urls";
export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n()
const { language, setLanguage } = useDashboardI18n();
const searchParams = useSearchParams();
const requestedLanguage = searchParams.get("lang");
const initialLanguage =
requestedLanguage === "en" ||
requestedLanguage === "fr" ||
requestedLanguage === "ar"
? requestedLanguage
: language;
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [preferredLanguage, setPreferredLanguage] = useState<
"en" | "fr" | "ar"
>(initialLanguage);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const dict = {
en: {
title: 'Create your workspace',
subtitle: 'Enter your email and password to get started.',
email: 'Email',
emailPlaceholder: 'you@company.com',
password: 'Password',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 characters',
languageLabel: 'Preferred language',
create: 'Create workspace',
creating: 'Creating\u2026',
alreadyHave: 'Already have an account?',
signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
successTitle: 'Check your email',
successMessage: 'We sent a verification link to {email}. Click the link to activate your account, then sign in.',
successSignIn: 'Go to sign in',
title: "Create your workspace",
subtitle: "Enter your email and password to get started.",
email: "Email",
emailPlaceholder: "you@company.com",
password: "Password",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
passwordHint: "Minimum 8 characters",
languageLabel: "Preferred language",
create: "Create workspace",
creating: "Creating\u2026",
alreadyHave: "Already have an account?",
signIn: "Sign in",
errorEmailTaken: "An account with this email already exists.",
errorGeneric: "Something went wrong. Please try again.",
successTitle: "Check your email",
successMessage:
"We sent a verification link to {email}. Click the link to activate your account, then sign in.",
successSignIn: "Go to sign in",
},
fr: {
title: 'Cr\u00e9ez votre espace',
subtitle: 'Saisissez votre email et mot de passe pour commencer.',
email: 'Email',
emailPlaceholder: 'vous@entreprise.com',
password: 'Mot de passe',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 caract\u00e8res',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026',
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
successTitle: 'V\u00e9rifiez votre email',
successMessage: 'Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.',
successSignIn: 'Aller \u00e0 la connexion',
title: "Cr\u00e9ez votre espace",
subtitle: "Saisissez votre email et mot de passe pour commencer.",
email: "Email",
emailPlaceholder: "vous@entreprise.com",
password: "Mot de passe",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
passwordHint: "Minimum 8 caract\u00e8res",
languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e",
create: "Cr\u00e9er mon espace",
creating: "Cr\u00e9ation\u2026",
alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?",
signIn: "Se connecter",
errorEmailTaken: "Un compte avec cet email existe d\u00e9j\u00e0.",
errorGeneric: "Une erreur est survenue. Veuillez r\u00e9essayer.",
successTitle: "V\u00e9rifiez votre email",
successMessage:
"Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.",
successSignIn: "Aller \u00e0 la connexion",
},
ar: {
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.',
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
emailPlaceholder: 'you@company.com',
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: '\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641',
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
successTitle: '\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
successMessage: '\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
successSignIn: '\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
title:
"\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643",
subtitle:
"\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.",
email:
"\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a",
emailPlaceholder: "you@company.com",
password: "\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
passwordHint:
"\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641",
languageLabel:
"\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629",
create:
"\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629",
creating:
"\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026",
alreadyHave:
"\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f",
signIn:
"\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644",
errorEmailTaken:
"\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.",
errorGeneric:
"\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.",
successTitle:
"\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a",
successMessage:
"\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.",
successSignIn:
"\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644",
},
}[preferredLanguage]
}[preferredLanguage];
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
e.preventDefault();
setLoading(true);
setError(null);
try {
await apiFetch('/auth/account/start', {
method: 'POST',
await apiFetch("/auth/account/start", {
method: "POST",
body: JSON.stringify({ email, password, preferredLanguage }),
})
});
setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
setSuccess(true)
setLanguage(preferredLanguage);
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`;
setSuccess(true);
} catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
setError(dict.errorEmailTaken)
if (
err?.message?.includes("email_taken") ||
err?.code === "email_taken"
) {
setError(dict.errorEmailTaken);
} else {
setError(dict.errorGeneric)
setError(dict.errorGeneric);
}
} finally {
setLoading(false)
setLoading(false);
}
}
if (success) {
return (
<PublicShell>
<PublicShell hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="FleetOS"
alt="RentalDriveGo"
width={80}
height={80}
priority
@@ -123,7 +153,7 @@ export default function SignUpForm() {
{dict.successTitle}
</h1>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
{dict.successMessage.replace('{email}', email)}
{dict.successMessage.replace("{email}", email)}
</p>
<a
href="/sign-in"
@@ -135,11 +165,11 @@ export default function SignUpForm() {
</div>
</main>
</PublicShell>
)
);
}
return (
<PublicShell>
<PublicShell hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
@@ -147,7 +177,7 @@ export default function SignUpForm() {
<a href={marketplaceUrl} target="_top">
<Image
src="/dashboard/rentalcardrive.png"
alt="FleetOS"
alt="RentalDriveGo"
width={80}
height={80}
priority
@@ -191,34 +221,64 @@ export default function SignUpForm() {
{dict.password} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
<input
type={showPassword ? "text" : "password"}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={
"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
)}
</button>
</div>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">{dict.passwordHint}</p>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.passwordHint}
</p>
</div>
<div>
@@ -226,18 +286,22 @@ export default function SignUpForm() {
{dict.languageLabel} <span className="text-red-500">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
{(["en", "fr", "ar"] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setPreferredLanguage(lang)}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
preferredLanguage === lang
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
? "border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400"
: "border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600"
}`}
>
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
{lang === "en"
? "English"
: lang === "fr"
? "Fran\u00e7ais"
: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629"}
</button>
))}
</div>
@@ -253,7 +317,7 @@ export default function SignUpForm() {
</form>
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
{dict.alreadyHave}{' '}
{dict.alreadyHave}{" "}
<a
href="/sign-in"
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
@@ -265,5 +329,5 @@ export default function SignUpForm() {
</div>
</main>
</PublicShell>
)
);
}
+1 -1
View File
@@ -76,7 +76,7 @@ export default function VerifyEmailPage() {
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="FleetOS"
alt="RentalDriveGo"
width={80}
height={80}
priority
@@ -165,7 +165,7 @@ export default function PublicFooter() {
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} FleetOS. {footerContent.rightsLabel}
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
@@ -1,209 +1,212 @@
'use client'
"use client";
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import { useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { ChevronDown } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useDashboardI18n } from "@/components/I18nProvider";
import { marketplaceUrl } from "@/lib/urls";
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const BRAND_LOGO_SRC = "/dashboard/rentaldrivego.jpeg";
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
en: { flag: "🇺🇸", shortLabel: "EN" },
fr: { flag: "🇫🇷", shortLabel: "FR" },
ar: { flag: "🇲🇦", shortLabel: "AR" },
} as const;
export default function PublicHeader() {
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const searchParams = useSearchParams()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const currentLanguage = languageMeta[language]
const availableLanguages = (['en', 'fr', 'ar'] as const)
export default function PublicHeader({
hideActions = false,
}: {
hideActions?: boolean;
}) {
const { language, setLanguage, theme, setTheme } = useDashboardI18n();
const localeMenuRef = useRef<HTMLDivElement | null>(null);
const [localeMenuOpen, setLocaleMenuOpen] = useState(false);
const themeMenuRef = useRef<HTMLDivElement | null>(null);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const currentLanguage = languageMeta[language];
const availableLanguages = (["en", "fr", "ar"] as const)
.filter((value) => value !== language)
.map((value) => ({ value, ...languageMeta[value] }))
.map((value) => ({ value, ...languageMeta[value] }));
const dict = {
en: {
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createAccount: 'Create Agency Space',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
signIn: "Sign in",
createAccount: "Get Started",
theme: "Theme",
light: "Light",
dark: "Dark",
},
fr: {
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createAccount: 'Créer un espace dagence',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
signIn: "Connexion",
createAccount: "Démarrer",
theme: "Mode",
light: "Clair",
dark: "Sombre",
},
ar: {
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createAccount: 'إنشاء مساحة الوكالة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
signIn: "تسجيل الدخول",
createAccount: "ابدأ الآن",
theme: "الوضع",
light: "فاتح",
dark: "داكن",
},
}[language]
}[language];
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]
{ 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 localeMenuPositionClass =
language === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
language === "ar" ? "right-0 sm:right-0" : "left-0 sm:left-auto sm:right-0";
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
setLocaleMenuOpen(false);
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
setThemeMenuOpen(false);
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
setThemeMenuOpen(false);
setLocaleMenuOpen((open) => !open);
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
setLocaleMenuOpen(false);
setThemeMenuOpen((open) => !open);
}
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
const next = searchParams.get('next')
const redirect = searchParams.get('redirect')
if (next) signInParams.set('next', next)
if (redirect) signInParams.set('redirect', redirect)
const signInHref = `${marketplaceUrl}/sign-in?${signInParams.toString()}`
const signInHref = `${marketplaceUrl}/${language}#signin`;
const signUpParams = new URLSearchParams()
signUpParams.set('lang', language)
signUpParams.set('theme', theme)
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
const signUpHref = `${marketplaceUrl}/${language}#demo`;
return (
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
<div className="mx-auto flex max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src={DASHBOARD_LOGO_SRC}
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>
</a>
<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="mx-auto flex min-h-14 max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
<div className="flex items-center justify-between gap-3">
<a
href={`${marketplaceUrl}/${language}`}
className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 no-underline sm:text-sm sm:tracking-[0.2em] dark:text-stone-100"
aria-label="RentalDriveGo home"
>
<Image
src={BRAND_LOGO_SRC}
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</a>
</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">
<a href={marketplaceUrl} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-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}
</a>
<a href={`${marketplaceUrl}/explore`} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-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.marketplace}
</a>
<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-blue-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>
<a href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
{dict.createAccount}
</a>
</nav>
<div className="shrink-0">
<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"
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm sm:self-auto sm:p-1 dark:border-blue-800 dark:bg-blue-950/85">
<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 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs dark:text-stone-200 dark:hover:bg-blue-900/40"
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-48 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)] ${localeMenuPositionClass}`}
>
<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-48 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)] ${localeMenuPositionClass}`}>
{availableLanguages.map((option) => (
{availableLanguages.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(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 sm:px-2.5 sm:py-1.5 dark:hover:bg-blue-900/40"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white sm:px-3.5 sm:py-1.5 sm:text-xs dark:bg-orange-400 dark:text-white">
{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)] ${localeMenuPositionClass}`}
>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
setTheme(option.value);
setThemeMenuOpen(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"
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 aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
<span>{option.label}</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)] ${localeMenuPositionClass}`}>
{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>
) : null}
</div>
</div>
{hideActions ? null : (
<div className="flex items-center gap-2">
<a
href={signInHref}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 no-underline transition hover:bg-stone-100 hover:text-stone-900 sm:px-4 sm:py-2 sm:text-sm dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100"
>
{dict.signIn}
</a>
<a
href={signUpHref}
className="rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white no-underline transition hover:bg-orange-700 sm:px-5 sm:py-2 sm:text-sm dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.createAccount}
</a>
</div>
)}
</div>
</div>
</header>
)
);
}
@@ -1,20 +1,24 @@
'use client'
"use client";
import * as React from "react";
import PublicFooter from '@/components/layout/PublicFooter'
import PublicHeader from '@/components/layout/PublicHeader'
import PublicFooter from "@/components/layout/PublicFooter";
import PublicHeader from "@/components/layout/PublicHeader";
export default function PublicShell({
children,
embedded = false,
hideFooter = false,
hideHeaderActions = false,
}: {
children: React.ReactNode
embedded?: boolean
children: React.ReactNode;
embedded?: boolean;
hideFooter?: boolean;
hideHeaderActions?: boolean;
}) {
return (
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] dark:text-slate-100">
{embedded ? null : <PublicHeader />}
{embedded ? null : <PublicHeader hideActions={hideHeaderActions} />}
<div className="flex flex-1 flex-col">{children}</div>
{embedded ? null : <PublicFooter />}
{embedded || hideFooter ? null : <PublicFooter />}
</div>
)
);
}
@@ -401,7 +401,7 @@ export default function Sidebar() {
)}
</div>
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
{brand?.displayName ?? 'FleetOS'}
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</a>
@@ -0,0 +1,26 @@
# Navbar Restoration Notes v1.1
## Correction
The RentalDriveGo application shell is preserved independently from the modular homepage sections.
## Navbar behavior
- Product, Workflow, Modules, Pricing, and FAQ remain visible in the desktop navbar.
- The full navbar stays visible through common laptop and tablet-landscape widths, including 1024 px.
- Below 900 px, the same five destinations remain available in the mobile navigation drawer.
- Language, theme, login, and account-creation controls are preserved.
- Homepage section IDs remain aligned with navbar anchors.
## Regression protection
Browser specifications now assert that all primary destinations are visible at 1024 px and remain present in the mobile drawer.
## Validation
- TypeScript: passed
- ESLint: passed
- Unit tests: 51 passed
- Production build: passed with an explicit test `SITE_ORIGIN`
- Server-rendered `/en` markup: confirmed all five navbar links
- Browser execution requires Playwright browser binaries in the target CI environment
@@ -0,0 +1,322 @@
# RentalDriveGo Pricing Section and Homepage Modularization Plan
**Version:** 1.0
**Date:** June 26, 2026
**Product:** RentalDriveGo
**Scope:** Localized public homepage for English, French, and Arabic
## 1. Objective
Refactor the homepage into independently maintainable sections and replace the existing quote-only pricing placeholder with a hybrid pricing section that can support transparent starting prices for standard plans and custom pricing for enterprise accounts.
The implementation must let a developer update one section without editing the primary page composition or accidentally changing unrelated sections.
## 2. Commercial decision
RentalDriveGo should use a hybrid model:
- Public starting prices for standard small and mid-market plans after finance approval.
- A fleet-size selector to help visitors self-qualify.
- Monthly and annual billing presentation.
- Custom pricing for large fleets, franchises, migrations, and complex integrations.
- A sales or demonstration CTA for every plan.
Prices must not be invented in presentation components. All commercial amounts and approval state live in one configuration file.
## 3. Homepage architecture
The route component remains a thin composition layer:
`src/app/[locale]/page.tsx`
It renders these independent sections in order:
1. `HeroSection`
2. `TrustSection`
3. `ComparisonSection`
4. `WorkflowSection`
5. `RolesSection`
6. `ModulesSection`
7. `ResultsSection`
8. `IntegrationsSection`
9. `SecuritySection`
10. `PricingSection`
11. `FaqSection`
12. `FinalCtaSection`
Each section lives in:
`src/components/homepage/sections/`
Section-specific CSS stays beside the section in its own CSS module. Shared tokens and generic components remain in the existing design system.
## 3.1 Application shell preservation
Homepage modularization must not remove, replace, or weaken the shared application shell. The following elements remain independent of homepage sections and must be preserved across future section updates:
- RentalDriveGo brand link
- Product, Workflow, Modules, Pricing, and FAQ navigation destinations
- English, French, and Arabic locale controls
- Light, dark, and system theme controls
- Log in and account-creation actions
- Mobile navigation drawer containing the same primary destinations
- Shared footer navigation
The full primary navigation remains visible at common laptop widths. At narrower widths, the same destinations move into the mobile drawer rather than being removed.
## 4. Pricing section structure
### 4.1 Header
- Eyebrow: simple, scalable pricing.
- Headline: pricing built around the fleet.
- Supporting copy: explains that fleet size drives plan recommendations and that public values are controlled centrally.
### 4.2 Fleet-size selector
Four radio-button ranges:
- 125 vehicles
- 2675 vehicles
- 76200 vehicles
- 200+ vehicles
The selected range changes the recommended plan and, after commercial approval, the displayed starting price.
### 4.3 Billing selector
- Monthly
- Annual
- Annual savings label only after the discount is financially approved
Annual pricing is shown as a monthly equivalent and must be calculated from the approved annual discount in the commercial configuration.
### 4.4 Plan cards
#### Launch
For independent rental companies moving away from fragmented manual processes.
Core capabilities:
- Reservations and availability
- Fleet and vehicle records
- Rental agreements
- Customer records
- Basic operational reporting
- Standard support
#### Growth
For growing fleets coordinating more vehicles, users, and locations.
Core capabilities:
- Everything in Launch
- Multiple locations
- Online booking workflows
- Digital signatures and deposits
- Maintenance workflows
- Advanced reporting and automation
- Priority support
#### Enterprise
For large fleets, franchise operations, and complex integration requirements.
Core capabilities:
- Everything in Growth
- Custom integrations and API access
- Advanced permissions
- Migration planning
- Custom onboarding
- Service-level agreement options
- Dedicated account support
### 4.5 Supporting commercial information
The section also shows:
- Factors that affect final price
- Optional modules
- Commercial assurances
- A link back to product modules
- A configuration note while prices remain unapproved
## 5. Commercial configuration
All financial values are stored in:
`src/content/pricing-config.ts`
The configuration controls:
- Currency
- Annual discount percentage
- Public-price approval state
- Monthly prices by plan and fleet band
- Recommended plan by fleet band
### Publishing prices
Before setting `publicPricesApproved` to `true`, finance and product must approve:
1. Monthly prices
2. Included active vehicles
3. Included locations and users
4. Additional-vehicle rules
5. Annual discount
6. Onboarding charges
7. Migration charges
8. Taxes and currency presentation
9. Optional-module pricing
10. Enterprise qualification threshold
Until approval, the UI displays a localized “starting price pending approval” state rather than fabricated numbers.
## 6. Localization
Pricing copy is stored in:
- `src/content/locales/en/homepage.json`
- `src/content/locales/fr/homepage.json`
- `src/content/locales/ar/homepage.json`
Rules:
- All three locales must preserve the same data structure.
- Arabic remains true document-level RTL.
- Prices use `Intl.NumberFormat` with the active locale.
- Monetary values use bidirectional isolation in Arabic.
- Plan IDs and fleet-band IDs remain stable, non-translated identifiers.
## 7. Accessibility requirements
- Fleet and billing selectors use native radio inputs and fieldsets.
- Every control has a visible label.
- Keyboard focus is visible.
- The recommended plan is not communicated by color alone.
- Price updates use an `aria-live` region.
- CTA controls retain minimum touch-target sizing.
- Layout supports forced colors and reduced motion.
- Mobile presentation does not require horizontal scrolling.
## 8. Responsive behavior
### Desktop
- Three plan cards in one row.
- Recommended card receives stronger visual emphasis.
- Fleet and billing controls share one row.
### Tablet
- Two cards on the first row.
- Enterprise spans the second row.
- Controls collapse when necessary.
### Mobile
- Controls stack.
- Plan cards stack.
- The recommended plan moves first.
- CTAs use full width.
- No pricing comparison table is forced into horizontal scrolling.
## 9. Analytics
The pricing section introduces these events:
- `pricing_fleet_band_selected`
- `pricing_billing_period_changed`
- `pricing_plan_cta_clicked`
Safe event properties:
- Locale
- Direction
- Theme
- Fleet band
- Billing period
- Plan ID
- Source
No name, email, company, phone number, or free-text form data is included.
## 10. Testing
Required checks:
- All locales build the same pricing model.
- Four fleet bands exist.
- Three plans exist.
- Public prices remain blocked until approval.
- The recommended plan changes with fleet selection.
- Annual billing selection works.
- RTL layout remains valid.
- Homepage still contains exactly twelve sections.
- Navigation hashes still resolve.
- Pricing controls are keyboard-accessible.
## 11. Files added or changed
### Added
- `src/components/homepage/Homepage.module.css`
- `src/components/homepage/README.md`
- `src/components/homepage/sections/*.tsx`
- Section-specific CSS modules
- `src/content/pricing-config.ts`
- `tests/unit/pricing.test.ts`
### Changed
- `src/app/[locale]/page.tsx`
- `src/content/homepage-model.ts`
- English, French, and Arabic homepage resources
- Analytics event contract
- Demo source contract
- Homepage unit and browser tests
### Removed
- `src/app/[locale]/page.module.css`
Its rules were moved into section-owned CSS modules so unrelated sections no longer share one page stylesheet.
## 12. Definition of done
The work is complete when:
- The route page only composes section components.
- Each section can be edited independently.
- Pricing is fully localized and responsive.
- Commercial values are centralized and guarded.
- No unapproved prices are published.
- Type checking, linting, unit tests, and browser tests pass.
- The updated repository archive includes this plan and implementation.
## 13. Implementation status
Completed in this package:
- The homepage route is reduced to section composition.
- Twelve homepage sections are independently editable.
- Section-specific CSS is colocated with each section.
- The interactive pricing section is implemented.
- English, French, and Arabic pricing content is implemented.
- RTL, dark theme, responsive layout, forced colors, and reduced motion are covered.
- Pricing analytics events and the pricing demo source are registered.
- Commercial amounts and discount values remain blocked pending approval.
- The package was made standalone by removing an unused workspace-only dependency and its pre-scripts.
- The missing `@axe-core/playwright` test dependency was added.
Validation completed:
- TypeScript strict check: passed.
- ESLint with zero warnings: passed.
- Unit and component tests: 51 passed.
- Next.js production build: passed with an explicit local `SITE_ORIGIN`.
Browser test files were updated, but full Playwright execution could not be completed in the build container. The managed system Chromium blocks loopback navigation, while downloading Playwright Chromium was prevented by unavailable DNS access to the Playwright CDN. This is an environment restriction, not a passing test result. Run the browser suite in the project CI environment before release.
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTypeScript from 'eslint-config-next/typescript';
import prettier from 'eslint-config-prettier';
export default defineConfig([
...nextVitals,
...nextTypeScript,
prettier,
{
rules: {
'no-console': ['error', { allow: ['log', 'warn', 'error'] }],
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
},
},
globalIgnores([
'.next/**',
'coverage/**',
'playwright-report/**',
'test-results/**',
'contracts/**',
'next-env.d.ts',
]),
]);
@@ -1,17 +1,21 @@
/** @type {import('next').NextConfig} */
import type { NextConfig } from 'next';
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
const securityHeaders = [
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
];
const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.3.3'],
compress: true,
poweredByHeader: false,
reactStrictMode: true,
transpilePackages: ['@rentaldrivego/types'],
// The marketplace proxies /dashboard and /admin to their own Next dev servers.
// Allow those asset-prefix origins so proxied pages can load chunks and HMR.
const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
'/dashboard',
)
const securityHeaders = buildSecurityHeaders({
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
})
const nextConfig = {
images: {
remotePatterns: [
{
@@ -20,15 +24,16 @@ const nextConfig = {
},
],
},
transpilePackages: ['@rentaldrivego/types'],
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
]
];
},
async redirects() {
return [
{
@@ -41,15 +46,16 @@ const nextConfig = {
destination: '/dashboard/:path*',
permanent: false,
},
]
];
},
async rewrites() {
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
const dashboardOrigin =
process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001';
const adminOrigin =
process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002';
return [
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
// so the dashboard can strip it and route internally.
{
source: '/dashboard',
destination: `${dashboardOrigin}/dashboard`,
@@ -58,7 +64,6 @@ const nextConfig = {
source: '/dashboard/:path*',
destination: `${dashboardOrigin}/dashboard/:path*`,
},
// Admin routes (also uses basePath)
{
source: '/admin',
destination: `${adminOrigin}/admin`,
@@ -67,8 +72,8 @@ const nextConfig = {
source: '/admin/:path*',
destination: `${adminOrigin}/admin/:path*`,
},
]
];
},
}
};
module.exports = nextConfig
export default nextConfig;
+27 -16
View File
@@ -1,34 +1,45 @@
{
"name": "@rentaldrivego/homepage",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"scripts": {
"predev": "npm run build --workspace @rentaldrivego/types",
"dev": "next dev -H 0.0.0.0 -p 3000",
"prebuild": "npm run build --workspace @rentaldrivego/types",
"build": "next build",
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "next start -H 0.0.0.0 -p 3000",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"lint": "eslint . --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@rentaldrivego/types": "*",
"autoprefixer": "^10.4.19",
"lucide-react": "^0.376.0",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
"next": "^16.2.7",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.3"
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.4.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.61.0",
"@testing-library/jest-dom": "^6.9.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.0",
"@types/node": "^20.12.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.0.0",
"eslint-config-next": "^16.2.0",
"eslint-config-prettier": "^10.1.0",
"globals": "^17.0.0",
"jsdom": "^29.0.0",
"prettier": "^3.8.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
"typescript-eslint": "^8.0.0",
"vitest": "^4.1.0"
}
}
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig, devices } from '@playwright/test';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3000';
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
const serverCommand = process.env.PLAYWRIGHT_SERVER_COMMAND ?? 'pnpm start';
export default defineConfig({
testDir: './tests',
testMatch: ['browser/**/*.spec.ts', 'a11y/**/*.spec.ts', 'visual/**/*.spec.ts'],
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
...(process.env.CI ? { workers: 2 } : {}),
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
expect: {
timeout: 5_000,
toHaveScreenshot: {
animations: 'disabled',
caret: 'hide',
maxDiffPixelRatio: 0.005,
},
},
use: {
baseURL,
locale: 'en-US',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video:
process.env.PLAYWRIGHT_VIDEO === 'off' ? 'off' : process.env.CI ? 'retain-on-failure' : 'off',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
...(executablePath ? { launchOptions: { executablePath } } : {}),
},
},
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
],
webServer: {
command: serverCommand,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
SITE_ORIGIN: baseURL,
PUBLIC_RELEASE_APPROVED: 'false',
COMPONENT_FIXTURES_ENABLED: 'true',
NEXT_PUBLIC_DEMO_ENABLED: 'true',
DEMO_SUBMISSION_MODE: 'local',
DEMO_LOCAL_TEST_MODE: 'true',
DEMO_TEST_SCENARIOS_ENABLED: 'true',
NEXT_PUBLIC_ANALYTICS_MODE: 'disabled',
HOSTNAME: '127.0.0.1',
PORT: '3000',
},
},
});
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("prettier").Config} */
const config = {
printWidth: 100,
singleQuote: true,
trailingComma: 'all',
plugins: [],
};
export default config;
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

-14
View File
@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

Before

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

@@ -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&currency=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} />
}
-41
View File
@@ -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>
)
}
-5
View File
@@ -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()}&currency=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>
);
}
+23
View File
@@ -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>
);
}
+98
View File
@@ -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 />;
}
+79
View File
@@ -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>
);
}
+159
View File
@@ -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 });
}
-108
View File
@@ -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;
}
-41
View File
@@ -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>
)
}
+11
View File
@@ -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">
&copy; {new Date().getFullYear()} FleetOS. {rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildDashboardSignInHref,
companyInitial,
localeMenuPositionClass,
ownerWorkspaceHref,
} from './MarketplaceHeader'
describe('MarketplaceHeader helpers', () => {
it('builds dashboard sign-in links with language and theme query parameters', () => {
expect(buildDashboardSignInHref('https://app.example.com/dashboard', 'fr', 'dark')).toBe(
'https://app.example.com/dashboard/sign-in?lang=fr&theme=dark'
)
expect(buildDashboardSignInHref('/dashboard', 'ar', 'light')).toBe('/dashboard/sign-in?lang=ar&theme=light')
})
it('keeps the locale dropdown anchored to the readable side for RTL and LTR languages', () => {
expect(localeMenuPositionClass('ar')).toBe('right-0 sm:right-0')
expect(localeMenuPositionClass('en')).toBe('left-0 sm:left-auto sm:right-0')
expect(localeMenuPositionClass('fr')).toBe('left-0 sm:left-auto sm:right-0')
})
it('routes existing companies to their workspace and new owners to sign-up', () => {
expect(ownerWorkspaceHref('https://dashboard.example.com', 'Atlas Cars')).toBe('https://dashboard.example.com')
expect(ownerWorkspaceHref('https://dashboard.example.com', null)).toBe('https://dashboard.example.com/sign-up')
})
it('normalizes company initials for the owner workspace pill', () => {
expect(companyInitial('atlas cars')).toBe('A')
expect(companyInitial(' زاكورة كار ')).toBe('ز')
})
})
@@ -1,237 +0,0 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
export type Theme = 'light' | 'dark'
export type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
home: string
features: string
pricing: string
signIn: string
ownerSignIn: string
theme: string
light: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export function buildDashboardSignInHref(dashboardUrl: string, language: Language, theme: Theme): string {
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
return `${dashboardUrl}/sign-in?${signInParams.toString()}`
}
export function localeMenuPositionClass(language: Language): string {
return language === 'ar' ? 'right-0 sm:right-0' : 'left-0 sm:left-auto sm:right-0'
}
export function ownerWorkspaceHref(dashboardUrl: string, companyName: string | null): string {
return companyName ? dashboardUrl : `${dashboardUrl}/sign-up`
}
export function companyInitial(companyName: string): string {
return companyName.trim().charAt(0).toUpperCase()
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const menuPositionClass = localeMenuPositionClass(currentLanguage.value)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInHref = buildDashboardSignInHref(dashboardUrl, currentLanguage.value, theme)
return (
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="FleetOS"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>FleetOS</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.home}
</Link>
<Link
href="/features"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.features}
</Link>
<Link
href="/pricing"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.pricing}
</Link>
<a
href={signInHref}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.signIn}
</a>
{companyName ? (
<a
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-blue-950/20">
{companyInitial(companyName)}
</span>
{companyName}
</a>
) : (
<a
href={ownerWorkspaceHref(dashboardUrl, companyName)}
className="ml-1 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"
>
{dict.ownerSignIn}
</a>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${menuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
)
}
@@ -1,41 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('MarketplaceShell auth helpers', () => {
it('does not report an employee profile when the browser cache is empty', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn(() => null),
},
})
expect(hasCachedEmployeeProfile()).toBe(false)
})
it('detects the cached employee profile marker written by dashboard sign-in', () => {
vi.stubGlobal('window', {
localStorage: {
getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)),
},
})
expect(hasCachedEmployeeProfile()).toBe(true)
})
it('clears the cached employee profile marker', () => {
const removeItem = vi.fn()
vi.stubGlobal('window', {
localStorage: {
removeItem,
},
})
clearCachedEmployeeProfile()
expect(removeItem).toHaveBeenCalledWith('employee_profile')
})
})
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getFooterContent, localeOptions } from './MarketplaceShell'
describe('MarketplaceShell footer content registry', () => {
it('exposes exactly the supported marketplace locales in selector order', () => {
expect(localeOptions.map((option) => option.value)).toEqual(['en', 'ar', 'fr'])
expect(localeOptions.every((option) => option.label.length > 0 && option.flag.length > 0)).toBe(true)
})
it('returns English footer links with app privacy pointing at the English mobile policy', () => {
const content = getFooterContent('en')
expect(content.localeLabel).toBe('Global (English)')
expect(content.rightsLabel).toBe('All rights reserved.')
expect(content.primary).toContainEqual({ label: 'Privacy Policy', href: '/app-privacy-en' })
expect(content.secondary).toContainEqual({ label: 'Contact Sales', href: '/footer/contact-sales' })
})
it('returns French labels while preserving canonical footer slugs', () => {
const content = getFooterContent('fr')
expect(content.localeLabel).toBe('Europe (French)')
expect(content.primary).toContainEqual({ label: "Conditions d'utilisation", href: '/footer/terms-of-service' })
expect(content.secondary).toContainEqual({ label: 'Conditions générales', href: '/footer/general-conditions' })
})
it('returns Arabic labels and app privacy href without falling back to English copy', () => {
const content = getFooterContent('ar')
expect(content.localeLabel).toBe('North Africa (Arabic)')
expect(content.rightsLabel).toBe('جميع الحقوق محفوظة.')
expect(content.primary).toContainEqual({ label: 'سياسة الخصوصية', href: '/app-privacy-ar' })
expect(content.primary.map((item) => item.label)).not.toContain('Privacy Policy')
})
})
@@ -1,368 +0,0 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { appPrivacyHref, footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
type Theme = 'light' | 'dark'
type Dictionary = {
home: string
features: string
pricing: string
signIn: string
ownerSignIn: string
language: string
theme: string
light: string
dark: string
preferences: string
}
const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
en: {
home: 'Home',
features: 'Features',
pricing: 'Pricing',
signIn: 'Sign in',
ownerSignIn: 'Get Started',
language: 'Language',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
preferences: 'Preferences',
},
fr: {
home: 'Accueil',
features: 'Fonctionnalités',
pricing: 'Tarifs',
signIn: 'Connexion',
ownerSignIn: 'Démarrer',
language: 'Langue',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
preferences: 'Préférences',
},
ar: {
home: 'الرئيسية',
features: 'الميزات',
pricing: 'الأسعار',
signIn: 'تسجيل الدخول',
ownerSignIn: 'ابدأ الآن',
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
preferences: 'التفضيلات',
},
}
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
export function getFooterContent(language: MarketplaceLanguage): {
primary: Array<{ label: string; href?: string }>
secondary: Array<{ label: string; href?: string }>
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: footerPageHref['about-us'] },
{ label: "Conditions d'utilisation", href: footerPageHref['terms-of-service'] },
{ label: 'Sécurité', href: footerPageHref.security },
{ label: 'Conformité', href: footerPageHref.compliance },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contacter les ventes', href: footerPageHref['contact-sales'] },
{ label: 'Conditions générales', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: footerPageHref['about-us'] },
{ label: 'شروط الاستخدام', href: footerPageHref['terms-of-service'] },
{ label: 'الأمان', href: footerPageHref.security },
{ label: 'الامتثال', href: footerPageHref.compliance },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'النشرة الإخبارية', href: footerPageHref.newsletter },
{ label: 'تواصل مع المبيعات', href: footerPageHref['contact-sales'] },
{ label: 'الشروط العامة', href: footerPageHref['general-conditions'] },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: footerPageHref['about-us'] },
{ label: 'Terms of Service', href: footerPageHref['terms-of-service'] },
{ label: 'Security', href: footerPageHref.security },
{ label: 'Compliance', href: footerPageHref.compliance },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: footerPageHref['cookie-policy'] },
],
secondary: [
{ label: 'Newsletter', href: footerPageHref.newsletter },
{ label: 'Contact Sales', href: footerPageHref['contact-sales'] },
{ label: 'General Conditions', href: footerPageHref['general-conditions'] },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export function hasCachedEmployeeProfile(): boolean {
if (typeof window === 'undefined') return false
try {
return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY))
} catch {
return false
}
}
export function clearCachedEmployeeProfile() {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
} catch {}
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code
}
return null
}
type PreferencesContextValue = {
language: MarketplaceLanguage
theme: Theme
dict: Dictionary
companyName: string | null
setLanguage: (language: MarketplaceLanguage) => void
setTheme: (theme: Theme) => void
}
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export function useMarketplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
return context
}
export default function MarketplaceShell({
children,
initialLanguage = 'en',
initialTheme = 'light',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
initialTheme?: Theme
}) {
const router = useRouter()
const pathname = usePathname()
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setThemeState] = useState<Theme>(initialTheme)
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const [companyName, setCompanyName] = useState<string | null>(null)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() {
if (!hasCachedEmployeeProfile()) {
setCompanyName(null)
return
}
try {
const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include',
})
if (!response.ok) {
if (response.status === 401) {
clearCachedEmployeeProfile()
}
setCompanyName(null)
return
}
const json = await response.json()
const name = json?.data?.displayName ?? json?.displayName ?? null
setCompanyName(name)
} catch {
setCompanyName(null)
}
}
function readSessionLanguage(): MarketplaceLanguage | null {
try {
const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
function syncScopedPreferencesForSession() {
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
}
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'dark') && scopedTheme !== theme) {
setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}
useEffect(() => {
const sessionLang = readSessionLanguage()
if (sessionLang) {
setLanguageState(sessionLang)
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
}
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme)
}
setHydrated(true)
void syncCompanyBrand()
}, [])
useEffect(() => {
function handleWindowFocus() {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
function handleAuthMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string }
if (message.type === 'rentaldrivego:employee-login' || message.type === 'rentaldrivego:employee-logout') {
syncScopedPreferencesForSession()
void syncCompanyBrand()
}
}
window.addEventListener('focus', handleWindowFocus)
window.addEventListener('message', handleAuthMessage)
document.addEventListener('visibilitychange', handleWindowFocus)
return () => {
window.removeEventListener('focus', handleWindowFocus)
window.removeEventListener('message', handleAuthMessage)
document.removeEventListener('visibilitychange', handleWindowFocus)
}
}, [language, theme])
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
if (previousLanguage.current !== language && pathname !== '/sign-in') {
router.refresh()
}
previousLanguage.current = language
}, [hydrated, language, pathname, router])
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light'
document.body.dataset.theme = theme
if (hydrated) {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
}
}, [theme, hydrated])
const value = useMemo(
() => ({ language, theme, dict: dictionaries[language], companyName, setLanguage: applyLanguage, setTheme: applyTheme }),
[language, theme, companyName],
)
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>
}
@@ -1,45 +0,0 @@
'use client'
import { useState } from 'react'
export function PricingToggle({
onToggle,
}: {
onToggle: (isAnnual: boolean) => void
}) {
const [isAnnual, setIsAnnual] = useState(false)
const handleToggle = () => {
const newValue = !isAnnual
setIsAnnual(newValue)
onToggle(newValue)
}
return (
<div className="flex justify-center">
<div className="inline-flex items-center gap-4 rounded-full border border-stone-200 bg-white/80 p-1 dark:border-blue-800 dark:bg-blue-950/40">
<button
onClick={() => !isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
!isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Monthly
</button>
<button
onClick={() => isAnnual && handleToggle()}
className={`rounded-full px-6 py-2 text-sm font-semibold transition ${
isAnnual
? 'bg-orange-600 text-white dark:bg-orange-500'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'
}`}
>
Annual
<span className="ml-2 text-xs font-bold text-orange-200 dark:text-orange-300">-20%</span>
</button>
</div>
</div>
)
}
@@ -1,86 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Stat {
label: string
value: number
suffix?: string
}
interface StatsStripProps {
stats: Stat[]
kicker?: string
}
function Counter({ value, suffix = '' }: { value: number; suffix?: string }) {
const [count, setCount] = useState(0)
const ref = useRef<HTMLParagraphElement>(null)
const hasAnimated = useRef(false)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !hasAnimated.current) {
hasAnimated.current = true
const duration = 2000
const steps = 60
const increment = value / steps
let current = 0
const startTime = Date.now()
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
current = Math.floor(value * progress)
setCount(current)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
animate()
}
},
{ threshold: 0.1 }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => {
if (ref.current) {
observer.unobserve(ref.current)
}
}
}, [value])
return (
<p ref={ref} className="text-3xl font-black text-blue-950 dark:text-white sm:text-4xl">
{count.toLocaleString()}
{suffix}
</p>
)
}
export function StatsStrip({ stats, kicker = 'BY THE NUMBERS' }: StatsStripProps) {
return (
<section className="site-section">
<div className="site-panel">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">Our Impact</h2>
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col">
<Counter value={stat.value} suffix={stat.suffix} />
<p className="mt-4 text-sm font-semibold text-stone-600 dark:text-stone-300">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
)
}
@@ -1,116 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
interface Testimonial {
quote: string
author: string
role: string
company?: string
image?: string
}
interface TestimonialsSectionProps {
testimonials: Testimonial[]
kicker?: string
title?: string
}
export function TestimonialsSection({
testimonials,
kicker = 'WHAT CUSTOMERS SAY',
title = 'Testimonials',
}: TestimonialsSectionProps) {
const [activeIndex, setActiveIndex] = useState(0)
const autoScrollRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
autoScrollRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => {
if (autoScrollRef.current) clearInterval(autoScrollRef.current)
}
}, [testimonials.length])
const handlePrev = () => {
setActiveIndex((prev) => (prev - 1 + testimonials.length) % testimonials.length)
}
const handleNext = () => {
setActiveIndex((prev) => (prev + 1) % testimonials.length)
}
if (!testimonials || testimonials.length === 0) return null
return (
<section className="site-section">
<div className="flex flex-col">
<p className="site-kicker">{kicker}</p>
<h2 className="site-title">{title}</h2>
<div className="mt-12 relative">
<div className="site-panel">
<div className="flex items-start gap-4">
<div className="flex-1">
<p className="text-lg leading-8 text-blue-950 dark:text-stone-200 sm:text-xl">
"{testimonials[activeIndex].quote}"
</p>
<div className="mt-8 flex flex-col gap-2">
<p className="font-bold text-blue-950 dark:text-white">{testimonials[activeIndex].author}</p>
<p className="text-sm text-stone-600 dark:text-stone-400">
{testimonials[activeIndex].role}
{testimonials[activeIndex].company && ` at ${testimonials[activeIndex].company}`}
</p>
</div>
</div>
{testimonials[activeIndex].image && (
<img
src={testimonials[activeIndex].image}
alt={testimonials[activeIndex].author}
className="h-16 w-16 rounded-full object-cover"
/>
)}
</div>
</div>
{testimonials.length > 1 && (
<div className="mt-6 flex justify-center gap-4">
<button
onClick={handlePrev}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Previous testimonial"
>
Prev
</button>
<div className="flex items-center gap-2">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={`h-2 w-2 rounded-full transition ${
index === activeIndex ? 'bg-orange-600 dark:bg-orange-500' : 'bg-stone-300 dark:bg-stone-600'
}`}
aria-label={`Go to testimonial ${index + 1}`}
/>
))}
</div>
<button
onClick={handleNext}
className="rounded-full border border-stone-300 bg-white px-4 py-2 font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/30 dark:text-stone-200 dark:hover:bg-blue-950"
aria-label="Next testimonial"
>
Next
</button>
</div>
)}
</div>
</div>
</section>
)
}
@@ -1,234 +0,0 @@
'use client'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
import Image from 'next/image'
import Link from 'next/link'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export default function WorkspaceTabs() {
const { language, theme } = useMarketplacePreferences()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const copy = {
en: {
badge: 'Unified Workspace',
title: 'One launch point on port 3000 for marketplace discovery, company operations, and platform admin work.',
intro:
'The apps still run as separate Next.js surfaces internally, but the navbar now takes you straight to each workspace area without hunting for ports.',
sectionBadge: 'RentalDriveGo Marketplace',
sectionTitle: 'Browse local rental fleets without losing the company behind the wheel.',
sectionBodyA: 'Discovery happens here. Booking and payment happen on each rental company\'s own branded site.',
sectionBodyB: 'Customers only reach that branded booking site after selecting a vehicle from a company\'s fleet.',
exploreVehicles: 'Explore vehicles',
viewPricing: 'View pricing',
nowServing: 'Now serving',
promoTitle: 'Discovery, pricing, owner sign-in, and booking entry all stay anchored on the marketplace surface.',
promoBody:
'Company Workspace and Platform Operations stay available from the navbar, while branded booking starts after a renter picks a vehicle.',
customerPaths: 'Customer Paths',
ownerSignIn: 'Owner sign in',
pricing: 'Pricing',
workspaceAreas: 'Workspace Areas',
workspaceA: 'Company Workspace for fleet operations, team, billing, and analytics.',
workspaceB: 'Platform Operations for platform-level support and tenant oversight.',
workspaceC: 'Branded booking handoff begins only after a renter chooses a specific vehicle.',
},
fr: {
badge: 'Espace unifié',
title: 'Un point dentrée sur le port 3000 pour la découverte marketplace, les opérations de lentreprise et ladministration de la plateforme.',
intro:
'Les applications restent des interfaces Next.js séparées en interne, mais la barre de navigation mène directement à chaque espace sans avoir à changer de port manuellement.',
sectionBadge: 'Marketplace RentalDriveGo',
sectionTitle: 'Parcourez les flottes locales sans perdre lidentité de lentreprise qui gère la location.',
sectionBodyA: 'La découverte commence ici. La réservation et le paiement se font sur le site de réservation propre à chaque entreprise.',
sectionBodyB: 'Les clients accèdent à ce site de réservation seulement après avoir choisi un véhicule dans la flotte dune entreprise.',
exploreVehicles: 'Explorer les véhicules',
viewPricing: 'Voir les tarifs',
nowServing: 'Disponible',
promoTitle: 'La découverte, les tarifs, la connexion propriétaire et laccès à la réservation restent ancrés dans la marketplace.',
promoBody:
'Lespace entreprise et les opérations de la plateforme restent accessibles depuis la navigation, tandis que la réservation de marque commence après le choix dun véhicule.',
customerPaths: 'Parcours client',
ownerSignIn: 'Connexion propriétaire',
pricing: 'Tarifs',
workspaceAreas: 'Espaces de travail',
workspaceA: 'Espace entreprise pour la flotte, l’équipe, la facturation et lanalyse.',
workspaceB: 'Opérations plateforme pour le support global et la supervision multi-entreprises.',
workspaceC: 'Le transfert vers la réservation de marque commence seulement après le choix dun véhicule précis.',
},
ar: {
badge: 'مساحة موحدة',
title: 'نقطة دخول واحدة على المنفذ 3000 لاكتشاف السوق وعمليات الشركات وإدارة المنصة.',
intro:
'ما زالت التطبيقات تعمل كواجهات Next.js منفصلة داخلياً، لكن شريط التنقل ينقلك مباشرة إلى كل مساحة عمل من دون الحاجة إلى البحث عن المنافذ.',
sectionBadge: 'سوق RentalDriveGo',
sectionTitle: 'تصفح أساطيل التأجير المحلية مع الحفاظ على هوية الشركة التي تدير الحجز.',
sectionBodyA: 'الاكتشاف يبدأ هنا. الحجز والدفع يتمان على موقع الحجز الخاص بكل شركة.',
sectionBodyB: 'لا يصل العميل إلى موقع الحجز الخاص بالشركة إلا بعد اختيار سيارة من أسطول تلك الشركة.',
exploreVehicles: 'استكشف السيارات',
viewPricing: 'عرض الأسعار',
nowServing: 'المتاح الآن',
promoTitle: 'الاكتشاف والأسعار وتسجيل دخول المالك وبداية الحجز تبقى كلها داخل واجهة السوق.',
promoBody:
'تبقى مساحة الشركة وعمليات المنصة متاحتين من شريط التنقل، بينما يبدأ الحجز المرتبط بالعلامة التجارية بعد اختيار المستأجر لسيارة.',
customerPaths: 'مسارات العملاء',
ownerSignIn: 'دخول المالك',
pricing: 'الأسعار',
workspaceAreas: 'مساحات العمل',
workspaceA: 'مساحة الشركة لإدارة الأسطول والفريق والفوترة والتحليلات.',
workspaceB: 'عمليات المنصة للدعم والإشراف على الشركات على مستوى المنصة.',
workspaceC: 'الانتقال إلى موقع الحجز الخاص بالشركة يبدأ فقط بعد اختيار سيارة محددة.',
},
}[language]
return (
<main className={`min-h-screen transition-colors ${
theme === 'dark'
? 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.20),transparent_28%),linear-gradient(180deg,#0f1f4a,#112d6e)]'
: 'bg-[radial-gradient(circle_at_top,rgba(234,88,12,0.08),transparent_28%),linear-gradient(180deg,#f5f8ff,white)]'
}`}>
<div className="shell py-16 sm:py-20">
<div className="max-w-4xl">
<div className="flex items-center gap-4">
<Link href="/" aria-label="RentalDriveGo home">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={72}
height={72}
className={`h-16 w-16 rounded-2xl object-contain p-1 shadow-sm ${
theme === 'dark' ? 'border border-orange-500/40 bg-blue-950' : 'border border-orange-200 bg-white'
}`}
/>
</Link>
<p className={`text-sm font-semibold uppercase tracking-[0.24em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.badge}
</p>
</div>
<h1 className={`mt-5 text-4xl font-black tracking-tight sm:text-6xl ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.title}
</h1>
<p className={`mt-6 max-w-3xl text-lg leading-8 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.intro}
</p>
</div>
<section className={`mt-10 overflow-hidden rounded-[2rem] border shadow-xl transition-colors ${
theme === 'dark'
? 'border-blue-900 bg-blue-950 shadow-black/20'
: 'border-stone-200 bg-white shadow-stone-200/40'
}`}>
<div className={`px-6 py-6 ${theme === 'dark' ? 'border-b border-stone-800' : 'border-b border-stone-200'}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-orange-300' : 'text-orange-700'
}`}>
{copy.sectionBadge}
</p>
<div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<h2 className={`text-2xl font-black tracking-tight ${
theme === 'dark' ? 'text-slate-100' : 'text-blue-900'
}`}>
{copy.sectionTitle}
</h2>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyA}
</p>
<p className={`mt-3 text-sm leading-7 ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
{copy.sectionBodyB}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/features"
className={`rounded-full px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'bg-orange-400 text-blue-950 hover:bg-orange-300'
: 'bg-blue-950 text-white hover:bg-blue-900/40'
}`}
>
{copy.exploreVehicles}
</Link>
<Link
href="/pricing"
className={`rounded-full border px-5 py-2.5 text-sm font-semibold transition ${
theme === 'dark'
? 'border-blue-800 text-slate-200 hover:border-blue-600 hover:bg-blue-900/40'
: 'border-stone-300 text-stone-700 hover:border-stone-400 hover:bg-stone-50'
}`}
>
{copy.viewPricing}
</Link>
</div>
</div>
</div>
<div className={`grid gap-6 px-6 py-8 lg:grid-cols-[1.4fr_0.9fr] ${
theme === 'dark' ? 'bg-blue-950' : 'bg-stone-50'
}`}>
<div className="rounded-[1.5rem] bg-[linear-gradient(135deg,#06132e,#0d1b38)] p-8 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-300">{copy.nowServing}</p>
<h3 className="mt-4 text-3xl font-black tracking-tight">
{copy.promoTitle}
</h3>
<p className="mt-4 max-w-2xl text-sm leading-7 text-stone-200">
{copy.promoBody}
</p>
</div>
<div className="grid gap-4">
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.customerPaths}</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href="/features" className="rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white">
{copy.exploreVehicles}
</Link>
<a className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href={`${dashboardUrl}/sign-in`}>
{copy.ownerSignIn}
</a>
<Link className={`rounded-full border px-4 py-2 text-sm font-semibold ${
theme === 'dark' ? 'border-stone-700 text-stone-200' : 'border-stone-300 text-stone-700'
}`} href="/pricing">
{copy.pricing}
</Link>
</div>
</div>
<div className={`rounded-[1.5rem] border p-5 ${
theme === 'dark' ? 'border-blue-900 bg-blue-950' : 'border-stone-200 bg-white'
}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${
theme === 'dark' ? 'text-stone-400' : 'text-stone-500'
}`}>{copy.workspaceAreas}</p>
<ul className={`mt-4 space-y-3 text-sm ${
theme === 'dark' ? 'text-stone-300' : 'text-stone-600'
}`}>
<li>{copy.workspaceA}</li>
<li>{copy.workspaceB}</li>
<li>{copy.workspaceC}</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</main>
)
}
@@ -0,0 +1,59 @@
.link {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
gap: var(--space-2);
font-weight: 650;
text-underline-offset: 0.18em;
}
.inline {
min-block-size: auto;
font-weight: inherit;
text-decoration-thickness: 0.08em;
}
.navigation {
color: var(--text-primary);
text-decoration: none;
}
.navigation:hover {
color: var(--link-hover);
text-decoration: underline;
}
.standalone {
text-decoration: none;
}
.standalone:hover {
text-decoration: underline;
}
.button-primary,
.button-conversion {
justify-content: center;
padding-block: var(--space-3);
padding-inline: var(--space-5);
border-radius: var(--radius-sm);
color: var(--text-inverse);
text-decoration: none;
}
.button-primary {
background: var(--interactive-primary);
}
.button-primary:hover {
background: var(--interactive-primary-hover);
color: var(--text-inverse);
}
.button-conversion {
background: var(--interactive-conversion);
}
.button-conversion:hover {
background: var(--interactive-conversion-hover);
color: var(--text-inverse);
}
.muted {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.disabled {
cursor: not-allowed;
color: var(--control-disabled-text);
text-decoration: none;
}
@@ -0,0 +1,76 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import styles from './ActionLink.module.css';
type LinkVariant =
| 'inline'
| 'navigation'
| 'standalone'
| 'button-primary'
| 'button-conversion'
| 'muted';
interface ActionLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
href: string;
children: ReactNode;
variant?: LinkVariant;
icon?: ApprovedIconName;
external?: boolean;
newTab?: boolean;
disabledReason?: string;
}
export function ActionLink({
href,
children,
variant = 'inline',
icon,
external = false,
newTab = false,
disabledReason,
className,
...props
}: ActionLinkProps) {
if (disabledReason) {
return (
<span
className={classNames(styles.link, styles[variant], styles.disabled, className)}
aria-disabled="true"
title={disabledReason}
>
<span>{children}</span>
<span className="visually-hidden">: {disabledReason}</span>
</span>
);
}
const isExternal = external || /^https?:\/\//.test(href);
const trailingIcon = icon ?? (isExternal ? 'external' : undefined);
const content = (
<>
<span>{children}</span>
{trailingIcon ? (
<Icon
name={trailingIcon}
size="sm"
directionBehavior={
trailingIcon === 'chevron-forward' || trailingIcon === 'arrow-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
{newTab ? <span className="visually-hidden"> (opens in a new tab)</span> : null}
</>
);
const shared = {
className: classNames(styles.link, styles[variant], className),
...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
...props,
};
return (
<a href={href} {...shared}>
{content}
</a>
);
}
@@ -0,0 +1,139 @@
.button {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 750;
line-height: 1.25;
text-align: center;
text-decoration: none;
transition:
background-color var(--duration-fast) var(--easing-productive),
border-color var(--duration-fast) var(--easing-productive),
color var(--duration-fast) var(--easing-productive),
transform var(--duration-fast) var(--easing-productive);
}
.button:active:not(:disabled) {
transform: translateY(1px);
}
.button:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.small {
min-block-size: 2.5rem;
padding-block: var(--space-2);
padding-inline: var(--space-3);
font-size: var(--type-label-size);
}
.medium {
padding-block: var(--space-3);
padding-inline: var(--space-5);
}
.large {
min-block-size: 3.25rem;
padding-block: var(--space-3);
padding-inline: var(--space-6);
font-size: var(--type-body-large-size);
}
.primary {
background: var(--interactive-primary);
color: var(--text-inverse);
}
.primary:hover:not(:disabled) {
background: var(--interactive-primary-hover);
}
.conversion {
background: var(--interactive-conversion);
color: var(--text-inverse);
}
.conversion:hover:not(:disabled) {
background: var(--interactive-conversion-hover);
}
.secondary {
border-color: var(--interactive-primary);
background: var(--surface-primary);
color: var(--interactive-primary);
}
.secondary:hover:not(:disabled) {
background: var(--status-info-surface);
}
.tertiary {
border-color: var(--border-standard);
background: var(--surface-elevated);
color: var(--text-primary);
}
.tertiary:hover:not(:disabled) {
border-color: var(--border-strong);
background: var(--surface-muted);
}
.ghost {
background: transparent;
color: var(--text-primary);
}
.ghost:hover:not(:disabled) {
background: var(--surface-muted);
}
.destructive {
background: var(--interactive-danger);
color: var(--text-inverse);
}
.destructive:hover:not(:disabled) {
filter: brightness(0.92);
}
.link {
min-block-size: 2.75rem;
padding-inline: 0;
background: transparent;
color: var(--link-default);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.link:hover:not(:disabled) {
color: var(--link-hover);
}
.iconOnly {
aspect-ratio: 1;
padding: var(--space-3);
}
.fullWidth {
inline-size: 100%;
}
.loading {
position: relative;
}
.spinner {
position: absolute;
animation: spin 0.9s linear infinite;
}
.loadingContent {
display: inline-flex;
align-items: center;
gap: var(--space-2);
opacity: 0;
}
.label {
min-inline-size: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
.spinner {
animation-duration: 1.8s;
}
}
@media (forced-colors: active) {
.button {
border-color: ButtonText;
}
}
@@ -0,0 +1,97 @@
'use client';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import styles from './Button.module.css';
export type ButtonIntent =
| 'primary'
| 'conversion'
| 'secondary'
| 'tertiary'
| 'ghost'
| 'destructive'
| 'link';
export type ButtonSize = 'small' | 'medium' | 'large';
interface CommonButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
intent?: ButtonIntent;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
leadingIcon?: ApprovedIconName;
trailingIcon?: ApprovedIconName;
children: ReactNode;
}
interface IconOnlyButtonProps extends Omit<
CommonButtonProps,
'children' | 'leadingIcon' | 'trailingIcon'
> {
icon: ApprovedIconName;
'aria-label': string;
children?: never;
}
export type ButtonProps = CommonButtonProps | IconOnlyButtonProps;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
const {
intent = 'primary',
size = 'medium',
loading = false,
fullWidth = false,
disabled = false,
className,
type = 'button',
...rest
} = props;
const iconOnly = 'icon' in props;
const content = iconOnly ? (
<Icon name={props.icon} />
) : (
<>
{props.leadingIcon ? <Icon name={props.leadingIcon} /> : null}
<span className={styles.label}>{props.children}</span>
{props.trailingIcon ? (
<Icon
name={props.trailingIcon}
directionBehavior={
props.trailingIcon === 'arrow-forward' || props.trailingIcon === 'chevron-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
</>
);
const nativeProps = { ...rest } as ButtonHTMLAttributes<HTMLButtonElement>;
delete (nativeProps as Partial<CommonButtonProps>).leadingIcon;
delete (nativeProps as Partial<CommonButtonProps>).trailingIcon;
delete (nativeProps as Partial<IconOnlyButtonProps>).icon;
return (
<button
ref={ref}
type={type}
className={classNames(
styles.button,
styles[intent],
styles[size],
iconOnly && styles.iconOnly,
fullWidth && styles.fullWidth,
loading && styles.loading,
className,
)}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-loading={loading || undefined}
{...nativeProps}
>
{loading ? <Icon name="spinner" className={styles.spinner} /> : null}
<span className={loading ? styles.loadingContent : undefined}>{content}</span>
</button>
);
});
@@ -0,0 +1,28 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import styles from './SiteHeader.module.css';
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
const pathname = usePathname();
return (
<a
className={styles.brand}
href={localizedPath('home', locale)}
aria-label={label}
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
>
<Image
src="/rentaldrivego.jpeg"
alt=""
width={36}
height={36}
className={styles.mark}
priority
/>
<span className={styles.brandName}>RentalDriveGo</span>
</a>
);
}
@@ -0,0 +1,38 @@
.field {
display: grid;
gap: var(--space-1);
}
.label {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 700;
}
.select {
min-block-size: var(--size-touch-min);
min-inline-size: 7.25rem;
max-inline-size: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3) var(--space-8);
background: var(--surface-elevated);
color: var(--text-primary);
}
.select:hover {
border-color: var(--interactive-primary);
}
.compactLabel {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,29 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import styles from './SiteHeader.module.css';
interface DisabledActionProps {
label: string;
reason: string;
pendingLabel?: string;
conversion?: boolean;
}
export function DisabledAction({
label,
reason,
pendingLabel,
conversion = false,
}: DisabledActionProps) {
return (
<span
className={conversion ? styles.conversionDisabled : styles.actionDisabled}
role="link"
aria-disabled="true"
aria-label={`${label}. ${reason}`}
title={reason}
>
<span>{label}</span>
{pendingLabel ? <StatusBadge>{pendingLabel}</StatusBadge> : null}
</span>
);
}
@@ -0,0 +1,57 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import styles from './SiteHeader.module.css';
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
interface HeaderNavigationProps {
locale: Locale;
label: string;
labels: Record<HeaderNavId, string>;
onNavigate?: () => void;
mobile?: boolean;
}
const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing', 'faq'];
export function HeaderNavigation({
locale,
label,
labels,
onNavigate,
mobile = false,
}: HeaderNavigationProps) {
const pathname = usePathname();
const currentRoute = routeIdFromPathname(pathname);
const [currentHash, setCurrentHash] = useState('');
useEffect(() => {
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
updateHash();
window.addEventListener('hashchange', updateHash);
return () => window.removeEventListener('hashchange', updateHash);
}, []);
const home = localizedPath('home', locale);
return (
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
<ul>
{navigationIds.map((id) => (
<li key={id}>
<a
href={`${home}#${id}`}
aria-current={currentRoute === 'home' && currentHash === id ? 'location' : undefined}
onClick={onNavigate}
>
{labels[id]}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,52 @@
'use client';
import { persistLocale } from '@/lib/localization/client';
import {
localeSwitchUrl,
locales,
routeIdFromPathname,
type Locale,
} from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import styles from './Controls.module.css';
interface LocaleSelectorProps {
currentLocale: Locale;
label: string;
localeNames: Record<Locale, string>;
compact?: boolean;
}
export function LocaleSelector({
currentLocale,
label,
localeNames,
compact = false,
}: LocaleSelectorProps) {
const pathname = usePathname();
const routeId = routeIdFromPathname(pathname) ?? 'home';
return (
<label className={styles.field}>
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
<select
className={styles.select}
aria-label={label}
value={currentLocale}
onChange={(event) => {
const targetLocale = event.target.value as Locale;
persistLocale(targetLocale);
window.location.assign(
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
);
}}
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</label>
);
}
@@ -0,0 +1,15 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { useParams } from 'next/navigation';
import { LoadingSkeleton, StateShell } from './StateShell';
export function LocalizedLoading() {
const params = useParams<{ locale?: string }>();
const messages = getClientShellMessages(params?.locale);
return (
<StateShell title={messages.states.loadingTitle} body={messages.states.loadingBody} status>
<LoadingSkeleton />
</StateShell>
);
}
@@ -0,0 +1,20 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { isLocale, localizedPath } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { StateAction, StateActions, StateShell } from './StateShell';
export function LocalizedNotFound() {
const pathname = usePathname();
const localeValue = pathname.split('/').filter(Boolean)[0];
const locale = isLocale(localeValue) ? localeValue : 'en';
const messages = getClientShellMessages(locale);
return (
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
<StateActions>
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
</StateActions>
</StateShell>
);
}
@@ -0,0 +1,146 @@
'use client';
import { ActionLink } from '@/components/actions/ActionLink';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import type { ShellMessages } from '@/lib/localization/messages';
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config';
import { useEffect, useRef, useState } from 'react';
import { HeaderNavigation } from './HeaderNavigation';
import { LocaleSelector } from './LocaleSelector';
import styles from './SiteHeader.module.css';
import { ThemeSelector } from './ThemeSelector';
function signInUrl(locale: Locale): string {
return localizedPath('sign-in', locale);
}
interface MobileNavigationProps {
locale: Locale;
messages: ShellMessages;
themePreference: ThemePreference;
demoEnabled: boolean;
}
export function MobileNavigation({
locale,
messages,
themePreference,
demoEnabled,
}: MobileNavigationProps) {
const [open, setOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
document.body.dataset.navigationOpen = 'true';
closeRef.current?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
return () => {
delete document.body.dataset.navigationOpen;
};
}, [open]);
const close = () => {
dialogRef.current?.close();
setOpen(false);
delete document.body.dataset.navigationOpen;
window.requestAnimationFrame(() => triggerRef.current?.focus());
};
return (
<div className={styles.mobileNavigation}>
<button
ref={triggerRef}
type="button"
className={styles.menuButton}
aria-label={messages.header.menuOpen}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span aria-hidden="true" className={styles.menuIcon}>
<span />
<span />
<span />
</span>
</button>
<dialog
ref={dialogRef}
className={styles.drawer}
aria-label={messages.header.navigationLabel}
onClose={() => {
setOpen(false);
delete document.body.dataset.navigationOpen;
}}
onCancel={(event) => {
event.preventDefault();
close();
}}
>
<div className={styles.drawerPanel}>
<div className={styles.drawerHeader}>
<strong>RentalDriveGo</strong>
<button
ref={closeRef}
type="button"
className={styles.closeButton}
aria-label={messages.header.menuClose}
onClick={close}
>
<span aria-hidden="true">×</span>
</button>
</div>
<HeaderNavigation
locale={locale}
label={messages.header.navigationLabel}
labels={messages.header.nav}
mobile
onNavigate={close}
/>
<div
className={styles.drawerControls}
role="group"
aria-label={messages.header.controlsLabel}
>
<LocaleSelector
currentLocale={locale}
label={messages.controls.language}
localeNames={messages.controls.localeNames}
/>
<ThemeSelector
initialPreference={themePreference}
label={messages.controls.theme}
statusTemplate={messages.controls.themeStatus}
optionLabels={messages.controls.themes}
/>
</div>
<div className={styles.drawerActions}>
<ActionLink href={signInUrl(locale)} variant="button-primary">
{messages.header.login}
</ActionLink>
<DemoTrigger
label={messages.header.demo}
source="mobile-header"
fullWidth
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
</div>
</div>
</dialog>
</div>
);
}
@@ -0,0 +1,118 @@
.footer {
border-block-start: 1px solid var(--border-standard);
background: var(--surface-primary);
}
.inner {
display: grid;
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-block: var(--space-12) var(--space-6);
padding-inline: var(--layout-gutter);
gap: var(--space-10);
}
.brandColumn {
display: grid;
align-content: start;
gap: var(--space-4);
max-inline-size: 32rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
}
.mark {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
padding-inline: var(--space-2);
place-items: center;
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
font-size: var(--type-caption-size);
}
.tagline,
.releaseNotice {
margin: 0;
color: var(--text-secondary);
}
.groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: var(--space-8);
}
.group h2 {
margin-block-end: var(--space-3);
font-size: var(--type-label-size);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
html[dir='rtl'] .group h2 {
letter-spacing: normal;
text-transform: none;
}
.group ul {
display: grid;
margin: 0;
padding: 0;
gap: var(--space-2);
list-style: none;
}
.group a,
.pendingItem {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
text-decoration: none;
}
.group a:hover {
color: var(--interactive-primary);
text-decoration: underline;
}
.pendingItem {
flex-wrap: wrap;
opacity: var(--opacity-disabled);
}
.legalLink {
flex-wrap: wrap;
}
.bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block-start: var(--space-6);
border-block-start: 1px solid var(--border-standard);
color: var(--text-subdued);
font-size: var(--type-caption-size);
}
@media (min-width: 768px) {
.inner {
grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 2fr);
}
.bottom {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,161 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ShellMessages } from '@/lib/localization/messages';
import styles from './SiteFooter.module.css';
function signInUrl(locale: Locale): string {
return localizedPath('sign-in', locale);
}
interface SiteFooterProps {
locale: Locale;
messages: ShellMessages;
demoEnabled: boolean;
}
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
}
function PendingRouteLink({
locale,
routeId,
label,
pending,
}: {
locale: Locale;
routeId: 'sign-in' | 'forgot-password' | 'privacy' | 'terms' | 'accessibility';
label: string;
pending: string;
}) {
return (
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</a>
);
}
function PendingItem({ label, pending }: { label: string; pending: string }) {
return (
<span className={styles.pendingItem} aria-disabled="true">
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</span>
);
}
export function SiteFooter({ locale, messages, demoEnabled }: SiteFooterProps) {
const footer = messages.footer;
const year = new Date().getUTCFullYear();
return (
<footer className={styles.footer} aria-label={footer.landmarkLabel}>
<div className={styles.inner}>
<div className={styles.brandColumn}>
<div className={styles.brand}>
<span aria-hidden="true" className={styles.mark}>
RDG
</span>
<span>RentalDriveGo</span>
</div>
<p className={styles.tagline}>{footer.tagline}</p>
<p className={styles.releaseNotice}>{footer.releaseNotice}</p>
</div>
<div className={styles.groups}>
<section className={styles.group} aria-labelledby="footer-product">
<h2 id="footer-product">{footer.groups.product}</h2>
<ul>
<li>
<HashLink locale={locale} hash="workflow">
{footer.links.workflow}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="modules">
{footer.links.modules}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="pricing">
{footer.links.pricing}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="faq">
{footer.links.faq}
</HashLink>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-company">
<h2 id="footer-company">{footer.groups.company}</h2>
<ul>
<li>
<PendingItem label={footer.links.contact} pending={footer.pending} />
</li>
<li>
<DemoTrigger
label={footer.links.demo}
source="footer"
size="small"
disabledReason={demoEnabled ? undefined : messages.header.demoUnavailable}
/>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-account">
<h2 id="footer-account">{footer.groups.account}</h2>
<ul>
<li>
<a href={signInUrl(locale)}>
{footer.links.login}
</a>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-legal">
<h2 id="footer-legal">{footer.groups.legal}</h2>
<ul>
<li>
<PendingRouteLink
locale={locale}
routeId="privacy"
label={footer.links.privacy}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="terms"
label={footer.links.terms}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="accessibility"
label={footer.links.accessibility}
pending={footer.pending}
/>
</li>
</ul>
</section>
</div>
<div className={styles.bottom}>
<span>
{footer.copyrightPrefix} {year} RentalDriveGo
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,303 @@
.header {
position: sticky;
z-index: var(--layer-sticky);
inset-block-start: 0;
border-block-end: 1px solid var(--border-standard);
background: color-mix(in srgb, var(--surface-primary) 94%, transparent);
box-shadow: var(--shadow-subtle);
backdrop-filter: blur(var(--space-3));
}
.inner {
display: grid;
min-block-size: var(--header-min-block-size);
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-inline: var(--layout-gutter);
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5);
}
.brand {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
text-decoration: none;
white-space: nowrap;
}
.mark {
inline-size: 36px;
block-size: 36px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.navigation ul,
.drawerNavigation ul {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.navigation ul {
justify-content: center;
gap: var(--space-1);
}
.navigation a,
.drawerNavigation a {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-weight: 650;
text-decoration: none;
}
.navigation a {
padding-inline: var(--space-3);
}
.navigation a:hover,
.navigation a[aria-current],
.drawerNavigation a:hover,
.drawerNavigation a[aria-current] {
background: var(--surface-muted);
color: var(--interactive-primary);
}
.desktopControls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.actionDisabled,
.conversionDisabled {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3);
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
font-weight: 700;
opacity: var(--opacity-disabled);
white-space: nowrap;
}
.actionLink {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3);
color: var(--text-primary);
font-weight: 700;
white-space: nowrap;
text-decoration: none;
}
.actionLink:hover {
background: var(--interactive-primary);
color: var(--text-inverse);
border-color: var(--interactive-primary);
}
.conversionDisabled {
border-color: var(--interactive-conversion);
}
.mobileActions,
.mobileNavigation {
display: none;
}
.menuButton,
.closeButton {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
place-items: center;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
}
.menuIcon {
display: grid;
inline-size: var(--space-5);
gap: var(--space-1);
}
.menuIcon span {
display: block;
block-size: 2px;
border-radius: var(--radius-pill);
background: currentColor;
}
.drawer {
inline-size: min(88vw, 384px);
max-inline-size: none;
block-size: 100dvh;
max-block-size: none;
margin-block: 0;
margin-inline: 0 auto;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
}
html[dir='rtl'] .drawer {
margin-inline: auto 0;
}
.drawer::backdrop {
background: var(--overlay-scrim);
}
.drawerPanel {
display: flex;
block-size: 100%;
flex-direction: column;
gap: var(--space-6);
overflow-y: auto;
padding: var(--space-5);
background: var(--surface-elevated);
box-shadow: var(--shadow-overlay);
}
.drawerHeader {
display: flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.closeButton {
font-size: var(--type-heading-3-size);
line-height: 1;
}
.drawerNavigation ul {
flex-direction: column;
gap: var(--space-1);
}
.drawerNavigation a {
inline-size: 100%;
padding-inline: var(--space-3);
}
.drawerControls,
.drawerActions {
display: grid;
gap: var(--space-4);
}
.drawerActions {
margin-block-start: auto;
}
/*
* Preserve the full navigation on laptop and tablet landscape widths.
* Only the secondary controls collapse into the menu until the viewport
* becomes too narrow for the five primary destinations.
*/
@media (min-width: 900px) and (max-width: 1279px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 899px) {
.inner {
grid-template-columns: minmax(0, 1fr) auto;
}
.desktopNavigation,
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 559px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.mobileActions > .conversionDisabled {
display: none;
}
}
@media (forced-colors: active) {
.header {
backdrop-filter: none;
}
.mark,
.actionDisabled,
.conversionDisabled,
.menuButton,
.closeButton {
border: 1px solid CanvasText;
}
}
@media (max-width: 559px) {
.mobileDemoTrigger {
display: none;
}
}

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