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
+19 -2
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
export type AdminLanguage = 'en' | 'fr' | 'ar' export type AdminLanguage = 'en' | 'fr' | 'ar'
export type AdminTheme = 'light' | 'dark' export type AdminTheme = 'light' | 'dark'
@@ -81,6 +81,12 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
}, },
} }
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
type AdminI18nContext = { type AdminI18nContext = {
language: AdminLanguage language: AdminLanguage
setLanguage: (value: AdminLanguage) => void setLanguage: (value: AdminLanguage) => void
@@ -94,6 +100,9 @@ const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) { export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('en') const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('dark') const [theme, setTheme] = useState<AdminTheme>('dark')
// Skip the very first write so we don't overwrite a stored preference with
// the default 'en' value before the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true)
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem('admin-language') const stored = window.localStorage.getItem('admin-language')
@@ -115,6 +124,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
document.documentElement.lang = language document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr' document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (skipFirstLangWrite.current) {
skipFirstLangWrite.current = false
return
}
window.localStorage.setItem('admin-language', language) window.localStorage.setItem('admin-language', language)
}, [language]) }, [language])
@@ -154,6 +167,7 @@ export function AdminLanguageSwitcher() {
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span> <span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => { {(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
const active = value === language const active = value === language
const meta = languageMeta[value]
return ( return (
<button <button
key={value} key={value}
@@ -163,7 +177,10 @@ export function AdminLanguageSwitcher() {
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800' active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
}`} }`}
> >
{value.toUpperCase()} <span className="inline-flex items-center gap-1.5">
<span aria-hidden="true">{meta.flag}</span>
<span>{meta.shortLabel}</span>
</span>
</button> </button>
) )
})} })}
@@ -0,0 +1,47 @@
'use client'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
useAdminI18n,
} from '@/components/I18nProvider'
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
export default function PublicFooter() {
const { language } = useAdminI18n()
const currentLanguage = languageMeta[language]
const dict = {
en: {
preferences: 'Admin preferences',
},
fr: {
preferences: 'Preferences admin',
},
ar: {
preferences: 'تفضيلات الإدارة',
},
}[language]
return (
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
<div className="flex items-center gap-3 text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
<p>{dict.preferences}</p>
<span className="inline-flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-zinc-200">
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
</span>
</div>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
</div>
</div>
</footer>
)
}
@@ -0,0 +1,49 @@
'use client'
import Link from 'next/link'
import { useAdminI18n } from '@/components/I18nProvider'
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
export default function PublicHeader() {
const { language } = useAdminI18n()
const currentLanguage = languageMeta[language]
const dict = {
en: {
admin: 'Admin Console',
signIn: 'Sign in',
},
fr: {
admin: 'Console admin',
signIn: 'Connexion',
},
ar: {
admin: 'لوحة الإدارة',
signIn: 'تسجيل الدخول',
},
}[language]
return (
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
<Link href="/" className="flex items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
</Link>
<nav className="flex items-center gap-2">
<span className="inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2.5 py-2 text-xs font-semibold text-zinc-200">
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
</span>
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
{dict.signIn}
</Link>
</nav>
</div>
</header>
)
}
+6 -51
View File
@@ -1,59 +1,14 @@
'use client' 'use client'
import Link from 'next/link' import PublicFooter from '@/components/PublicFooter'
import { import PublicHeader from '@/components/PublicHeader'
AdminLanguageSwitcher,
AdminThemeSwitcher,
useAdminI18n,
} from '@/components/I18nProvider'
export default function PublicShell({ children }: { children: React.ReactNode }) { export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useAdminI18n()
const dict = {
en: {
admin: 'Admin Console',
signIn: 'Sign in',
preferences: 'Admin preferences',
},
fr: {
admin: 'Console admin',
signIn: 'Connexion',
preferences: 'Preferences admin',
},
ar: {
admin: 'لوحة الإدارة',
signIn: 'تسجيل الدخول',
preferences: 'تفضيلات الإدارة',
},
}[language]
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors"> <div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 transition-colors">
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors"> <PublicHeader />
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4"> <div className="flex flex-1 flex-col">{children}</div>
<Link href="/" className="flex items-center gap-3"> <PublicFooter />
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
{dict.signIn}
</Link>
</nav>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
</div>
</div>
</footer>
</div> </div>
) )
} }
+1
View File
@@ -67,6 +67,7 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' }, { method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' }, { method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' }, { method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' }, { method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' }, { method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' }, { method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
+290
View File
@@ -0,0 +1,290 @@
export type Lang = 'en' | 'fr' | 'ar'
function t<T>(map: Record<Lang, T>, lang: Lang): T {
return map[lang] ?? map['fr']
}
const dateLocale: Record<Lang, string> = { en: 'en-GB', fr: 'fr-FR', ar: 'ar-MA' }
export function formatDate(date: Date, lang: Lang, opts?: Intl.DateTimeFormatOptions): string {
return date.toLocaleDateString(dateLocale[lang], opts ?? { year: 'numeric', month: 'long', day: 'numeric' })
}
// ─── Signup confirmation ──────────────────────────────────────────────────────
export const signupEmail = {
subject: (lang: Lang) => t({
en: 'Your workspace is ready — RentalDriveGo',
fr: 'Votre espace de travail est prêt — RentalDriveGo',
ar: 'مساحة عملك جاهزة — RentalDriveGo',
}, lang),
text: (opts: {
firstName: string
companyName: string
plan: string
billingPeriod: string
currency: string
paymentProvider: string
trialEnd: Date
}, lang: Lang): string => {
const trialStr = formatDate(opts.trialEnd, lang)
return t({
en: [
`Hi ${opts.firstName},`,
'',
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`,
`Currency: ${opts.currency}`,
`Primary payment provider: ${opts.paymentProvider}`,
`Free trial ends on ${trialStr}.`,
'',
'Your workspace is ready. Sign in with the email and password you chose during signup.',
'',
'RentalDriveGo',
].join('\n'),
fr: [
`Bonjour ${opts.firstName},`,
'',
`Votre espace de travail RentalDriveGo pour ${opts.companyName} a été créé avec succès.`,
`Forfait : ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'mensuel' : 'annuel'})`,
`Devise : ${opts.currency}`,
`Fournisseur de paiement principal : ${opts.paymentProvider}`,
`La période d'essai gratuit se termine le ${trialStr}.`,
'',
"Votre espace de travail est prêt. Connectez-vous avec l'e-mail et le mot de passe choisis lors de l'inscription.",
'',
'RentalDriveGo',
].join('\n'),
ar: [
`مرحباً ${opts.firstName}،`,
'',
`تم إنشاء مساحة عمل RentalDriveGo الخاصة بـ ${opts.companyName} بنجاح.`,
`الخطة: ${opts.plan} (${opts.billingPeriod === 'MONTHLY' ? 'شهري' : 'سنوي'})`,
`العملة: ${opts.currency}`,
`مزود الدفع الرئيسي: ${opts.paymentProvider}`,
`تنتهي الفترة التجريبية المجانية في ${trialStr}.`,
'',
'مساحة عملك جاهزة. سجّل الدخول باستخدام البريد الإلكتروني وكلمة المرور التي اخترتهما عند التسجيل.',
'',
'RentalDriveGo',
].join('\n'),
}, lang)
},
}
// ─── Password reset ───────────────────────────────────────────────────────────
export const resetPasswordEmail = {
subject: (lang: Lang) => t({
en: 'Reset your RentalDriveGo password',
fr: 'Réinitialisez votre mot de passe RentalDriveGo',
ar: 'إعادة تعيين كلمة مرور RentalDriveGo',
}, lang),
html: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => {
const isRtl = lang === 'ar'
const dir = isRtl ? 'rtl' : 'ltr'
return t({
en: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>Hi ${firstName},</p>
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
<p>This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.</p>
<p>RentalDriveGo</p>
</div>`,
fr: `<div dir="ltr" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>Bonjour ${firstName},</p>
<p>Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p>Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.</p>
<p>RentalDriveGo</p>
</div>`,
ar: `<div dir="rtl" style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<p>مرحباً ${firstName}،</p>
<p>طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">إعادة تعيين كلمة المرور</a></p>
<p>ينتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.</p>
<p>RentalDriveGo</p>
</div>`,
}, lang)
},
text: (resetUrl: string, firstName: string, expireMinutes: number, lang: Lang): string => t({
en: `Hi ${firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${expireMinutes} minutes.\n\nRentalDriveGo`,
fr: `Bonjour ${firstName},\n\nRéinitialisez votre mot de passe ici : ${resetUrl}\n\nCe lien expire dans ${expireMinutes} minutes.\n\nRentalDriveGo`,
ar: `مرحباً ${firstName}،\n\nأعد تعيين كلمة مرورك هنا: ${resetUrl}\n\nينتهي هذا الرابط خلال ${expireMinutes} دقيقة.\n\nRentalDriveGo`,
}, lang),
}
// ─── Marketplace reservation request ─────────────────────────────────────────
export const marketplaceReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`,
ar: `تم استلام طلب الحجز — ${vehicleName}`,
}, lang),
html: (opts: {
firstName: string
vehicleYear: number
vehicleMake: string
vehicleModel: string
companyName: string
startDate: Date
endDate: Date
totalDays: number
rateDisplay: string
totalDisplay: string
email: string
phone?: string
}, lang: Lang): string => {
const startStr = formatDate(opts.startDate, lang)
const endStr = formatDate(opts.endDate, lang)
const isRtl = lang === 'ar'
const l = t({
en: {
greeting: `Hi ${opts.firstName},`,
intro: 'Your reservation request has been received and is pending company confirmation.',
company: 'Company', pickup: 'Pick-up date', returnD: 'Return date',
duration: 'Duration', daily: 'Daily rate', total: 'Estimated total', days: 'day(s)',
footer: `The company will review your request and get in touch shortly. You may be contacted at ${opts.email}${opts.phone ? ` or ${opts.phone}` : ''}.`,
},
fr: {
greeting: `Bonjour ${opts.firstName},`,
intro: 'Votre demande de réservation a été reçue et est en attente de confirmation.',
company: 'Société', pickup: 'Date de prise en charge', returnD: 'Date de retour',
duration: 'Durée', daily: 'Tarif journalier', total: 'Total estimé', days: 'jour(s)',
footer: `La société examinera votre demande et vous contactera prochainement. Vous pouvez être contacté à ${opts.email}${opts.phone ? ` ou ${opts.phone}` : ''}.`,
},
ar: {
greeting: `مرحباً ${opts.firstName}،`,
intro: 'تم استلام طلب الحجز وهو في انتظار تأكيد الشركة.',
company: 'الشركة', pickup: 'تاريخ الاستلام', returnD: 'تاريخ الإرجاع',
duration: 'المدة', daily: 'السعر اليومي', total: 'الإجمالي التقديري', days: 'يوم',
footer: `ستراجع الشركة طلبك وستتواصل معك قريباً. يمكن التواصل معك على ${opts.email}${opts.phone ? ` أو ${opts.phone}` : ''}.`,
},
}, lang)
return `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
<h2 style="margin-bottom:4px">${l.greeting}</h2>
<p style="color:#57534e">${l.intro}</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<h3 style="margin-bottom:12px">${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}</h3>
<p><strong>${l.company}:</strong> ${opts.companyName}</p>
<p><strong>${l.pickup}:</strong> ${startStr}</p>
<p><strong>${l.returnD}:</strong> ${endStr}</p>
<p><strong>${l.duration}:</strong> ${opts.totalDays} ${l.days}</p>
<p><strong>${l.daily}:</strong> ${opts.rateDisplay} MAD</p>
<p><strong>${l.total}:</strong> ${opts.totalDisplay} MAD</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<p style="color:#57534e;font-size:13px">${l.footer}</p>
</div>
`
},
text: (opts: {
firstName: string
vehicleYear: number
vehicleMake: string
vehicleModel: string
companyName: string
startDate: Date
endDate: Date
totalDays: number
rateDisplay: string
totalDisplay: string
}, lang: Lang): string => {
const startStr = formatDate(opts.startDate, lang)
const endStr = formatDate(opts.endDate, lang)
return t({
en: `Hi ${opts.firstName},\n\nYour reservation request for the ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} from ${opts.companyName} has been received.\n\nDates: ${startStr}${endStr}\nDuration: ${opts.totalDays} day(s)\nDaily rate: ${opts.rateDisplay} MAD\nEstimated total: ${opts.totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
fr: `Bonjour ${opts.firstName},\n\nVotre demande de réservation pour la ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} auprès de ${opts.companyName} a été reçue.\n\nDates : ${startStr}${endStr}\nDurée : ${opts.totalDays} jour(s)\nTarif journalier : ${opts.rateDisplay} MAD\nTotal estimé : ${opts.totalDisplay} MAD\n\nLa société confirmera votre demande prochainement.\n\nMerci !`,
ar: `مرحباً ${opts.firstName}،\n\nتم استلام طلب حجزك لسيارة ${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel} من ${opts.companyName}.\n\nالتواريخ: ${startStr}${endStr}\nالمدة: ${opts.totalDays} يوم\nالسعر اليومي: ${opts.rateDisplay} MAD\nالإجمالي التقديري: ${opts.totalDisplay} MAD\n\nستؤكد الشركة طلبك قريباً.\n\nشكراً!`,
}, lang)
},
}
// ─── Booking confirmed ────────────────────────────────────────────────────────
export const bookingConfirmedNotif = {
title: (lang: Lang) => t({
en: 'Booking Confirmed',
fr: 'Réservation confirmée',
ar: 'تم تأكيد الحجز',
}, lang),
body: (lang: Lang) => t({
en: 'Your booking has been confirmed.',
fr: 'Votre réservation a été confirmée.',
ar: 'تم تأكيد حجزك.',
}, lang),
}
// ─── Post-rental review request ───────────────────────────────────────────────
export const reviewRequestEmail = {
subject: (vehicleLabel: string, lang: Lang) => t({
en: `How was your rental? — ${vehicleLabel}`,
fr: `Comment s'est passée votre location ? — ${vehicleLabel}`,
ar: `كيف كانت تجربة الإيجار؟ — ${vehicleLabel}`,
}, lang),
html: (opts: {
firstName: string
companyName: string
vehicleLabel: string
reviewUrl: string
}, lang: Lang): string => {
const isRtl = lang === 'ar'
const l = t({
en: {
greeting: `Hi ${opts.firstName},`,
body1: `Thank you for renting with <strong>${opts.companyName}</strong>. We hope you enjoyed your <strong>${opts.vehicleLabel}</strong>.`,
body2: 'Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.',
cta: 'Leave a review',
disclaimer: 'This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.',
},
fr: {
greeting: `Bonjour ${opts.firstName},`,
body1: `Merci d'avoir loué chez <strong>${opts.companyName}</strong>. Nous espérons que vous avez apprécié votre <strong>${opts.vehicleLabel}</strong>.`,
body2: "Votre avis aide la société à s'améliorer et aide les autres clients à faire des choix éclairés. Cela ne prend que 30 secondes.",
cta: 'Laisser un avis',
disclaimer: "Ce lien est unique à votre réservation et ne peut être utilisé qu'une seule fois. Si vous n'avez pas loué de véhicule récemment, vous pouvez ignorer cet e-mail.",
},
ar: {
greeting: `مرحباً ${opts.firstName}،`,
body1: `شكراً لاستئجارك مع <strong>${opts.companyName}</strong>. نأمل أنك استمتعت بـ <strong>${opts.vehicleLabel}</strong>.`,
body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.',
cta: 'اترك تقييماً',
disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.',
},
}, lang)
return `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917;direction:${isRtl ? 'rtl' : 'ltr'}">
<h2 style="margin-bottom:4px">${l.greeting}</h2>
<p style="color:#57534e">${l.body1}</p>
<p style="color:#57534e">${l.body2}</p>
<div style="margin:32px 0;text-align:center">
<a href="${opts.reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
${l.cta}
</a>
</div>
<p style="color:#a8a29e;font-size:12px">${l.disclaimer}</p>
</div>
`
},
text: (opts: {
firstName: string
companyName: string
vehicleLabel: string
reviewUrl: string
}, lang: Lang): string => t({
en: `Hi ${opts.firstName},\n\nThank you for renting with ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.\n\nLeave a review here (one-time link):\n${opts.reviewUrl}\n\nThank you!`,
fr: `Bonjour ${opts.firstName},\n\nMerci d'avoir loué chez ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.\n\nLaissez un avis ici (lien unique) :\n${opts.reviewUrl}\n\nMerci !`,
ar: `مرحباً ${opts.firstName}،\n\nشكراً لاستئجارك مع ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.\n\nاترك تقييماً هنا (رابط فريد):\n${opts.reviewUrl}\n\nشكراً!`,
}, lang),
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { requireRole } from '../middleware/requireRole'
import { generateFinancialReport, toCsv } from '../services/financialReportService' import { generateFinancialReport, toCsv } from '../services/financialReportService'
const router = Router() const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('MANAGER')) router.use(requireCompanyAuth, requireTenant, requireSubscription)
function getRangeFromPeriod(period: string) { function getRangeFromPeriod(period: string) {
const now = new Date() const now = new Date()
@@ -158,7 +158,7 @@ router.get('/sources', async (req, res, next) => {
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.get('/report', async (req, res, next) => { router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
try { try {
const { from, to, format = 'JSON', period } = req.query as Record<string, string> const { from, to, format = 'JSON', period } = req.query as Record<string, string>
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } }) const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
+14 -20
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { sendNotification } from '../services/notificationService' import { sendNotification } from '../services/notificationService'
import { signupEmail, type Lang } from '../lib/emailTranslations'
const router = Router() const router = Router()
@@ -66,26 +67,17 @@ function buildSignupConfirmationEmail(opts: {
currency: 'MAD' | 'USD' | 'EUR' currency: 'MAD' | 'USD' | 'EUR'
paymentProvider: 'AMANPAY' | 'PAYPAL' paymentProvider: 'AMANPAY' | 'PAYPAL'
trialEndAt: Date trialEndAt: Date
lang: Lang
}) { }) {
const greetingName = opts.firstName.trim() || 'there' return signupEmail.text({
firstName: opts.firstName.trim() || 'there',
return [ companyName: opts.companyName,
`Hi ${greetingName},`, plan: opts.plan,
'', billingPeriod: opts.billingPeriod,
`Your RentalDriveGo workspace for ${opts.companyName} has been created successfully.`, currency: opts.currency,
`Plan: ${opts.plan} (${opts.billingPeriod.toLowerCase()})`, paymentProvider: opts.paymentProvider,
`Currency: ${opts.currency}`, trialEnd: opts.trialEndAt,
`Primary payment provider: ${opts.paymentProvider}`, }, opts.lang)
`Free trial ends on ${opts.trialEndAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}.`,
'',
'Your workspace is ready and you can sign in with the email address and password you chose during signup.',
'',
'RentalDriveGo',
].join('\n')
} }
async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> { async function sendSignupEmailNotification(opts: Parameters<typeof sendNotification>[0]): Promise<EmailDeliveryResult> {
@@ -210,7 +202,7 @@ router.post('/signup', async (req, res, next) => {
const emailDelivery = await sendSignupEmailNotification({ const emailDelivery = await sendSignupEmailNotification({
type: 'SUBSCRIPTION_TRIAL_ENDING', type: 'SUBSCRIPTION_TRIAL_ENDING',
title: 'Your workspace is ready', title: signupEmail.subject(body.preferredLanguage),
body: buildSignupConfirmationEmail({ body: buildSignupConfirmationEmail({
firstName: body.firstName, firstName: body.firstName,
companyName: body.companyName, companyName: body.companyName,
@@ -219,11 +211,13 @@ router.post('/signup', async (req, res, next) => {
currency: body.currency, currency: body.currency,
paymentProvider: body.paymentProvider, paymentProvider: body.paymentProvider,
trialEndAt, trialEndAt,
lang: body.preferredLanguage,
}), }),
companyId: result.company.id, companyId: result.company.id,
employeeId: result.employeeId, employeeId: result.employeeId,
email: body.email, email: body.email,
channels: ['EMAIL', 'IN_APP'], channels: ['EMAIL', 'IN_APP'],
locale: body.preferredLanguage,
}) })
res.status(201).json({ res.status(201).json({
+28 -12
View File
@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'
import crypto from 'crypto' import crypto from 'crypto'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { sendTransactionalEmail } from '../services/notificationService' import { sendTransactionalEmail } from '../services/notificationService'
import { resetPasswordEmail, type Lang } from '../lib/emailTranslations'
const router = Router() const router = Router()
@@ -34,15 +35,6 @@ function signEmployeeToken(employeeId: string) {
) )
} }
function buildResetEmailHtml(resetUrl: string, firstName: string) {
return `
<p>Hi ${firstName},</p>
<p>You requested a password reset for your RentalDriveGo workspace account.</p>
<p><a href="${resetUrl}" style="background:#1d4ed8;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;display:inline-block;font-weight:600;">Reset your password</a></p>
<p>This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.</p>
<p>RentalDriveGo</p>
`
}
router.get('/me', async (req, res, next) => { router.get('/me', async (req, res, next) => {
try { try {
@@ -174,11 +166,12 @@ router.post('/forgot-password', async (req, res, next) => {
) )
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
await sendTransactionalEmail({ await sendTransactionalEmail({
to: email, to: email,
subject: 'Reset your RentalDriveGo password', subject: resetPasswordEmail.subject(lang),
html: buildResetEmailHtml(resetUrl, employee.firstName), html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`, text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
}).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message)) }).catch((err) => console.error('[ForgotPassword] Email send failed:', err?.message))
} }
@@ -186,6 +179,29 @@ router.post('/forgot-password', async (req, res, next) => {
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.patch('/me/language', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) return res.status(401).json({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
let employeeId = ''
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'employee') return res.status(401).json({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
employeeId = payload.sub
} catch {
return res.status(401).json({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
}
const { language } = z.object({ language: z.enum(['en', 'fr', 'ar']) }).parse(req.body)
await prisma.employee.update({ where: { id: employeeId }, data: { preferredLanguage: language } })
res.json({ data: { language } })
} catch (err) { next(err) }
})
router.post('/reset-password', async (req, res, next) => { router.post('/reset-password', async (req, res, next) => {
try { try {
const { token, password } = z.object({ const { token, password } = z.object({
+50 -21
View File
@@ -4,6 +4,7 @@ import { prisma } from '../lib/prisma'
import { optionalRenterAuth } from '../middleware/requireRenterAuth' import { optionalRenterAuth } from '../middleware/requireRenterAuth'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService' import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService' import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
import { marketplaceReservationEmail, type Lang } from '../lib/emailTranslations'
const router = Router() const router = Router()
router.use(optionalRenterAuth) router.use(optionalRenterAuth)
@@ -35,6 +36,47 @@ router.get('/offers', async (req, res, next) => {
} }
}) })
router.get('/cities', async (_req, res, next) => {
try {
const companies = await prisma.company.findMany({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: {
isListedOnMarketplace: true,
publicCity: { not: null },
},
},
select: {
brand: {
select: {
publicCity: true,
},
},
},
})
const uniqueCities = Array.from(
companies.reduce((cities, company) => {
const city = company.brand?.publicCity?.trim()
if (!city) return cities
const normalizedCity = city.toLocaleLowerCase()
if (!cities.has(normalizedCity)) {
cities.set(normalizedCity, city)
}
return cities
}, new Map<string, string>()),
)
.map(([, city]) => city)
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }))
res.json({ data: uniqueCities })
} catch (err) {
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
next(err)
}
})
router.get('/companies', async (req, res, next) => { router.get('/companies', async (req, res, next) => {
try { try {
const { city, hasOffer } = req.query as Record<string, string> const { city, hasOffer } = req.query as Record<string, string>
@@ -136,6 +178,7 @@ router.post('/reservations', async (req, res, next) => {
startDate: z.string().datetime(), startDate: z.string().datetime(),
endDate: z.string().datetime(), endDate: z.string().datetime(),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'),
}) })
const body = schema.parse(req.body) const body = schema.parse(req.body)
@@ -189,34 +232,20 @@ router.post('/reservations', async (req, res, next) => {
}, },
}) })
const lang = body.language as Lang
const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name const companyName = vehicle.company.brand?.displayName ?? vehicle.company.name
const rateDisplay = (vehicle.dailyRate / 100).toFixed(2) const rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
const totalDisplay = (totalAmount / 100).toFixed(2) const totalDisplay = (totalAmount / 100).toFixed(2)
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' } const startStr = startDate.toISOString().slice(0, 10)
const startStr = startDate.toLocaleDateString('en-GB', dateOpts) const endStr = endDate.toISOString().slice(0, 10)
const endStr = endDate.toLocaleDateString('en-GB', dateOpts) const emailOpts = { firstName: body.firstName, vehicleYear: vehicle.year, vehicleMake: vehicle.make, vehicleModel: vehicle.model, companyName, startDate, endDate, totalDays, rateDisplay, totalDisplay, email: body.email, phone: body.phone }
try { try {
await sendTransactionalEmail({ await sendTransactionalEmail({
to: body.email, to: body.email,
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`, subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: ` html: marketplaceReservationEmail.html(emailOpts, lang),
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917"> text: marketplaceReservationEmail.text(emailOpts, lang),
<h2 style="margin-bottom:4px">Hi ${body.firstName},</h2>
<p style="color:#57534e">Your reservation request has been received and is pending company confirmation.</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<h3 style="margin-bottom:12px">${vehicle.year} ${vehicle.make} ${vehicle.model}</h3>
<p><strong>Company:</strong> ${companyName}</p>
<p><strong>Pick-up date:</strong> ${startStr}</p>
<p><strong>Return date:</strong> ${endStr}</p>
<p><strong>Duration:</strong> ${totalDays} day${totalDays > 1 ? 's' : ''}</p>
<p><strong>Daily rate:</strong> ${rateDisplay} MAD</p>
<p><strong>Estimated total:</strong> ${totalDisplay} MAD</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0"/>
<p style="color:#57534e;font-size:13px">The company will review your request and get in touch with you shortly. You may be contacted at ${body.email}${body.phone ? ` or ${body.phone}` : ''}.</p>
</div>
`,
text: `Hi ${body.firstName},\n\nYour reservation request for the ${vehicle.year} ${vehicle.make} ${vehicle.model} from ${companyName} has been received.\n\nDates: ${startStr}${endStr}\nDuration: ${totalDays} day(s)\nDaily rate: ${rateDisplay} MAD\nEstimated total: ${totalDisplay} MAD\n\nThe company will confirm your request shortly.\n\nThank you!`,
}) })
} catch { } catch {
// email failure is non-blocking — reservation still created // email failure is non-blocking — reservation still created
+13 -18
View File
@@ -9,6 +9,7 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService' import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService' import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../lib/emailTranslations'
import crypto from 'crypto' import crypto from 'crypto'
const router = Router() const router = Router()
@@ -750,8 +751,12 @@ router.post('/:id/confirm', async (req, res, next) => {
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } }) const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
// Notify // Notify
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }) const [customer, companyBrand] = await Promise.all([
await sendNotification({ type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed.`, companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'] }).catch(() => null) prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }),
prisma.brandSettings.findUnique({ where: { companyId: req.companyId }, select: { defaultLocale: true } }),
])
const lang = ((companyBrand?.defaultLocale ?? 'fr') as Lang)
await sendNotification({ type: 'BOOKING_CONFIRMED', title: bookingConfirmedNotif.title(lang), body: bookingConfirmedNotif.body(lang), companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'], locale: lang }).catch(() => null)
res.json({ data: updated }) res.json({ data: updated })
} catch (err) { next(err) } } catch (err) { next(err) }
@@ -787,29 +792,19 @@ router.post('/:id/checkout', async (req, res, next) => {
// Send "How was your rental?" email with secure one-time review link // Send "How was your rental?" email with secure one-time review link
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }) const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } }) const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true, defaultLocale: true } } } })
const lang = ((company?.brand?.defaultLocale ?? 'fr') as Lang)
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company' const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}` const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}` const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
const reviewOpts = { firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }
sendTransactionalEmail({ sendTransactionalEmail({
to: customer.email, to: customer.email,
subject: `How was your rental? — ${vehicleLabel}`, subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: ` html: reviewRequestEmail.html(reviewOpts, lang),
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917"> text: reviewRequestEmail.text(reviewOpts, lang),
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
<div style="margin:32px 0;text-align:center">
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
Leave a review
</a>
</div>
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
</div>
`,
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
}).catch(() => null) }).catch(() => null)
res.json({ data: updated }) res.json({ data: updated })
@@ -648,18 +648,9 @@ export default function FleetPage() {
{filtered.map((vehicle) => ( {filtered.map((vehicle) => (
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors"> <tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div>
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0"> <p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
{vehicle.primaryPhoto ? ( <p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
<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> </div>
</td> </td>
<td className="px-6 py-4"> <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 { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard' 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 { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
@@ -38,6 +38,10 @@ interface DashboardData {
} }
} }
interface EmployeeProfile {
role?: string
}
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
CONFIRMED: 'badge-blue', CONFIRMED: 'badge-blue',
PENDING: 'badge-amber', PENDING: 'badge-amber',
@@ -108,12 +112,22 @@ export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
apiFetch<DashboardData>('/analytics/dashboard') apiFetch<DashboardData>('/analytics/dashboard')
.then(setData) .then(setData)
.catch((err) => setError(err.message)) .catch((err) => setError({ code: err.code ?? '', message: err.message }))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
@@ -126,15 +140,16 @@ export default function DashboardPage() {
} }
if (error) { if (error) {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return ( return (
<div className="card p-6 text-center"> <div className="card p-6 text-center">
<p className="text-red-600 font-medium">{d.failedToLoad}</p> <p className="text-slate-500 text-sm">{errorMsg}</p>
<p className="text-slate-500 text-sm mt-1">{error}</p>
</div> </div>
) )
} }
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 } 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) => const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' }) new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
@@ -156,7 +171,7 @@ export default function DashboardPage() {
)} )}
{/* KPI Cards */} {/* 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 <StatCard
title={d.kpiTotalBookings} title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()} value={kpis.totalBookings.toLocaleString()}
@@ -175,15 +190,17 @@ export default function DashboardPage() {
iconColor="text-green-600" iconColor="text-green-600"
iconBg="bg-green-50" iconBg="bg-green-50"
/> />
<StatCard {canViewRevenue ? (
title={d.kpiMonthlyRevenue} <StatCard
value={formatCurrency(kpis.monthlyRevenue, 'MAD')} title={d.kpiMonthlyRevenue}
change={kpis.revenueChange} value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
vsLastMonthLabel={d.vsLastMonth} change={kpis.revenueChange}
icon={DollarSign} vsLastMonthLabel={d.vsLastMonth}
iconColor="text-amber-600" icon={DollarSign}
iconBg="bg-amber-50" iconColor="text-amber-600"
/> iconBg="bg-amber-50"
/>
) : null}
<StatCard <StatCard
title={d.kpiTotalCustomers} title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()} value={kpis.totalCustomers.toLocaleString()}
@@ -1,8 +1,9 @@
'use client' 'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' 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' import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO'
@@ -35,6 +36,10 @@ interface ProviderAvailability {
paypal: boolean paypal: boolean
} }
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700', TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700', ACTIVE: 'bg-green-100 text-green-700',
@@ -52,11 +57,13 @@ const INVOICE_STATUS: Record<string, string> = {
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() { export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null) const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER') const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY') const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
@@ -198,6 +205,33 @@ export default function SubscriptionPage() {
}[language] }[language]
useEffect(() => { 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([ Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'), apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'), apiFetch<Invoice[]>('/subscriptions/invoices'),
@@ -217,7 +251,11 @@ export default function SubscriptionPage() {
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [canViewPage])
if (canViewPage !== true) {
return null
}
async function handleCheckout() { async function handleCheckout() {
setPaying(true) 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' export default async function ForgotPasswordPage({
import { useState } from 'react' searchParams,
import { useDashboardI18n } from '@/components/I18nProvider' }: {
import { API_BASE } from '@/lib/api' searchParams: Promise<Record<string, string | string[] | undefined>>
import { marketplaceUrl } from '@/lib/urls' }) {
const params = await searchParams
const embedded = params.embedded === '1'
export default function ForgotPasswordPage() { return <ForgotPasswordPageClient embedded={embedded} />
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>
)
} }
@@ -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' export default async function ResetPasswordPage({
import { useState, Suspense } from 'react' searchParams,
import { useSearchParams } from 'next/navigation' }: {
import { useDashboardI18n } from '@/components/I18nProvider' searchParams: Promise<Record<string, string | string[] | undefined>>
import { API_BASE } from '@/lib/api' }) {
const params = await searchParams
const embedded = params.embedded === '1'
export default function ResetPasswordPage() { return <ResetPasswordPageClient embedded={embedded} />
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>
)
} }
@@ -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' export default async function SignInPage({
import Link from 'next/link' searchParams,
import { useEffect, useState } from 'react' }: {
import { usePathname, useRouter, useSearchParams } from 'next/navigation' searchParams: Promise<Record<string, string | string[] | undefined>>
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
}
}) { }) {
const router = useRouter() const params = await searchParams
const searchParams = useSearchParams() const embedded = params.embedded === '1'
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) { return <SignInPageClient embedded={embedded} />
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>
)}
</>
)
} }
@@ -1,5 +1,6 @@
'use client' 'use client'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react' import { useState } from 'react'
import { useSearchParams } from 'next/navigation' 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 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 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() { export default function SignUpPage() {
const { language, setLanguage } = useDashboardI18n() const { language, setLanguage } = useDashboardI18n()
@@ -405,7 +407,19 @@ export default function SignUpPage() {
<PublicShell> <PublicShell>
<main className="flex-1 px-4 py-16"> <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"> <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> <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"> <p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody} <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"> <Link href="/dashboard" className="btn-primary justify-center">
{dict.enterDashboard} {dict.enterDashboard}
</Link> </Link>
<Link href="/sign-in" className="btn-secondary justify-center"> <a href="/sign-in" className="btn-secondary justify-center">
{dict.signInAnotherDevice} {dict.signInAnotherDevice}
</Link> </a>
</div> </div>
</div> </div>
</main> </main>
@@ -435,7 +449,19 @@ export default function SignUpPage() {
<main className="px-4 py-12"> <main className="px-4 py-12">
<div className="mx-auto max-w-4xl space-y-8"> <div className="mx-auto max-w-4xl space-y-8">
<div className={`text-center ${isArabic ? 'rtl' : ''}`}> <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> <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> <p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
</div> </div>
+52 -11
View File
@@ -2,6 +2,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' 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 { 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 DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'medium' | 'dark' export type DashboardTheme = 'light' | 'medium' | 'dark'
@@ -143,6 +144,7 @@ type ReservationsDict = {
type DashboardPageDict = { type DashboardPageDict = {
failedToLoad: string failedToLoad: string
forbidden: string
kpiTotalBookings: string kpiTotalBookings: string
kpiActiveVehicles: string kpiActiveVehicles: string
kpiMonthlyRevenue: string kpiMonthlyRevenue: string
@@ -556,6 +558,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
}, },
dashboardPage: { dashboardPage: {
failedToLoad: 'Failed to load dashboard', 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', kpiTotalBookings: 'Total Bookings',
kpiActiveVehicles: 'Active Vehicles', kpiActiveVehicles: 'Active Vehicles',
kpiMonthlyRevenue: 'Monthly Revenue', kpiMonthlyRevenue: 'Monthly Revenue',
@@ -899,6 +902,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
}, },
dashboardPage: { dashboardPage: {
failedToLoad: 'Échec du chargement du tableau de bord', 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', kpiTotalBookings: 'Total des réservations',
kpiActiveVehicles: 'Véhicules actifs', kpiActiveVehicles: 'Véhicules actifs',
kpiMonthlyRevenue: 'Revenus du mois', kpiMonthlyRevenue: 'Revenus du mois',
@@ -1242,6 +1246,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
}, },
dashboardPage: { dashboardPage: {
failedToLoad: 'فشل تحميل لوحة التحكم', failedToLoad: 'فشل تحميل لوحة التحكم',
forbidden: 'ليس لديك صلاحية الوصول إلى هذه الصفحة. يلزم دور المدير أو أعلى.',
kpiTotalBookings: 'إجمالي الحجوزات', kpiTotalBookings: 'إجمالي الحجوزات',
kpiActiveVehicles: 'المركبات النشطة', kpiActiveVehicles: 'المركبات النشطة',
kpiMonthlyRevenue: 'الإيرادات الشهرية', kpiMonthlyRevenue: 'الإيرادات الشهرية',
@@ -1368,33 +1373,61 @@ export function DashboardI18nProvider({
children: React.ReactNode children: React.ReactNode
initialLanguage?: DashboardLanguage initialLanguage?: DashboardLanguage
}) { }) {
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage) const [language, setLanguageState] = useState<DashboardLanguage>(initialLanguage)
const [theme, setTheme] = useState<DashboardTheme>('light') const [theme, setThemeState] = useState<DashboardTheme>('light')
const themeInitialized = useRef(false) const themeInitialized = useRef(false)
// Skip the very first write so we don't overwrite a stored preference with the // 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. // server-resolved initialLanguage before the read effect has a chance to apply it.
const skipFirstLangWrite = useRef(true) 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(() => { useEffect(() => {
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY]) const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
if (stored !== null) { if (stored !== null) {
const next = normalizeLanguage(stored) const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next) if (next !== language) setLanguageState(next)
} else { } else {
const detected = detectBrowserLanguage() const detected = detectBrowserLanguage()
if (detected && detected !== language) setLanguage(detected) if (detected && detected !== language) setLanguageState(detected)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme']) const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') { if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
if (storedTheme !== theme) setTheme(storedTheme) if (storedTheme !== theme) setThemeState(storedTheme)
return return
} }
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') { if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
setTheme('dark') setThemeState('dark')
} }
}, []) }, [])
@@ -1431,18 +1464,18 @@ export function DashboardI18nProvider({
if (scopedLanguage) { if (scopedLanguage) {
// Employee has a stored preference — apply it. // Employee has a stored preference — apply it.
const next = normalizeLanguage(scopedLanguage) const next = normalizeLanguage(scopedLanguage)
if (next !== language) setLanguage(next) if (next !== language) setLanguageState(next)
} else { } else {
// No scoped pref yet (new login). Promote any base preference that was set // 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. // 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 baseLang = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
const langToWrite = baseLang ? normalizeLanguage(baseLang) : language const langToWrite = baseLang ? normalizeLanguage(baseLang) : language
if (langToWrite !== language) setLanguage(langToWrite) if (langToWrite !== language) setLanguageState(langToWrite)
writeScopedPreference(SHARED_LANGUAGE_KEY, langToWrite, [DASHBOARD_LANGUAGE_KEY]) writeScopedPreference(SHARED_LANGUAGE_KEY, langToWrite, [DASHBOARD_LANGUAGE_KEY])
} }
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') { if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
if (scopedTheme !== theme) setTheme(scopedTheme) if (scopedTheme !== theme) setThemeState(scopedTheme)
} else { } else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme']) writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
} }
@@ -1453,7 +1486,7 @@ export function DashboardI18nProvider({
}, [language, theme]) }, [language, theme])
const value = useMemo( const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }), () => ({ language, setLanguage: applyLanguage, theme, setTheme: applyTheme, dict: dictionaries[language] }),
[language, theme], [language, theme],
) )
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider> return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
@@ -1475,6 +1508,14 @@ export function DashboardLanguageSwitcher() {
if (embedded) return null if (embedded) return null
function handleLanguageChange(value: DashboardLanguage) {
setLanguage(value)
apiFetch('/auth/employee/me/language', {
method: 'PATCH',
body: JSON.stringify({ language: value }),
}).catch(() => {})
}
return ( 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"> <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"> <span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
@@ -1486,7 +1527,7 @@ export function DashboardLanguageSwitcher() {
<button <button
key={value} key={value}
type="button" type="button"
onClick={() => setLanguage(value)} onClick={() => handleLanguageChange(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${ className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active active
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950' ? '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' 'use client'
import { ChevronDown, Globe } from 'lucide-react' import PublicFooter from '@/components/layout/PublicFooter'
import Image from 'next/image' import PublicHeader from '@/components/layout/PublicHeader'
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)
}, [])
export default function PublicShell({
children,
embedded = false,
}: {
children: React.ReactNode
embedded?: boolean
}) {
return ( 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"> <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">
<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"> {embedded ? null : <PublicHeader />}
<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"> <div className="flex flex-1 flex-col">{children}</div>
<a href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100"> {embedded ? null : <PublicFooter />}
<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> </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 { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls' import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings { interface BrandSettings {
displayName: string displayName: string
@@ -131,8 +132,13 @@ export default function Sidebar() {
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth)) setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role) if (employee.role) setRole(employee.role)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
setLanguage(employee.preferredLanguage) // Only apply the server-side preference when the employee has no local preference
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax` // 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(() => { .catch(() => {
@@ -190,7 +196,7 @@ export default function Sidebar() {
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'), open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')} ].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"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
{brand?.logoUrl ? ( {brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -0,0 +1,251 @@
'use client'
import { CalendarDays, Clock3, Info, MapPin, TicketPercent } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
type ExploreSearchFormDict = {
searchTitle: string
pickupLocation: string
dropoffLocation: string
pickupDate: string
returnDate: string
hour: string
promoCode: string
myAge: string
ageDescription: string
search: string
ageOptions: string[]
}
function toUtcDateTime(date: string, time: string) {
if (!date) return ''
return `${date}T${time || '10:00'}:00.000Z`
}
function inputClassName(hasLeadingIcon = true) {
return `w-full border-0 bg-white px-4 py-4 text-sm text-stone-900 outline-none placeholder:text-stone-400 ${hasLeadingIcon ? 'pl-2' : ''}`
}
function selectClassName() {
return 'w-full border-0 bg-white py-4 text-sm text-stone-900 outline-none'
}
export default function ExploreSearchForm({
pickupLocation,
dropoffLocation,
pickupDate,
pickupTime,
returnDate,
returnTime,
promoCode,
driverAge,
cities,
dict,
}: {
pickupLocation: string
dropoffLocation: string
pickupDate: string
pickupTime: string
returnDate: string
returnTime: string
promoCode: string
driverAge: string
cities: string[]
dict: ExploreSearchFormDict
}) {
const router = useRouter()
const [filters, setFilters] = useState({
pickupLocation,
dropoffLocation,
pickupDate,
pickupTime: pickupTime || '10:00',
returnDate,
returnTime: returnTime || '10:00',
promoCode,
driverAge: driverAge || '25+',
})
const [ageHelpOpen, setAgeHelpOpen] = useState(false)
function updateFilter(name: keyof typeof filters, value: string) {
setFilters((current) => ({ ...current, [name]: value }))
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const query = new URLSearchParams()
const normalizedPickup = filters.pickupLocation.trim()
const normalizedDropoff = filters.dropoffLocation.trim()
const normalizedPromo = filters.promoCode.trim()
if (normalizedPickup) {
query.set('pickupLocation', normalizedPickup)
query.set('city', normalizedPickup)
}
if (normalizedDropoff) query.set('dropoffLocation', normalizedDropoff)
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
if (filters.returnDate) query.set('returnDate', filters.returnDate)
if (filters.returnTime) query.set('returnTime', filters.returnTime)
if (normalizedPromo) query.set('promoCode', normalizedPromo)
if (filters.driverAge) query.set('driverAge', filters.driverAge)
const startDateTime = toUtcDateTime(filters.pickupDate, filters.pickupTime)
const endDateTime = toUtcDateTime(filters.returnDate, filters.returnTime)
if (startDateTime) query.set('startDate', startDateTime)
if (endDateTime) query.set('endDate', endDateTime)
const target = query.toString() ? `/explore?${query.toString()}` : '/explore'
router.push(target)
}
return (
<div className="mt-8 rounded-[1.75rem] bg-white p-5 text-stone-900 shadow-[0_24px_80px_rgba(0,0,0,0.16)]">
<h2 className="text-3xl font-black tracking-tight">{dict.searchTitle}</h2>
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 bg-stone-200 md:grid-cols-2">
<label className="flex items-center gap-3 bg-white px-4">
<MapPin className="h-5 w-5 text-stone-300" />
<select
name="pickupLocation"
value={filters.pickupLocation}
onChange={(event) => updateFilter('pickupLocation', event.target.value)}
className={selectClassName()}
>
<option value="">{dict.pickupLocation}</option>
{cities.map((city) => (
<option key={`pickup-${city}`} value={city}>{city}</option>
))}
</select>
</label>
<label className="flex items-center gap-3 bg-white px-4">
<MapPin className="h-5 w-5 text-stone-300" />
<select
name="dropoffLocation"
value={filters.dropoffLocation}
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
className={selectClassName()}
>
<option value="">{dict.dropoffLocation}</option>
{cities.map((city) => (
<option key={`dropoff-${city}`} value={city}>{city}</option>
))}
</select>
</label>
</div>
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 bg-stone-200 md:grid-cols-4">
<label className="flex items-center gap-3 bg-white px-4">
<CalendarDays className="h-5 w-5 text-stone-500" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.pickupDate}</span>
<input
name="pickupDate"
type="date"
value={filters.pickupDate}
min={new Date().toISOString().slice(0, 10)}
onChange={(event) => updateFilter('pickupDate', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white px-4">
<Clock3 className="h-5 w-5 text-stone-500" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.hour}</span>
<input
name="pickupTime"
type="time"
value={filters.pickupTime}
onChange={(event) => updateFilter('pickupTime', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white px-4">
<CalendarDays className="h-5 w-5 text-stone-500" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.returnDate}</span>
<input
name="returnDate"
type="date"
value={filters.returnDate}
min={filters.pickupDate || new Date().toISOString().slice(0, 10)}
onChange={(event) => updateFilter('returnDate', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
/>
</div>
</label>
<label className="flex items-center gap-3 bg-white px-4">
<Clock3 className="h-5 w-5 text-stone-500" />
<div className="flex w-full flex-col py-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400">{dict.hour}</span>
<input
name="returnTime"
type="time"
value={filters.returnTime}
onChange={(event) => updateFilter('returnTime', event.target.value)}
className="border-0 bg-transparent px-0 py-0 text-sm text-stone-900 outline-none"
/>
</div>
</label>
</div>
<div className="relative z-10 grid gap-px overflow-visible rounded-[1.35rem] border border-stone-200 bg-stone-200 lg:grid-cols-[1.1fr_1.1fr_1.35fr]">
<label className="flex items-center gap-3 rounded-t-[1.35rem] bg-white px-4 lg:rounded-l-[1.35rem] lg:rounded-tr-none">
<TicketPercent className="h-5 w-5 text-stone-300" />
<input
name="promoCode"
value={filters.promoCode}
onChange={(event) => updateFilter('promoCode', event.target.value)}
placeholder={dict.promoCode}
className={inputClassName()}
/>
</label>
<label className="flex items-center justify-between gap-3 bg-white px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<span className="text-sm font-semibold text-stone-600">{dict.myAge}</span>
<select
name="driverAge"
value={filters.driverAge}
onChange={(event) => updateFilter('driverAge', event.target.value)}
className="border-0 bg-transparent text-sm font-semibold text-stone-900 outline-none"
>
{dict.ageOptions.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
<div
className="relative shrink-0"
onMouseEnter={() => setAgeHelpOpen(true)}
onMouseLeave={() => setAgeHelpOpen(false)}
>
<button
type="button"
aria-label={dict.ageDescription}
onClick={() => setAgeHelpOpen((open) => !open)}
className="text-stone-400 transition hover:text-stone-600"
>
<Info className="h-5 w-5" />
</button>
{ageHelpOpen ? (
<div className="absolute bottom-[calc(100%+0.85rem)] right-0 z-30 w-72 rounded-2xl border border-[#ffc400] bg-white px-4 py-3 text-left text-sm leading-6 text-stone-700 shadow-[0_16px_40px_rgba(0,0,0,0.14)]">
{dict.ageDescription}
<span className="absolute right-6 top-full h-3 w-3 -translate-y-1/2 rotate-45 border-b border-r border-[#ffc400] bg-white" />
</div>
) : null}
</div>
</label>
<button
type="submit"
className="min-h-[76px] rounded-b-[1.35rem] bg-stone-900 px-6 text-sm font-black uppercase tracking-[0.08em] text-white transition hover:bg-stone-700 lg:rounded-b-none lg:rounded-r-[1.35rem]"
>
{dict.search}
</button>
</div>
</form>
</div>
)
}
@@ -1,7 +1,4 @@
'use client' import Link from 'next/link'
import { useState } from 'react'
import { marketplacePost } from '@/lib/api'
interface Vehicle { interface Vehicle {
id: string id: string
@@ -33,372 +30,82 @@ interface Dict {
maintenance: string maintenance: string
unavailableStatus: string unavailableStatus: string
from: string from: string
bookNow: string viewVehicle: string
availableFrom: string availableFrom: string
reserveAfterDate: string
unavailableNoDate: string unavailableNoDate: string
details: string
vehicleUnavailable: string vehicleUnavailable: string
listings: string listings: string
availableVehicles: string availableVehicles: string
// modal
reserveVehicle: string
pickupDate: string
pickupTime: string
returnDate: string
dropoffTime: string
firstName: string
lastName: string
email: string
phone: string
notes: string
cancel: string
submitRequest: string
submitting: string
successTitle: string
successBody: string
close: string
errorUnavailable: string
errorGeneric: string
} }
function formatCents(cents: number) { function formatCents(cents: number) {
return `${(cents / 100).toLocaleString('fr-MA', { minimumFractionDigits: 2 })} MAD` return `${(cents / 100).toLocaleString('fr-MA', { minimumFractionDigits: 2 })} MAD`
} }
function daysBetween(start: string, end: string) {
const ms = new Date(end).getTime() - new Date(start).getTime()
return Math.max(1, Math.ceil(ms / 86_400_000))
}
function formatAvailabilityDate(value?: string | null) { function formatAvailabilityDate(value?: string | null) {
if (!value) return null if (!value) return null
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
} }
const today = () => new Date().toISOString().slice(0, 10) function getStatusCopy(vehicle: Vehicle, dict: Dict) {
const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10) if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
if (vehicle.availability === false) return dict.checkAvailability
return dict.available
}
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) { export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
const [selected, setSelected] = useState<Vehicle | null>(null)
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
startDate: today(),
startTime: '10:00',
endDate: tomorrow(),
endTime: '10:00',
notes: '',
})
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function openModal(vehicle: Vehicle) {
const minStart = vehicle.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : today()
const minEnd = new Date(new Date(minStart).getTime() + 86_400_000).toISOString().slice(0, 10)
setSelected(vehicle)
setStatus('idle')
setErrorMsg('')
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, startTime: '10:00', endDate: minEnd, endTime: '10:00', notes: '' })
}
function closeModal() {
setSelected(null)
setStatus('idle')
setErrorMsg('')
}
function set(field: keyof typeof form) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((prev) => {
if (field !== 'startDate') return { ...prev, [field]: e.target.value }
const nextStart = e.target.value
const nextEnd = !prev.endDate || prev.endDate <= nextStart
? new Date(new Date(nextStart).getTime() + 86_400_000).toISOString().slice(0, 10)
: prev.endDate
return { ...prev, startDate: nextStart, endDate: nextEnd }
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!selected) return
const minStart = selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()
if (form.startDate < minStart) {
setStatus('error')
setErrorMsg(`${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt) ?? minStart}.`)
return
}
setStatus('submitting')
setErrorMsg('')
try {
await marketplacePost('/marketplace/reservations', {
vehicleId: selected.id,
companySlug: selected.company.slug,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
notes: form.notes || undefined,
})
setStatus('success')
} catch (err: any) {
setStatus('error')
if (err?.code === 'unavailable') {
setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable)
} else {
setErrorMsg(err?.message ?? dict.errorGeneric)
}
}
}
const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0
const estimatedTotal = selected ? selected.dailyRate * days : 0
function getStatusCopy(vehicle: Vehicle) {
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
if (vehicle.availability === false) return dict.checkAvailability
return dict.available
}
return ( return (
<> <section>
<section> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2> <p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p>
<p className="text-sm text-stone-500">{vehicles.length} {dict.listings}</p> </div>
</div> <div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3"> {vehicles.map((vehicle) => (
{vehicles.map((vehicle) => ( <article key={vehicle.id} className="card overflow-hidden">
<article key={vehicle.id} className="card overflow-hidden"> <div className="aspect-[16/10] bg-stone-100">
<div className="aspect-[16/10] bg-stone-100"> {vehicle.photos[0] ? (
{vehicle.photos[0] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
) : null}
</div>
<div className="p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
{getStatusCopy(vehicle)}
</span>
</div>
{vehicle.availability === false && (
<p className="mt-3 text-sm text-stone-500">
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
</p>
)}
<div className="mt-5 flex items-end justify-between">
<div>
<p className="text-sm text-stone-500">{dict.from}</p>
<p className="text-2xl font-black text-stone-900">{formatCents(vehicle.dailyRate)}</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => openModal(vehicle)}
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white"
>
{dict.bookNow}
</button>
</div>
</div>
</div>
</article>
))}
{vehicles.length === 0 && (
<div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
)}
</div>
</section>
{selected && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={closeModal}>
<div
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-3xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start gap-4 p-6 border-b border-stone-100">
{selected.photos[0] && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={selected.photos[0]} alt="" className="h-16 w-24 rounded-xl object-cover flex-shrink-0" /> <img src={vehicle.photos[0]} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
)} ) : null}
<div className="flex-1 min-w-0">
<p className="text-xs uppercase tracking-widest text-stone-400">{selected.company.brand?.displayName ?? dict.rentalCompany}</p>
<h2 className="mt-1 text-xl font-bold text-stone-900 truncate">{selected.year} {selected.make} {selected.model}</h2>
<p className="mt-0.5 text-sm text-stone-500">{selected.category} · {formatCents(selected.dailyRate)}/day</p>
</div>
<button onClick={closeModal} className="flex-shrink-0 rounded-full p-2 hover:bg-stone-100 text-stone-500">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" /></svg>
</button>
</div> </div>
<div className="p-5">
{status === 'success' ? ( <div className="flex items-start justify-between gap-4">
<div className="p-8 text-center space-y-4"> <div>
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100"> <p className="text-xs uppercase tracking-[0.16em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg> <h3 className="mt-2 text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
</div> </div>
<h3 className="text-xl font-bold text-stone-900">{dict.successTitle}</h3> <span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
<p className="text-stone-500">{dict.successBody}</p> {getStatusCopy(vehicle, dict)}
<button onClick={closeModal} className="mt-4 rounded-full bg-stone-900 px-8 py-3 text-sm font-semibold text-white"> </span>
{dict.close}
</button>
</div> </div>
) : ( {vehicle.availability === false && (
<form onSubmit={handleSubmit} className="p-6 space-y-5"> <p className="mt-3 text-sm text-stone-500">
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3> {vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
{selected.availability === false && ( </p>
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> )}
{selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate} <div className="mt-5 flex items-end justify-between">
</div>
)}
{/* Dates + Times */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate} <span className="text-red-500">*</span></label>
<input
type="date"
required
min={selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()}
value={form.startDate}
onChange={set('startDate')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.returnDate} <span className="text-red-500">*</span></label>
<input
type="date"
required
min={form.startDate || today()}
value={form.endDate}
onChange={set('endDate')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime} <span className="text-red-500">*</span></label>
<input
type="time"
required
value={form.startTime}
onChange={set('startTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime} <span className="text-red-500">*</span></label>
<input
type="time"
required
value={form.endTime}
onChange={set('endTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div>
{/* Estimated total */}
{days > 0 && (
<div className="rounded-2xl bg-stone-50 px-4 py-3 flex items-center justify-between">
<p className="text-sm text-stone-500">{days} day{days > 1 ? 's' : ''} × {formatCents(selected.dailyRate)}</p>
<p className="font-bold text-stone-900">{formatCents(estimatedTotal)}</p>
</div>
)}
{/* Customer info */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.firstName} <span className="text-red-500">*</span></label>
<input
type="text"
required
value={form.firstName}
onChange={set('firstName')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.lastName} <span className="text-red-500">*</span></label>
<input
type="text"
required
value={form.lastName}
onChange={set('lastName')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div>
<div> <div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email} <span className="text-red-500">*</span></label> <p className="text-sm text-stone-500">{dict.from}</p>
<input <p className="text-2xl font-black text-stone-900">{formatCents(vehicle.dailyRate)}</p>
type="email"
required
value={form.email}
onChange={set('email')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div> </div>
<Link
<div> href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone} <span className="text-red-500">*</span></label> className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
<input >
type="tel" {dict.viewVehicle}
required </Link>
value={form.phone} </div>
onChange={set('phone')} </div>
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900" </article>
/> ))}
</div> {vehicles.length === 0 && (
<div className="card p-6 text-sm text-stone-500 md:col-span-2 xl:col-span-3">{dict.vehicleUnavailable}</div>
<div> )}
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.notes}</label> </div>
<textarea </section>
rows={3}
value={form.notes}
onChange={set('notes')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
/>
</div>
{status === 'error' && (
<p className="rounded-2xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
)}
<div className="flex gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="flex-1 rounded-full border border-stone-300 px-4 py-3 text-sm font-semibold text-stone-700"
>
{dict.cancel}
</button>
<button
type="submit"
disabled={status === 'submitting'}
className="flex-1 rounded-full bg-stone-900 px-4 py-3 text-sm font-semibold text-white disabled:opacity-60"
>
{status === 'submitting' ? dict.submitting : dict.submitRequest}
</button>
</div>
</form>
)}
</div>
</div>
)}
</>
) )
} }
@@ -43,8 +43,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
}, },
fr: { fr: {
unavailable: 'Marketplace indisponible', unavailable: 'Marketplace indisponible',
unavailableTitle: 'Les détails de lentreprise sont temporairement indisponibles.', unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
unavailableBody: 'LAPI marketplace fonctionne, mais la base de données nest pas accessible dans cet environnement local.', unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
backToExplore: 'Retour à explorer', backToExplore: 'Retour à explorer',
partner: 'Partenaire marketplace', partner: 'Partenaire marketplace',
vehicles: 'véhicules', vehicles: 'véhicules',
@@ -52,7 +52,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
rating: 'Note', rating: 'Note',
new: 'Nouveau', new: 'Nouveau',
currentOffers: 'Offres actuelles', currentOffers: 'Offres actuelles',
validUntil: 'Valable jusquau', validUntil: "Valable jusqu'au",
noOffers: 'Aucune offre active pour le moment.', noOffers: 'Aucune offre active pour le moment.',
fleet: 'Flotte', fleet: 'Flotte',
viewVehicle: 'Voir le véhicule', viewVehicle: 'Voir le véhicule',
@@ -33,39 +33,76 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
const language = getMarketplaceLanguage() const language = getMarketplaceLanguage()
const dict = { const dict = {
en: { en: {
unavailable: Marketplace unavailable, unavailable: 'Marketplace unavailable',
unavailableTitle: Vehicle details are temporarily unavailable., unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: The marketplace frontend is running, but the backing database is not reachable right now., unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
backToFleet: Back to company fleet, backToFleet: 'Back to fleet',
noPhotos: No photos available., backToExplore: 'Back to explore',
rentalCompany: Rental company, noPhotos: 'No photos available.',
perDay: / day, rentalCompany: 'Rental company',
availableFrom: Available from, perDay: '/ day',
unavailableNoDate: This vehicle is temporarily unavailable for new reservations., availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
specs: 'Specifications',
year: 'Year',
category: 'Category',
seats: 'Seats',
transmission: 'Transmission',
fuel: 'Fuel type',
features: 'Features & extras',
contact: 'Contact company',
phone: 'Phone',
whatsapp: 'WhatsApp',
photos: 'Photos',
}, },
fr: { fr: {
unavailable: Marketplace indisponible, unavailable: 'Marketplace indisponible',
unavailableTitle: Les détails du véhicule sont temporairement indisponibles., unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: Le frontend marketplace fonctionne, mais la base de données nest pas accessible pour le moment., unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
backToFleet: Retour à la flotte, backToFleet: 'Retour à la flotte',
noPhotos: Aucune photo disponible., backToExplore: 'Retour à explorer',
rentalCompany: Société de location, noPhotos: 'Aucune photo disponible.',
perDay: / jour, rentalCompany: 'Société de location',
availableFrom: Disponible à partir du, perDay: '/ jour',
unavailableNoDate: Ce véhicule est temporairement indisponible pour les nouvelles réservations., availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
specs: 'Caractéristiques',
year: 'Année',
category: 'Catégorie',
seats: 'Places',
transmission: 'Transmission',
fuel: 'Carburant',
features: 'Équipements & extras',
contact: 'Contacter la société',
phone: 'Téléphone',
whatsapp: 'WhatsApp',
photos: 'Photos',
}, },
ar: { ar: {
unavailable: السوق غير متاح, unavailable: 'السوق غير متاح',
unavailableTitle: تفاصيل السيارة غير متاحة مؤقتاً., unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً., unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
backToFleet: العودة إلى أسطول الشركة, backToFleet: 'العودة إلى الأسطول',
noPhotos: لا توجد صور متاحة., backToExplore: 'العودة إلى الاستكشاف',
rentalCompany: شركة تأجير, noPhotos: 'لا توجد صور متاحة.',
perDay: / يوم, rentalCompany: 'شركة تأجير',
availableFrom: متاح ابتداءً من, perDay: '/ يوم',
unavailableNoDate: هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة., availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
specs: 'المواصفات',
year: 'السنة',
category: 'الفئة',
seats: 'المقاعد',
transmission: 'ناقل الحركة',
fuel: 'نوع الوقود',
features: 'المميزات والإضافات',
contact: 'تواصل مع الشركة',
phone: 'الهاتف',
whatsapp: 'واتساب',
photos: 'الصور',
}, },
}[language] }[language]
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null) const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
if (!vehicle) { if (!vehicle) {
@@ -74,10 +111,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
<div className="shell"> <div className="shell">
<section className="card p-8"> <section className="card p-8">
<p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p> <p className="text-sm uppercase tracking-[0.18em] text-amber-700">{dict.unavailable}</p>
<h1 className="mt-3 text-3xl font-black text-stone-900">{dict.unavailableTitle}</h1> <h1 className="mt-3 text-3xl font-black text-stone-900 dark:text-stone-100">{dict.unavailableTitle}</h1>
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p> <p className="mt-3 text-stone-600 dark:text-stone-400">{dict.unavailableBody}</p>
<div className="mt-6"> <div className="mt-6 flex gap-3">
<Link href={`/explore/${params.slug}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToFleet}</Link> <Link href={`/explore/${params.slug}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.backToFleet}</Link>
<Link href="/explore" className="rounded-full border border-stone-300 px-5 py-2.5 text-sm font-semibold text-stone-700 dark:border-stone-600 dark:text-stone-300">{dict.backToExplore}</Link>
</div> </div>
</section> </section>
</div> </div>
@@ -89,48 +127,150 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) ? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
: null : null
const [heroPhoto, ...galleryPhotos] = vehicle.photos
return ( return (
<main className="min-h-screen bg-stone-50 py-10"> <main className="min-h-screen bg-stone-50 dark:bg-stone-950 py-10">
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]"> <div className="shell space-y-8">
<section className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> {/* Breadcrumb */}
{vehicle.photos.map((photo, index) => ( <nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
<div key={`${photo}-${index}`} className="card overflow-hidden"> <Link href="/explore" className="hover:text-stone-900 dark:hover:text-stone-100 transition">{dict.backToExplore}</Link>
<span>/</span>
<Link href={`/explore/${params.slug}`} className="hover:text-stone-900 dark:hover:text-stone-100 transition">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</Link>
<span>/</span>
<span className="text-stone-900 dark:text-stone-100 font-medium">{vehicle.make} {vehicle.model}</span>
</nav>
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* Left — photos + specs */}
<div className="space-y-6">
{/* Hero photo */}
{heroPhoto ? (
<div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-stone-800 aspect-[16/9]">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 1}`} className="h-full w-full object-cover" /> <img src={heroPhoto} alt={`${vehicle.make} ${vehicle.model}`} className="h-full w-full object-cover" />
</div> </div>
))} ) : (
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>} <div className="overflow-hidden rounded-3xl bg-stone-100 dark:bg-stone-800 aspect-[16/9] flex items-center justify-center text-sm text-stone-400">{dict.noPhotos}</div>
</div> )}
</section>
<aside className="card p-6 h-fit space-y-6"> {/* Gallery */}
<div> {galleryPhotos.length > 0 && (
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p> <div>
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1> <p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p> {galleryPhotos.map((photo, index) => (
{!vehicle.availability && ( <div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-stone-800 aspect-[4/3]">
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"> {/* eslint-disable-next-line @next/next/no-img-element */}
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate} <img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
</div>
))}
</div>
</div> </div>
)} )}
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => ( {/* Specs */}
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span> <div className="card p-6 space-y-4">
))} <h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.specs}</h2>
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.year}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.year}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.category}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.category}</dd>
</div>
{vehicle.seats > 0 && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.seats}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.seats}</dd>
</div>
)}
{vehicle.transmission && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.transmission}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.transmission}</dd>
</div>
)}
{vehicle.fuelType && (
<div>
<dt className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.fuel}</dt>
<dd className="mt-1 text-base font-semibold text-stone-900 dark:text-stone-100">{vehicle.fuelType}</dd>
</div>
)}
</dl>
{vehicle.features.length > 0 && (
<div className="pt-2 border-t border-stone-100 dark:border-stone-800">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500 mb-3">{dict.features}</p>
<div className="flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-stone-100 dark:bg-stone-800 px-3 py-1 text-xs font-medium text-stone-700 dark:text-stone-300">{feature}</span>
))}
</div>
</div>
)}
</div> </div>
{/* Contact */}
{(vehicle.company.brand?.publicPhone || vehicle.company.brand?.whatsappNumber) && (
<div className="card p-6 space-y-3">
<h2 className="text-lg font-bold text-stone-900 dark:text-stone-100">{dict.contact}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<div className="flex flex-wrap gap-3">
{vehicle.company.brand?.publicPhone && (
<a href={`tel:${vehicle.company.brand.publicPhone}`} className="inline-flex items-center gap-2 rounded-full border border-stone-300 dark:border-stone-700 px-4 py-2 text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition">
{dict.phone}: {vehicle.company.brand.publicPhone}
</a>
)}
{vehicle.company.brand?.whatsappNumber && (
<a href={`https://wa.me/${vehicle.company.brand.whatsappNumber.replace(/\D/g, '')}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 transition">
{dict.whatsapp}
</a>
)}
</div>
</div>
)}
</div> </div>
{vehicle.availability ? ( {/* Right — sticky booking panel */}
<BookingForm <aside className="space-y-4">
vehicleId={vehicle.id} <div className="card p-6 space-y-4 lg:sticky lg:top-20">
companySlug={params.slug} <p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
dailyRate={vehicle.dailyRate} <h1 className="text-3xl font-black text-stone-900 dark:text-stone-100">{vehicle.make} {vehicle.model}</h1>
/> <p className="text-stone-500 dark:text-stone-400">{vehicle.year} · {vehicle.category}</p>
) : null} <p className="text-3xl font-black text-stone-900 dark:text-stone-100">
{formatCurrency(vehicle.dailyRate, 'MAD')}
<span className="text-base font-medium text-stone-500 dark:text-stone-400"> {dict.perDay}</span>
</p>
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link> {!vehicle.availability && (
</aside> <div className="rounded-2xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
</div>
)}
{vehicle.availability && (
<BookingForm
vehicleId={vehicle.id}
companySlug={params.slug}
dailyRate={vehicle.dailyRate}
/>
)}
<Link
href={`/explore/${params.slug}`}
className="block rounded-full border border-stone-300 dark:border-stone-700 px-6 py-3 text-center text-sm font-semibold text-stone-700 dark:text-stone-300 hover:bg-stone-100 dark:hover:bg-stone-800 transition"
>
{dict.backToFleet}
</Link>
</div>
</aside>
</div>
</div> </div>
</main> </main>
) )
+78 -101
View File
@@ -1,6 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api' import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server' import { getMarketplaceLanguage } from '@/lib/i18n.server'
import ExploreSearchForm from './ExploreSearchForm'
import ExploreVehicleGrid from './ExploreVehicleGrid' import ExploreVehicleGrid from './ExploreVehicleGrid'
interface Vehicle { interface Vehicle {
@@ -51,6 +52,19 @@ interface CompanyCard {
} }
} }
type MarketplaceCity = string
const CATEGORY_VALUES = [
'ECONOMY',
'COMPACT',
'MIDSIZE',
'FULLSIZE',
'SUV',
'LUXURY',
'VAN',
'TRUCK',
] as const
export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) { export default async function ExplorePage({ searchParams }: { searchParams?: Record<string, string | string[] | undefined> }) {
const language = getMarketplaceLanguage() const language = getMarketplaceLanguage()
const dict = { const dict = {
@@ -58,11 +72,16 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
kicker: 'Discovery only', kicker: 'Discovery only',
title: 'Find your next rental from trusted local companies.', title: 'Find your next rental from trusted local companies.',
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.', body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
city: 'City or location', searchTitle: 'Book a vehicle',
allCategories: 'All categories', pickupLocation: 'Pick-up location',
anyTransmission: 'Any transmission', dropoffLocation: 'Drop-off location',
makePlaceholder: 'Make (e.g. Toyota)', pickupDate: 'Start date',
modelPlaceholder: 'Model (e.g. Corolla)', returnDate: 'Return date',
hour: 'Hour',
promoCode: 'I have a promo code',
myAge: 'My age',
ageDescription: 'We use your age to estimate your rental price more accurately. Some age groups may have extra fees.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Search', search: 'Search',
currentDeals: 'Current deals', currentDeals: 'Current deals',
featuredOffers: 'Featured marketplace offers', featuredOffers: 'Featured marketplace offers',
@@ -78,11 +97,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
maintenance: 'In maintenance', maintenance: 'In maintenance',
unavailableStatus: 'Unavailable', unavailableStatus: 'Unavailable',
from: 'From', from: 'From',
bookNow: 'Reserve', viewVehicle: 'View vehicle',
availableFrom: 'Available from', availableFrom: 'Available from',
reserveAfterDate: 'This vehicle can only be reserved starting',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
details: 'Details',
vehicleUnavailable: 'Vehicle listings are unavailable right now.', vehicleUnavailable: 'Vehicle listings are unavailable right now.',
browseByCategory: 'Browse by category', browseByCategory: 'Browse by category',
browseByCompany: 'Browse by company', browseByCompany: 'Browse by company',
@@ -92,36 +109,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
new: 'New', new: 'New',
publishedVehicles: 'published vehicles', publishedVehicles: 'published vehicles',
viewFleet: 'View fleet', viewFleet: 'View fleet',
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'], categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
// modal
reserveVehicle: 'Reserve this vehicle',
pickupDate: 'Pick-up date',
pickupTime: 'Pick-up time',
returnDate: 'Return date',
dropoffTime: 'Drop-off time',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
phone: 'Phone number',
notes: 'Additional notes (optional)',
cancel: 'Cancel',
submitRequest: 'Send reservation request',
submitting: 'Sending…',
successTitle: 'Request sent!',
successBody: 'Your reservation request has been received. Check your inbox for a confirmation email — the company will contact you shortly.',
close: 'Close',
errorUnavailable: 'This vehicle is no longer available for the selected dates. Please choose different dates.',
errorGeneric: 'Something went wrong. Please try again.',
}, },
fr: { fr: {
kicker: 'Découverte uniquement', kicker: 'Découverte uniquement',
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.", title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.", body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
city: 'Ville ou emplacement', searchTitle: 'Réserver un véhicule',
allCategories: 'Toutes les catégories', pickupLocation: 'Lieu de départ',
anyTransmission: 'Toute transmission', dropoffLocation: "Lieu de retour",
makePlaceholder: 'Marque (ex. Toyota)', pickupDate: 'Date de départ',
modelPlaceholder: 'Modèle (ex. Corolla)', returnDate: 'Date de retour',
hour: 'Heure',
promoCode: "J'ai un code promo",
myAge: 'Mon âge',
ageDescription: "Nous utilisons votre âge pour estimer plus précisément le prix de location. Des frais supplémentaires peuvent s'appliquer selon la tranche d'âge.",
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'Rechercher', search: 'Rechercher',
currentDeals: 'Offres du moment', currentDeals: 'Offres du moment',
featuredOffers: 'Offres marketplace mises en avant', featuredOffers: 'Offres marketplace mises en avant',
@@ -137,11 +140,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
maintenance: 'En maintenance', maintenance: 'En maintenance',
unavailableStatus: 'Indisponible', unavailableStatus: 'Indisponible',
from: 'À partir de', from: 'À partir de',
bookNow: 'Réserver', viewVehicle: 'Voir le véhicule',
availableFrom: 'Disponible à partir du', availableFrom: 'Disponible à partir du',
reserveAfterDate: 'Ce véhicule ne peut être réservé qu’à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
details: 'Détails',
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.', vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
browseByCategory: 'Parcourir par catégorie', browseByCategory: 'Parcourir par catégorie',
browseByCompany: 'Parcourir par entreprise', browseByCompany: 'Parcourir par entreprise',
@@ -151,35 +152,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
new: 'Nouveau', new: 'Nouveau',
publishedVehicles: 'véhicules publiés', publishedVehicles: 'véhicules publiés',
viewFleet: 'Voir la flotte', viewFleet: 'Voir la flotte',
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'], categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
reserveVehicle: 'Réserver ce véhicule',
pickupDate: 'Date de prise en charge',
pickupTime: 'Heure de prise en charge',
returnDate: 'Date de retour',
dropoffTime: 'Heure de restitution',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
phone: 'Numéro de téléphone',
notes: 'Notes supplémentaires (optionnel)',
cancel: 'Annuler',
submitRequest: 'Envoyer la demande',
submitting: 'Envoi…',
successTitle: 'Demande envoyée !',
successBody: "Votre demande de réservation a été reçue. Vérifiez votre boîte mail — l'entreprise vous contactera prochainement.",
close: 'Fermer',
errorUnavailable: "Ce véhicule n'est plus disponible pour les dates sélectionnées.",
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
}, },
ar: { ar: {
kicker: 'للاستكشاف فقط', kicker: 'للاستكشاف فقط',
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.', title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.', body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
city: 'المدينة أو الموقع', searchTitle: 'احجز سيارة',
allCategories: 'كل الفئات', pickupLocation: 'موقع الاستلام',
anyTransmission: 'أي ناقل حركة', dropoffLocation: 'موقع الإرجاع',
makePlaceholder: 'الماركة (مثل Toyota)', pickupDate: 'تاريخ البدء',
modelPlaceholder: 'الموديل (مثل Corolla)', returnDate: 'تاريخ الإرجاع',
hour: 'الساعة',
promoCode: 'لدي رمز ترويجي',
myAge: 'عمري',
ageDescription: 'نستخدم عمرك لتقديم تقدير أدق لسعر الإيجار. قد يتم تطبيق رسوم إضافية لبعض الفئات العمرية.',
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
search: 'بحث', search: 'بحث',
currentDeals: 'العروض الحالية', currentDeals: 'العروض الحالية',
featuredOffers: 'عروض السوق المميزة', featuredOffers: 'عروض السوق المميزة',
@@ -195,11 +183,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
maintenance: 'قيد الصيانة', maintenance: 'قيد الصيانة',
unavailableStatus: 'غير متاح', unavailableStatus: 'غير متاح',
from: 'ابتداءً من', from: 'ابتداءً من',
bookNow: 'احجز', viewVehicle: 'عرض السيارة',
availableFrom: 'متاح ابتداءً من', availableFrom: 'متاح ابتداءً من',
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.', unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
details: 'التفاصيل',
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.', vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
browseByCategory: 'تصفح حسب الفئة', browseByCategory: 'تصفح حسب الفئة',
browseByCompany: 'تصفح حسب الشركة', browseByCompany: 'تصفح حسب الشركة',
@@ -209,46 +195,41 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
new: 'جديد', new: 'جديد',
publishedVehicles: 'سيارات منشورة', publishedVehicles: 'سيارات منشورة',
viewFleet: 'عرض الأسطول', viewFleet: 'عرض الأسطول',
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'], categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
reserveVehicle: 'احجز هذه السيارة',
pickupDate: 'تاريخ الاستلام',
pickupTime: 'وقت الاستلام',
returnDate: 'تاريخ الإرجاع',
dropoffTime: 'وقت الإرجاع',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'رقم الهاتف',
notes: 'ملاحظات إضافية (اختياري)',
cancel: 'إلغاء',
submitRequest: 'إرسال طلب الحجز',
submitting: 'جارٍ الإرسال…',
successTitle: 'تم إرسال الطلب!',
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
close: 'إغلاق',
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
}, },
}[language] }[language]
const city = typeof searchParams?.city === 'string' ? searchParams.city : '' const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : ''
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : ''
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00'
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : ''
const returnTime = typeof searchParams?.returnTime === 'string' ? searchParams.returnTime : '10:00'
const promoCode = typeof searchParams?.promoCode === 'string' ? searchParams.promoCode : ''
const driverAge = typeof searchParams?.driverAge === 'string' ? searchParams.driverAge : '25+'
const category = typeof searchParams?.category === 'string' ? searchParams.category : '' const category = typeof searchParams?.category === 'string' ? searchParams.category : ''
const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : '' const transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
const make = typeof searchParams?.make === 'string' ? searchParams.make : '' const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
const model = typeof searchParams?.model === 'string' ? searchParams.model : '' const model = typeof searchParams?.model === 'string' ? searchParams.model : ''
const startDate = typeof searchParams?.startDate === 'string' ? searchParams.startDate : ''
const endDate = typeof searchParams?.endDate === 'string' ? searchParams.endDate : ''
const query = new URLSearchParams() const query = new URLSearchParams()
if (city) query.set('city', city) if (pickupLocation) query.set('city', pickupLocation)
if (startDate) query.set('startDate', startDate)
if (endDate) query.set('endDate', endDate)
if (category) query.set('category', category) if (category) query.set('category', category)
if (transmission) query.set('transmission', transmission) if (transmission) query.set('transmission', transmission)
if (make) query.set('make', make) if (make) query.set('make', make)
if (model) query.set('model', model) if (model) query.set('model', model)
query.set('pageSize', '24') query.set('pageSize', '24')
const [offers, vehicles, companies] = await Promise.all([ const [offers, vehicles, companies, cities] = await Promise.all([
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []), marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []), marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []), marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []),
marketplaceFetchOrDefault<MarketplaceCity[]>('/marketplace/cities', []),
]) ])
return ( return (
@@ -258,22 +239,18 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
<p className="text-sm uppercase tracking-[0.2em] text-amber-300">{dict.kicker}</p> <p className="text-sm uppercase tracking-[0.2em] text-amber-300">{dict.kicker}</p>
<h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1> <h1 className="mt-4 text-4xl font-black tracking-tight">{dict.title}</h1>
<p className="mt-4 max-w-2xl text-stone-300">{dict.body}</p> <p className="mt-4 max-w-2xl text-stone-300">{dict.body}</p>
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-3 xl:grid-cols-6"> <ExploreSearchForm
<input name="city" defaultValue={city} placeholder={dict.city} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" /> pickupLocation={pickupLocation}
<input name="make" defaultValue={make} placeholder={dict.makePlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" /> dropoffLocation={dropoffLocation}
<input name="model" defaultValue={model} placeholder={dict.modelPlaceholder} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900" /> pickupDate={pickupDate}
<select name="category" defaultValue={category} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900"> pickupTime={pickupTime}
<option value="">{dict.allCategories}</option> returnDate={returnDate}
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => ( returnTime={returnTime}
<option key={option} value={option}>{option}</option> promoCode={promoCode}
))} driverAge={driverAge}
</select> cities={cities}
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900"> dict={dict}
<option value="">{dict.anyTransmission}</option> />
{['AUTOMATIC', 'MANUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<button className="rounded-2xl bg-amber-400 px-4 py-3 text-sm font-semibold text-stone-950">{dict.search}</button>
</form>
</section> </section>
<section> <section>
@@ -300,7 +277,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
<h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2> <h2 className="text-2xl font-bold text-stone-900">{dict.browseByCategory}</h2>
<div className="mt-4 flex flex-wrap gap-3"> <div className="mt-4 flex flex-wrap gap-3">
{dict.categories.map((categoryName, index) => ( {dict.categories.map((categoryName, index) => (
<Link key={categoryName} href={`/explore?category=${['ECONOMY', 'COMPACT', 'SUV', 'LUXURY', 'VAN', 'TRUCK', 'ELECTRIC'][index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700"> <Link key={CATEGORY_VALUES[index] ?? categoryName} href={`/explore?category=${CATEGORY_VALUES[index]}`} className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm font-semibold text-stone-700">
{categoryName} {categoryName}
</Link> </Link>
))} ))}
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import MarketplaceShell from '@/components/MarketplaceShell' import MarketplaceShell from '@/components/MarketplaceShell'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -13,10 +14,11 @@ export const metadata: Metadata = {
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
const language = getMarketplaceLanguage()
return ( return (
<html lang="en" suppressHydrationWarning> <html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<MarketplaceShell>{children}</MarketplaceShell> <MarketplaceShell initialLanguage={language}>{children}</MarketplaceShell>
</body> </body>
</html> </html>
) )
@@ -42,7 +42,7 @@ const copy = {
submit: 'Demander une réservation', submit: 'Demander une réservation',
submitting: 'Envoi…', submitting: 'Envoi…',
successTitle: 'Demande reçue !', successTitle: 'Demande reçue !',
successBody: 'Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.', successBody: "Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.",
invalidDates: 'La date de retour doit être après la date de départ.', invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.', required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour', perDay: '/ jour',
@@ -72,6 +72,7 @@ const copy = {
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) { export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
const { language } = useMarketplacePreferences() const { language } = useMarketplacePreferences()
const t = copy[language] const t = copy[language]
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
@@ -118,6 +119,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
startDate: start.toISOString(), startDate: start.toISOString(),
endDate: end.toISOString(), endDate: end.toISOString(),
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
language,
}) })
setSuccess(true) setSuccess(true)
} catch (err: any) { } catch (err: any) {
@@ -0,0 +1,123 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type LocaleOption = {
value: 'en' | 'fr' | 'ar'
label: string
flag: string
}
type FooterItem = {
label: string
href?: string
}
export default function MarketplaceFooter({
primaryItems,
secondaryItems,
localeLabel,
rightsLabel,
localeOptions,
currentLocale,
onSelectLanguage,
}: {
primaryItems: FooterItem[]
secondaryItems: FooterItem[]
localeLabel: string
rightsLabel: string
localeOptions: LocaleOption[]
currentLocale: LocaleOption
onSelectLanguage: (language: LocaleOption['value']) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200 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="shell flex flex-col items-center gap-5 text-center">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{primaryItems.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < primaryItems.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{secondaryItems.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-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. {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,216 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
type Theme = 'light' | 'medium' | 'dark'
type Language = 'en' | 'fr' | 'ar'
type Dictionary = {
marketplace: string
explore: string
signIn: string
ownerSignIn: string
theme: string
light: string
medium: string
dark: string
}
type LanguageMeta = {
value: Language
flag: string
shortLabel: string
}
export default function MarketplaceHeader({
dict,
theme,
setTheme,
companyName,
dashboardUrl,
currentLanguage,
localeOptions,
onSelectLanguage,
}: {
dict: Dictionary
theme: Theme
setTheme: (theme: Theme) => void
companyName: string | null
dashboardUrl: string
currentLanguage: LanguageMeta
localeOptions: LanguageMeta[]
onSelectLanguage: (language: Language) => void
}) {
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: '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 =
currentLanguage.value === '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)
}
return (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-stone-800 dark:bg-stone-950 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
<div className="shell flex min-h-14 flex-col gap-1.5 py-1.5 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:py-2">
<div className="flex items-center justify-between gap-3">
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</Link>
</div>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<Link
href="/"
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"
>
{dict.marketplace}
</Link>
<Link
href="/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.explore}
</Link>
<Link
href="/sign-in"
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>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-1 flex items-center gap-1.5 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:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-up`}
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.ownerSignIn}
</Link>
)}
</nav>
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-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-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}`}>
{localeOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onSelectLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-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>
</header>
)
}
@@ -1,10 +1,9 @@
'use client' 'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import MarketplaceFooter from '@/components/MarketplaceFooter'
import MarketplaceHeader from '@/components/MarketplaceHeader'
import { footerPageHref } from '@/lib/footerContent' import { footerPageHref } from '@/lib/footerContent'
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n' import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
@@ -30,7 +29,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Home', marketplace: 'Home',
explore: 'Marketplace', explore: 'Marketplace',
signIn: 'Sign in', signIn: 'Sign in',
ownerSignIn: 'Create account', ownerSignIn: 'Create Agency Space',
language: 'Language', language: 'Language',
theme: 'Theme', theme: 'Theme',
light: 'Light', light: 'Light',
@@ -42,7 +41,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'Accueil', marketplace: 'Accueil',
explore: 'Marketplace', explore: 'Marketplace',
signIn: 'Connexion', signIn: 'Connexion',
ownerSignIn: 'Créer un compte', ownerSignIn: "Créer Espace d'Agence",
language: 'Langue', language: 'Langue',
theme: 'Mode', theme: 'Mode',
light: 'Clair', light: 'Clair',
@@ -54,7 +53,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
marketplace: 'الرئيسية', marketplace: 'الرئيسية',
explore: 'السوق', explore: 'السوق',
signIn: 'تسجيل الدخول', signIn: 'تسجيل الدخول',
ownerSignIn: 'إنشاء حساب', ownerSignIn: 'إنشاء مساحة الوكالة',
language: 'اللغة', language: 'اللغة',
theme: 'الوضع', theme: 'الوضع',
light: 'فاتح', light: 'فاتح',
@@ -74,20 +73,15 @@ type PreferencesContextValue = {
const PreferencesContext = createContext<PreferencesContextValue | null>(null) const PreferencesContext = createContext<PreferencesContextValue | null>(null)
type FooterItem = { const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
label: string { value: 'en', label: 'Global (English)', flag: '🇺🇸' },
href?: string { value: 'ar', label: 'NorthAfrica (Arabic)', flag: '🇲🇦' },
} { value: 'fr', label: 'Europ (French)', flag: '🇫🇷' },
const localeOptions: Array<{ value: MarketplaceLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
] ]
function getFooterContent(language: MarketplaceLanguage): { function getFooterContent(language: MarketplaceLanguage): {
primary: FooterItem[] primary: Array<{ label: string; href?: string }>
secondary: FooterItem[] secondary: Array<{ label: string; href?: string }>
localeLabel: string localeLabel: string
rightsLabel: string rightsLabel: string
} { } {
@@ -150,45 +144,6 @@ function getFooterContent(language: MarketplaceLanguage): {
} }
} }
function SegmentedControl<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: { value: T; label: string }[]
onChange: (value: T) => void
}) {
return (
<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">
{label}
</span>
<div className="flex items-center gap-1">
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.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'
}`}
>
{option.label}
</button>
)
})}
</div>
</div>
)
}
function detectBrowserLanguage(): string | null { function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language]) const langs = Array.from(navigator.languages ?? [navigator.language])
@@ -205,19 +160,50 @@ export function useMarketplacePreferences() {
return context return context
} }
export default function MarketplaceShell({ children }: { children: React.ReactNode }) { export default function MarketplaceShell({
children,
initialLanguage = 'en',
}: {
children: React.ReactNode
initialLanguage?: MarketplaceLanguage
}) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard') const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002/admin')
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguage] = useState<MarketplaceLanguage>('en') const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
const [theme, setTheme] = useState<Theme>('light') const [theme, setThemeState] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en') const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [companyName, setCompanyName] = useState<string | null>(null) const [companyName, setCompanyName] = useState<string | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
function applyLanguage(nextLanguage: MarketplaceLanguage) {
if (nextLanguage === language) return
if (typeof window !== 'undefined') {
document.documentElement.lang = nextLanguage
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
try {
sessionStorage.setItem('marketplace-language', nextLanguage)
} catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, ['marketplace-language'])
}
setLanguageState(nextLanguage)
}
function applyTheme(nextTheme: Theme) {
if (nextTheme === theme) return
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', nextTheme === 'dark' || nextTheme === 'medium')
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = nextTheme
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['marketplace-theme'])
}
setThemeState(nextTheme)
}
async function syncCompanyBrand() { async function syncCompanyBrand() {
const token = document.cookie const token = document.cookie
@@ -249,36 +235,63 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
} }
} }
function syncScopedPreferencesForSession() { function readSessionLanguage(): MarketplaceLanguage | null {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY) try {
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY) const val = sessionStorage.getItem('marketplace-language')
return isMarketplaceLanguage(val) ? val : null
} catch {
return null
}
}
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) { function syncScopedPreferencesForSession() {
setLanguage(scopedLanguage) // Tab-session choice always wins — never let an employee-scoped cookie override
// a language the user explicitly picked during this browsing session.
const sessionLang = readSessionLanguage()
if (sessionLang) {
if (sessionLang !== language) setLanguageState(sessionLang)
} else { } else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language']) const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
setLanguageState(scopedLanguage)
}
} }
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) { if ((scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') && scopedTheme !== theme) {
setTheme(scopedTheme) setThemeState(scopedTheme)
} else { } else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme']) writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
} }
} }
useEffect(() => { useEffect(() => {
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language']) // sessionStorage is tab-local — check it first so a selection made earlier in
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null // this tab is honoured even after a server refresh.
const sessionLang = readSessionLanguage()
if (isMarketplaceLanguage(storedLanguage)) { if (sessionLang) {
setLanguage(storedLanguage) setLanguageState(sessionLang)
// Write the persistent cookie immediately so any subsequent RSC request
// (e.g. router.refresh or next navigation) sees the correct language
// without waiting for the language effect's render cycle.
writeScopedPreference(SHARED_LANGUAGE_KEY, sessionLang, ['marketplace-language'])
} else { } else {
const detected = detectBrowserLanguage() const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
if (isMarketplaceLanguage(detected)) setLanguage(detected) if (isMarketplaceLanguage(storedLanguage)) {
setLanguageState(storedLanguage)
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) {
setLanguageState(detected)
writeScopedPreference(SHARED_LANGUAGE_KEY, detected, ['marketplace-language'])
}
}
} }
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') { if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
setTheme(storedTheme) setThemeState(storedTheme)
} }
setHydrated(true) setHydrated(true)
@@ -316,195 +329,77 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
useEffect(() => { useEffect(() => {
document.documentElement.lang = language document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr' document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
document.body.dataset.theme = theme
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
document.cookie = `${MARKETPLACE_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; SameSite=Lax`
if (hydrated && previousLanguage.current !== language) { // Don't write to storage on the initial render (language='en' default) — wait
// until hydration has resolved the correct stored preference. Writing 'en'
// here before hydration would overwrite a previously-saved 'ar' preference.
if (!hydrated) return
try { sessionStorage.setItem('marketplace-language', language) } catch {}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
if (previousLanguage.current !== language) {
router.refresh() router.refresh()
} }
previousLanguage.current = language previousLanguage.current = language
}, [hydrated, language, router, theme]) }, [hydrated, language, pathname, router])
useEffect(() => { useEffect(() => {
function handlePointerDown(event: MouseEvent) { document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
if (!localeMenuRef.current?.contains(event.target as Node)) { document.body.dataset.theme = theme
setLocaleMenuOpen(false) writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
} }, [theme])
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
const dict = dictionaries[language] const dict = dictionaries[language]
const footerContent = getFooterContent(language) const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language) const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
const isRenterApp = pathname.startsWith('/renter/') const isRenterApp = pathname.startsWith('/renter/')
const isEmbeddedWorkspace = isRenterApp const isAuthFrameRoute = pathname === '/sign-in'
const hideHeader = isRenterApp
const value = useMemo( const value = useMemo(
() => ({ language, theme, dict, setLanguage, setTheme }), () => ({ language, theme, dict, setLanguage: applyLanguage, setTheme: applyTheme }),
[dict, language, theme], [dict, language, theme],
) )
return ( return (
<PreferencesContext.Provider value={value}> <PreferencesContext.Provider value={value}>
<div className="min-h-screen bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100"> <div className="flex min-h-screen flex-col bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
{!hideHeader ? ( {isRenterApp || isAuthFrameRoute ? null : (
<header className="sticky top-0 z-50 border-b border-stone-200 bg-white/80 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/80"> <MarketplaceHeader
<div className="shell flex min-h-14 flex-col gap-3 py-3 lg:flex-row lg:items-center lg:justify-between lg:py-2"> dict={dict}
<div className="flex items-center justify-between gap-4"> theme={theme}
<Link href="/" className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100"> setTheme={applyTheme}
<Image companyName={companyName}
src="/rentalcardrive.png" dashboardUrl={dashboardUrl}
alt="RentalDriveGo" currentLanguage={{
width={36} value: currentLocale.value,
height={36} flag: currentLocale.flag,
className="h-9 w-9 rounded-md object-contain" shortLabel: currentLocale.value.toUpperCase(),
/> }}
<span>RentalDriveGo</span> localeOptions={availableLocaleOptions.map((option) => ({
</Link> value: option.value,
</div> flag: option.flag,
shortLabel: option.value.toUpperCase(),
}))}
onSelectLanguage={applyLanguage}
/>
)}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex flex-1 flex-col">
<nav className="flex items-center gap-1 overflow-x-auto"> <div className="flex-1">{children}</div>
<Link {isRenterApp || isAuthFrameRoute ? null : (
href="/" <MarketplaceFooter
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" primaryItems={footerContent.primary}
> secondaryItems={footerContent.secondary}
{dict.marketplace} localeLabel={footerContent.localeLabel}
</Link> rightsLabel={footerContent.rightsLabel}
<Link localeOptions={availableLocaleOptions}
href="/explore" currentLocale={currentLocale}
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" onSelectLanguage={applyLanguage}
> />
{dict.explore} )}
</Link>
<Link
href="/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}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 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"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/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.ownerSignIn}
</Link>
)}
</nav>
<SegmentedControl
label={dict.theme}
value={theme}
options={[
{ value: 'light', label: dict.light },
{ value: 'medium', label: dict.medium },
{ value: 'dark', label: dict.dark },
]}
onChange={setTheme}
/>
</div>
</div>
</header>
) : null}
<div className="flex min-h-[calc(100vh-57px)] flex-col">
<div className="flex-1">
{children}
</div>
{!isEmbeddedWorkspace ? (
<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="shell flex flex-col items-center gap-5 text-center">
<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>
) : null}
</div> </div>
</div> </div>
</PreferencesContext.Provider> </PreferencesContext.Provider>
) )
} }
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>
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { useMarketplacePreferences } from './MarketplaceShell' import { useMarketplacePreferences } from './MarketplaceShell'
@@ -44,11 +45,13 @@ function buildFrameUrl(appUrl: string, path: string) {
} }
export default function WorkspaceFrame({ target }: { target: FrameId }) { export default function WorkspaceFrame({ target }: { target: FrameId }) {
const pathname = usePathname()
const { language, theme } = useMarketplacePreferences() const { language, theme } = useMarketplacePreferences()
const [src, setSrc] = useState('') const [src, setSrc] = useState('')
const [framePath, setFramePath] = useState(() => getDefaultFramePath(target)) const [framePath, setFramePath] = useState(() => getDefaultFramePath(target))
const config = frameConfig[target] const config = frameConfig[target]
const frameHeight = 'calc(100vh - 56px)' const isStandaloneAuthRoute = target === 'sign-in' && pathname === '/sign-in'
const frameHeight = isStandaloneAuthRoute ? '100vh' : 'calc(100vh - 56px)'
const loadingLabel = { const loadingLabel = {
en: 'Loading', en: 'Loading',
fr: 'Chargement de', fr: 'Chargement de',
@@ -59,6 +62,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
setFramePath(getDefaultFramePath(target)) setFramePath(getDefaultFramePath(target))
}, [target]) }, [target])
useEffect(() => {
if (!isStandaloneAuthRoute || typeof window === 'undefined') return
if (window.location.pathname !== '/sign-in') return
window.history.replaceState(window.history.state, '', '/')
}, [isStandaloneAuthRoute])
useEffect(() => { useEffect(() => {
function handleFrameMessage(event: MessageEvent) { function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return if (!event.data || typeof event.data !== 'object') return
@@ -78,8 +88,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath)) const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
nextUrl.searchParams.set('theme', theme) nextUrl.searchParams.set('theme', theme)
nextUrl.searchParams.set('lang', language) nextUrl.searchParams.set('lang', language)
if (isStandaloneAuthRoute) {
nextUrl.searchParams.delete('embedded')
} else {
nextUrl.searchParams.set('embedded', '1')
}
setSrc(nextUrl.toString()) setSrc(nextUrl.toString())
}, [config.appUrl, framePath, language, theme]) }, [config.appUrl, framePath, isStandaloneAuthRoute, language, theme])
return ( return (
<main className="bg-stone-950" style={{ minHeight: frameHeight }}> <main className="bg-stone-950" style={{ minHeight: frameHeight }}>
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
function isValidLanguage(val: string | null | undefined): val is 'en' | 'fr' | 'ar' {
return val === 'en' || val === 'fr' || val === 'ar'
}
function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
if (!header) return 'en'
for (const entry of header.split(',')) {
const code = entry.split(';')[0].trim().split('-')[0].toLowerCase()
if (code === 'ar' || code === 'fr' || code === 'en') return code as 'en' | 'fr' | 'ar'
}
return 'en'
}
export function middleware(request: NextRequest) {
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
// Already has the canonical language cookie — no action needed
if (isValidLanguage(sharedLang)) return NextResponse.next()
const legacyLang = request.cookies.get(MARKETPLACE_LANGUAGE_COOKIE)?.value
const resolved: 'en' | 'fr' | 'ar' = isValidLanguage(legacyLang)
? legacyLang
: detectFromAcceptLanguage(request.headers.get('accept-language'))
// Inject the resolved language into the request cookie header so that
// getMarketplaceLanguage() (which calls cookies()) sees the correct value
// even on this very first render — before the browser has stored the cookie.
const requestHeaders = new Headers(request.headers)
const existing = request.headers.get('cookie') ?? ''
const injected = `${SHARED_LANGUAGE_COOKIE}=${resolved}`
requestHeaders.set('cookie', existing ? `${existing}; ${injected}` : injected)
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Persist in the browser for future requests
response.cookies.set(SHARED_LANGUAGE_COOKIE, resolved, {
maxAge: 365 * 24 * 60 * 60,
path: '/',
sameSite: 'lax',
})
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
],
}