update website home and dashboard

This commit is contained in:
root
2026-05-20 14:52:26 -04:00
parent d4f7de5762
commit fb15883f8e
38 changed files with 3043 additions and 1922 deletions
@@ -648,18 +648,9 @@ export default function FleetPage() {
{filtered.map((vehicle) => (
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
{vehicle.primaryPhoto ? (
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
)}
</div>
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
</div>
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
</div>
</td>
<td className="px-6 py-4">
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types'
@@ -38,6 +38,10 @@ interface DashboardData {
}
}
interface EmployeeProfile {
role?: string
}
const STATUS_COLORS: Record<string, string> = {
CONFIRMED: 'badge-blue',
PENDING: 'badge-amber',
@@ -108,12 +112,22 @@ export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
useEffect(() => {
try {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null)
} catch {}
}, [])
useEffect(() => {
apiFetch<DashboardData>('/analytics/dashboard')
.then(setData)
.catch((err) => setError(err.message))
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
.finally(() => setLoading(false))
}, [])
@@ -126,15 +140,16 @@ export default function DashboardPage() {
}
if (error) {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return (
<div className="card p-6 text-center">
<p className="text-red-600 font-medium">{d.failedToLoad}</p>
<p className="text-slate-500 text-sm mt-1">{error}</p>
<p className="text-slate-500 text-sm">{errorMsg}</p>
</div>
)
}
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const canViewRevenue = employeeRole !== 'AGENT'
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
@@ -156,7 +171,7 @@ export default function DashboardPage() {
)}
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
<StatCard
title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()}
@@ -175,15 +190,17 @@ export default function DashboardPage() {
iconColor="text-green-600"
iconBg="bg-green-50"
/>
<StatCard
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-amber-600"
iconBg="bg-amber-50"
/>
{canViewRevenue ? (
<StatCard
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-amber-600"
iconBg="bg-amber-50"
/>
) : null}
<StatCard
title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()}
@@ -1,8 +1,9 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
@@ -35,6 +36,10 @@ interface ProviderAvailability {
paypal: boolean
}
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
@@ -52,11 +57,13 @@ const INVOICE_STATUS: Record<string, string> = {
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
@@ -198,6 +205,33 @@ export default function SubscriptionPage() {
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/dashboard')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/dashboard')
})
.catch(() => {
setCanViewPage(false)
router.replace('/dashboard')
})
}, [router])
useEffect(() => {
if (canViewPage !== true) return
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
@@ -217,7 +251,11 @@ export default function SubscriptionPage() {
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
}, [canViewPage])
if (canViewPage !== true) {
return null
}
async function handleCheckout() {
setPaying(true)
@@ -0,0 +1,143 @@
'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'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
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',
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: 'البريد الإلكتروني',
submit: 'إرسال رابط الإعادة',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
return (
<PublicShell embedded={embedded}>
<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">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
/>
</a>
</div>
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-900 dark:text-slate-100">{dict.title}</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{dict.subtitle}</p>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900/90">
{sent ? (
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" 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-slate-900 dark:text-slate-100">{dict.successTitle}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">{dict.successBody}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
)}
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4 dark:text-slate-100 dark:decoration-slate-700">
{dict.backToLogin}
</Link>
</div>
</section>
</div>
</main>
</PublicShell>
)
}
+9 -125
View File
@@ -1,128 +1,12 @@
'use client'
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
import Link from 'next/link'
import { useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { API_BASE } from '@/lib/api'
import { marketplaceUrl } from '@/lib/urls'
export default async function ForgotPasswordPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const params = await searchParams
const embedded = params.embedded === '1'
export default function ForgotPasswordPage() {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
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',
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: 'البريد الإلكتروني',
submit: 'إرسال رابط الإعادة',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!res.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
RentalDriveGo
</a>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.title}</h1>
<p className="mt-2 text-sm text-slate-500">{dict.subtitle}</p>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{sent ? (
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" 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-slate-900">{dict.successTitle}</h2>
<p className="text-sm text-slate-500">{dict.successBody}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
)}
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.backToLogin}
</Link>
</div>
</section>
</div>
</main>
)
return <ForgotPasswordPageClient embedded={embedded} />
}
@@ -0,0 +1,214 @@
'use client'
import Link from 'next/link'
import { Suspense, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { API_BASE } from '@/lib/api'
export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
return (
<Suspense>
<ResetPasswordContent embedded={embedded} />
</Suspense>
)
}
function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your workspace account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: "Choisissez un mot de passe fort pour votre compte.",
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
requestNew: 'طلب رابط جديد',
},
}[language]
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (password.length < 8) { setError(dict.errorShort); return }
if (password !== confirm) { setError(dict.errorMismatch); return }
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const json = await res.json()
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
throw new Error(dict.errorGeneric)
}
setDone(true)
} catch (err: any) {
setError(err.message ?? dict.errorGeneric)
} finally {
setLoading(false)
}
}
if (!token) {
return (
<ResetShell embedded={embedded} title={dict.title}>
<p className="text-center text-sm text-slate-500">{dict.noToken}</p>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
</div>
</ResetShell>
)
}
if (done) {
return (
<ResetShell embedded={embedded} title={dict.title}>
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" 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-slate-900">{dict.successTitle}</h2>
<p className="text-sm text-slate-500">{dict.successBody}</p>
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
</div>
</ResetShell>
)
}
return (
<ResetShell embedded={embedded} title={dict.title} subtitle={dict.subtitle}>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.backToLogin}
</Link>
</div>
</ResetShell>
)
}
function ResetShell({
embedded = false,
title,
subtitle,
children,
}: {
embedded?: boolean
title: string
subtitle?: string
children: React.ReactNode
}) {
return (
<PublicShell embedded={embedded}>
<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-blue-600">RentalDriveGo</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{children}
</section>
</div>
</main>
</PublicShell>
)
}
+9 -198
View File
@@ -1,201 +1,12 @@
'use client'
import ResetPasswordPageClient from './ResetPasswordPageClient'
import Link from 'next/link'
import { useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { API_BASE } from '@/lib/api'
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const params = await searchParams
const embedded = params.embedded === '1'
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordContent />
</Suspense>
)
}
function ResetPasswordContent() {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your workspace account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: "Choisissez un mot de passe fort pour votre compte.",
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
requestNew: 'طلب رابط جديد',
},
}[language]
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (password.length < 8) { setError(dict.errorShort); return }
if (password !== confirm) { setError(dict.errorMismatch); return }
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const json = await res.json()
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
throw new Error(dict.errorGeneric)
}
setDone(true)
} catch (err: any) {
setError(err.message ?? dict.errorGeneric)
} finally {
setLoading(false)
}
}
if (!token) {
return (
<ResetShell title={dict.title}>
<p className="text-sm text-slate-500 text-center">{dict.noToken}</p>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
</div>
</ResetShell>
)
}
if (done) {
return (
<ResetShell title={dict.title}>
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" 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-slate-900">{dict.successTitle}</h2>
<p className="text-sm text-slate-500">{dict.successBody}</p>
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
</div>
</ResetShell>
)
}
return (
<ResetShell title={dict.title} subtitle={dict.subtitle}>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.backToLogin}
</Link>
</div>
</ResetShell>
)
}
function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] 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-blue-600">RentalDriveGo</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{children}
</section>
</div>
</main>
)
return <ResetPasswordPageClient embedded={embedded} />
}
@@ -0,0 +1,416 @@
'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, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
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, '*')
}
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: 'Space Agency',
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
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.',
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?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
brandName: 'Agence Espace',
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
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.",
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Email 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é ? »`,
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
brandName: ' فضاء الوكالة ',
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
useEffect(() => {
if (initializedFromQuery.current) return
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
if (
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
initializedFromQuery.current = true
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
useEffect(() => {
if (!initializedFromQuery.current) return
const params = new URLSearchParams(searchParams.toString())
let changed = false
if (params.get('lang') !== language) {
params.set('lang', language)
changed = true
}
if (params.get('theme') !== theme) {
params.set('theme', theme)
changed = true
}
if (!changed) return
const nextQuery = params.toString()
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
}, [language, pathname, router, searchParams, theme])
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
return (
<PublicShell embedded={embedded}>
<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">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
/>
</a>
</div>
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-900 dark:text-slate-100">
{dict.brandName}
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{dict.subtitle}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{(['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-blue-600 dark:text-amber-400 ${
language === lang
? 'ring-1 ring-current opacity-100'
: 'opacity-40 hover:opacity-80'
}`}
>
{lang}
</button>
))}
</div>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900/90">
<LocalSignInForm dict={dict} />
</section>
</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
unexpectedError: string
}
}) {
const router = useRouter()
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 adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
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`
}
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
router.push(employeeRedirect)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
return
}
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
return (
<>
{error ? (
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
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>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-slate-500 underline decoration-slate-300 underline-offset-4 hover:text-slate-700">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center text-xl tracking-[0.45em]"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-slate-500 hover:text-slate-700"
>
{dict.back}
</button>
</form>
)}
</>
)
}
@@ -1,396 +1,12 @@
'use client'
import SignInPageClient from './SignInPageClient'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
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, '*')
}
export default function SignInPage() {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const pathname = usePathname()
const searchParams = useSearchParams()
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const dict = {
en: {
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
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.',
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?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
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.',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Email 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é ? »',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const themeStyles = {
light: {
main: 'bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]',
logoFrame: 'border-blue-100 bg-white shadow-sm',
brand: 'text-blue-600',
title: 'text-slate-900',
subtitle: 'text-slate-500',
card: 'border-slate-200 bg-white shadow-sm',
},
medium: {
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
logoFrame: 'border-slate-300 bg-white/90 shadow-[0_12px_30px_rgba(71,85,105,0.18)]',
brand: 'text-slate-700',
title: 'text-slate-900',
subtitle: 'text-slate-600',
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
},
dark: {
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
logoFrame: 'border-slate-700 bg-slate-900 shadow-[0_12px_30px_rgba(2,6,23,0.45)]',
brand: 'text-amber-400',
title: 'text-slate-100',
subtitle: 'text-slate-400',
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
},
}[theme]
useEffect(() => {
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
}, [language, requestedLanguage, setLanguage])
useEffect(() => {
if (
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
}, [requestedTheme, setTheme, theme])
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
return (
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
className={`h-24 w-24 rounded-[1.75rem] border p-1.5 transition-colors ${themeStyles.logoFrame}`}
/>
</div>
<a href={marketplaceUrl} className={`text-xs font-semibold uppercase tracking-[0.24em] transition-colors ${themeStyles.brand}`}>
RentalDriveGo
</a>
<h1 className={`mt-3 text-3xl font-black tracking-tight transition-colors ${themeStyles.title}`}>
{dict.title}
</h1>
<p className={`mt-2 text-sm transition-colors ${themeStyles.subtitle}`}>
{dict.subtitle}
</p>
</div>
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
<LocalSignInForm dict={dict} />
</section>
</div>
</main>
)
}
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
unexpectedError: string
}
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const router = useRouter()
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 adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const params = await searchParams
const embedded = params.embedded === '1'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
// Try employee login first
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
// Apply the employee's stored language preference immediately so the
// dashboard renders in the correct language before any read-effect runs.
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`
}
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
router.push(employeeRedirect)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
return
}
// Fall back to admin login
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
return (
<>
{error ? (
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
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>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center text-xl tracking-[0.45em]"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-slate-500 hover:text-slate-700"
>
{dict.back}
</button>
</form>
)}
</>
)
return <SignInPageClient embedded={embedded} />
}
@@ -1,5 +1,6 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
@@ -53,6 +54,7 @@ type SignupResponse = {
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function SignUpPage() {
const { language, setLanguage } = useDashboardI18n()
@@ -405,7 +407,19 @@ export default function SignUpPage() {
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
@@ -420,9 +434,9 @@ export default function SignUpPage() {
<Link href="/dashboard" className="btn-primary justify-center">
{dict.enterDashboard}
</Link>
<Link href="/sign-in" className="btn-secondary justify-center">
<a href="/sign-in" className="btn-secondary justify-center">
{dict.signInAnotherDevice}
</Link>
</a>
</div>
</div>
</main>
@@ -435,7 +449,19 @@ export default function SignUpPage() {
<main className="px-4 py-12">
<div className="mx-auto max-w-4xl space-y-8">
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
</div>
+52 -11
View File
@@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
import { apiFetch } from '@/lib/api'
export type DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'medium' | 'dark'
@@ -143,6 +144,7 @@ type ReservationsDict = {
type DashboardPageDict = {
failedToLoad: string
forbidden: string
kpiTotalBookings: string
kpiActiveVehicles: string
kpiMonthlyRevenue: string
@@ -556,6 +558,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
},
dashboardPage: {
failedToLoad: 'Failed to load dashboard',
forbidden: 'You do not have permission to access this page. A Manager role or higher is required.',
kpiTotalBookings: 'Total Bookings',
kpiActiveVehicles: 'Active Vehicles',
kpiMonthlyRevenue: 'Monthly Revenue',
@@ -899,6 +902,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
},
dashboardPage: {
failedToLoad: 'Échec du chargement du tableau de bord',
forbidden: "Vous n'avez pas la permission d'accéder à cette page. Un rôle Gestionnaire ou supérieur est requis.",
kpiTotalBookings: 'Total des réservations',
kpiActiveVehicles: 'Véhicules actifs',
kpiMonthlyRevenue: 'Revenus du mois',
@@ -1242,6 +1246,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
},
dashboardPage: {
failedToLoad: 'فشل تحميل لوحة التحكم',
forbidden: 'ليس لديك صلاحية الوصول إلى هذه الصفحة. يلزم دور المدير أو أعلى.',
kpiTotalBookings: 'إجمالي الحجوزات',
kpiActiveVehicles: 'المركبات النشطة',
kpiMonthlyRevenue: 'الإيرادات الشهرية',
@@ -1368,33 +1373,61 @@ export function DashboardI18nProvider({
children: React.ReactNode
initialLanguage?: DashboardLanguage
}) {
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
const [theme, setTheme] = useState<DashboardTheme>('light')
const [language, setLanguageState] = useState<DashboardLanguage>(initialLanguage)
const [theme, setThemeState] = useState<DashboardTheme>('light')
const themeInitialized = useRef(false)
// Skip the very first write so we don't overwrite a stored preference with the
// server-resolved initialLanguage before the read effect has a chance to apply it.
const skipFirstLangWrite = useRef(true)
function applyLanguage(nextLanguage: DashboardLanguage) {
if (nextLanguage === language) return
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, [DASHBOARD_LANGUAGE_KEY])
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: DashboardTheme) {
if (nextTheme === theme) return
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(nextTheme === 'light' ? 'light' : 'dark')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['dashboard-theme'])
}
setThemeState(nextTheme)
}
useEffect(() => {
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
if (stored !== null) {
const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next)
if (next !== language) setLanguageState(next)
} else {
const detected = detectBrowserLanguage()
if (detected && detected !== language) setLanguage(detected)
if (detected && detected !== language) setLanguageState(detected)
}
}, [])
useEffect(() => {
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
if (storedTheme !== theme) setTheme(storedTheme)
if (storedTheme !== theme) setThemeState(storedTheme)
return
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
setTheme('dark')
setThemeState('dark')
}
}, [])
@@ -1431,18 +1464,18 @@ export function DashboardI18nProvider({
if (scopedLanguage) {
// Employee has a stored preference — apply it.
const next = normalizeLanguage(scopedLanguage)
if (next !== language) setLanguage(next)
if (next !== language) setLanguageState(next)
} else {
// No scoped pref yet (new login). Promote any base preference that was set
// before login (e.g., language selected on the sign-up page) to the scoped key.
const baseLang = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
const langToWrite = baseLang ? normalizeLanguage(baseLang) : language
if (langToWrite !== language) setLanguage(langToWrite)
if (langToWrite !== language) setLanguageState(langToWrite)
writeScopedPreference(SHARED_LANGUAGE_KEY, langToWrite, [DASHBOARD_LANGUAGE_KEY])
}
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
if (scopedTheme !== theme) setTheme(scopedTheme)
if (scopedTheme !== theme) setThemeState(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
}
@@ -1453,7 +1486,7 @@ export function DashboardI18nProvider({
}, [language, theme])
const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
() => ({ language, setLanguage: applyLanguage, theme, setTheme: applyTheme, dict: dictionaries[language] }),
[language, theme],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
@@ -1475,6 +1508,14 @@ export function DashboardLanguageSwitcher() {
if (embedded) return null
function handleLanguageChange(value: DashboardLanguage) {
setLanguage(value)
apiFetch('/auth/employee/me/language', {
method: 'PATCH',
body: JSON.stringify({ language: value }),
}).catch(() => {})
}
return (
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
@@ -1486,7 +1527,7 @@ export function DashboardLanguageSwitcher() {
<button
key={value}
type="button"
onClick={() => setLanguage(value)}
onClick={() => handleLanguageChange(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
@@ -0,0 +1,181 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import {
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'NorthAfrica (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europ (French)', flag: '🇫🇷' },
]
function getFooterContent(language: DashboardLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Conditions dutilisation', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Sécurité', href: `${marketplaceUrl}/footer/security` },
{ label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Politique de confidentialité', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Nos bureaux', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'Europ (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'الأمان', href: `${marketplaceUrl}/footer/security` },
{ label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'سياسة الخصوصية', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'مكاتبنا', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'NorthAfrica (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Security', href: `${marketplaceUrl}/footer/security` },
{ label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Privacy Policy', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Our Offices', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicFooter() {
const { language, setLanguage } = useDashboardI18n()
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
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 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 px-0 text-center sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.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">
{footerContent.secondary.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>{footerContent.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-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.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-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 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()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 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>
}
@@ -0,0 +1,217 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const languageMeta = {
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)
.filter((value) => value !== language)
.map((value) => ({ value, ...languageMeta[value] }))
const dict = {
en: {
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createAccount: 'Create Agency Space',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
},
fr: {
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createAccount: "Créer Espace d'Agence",
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
},
ar: {
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createAccount: 'إنشاء مساحة الوكالة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
},
}[language]
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'medium' as const, label: dict.medium },
{ 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'
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 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 = `/sign-in?${signInParams.toString()}`
const signUpParams = new URLSearchParams()
signUpParams.set('lang', language)
signUpParams.set('theme', theme)
const signUpHref = `/sign-up?${signUpParams.toString()}`
return (
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-stone-800 dark:bg-stone-950 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-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src={DASHBOARD_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 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-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 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-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
{dict.marketplace}
</a>
<Link 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-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
{dict.signIn}
</Link>
<Link href={signUpHref} className="ml-1 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
{dict.createAccount}
</Link>
</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-stone-700 dark:bg-stone-900/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-stone-800 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-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-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{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-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 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-1.5 py-1 transition hover:bg-stone-100 dark:hover:bg-stone-800 sm:gap-2 sm:px-2 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="px-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400 sm:px-2 sm:text-[11px] sm:tracking-[0.2em]">
{dict.theme}
</span>
<span className="rounded-full bg-stone-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-amber-400 dark:text-stone-950 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-stone-800 dark:bg-stone-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-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
</header>
)
}
@@ -1,276 +1,20 @@
'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import {
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: DashboardLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
]
function getFooterContent(language: DashboardLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Conditions dutilisation', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Sécurité', href: `${marketplaceUrl}/footer/security` },
{ label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Politique de confidentialité', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Nos bureaux', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'Europ (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'الأمان', href: `${marketplaceUrl}/footer/security` },
{ label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'سياسة الخصوصية', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'مكاتبنا', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'NorthAfrica (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Security', href: `${marketplaceUrl}/footer/security` },
{ label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Privacy Policy', href: `${marketplaceUrl}/footer/privacy-policy` },
{ label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Our Offices', href: `${marketplaceUrl}/footer/our-offices` },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const dict = {
en: {
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createAccount: 'Create account',
preferences: 'Workspace preferences',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
},
fr: {
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createAccount: 'Créer un compte',
preferences: 'Preferences espace',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
},
ar: {
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createAccount: 'إنشاء حساب',
preferences: 'تفضيلات المساحة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
},
}[language]
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
import PublicFooter from '@/components/layout/PublicFooter'
import PublicHeader from '@/components/layout/PublicHeader'
export default function PublicShell({
children,
embedded = false,
}: {
children: React.ReactNode
embedded?: boolean
}) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white/85 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/85">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8 lg:py-2">
<a href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={36}
height={36}
className="h-9 w-9 rounded-md object-contain"
/>
<span>RentalDriveGo</span>
</a>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<nav className="flex items-center gap-1 overflow-x-auto">
<a href={marketplaceUrl} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.home}
</a>
<a href={`${marketplaceUrl}/explore`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.marketplace}
</a>
<a href={`${marketplaceUrl}/sign-in`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.signIn}
</a>
<Link href="/sign-up" className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300">
{dict.createAccount}
</Link>
</nav>
<div className="shrink-0">
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{dict.theme}
</span>
{(['light', 'medium', 'dark'] as const).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
}`}
>
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
</button>
)
})}
</div>
</div>
</div>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 text-center px-0 sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.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">
{footerContent.secondary.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"
>
<Globe className="h-4 w-4" />
<span>{footerContent.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-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.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-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
<div className="flex min-h-screen flex-col bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
{embedded ? null : <PublicHeader />}
<div className="flex flex-1 flex-col">{children}</div>
{embedded ? null : <PublicFooter />}
</div>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<a href={item.href} className={className}>
{item.label}
</a>
)
}
return <span className={className}>{item.label}</span>
}
@@ -23,6 +23,7 @@ import {
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings {
displayName: string
@@ -131,8 +132,13 @@ export default function Sidebar() {
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
// Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to.
const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (!existingPref) {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
}
}
})
.catch(() => {
@@ -190,7 +196,7 @@ export default function Sidebar() {
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<a href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<a href={marketplaceUrl} target="_top" className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element