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
+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>