update website home and dashboard
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'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 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 = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
@@ -94,6 +100,9 @@ const Context = createContext<AdminI18nContext | null>(null)
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
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(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
@@ -115,6 +124,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
if (skipFirstLangWrite.current) {
|
||||
skipFirstLangWrite.current = false
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem('admin-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>
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
const meta = languageMeta[value]
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
@@ -163,7 +177,10 @@ export function AdminLanguageSwitcher() {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import PublicFooter from '@/components/PublicFooter'
|
||||
import PublicHeader from '@/components/PublicHeader'
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen 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">
|
||||
<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">
|
||||
<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 className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<PublicHeader />
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ const routeDocs = [
|
||||
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
|
||||
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
|
||||
{ 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}/site/:slug/brand`, description: 'Public site brand config' },
|
||||
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { requireRole } from '../middleware/requireRole'
|
||||
import { generateFinancialReport, toCsv } from '../services/financialReportService'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('MANAGER'))
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
function getRangeFromPeriod(period: string) {
|
||||
const now = new Date()
|
||||
@@ -158,7 +158,7 @@ router.get('/sources', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.get('/report', async (req, res, next) => {
|
||||
router.get('/report', requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { from, to, format = 'JSON', period } = req.query as Record<string, string>
|
||||
const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
import { signupEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -66,26 +67,17 @@ function buildSignupConfirmationEmail(opts: {
|
||||
currency: 'MAD' | 'USD' | 'EUR'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
trialEndAt: Date
|
||||
lang: Lang
|
||||
}) {
|
||||
const greetingName = opts.firstName.trim() || 'there'
|
||||
|
||||
return [
|
||||
`Hi ${greetingName},`,
|
||||
'',
|
||||
`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 ${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')
|
||||
return signupEmail.text({
|
||||
firstName: opts.firstName.trim() || 'there',
|
||||
companyName: opts.companyName,
|
||||
plan: opts.plan,
|
||||
billingPeriod: opts.billingPeriod,
|
||||
currency: opts.currency,
|
||||
paymentProvider: opts.paymentProvider,
|
||||
trialEnd: opts.trialEndAt,
|
||||
}, opts.lang)
|
||||
}
|
||||
|
||||
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({
|
||||
type: 'SUBSCRIPTION_TRIAL_ENDING',
|
||||
title: 'Your workspace is ready',
|
||||
title: signupEmail.subject(body.preferredLanguage),
|
||||
body: buildSignupConfirmationEmail({
|
||||
firstName: body.firstName,
|
||||
companyName: body.companyName,
|
||||
@@ -219,11 +211,13 @@ router.post('/signup', async (req, res, next) => {
|
||||
currency: body.currency,
|
||||
paymentProvider: body.paymentProvider,
|
||||
trialEndAt,
|
||||
lang: body.preferredLanguage,
|
||||
}),
|
||||
companyId: result.company.id,
|
||||
employeeId: result.employeeId,
|
||||
email: body.email,
|
||||
channels: ['EMAIL', 'IN_APP'],
|
||||
locale: body.preferredLanguage,
|
||||
})
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
@@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { sendTransactionalEmail } from '../services/notificationService'
|
||||
import { resetPasswordEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
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) => {
|
||||
try {
|
||||
@@ -174,11 +166,12 @@ router.post('/forgot-password', async (req, res, next) => {
|
||||
)
|
||||
const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}`
|
||||
|
||||
const lang = (employee.preferredLanguage as Lang) ?? 'fr'
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: 'Reset your RentalDriveGo password',
|
||||
html: buildResetEmailHtml(resetUrl, employee.firstName),
|
||||
text: `Hi ${employee.firstName},\n\nReset your password here: ${resetUrl}\n\nThis link expires in ${RESET_TOKEN_TTL_MINUTES} minutes.\n\nRentalDriveGo`,
|
||||
subject: resetPasswordEmail.subject(lang),
|
||||
html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang),
|
||||
}).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) }
|
||||
})
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const { token, password } = z.object({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from '../lib/prisma'
|
||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||
import { marketplaceReservationEmail, type Lang } from '../lib/emailTranslations'
|
||||
|
||||
const router = Router()
|
||||
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) => {
|
||||
try {
|
||||
const { city, hasOffer } = req.query as Record<string, string>
|
||||
@@ -136,6 +178,7 @@ router.post('/reservations', async (req, res, next) => {
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
})
|
||||
|
||||
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 rateDisplay = (vehicle.dailyRate / 100).toFixed(2)
|
||||
const totalDisplay = (totalAmount / 100).toFixed(2)
|
||||
const dateOpts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
|
||||
const startStr = startDate.toLocaleDateString('en-GB', dateOpts)
|
||||
const endStr = endDate.toLocaleDateString('en-GB', dateOpts)
|
||||
const startStr = startDate.toISOString().slice(0, 10)
|
||||
const endStr = endDate.toISOString().slice(0, 10)
|
||||
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 {
|
||||
await sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: `Reservation Request Received — ${vehicle.make} ${vehicle.model}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<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!`,
|
||||
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
|
||||
html: marketplaceReservationEmail.html(emailOpts, lang),
|
||||
text: marketplaceReservationEmail.text(emailOpts, lang),
|
||||
})
|
||||
} catch {
|
||||
// email failure is non-blocking — reservation still created
|
||||
|
||||
@@ -9,6 +9,7 @@ import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../lib/emailTranslations'
|
||||
import crypto from 'crypto'
|
||||
|
||||
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' } })
|
||||
|
||||
// Notify
|
||||
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
|
||||
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)
|
||||
const [customer, companyBrand] = await Promise.all([
|
||||
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 })
|
||||
} 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
|
||||
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 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 reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
|
||||
const reviewOpts = { firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
subject: `How was your rental? — ${vehicleLabel}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<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!`,
|
||||
subject: reviewRequestEmail.subject(vehicleLabel, lang),
|
||||
html: reviewRequestEmail.html(reviewOpts, lang),
|
||||
text: reviewRequestEmail.text(reviewOpts, lang),
|
||||
}).catch(() => null)
|
||||
|
||||
res.json({ data: updated })
|
||||
|
||||
@@ -648,19 +648,10 @@ export default function FleetPage() {
|
||||
{filtered.map((vehicle) => (
|
||||
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||
{vehicle.primaryPhoto ? (
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import StatCard from '@/components/ui/StatCard'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
@@ -38,6 +38,10 @@ interface DashboardData {
|
||||
}
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
role?: string
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
CONFIRMED: 'badge-blue',
|
||||
PENDING: 'badge-amber',
|
||||
@@ -108,12 +112,22 @@ export default function DashboardPage() {
|
||||
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<{ code: string; message: string } | null>(null)
|
||||
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (!cached) return
|
||||
const profile = JSON.parse(cached) as EmployeeProfile
|
||||
setEmployeeRole(profile.role ?? null)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<DashboardData>('/analytics/dashboard')
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
@@ -126,15 +140,16 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
|
||||
return (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-red-600 font-medium">{d.failedToLoad}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">{error}</p>
|
||||
<p className="text-slate-500 text-sm">{errorMsg}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
|
||||
const canViewRevenue = employeeRole !== 'AGENT'
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
@@ -156,7 +171,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
|
||||
<StatCard
|
||||
title={d.kpiTotalBookings}
|
||||
value={kpis.totalBookings.toLocaleString()}
|
||||
@@ -175,6 +190,7 @@ export default function DashboardPage() {
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
/>
|
||||
{canViewRevenue ? (
|
||||
<StatCard
|
||||
title={d.kpiMonthlyRevenue}
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
@@ -184,6 +200,7 @@ export default function DashboardPage() {
|
||||
iconColor="text-amber-600"
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
) : null}
|
||||
<StatCard
|
||||
title={d.kpiTotalCustomers}
|
||||
value={kpis.totalCustomers.toLocaleString()}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
@@ -35,6 +36,10 @@ interface ProviderAvailability {
|
||||
paypal: boolean
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
role?: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
@@ -52,11 +57,13 @@ const INVOICE_STATUS: Record<string, string> = {
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useDashboardI18n()
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
@@ -198,6 +205,33 @@ export default function SubscriptionPage() {
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
const profile = JSON.parse(cached) as EmployeeProfile
|
||||
const allowed = profile.role === 'OWNER'
|
||||
setCanViewPage(allowed)
|
||||
if (!allowed) router.replace('/dashboard')
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
|
||||
const allowed = employee.role === 'OWNER'
|
||||
setCanViewPage(allowed)
|
||||
if (!allowed) router.replace('/dashboard')
|
||||
})
|
||||
.catch(() => {
|
||||
setCanViewPage(false)
|
||||
router.replace('/dashboard')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (canViewPage !== true) return
|
||||
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
@@ -217,7 +251,11 @@ export default function SubscriptionPage() {
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [canViewPage])
|
||||
|
||||
if (canViewPage !== true) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
setPaying(true)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط الإعادة',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-900 dark:text-slate-100">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{dict.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900/90">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4 dark:text-slate-100 dark:decoration-slate-700">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -1,128 +1,12 @@
|
||||
'use client'
|
||||
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط الإعادة',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
||||
RentalDriveGo
|
||||
</a>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">{dict.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <ForgotPasswordPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
|
||||
export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent embedded={embedded} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<p className="text-center text-sm text-slate-500">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({
|
||||
embedded = false,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
embedded?: boolean
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +1,12 @@
|
||||
'use client'
|
||||
import ResetPasswordPageClient from './ResetPasswordPageClient'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<p className="text-sm text-slate-500 text-center">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <ResetPasswordPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
function notifyParent(message: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined' || window.parent === window) return
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
|
||||
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedLanguage = searchParams.get('lang')
|
||||
const requestedTheme = searchParams.get('theme')
|
||||
const initializedFromQuery = useRef(false)
|
||||
const dict = {
|
||||
en: {
|
||||
brandName: 'Space Agency',
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter your credentials to access your account.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
verify: 'Verify code',
|
||||
verifying: 'Verifying…',
|
||||
authCode: 'Authentication code',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
||||
back: 'Back to credentials',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
brandName: 'Agence Espace',
|
||||
title: 'Connexion',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
authCode: "Code d'authentification",
|
||||
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
invalidCredentials: 'Email ou mot de passe invalide.',
|
||||
passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`,
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
brandName: ' فضاء الوكالة ',
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedFromQuery.current) return
|
||||
|
||||
if (
|
||||
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
||||
requestedLanguage !== language
|
||||
) {
|
||||
setLanguage(requestedLanguage)
|
||||
}
|
||||
|
||||
if (
|
||||
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
|
||||
requestedTheme !== theme
|
||||
) {
|
||||
setTheme(requestedTheme)
|
||||
}
|
||||
|
||||
initializedFromQuery.current = true
|
||||
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializedFromQuery.current) return
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let changed = false
|
||||
|
||||
if (params.get('lang') !== language) {
|
||||
params.set('lang', language)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (params.get('theme') !== theme) {
|
||||
params.set('theme', theme)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
|
||||
const nextQuery = params.toString()
|
||||
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
|
||||
}, [language, pathname, router, searchParams, theme])
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname + (window.location.search || '')
|
||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-900 dark:text-slate-100">
|
||||
{dict.brandName}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setLanguage(lang)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-blue-600 dark:text-amber-400 ${
|
||||
language === lang
|
||||
? 'ring-1 ring-current opacity-100'
|
||||
: 'opacity-40 hover:opacity-80'
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900/90">
|
||||
<LocalSignInForm dict={dict} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalSignInForm({
|
||||
dict,
|
||||
}: {
|
||||
dict: {
|
||||
email: string
|
||||
password: string
|
||||
signIn: string
|
||||
signingIn: string
|
||||
verify: string
|
||||
verifying: string
|
||||
authCode: string
|
||||
enterCode: string
|
||||
back: string
|
||||
forgotPassword: string
|
||||
invalidCredentials: string
|
||||
passwordNotSet: string
|
||||
unexpectedError: string
|
||||
}
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { setLanguage } = useDashboardI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
window.location.href = `${adminUrl}/auth-redirect#${hash}`
|
||||
}
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
setLanguage(prefLang)
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
|
||||
router.push(employeeRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return
|
||||
}
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-slate-500 underline decoration-slate-300 underline-offset-4 hover:text-slate-700">
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
className="input-field text-center text-xl tracking-[0.45em]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,396 +1,12 @@
|
||||
'use client'
|
||||
import SignInPageClient from './SignInPageClient'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
function notifyParent(message: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined' || window.parent === window) return
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedLanguage = searchParams.get('lang')
|
||||
const requestedTheme = searchParams.get('theme')
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter your credentials to access your account.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
verify: 'Verify code',
|
||||
verifying: 'Verifying…',
|
||||
authCode: 'Authentication code',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app.',
|
||||
back: 'Back to credentials',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Connexion',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
authCode: 'Code d\'authentification',
|
||||
enterCode: 'Entrez le code à 6 chiffres de votre application d\'authentification.',
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
invalidCredentials: 'Email ou mot de passe invalide.',
|
||||
passwordNotSet: 'Aucun mot de passe défini. Vérifiez votre e-mail d\'invitation ou utilisez « Mot de passe oublié ? »',
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
const themeStyles = {
|
||||
light: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]',
|
||||
logoFrame: 'border-blue-100 bg-white shadow-sm',
|
||||
brand: 'text-blue-600',
|
||||
title: 'text-slate-900',
|
||||
subtitle: 'text-slate-500',
|
||||
card: 'border-slate-200 bg-white shadow-sm',
|
||||
},
|
||||
medium: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
|
||||
logoFrame: 'border-slate-300 bg-white/90 shadow-[0_12px_30px_rgba(71,85,105,0.18)]',
|
||||
brand: 'text-slate-700',
|
||||
title: 'text-slate-900',
|
||||
subtitle: 'text-slate-600',
|
||||
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
|
||||
},
|
||||
dark: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
|
||||
logoFrame: 'border-slate-700 bg-slate-900 shadow-[0_12px_30px_rgba(2,6,23,0.45)]',
|
||||
brand: 'text-amber-400',
|
||||
title: 'text-slate-100',
|
||||
subtitle: 'text-slate-400',
|
||||
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
|
||||
},
|
||||
}[theme]
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
||||
requestedLanguage !== language
|
||||
) {
|
||||
setLanguage(requestedLanguage)
|
||||
}
|
||||
}, [language, requestedLanguage, setLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
|
||||
requestedTheme !== theme
|
||||
) {
|
||||
setTheme(requestedTheme)
|
||||
}
|
||||
}, [requestedTheme, setTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname + (window.location.search || '')
|
||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return (
|
||||
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
className={`h-24 w-24 rounded-[1.75rem] border p-1.5 transition-colors ${themeStyles.logoFrame}`}
|
||||
/>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className={`text-xs font-semibold uppercase tracking-[0.24em] transition-colors ${themeStyles.brand}`}>
|
||||
RentalDriveGo
|
||||
</a>
|
||||
<h1 className={`mt-3 text-3xl font-black tracking-tight transition-colors ${themeStyles.title}`}>
|
||||
{dict.title}
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm transition-colors ${themeStyles.subtitle}`}>
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
|
||||
<LocalSignInForm dict={dict} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalSignInForm({ dict }: {
|
||||
dict: {
|
||||
email: string
|
||||
password: string
|
||||
signIn: string
|
||||
signingIn: string
|
||||
verify: string
|
||||
verifying: string
|
||||
authCode: string
|
||||
enterCode: string
|
||||
back: string
|
||||
forgotPassword: string
|
||||
invalidCredentials: string
|
||||
passwordNotSet: string
|
||||
unexpectedError: string
|
||||
}
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { setLanguage } = useDashboardI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
window.location.href = `${adminUrl}/auth-redirect#${hash}`
|
||||
}
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Try employee login first
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
// Apply the employee's stored language preference immediately so the
|
||||
// dashboard renders in the correct language before any read-effect runs.
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
|
||||
setLanguage(prefLang)
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
|
||||
router.push(employeeRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to admin login
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
return
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
|
||||
{dict.enterCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
className="input-field text-center text-xl tracking-[0.45em]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
}}
|
||||
className="w-full text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return <SignInPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -53,6 +54,7 @@ type SignupResponse = {
|
||||
|
||||
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
|
||||
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
@@ -405,7 +407,19 @@ export default function SignUpPage() {
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
|
||||
@@ -420,9 +434,9 @@ export default function SignUpPage() {
|
||||
<Link href="/dashboard" className="btn-primary justify-center">
|
||||
{dict.enterDashboard}
|
||||
</Link>
|
||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
||||
<a href="/sign-in" className="btn-secondary justify-center">
|
||||
{dict.signInAnotherDevice}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -435,7 +449,19 @@ export default function SignUpPage() {
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
export type DashboardTheme = 'light' | 'medium' | 'dark'
|
||||
@@ -143,6 +144,7 @@ type ReservationsDict = {
|
||||
|
||||
type DashboardPageDict = {
|
||||
failedToLoad: string
|
||||
forbidden: string
|
||||
kpiTotalBookings: string
|
||||
kpiActiveVehicles: string
|
||||
kpiMonthlyRevenue: string
|
||||
@@ -556,6 +558,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
dashboardPage: {
|
||||
failedToLoad: 'Failed to load dashboard',
|
||||
forbidden: 'You do not have permission to access this page. A Manager role or higher is required.',
|
||||
kpiTotalBookings: 'Total Bookings',
|
||||
kpiActiveVehicles: 'Active Vehicles',
|
||||
kpiMonthlyRevenue: 'Monthly Revenue',
|
||||
@@ -899,6 +902,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
dashboardPage: {
|
||||
failedToLoad: 'Échec du chargement du tableau de bord',
|
||||
forbidden: "Vous n'avez pas la permission d'accéder à cette page. Un rôle Gestionnaire ou supérieur est requis.",
|
||||
kpiTotalBookings: 'Total des réservations',
|
||||
kpiActiveVehicles: 'Véhicules actifs',
|
||||
kpiMonthlyRevenue: 'Revenus du mois',
|
||||
@@ -1242,6 +1246,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
},
|
||||
dashboardPage: {
|
||||
failedToLoad: 'فشل تحميل لوحة التحكم',
|
||||
forbidden: 'ليس لديك صلاحية الوصول إلى هذه الصفحة. يلزم دور المدير أو أعلى.',
|
||||
kpiTotalBookings: 'إجمالي الحجوزات',
|
||||
kpiActiveVehicles: 'المركبات النشطة',
|
||||
kpiMonthlyRevenue: 'الإيرادات الشهرية',
|
||||
@@ -1368,33 +1373,61 @@ export function DashboardI18nProvider({
|
||||
children: React.ReactNode
|
||||
initialLanguage?: DashboardLanguage
|
||||
}) {
|
||||
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
|
||||
const [theme, setTheme] = useState<DashboardTheme>('light')
|
||||
const [language, setLanguageState] = useState<DashboardLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<DashboardTheme>('light')
|
||||
const themeInitialized = useRef(false)
|
||||
// Skip the very first write so we don't overwrite a stored preference with the
|
||||
// server-resolved initialLanguage before the read effect has a chance to apply it.
|
||||
const skipFirstLangWrite = useRef(true)
|
||||
|
||||
function applyLanguage(nextLanguage: DashboardLanguage) {
|
||||
if (nextLanguage === language) return
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = nextLanguage
|
||||
document.documentElement.dir = nextLanguage === 'ar' ? 'rtl' : 'ltr'
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, nextLanguage, [DASHBOARD_LANGUAGE_KEY])
|
||||
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${nextLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
|
||||
setLanguageState(nextLanguage)
|
||||
}
|
||||
|
||||
function applyTheme(nextTheme: DashboardTheme) {
|
||||
if (nextTheme === theme) return
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(nextTheme === 'light' ? 'light' : 'dark')
|
||||
document.documentElement.style.colorScheme = nextTheme === 'light' ? 'light' : 'dark'
|
||||
document.body.dataset.theme = nextTheme
|
||||
writeScopedPreference(SHARED_THEME_KEY, nextTheme, ['dashboard-theme'])
|
||||
}
|
||||
|
||||
setThemeState(nextTheme)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
|
||||
if (stored !== null) {
|
||||
const next = normalizeLanguage(stored)
|
||||
if (next !== language) setLanguage(next)
|
||||
if (next !== language) setLanguageState(next)
|
||||
} else {
|
||||
const detected = detectBrowserLanguage()
|
||||
if (detected && detected !== language) setLanguage(detected)
|
||||
if (detected && detected !== language) setLanguageState(detected)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
|
||||
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
|
||||
if (storedTheme !== theme) setTheme(storedTheme)
|
||||
if (storedTheme !== theme) setThemeState(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
|
||||
setTheme('dark')
|
||||
setThemeState('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1431,18 +1464,18 @@ export function DashboardI18nProvider({
|
||||
if (scopedLanguage) {
|
||||
// Employee has a stored preference — apply it.
|
||||
const next = normalizeLanguage(scopedLanguage)
|
||||
if (next !== language) setLanguage(next)
|
||||
if (next !== language) setLanguageState(next)
|
||||
} else {
|
||||
// No scoped pref yet (new login). Promote any base preference that was set
|
||||
// before login (e.g., language selected on the sign-up page) to the scoped key.
|
||||
const baseLang = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
|
||||
const langToWrite = baseLang ? normalizeLanguage(baseLang) : language
|
||||
if (langToWrite !== language) setLanguage(langToWrite)
|
||||
if (langToWrite !== language) setLanguageState(langToWrite)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, langToWrite, [DASHBOARD_LANGUAGE_KEY])
|
||||
}
|
||||
|
||||
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
|
||||
if (scopedTheme !== theme) setTheme(scopedTheme)
|
||||
if (scopedTheme !== theme) setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
|
||||
}
|
||||
@@ -1453,7 +1486,7 @@ export function DashboardI18nProvider({
|
||||
}, [language, theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
||||
() => ({ language, setLanguage: applyLanguage, theme, setTheme: applyTheme, dict: dictionaries[language] }),
|
||||
[language, theme],
|
||||
)
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
@@ -1475,6 +1508,14 @@ export function DashboardLanguageSwitcher() {
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
function handleLanguageChange(value: DashboardLanguage) {
|
||||
setLanguage(value)
|
||||
apiFetch('/auth/employee/me/language', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ language: value }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2 py-1 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
@@ -1486,7 +1527,7 @@ export function DashboardLanguageSwitcher() {
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
onClick={() => handleLanguageChange(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-950'
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type DashboardLanguage,
|
||||
useDashboardI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [
|
||||
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
|
||||
{ value: 'ar', label: 'NorthAfrica (Arabic)', flag: '🇲🇦' },
|
||||
{ value: 'fr', label: 'Europ (French)', flag: '🇫🇷' },
|
||||
]
|
||||
|
||||
function getFooterContent(language: DashboardLanguage): {
|
||||
primary: FooterItem[]
|
||||
secondary: FooterItem[]
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
} {
|
||||
switch (language) {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
|
||||
{ label: 'Conditions d’utilisation', 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">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterNavItem({ item }: { item: FooterItem }) {
|
||||
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<Link href={item.href} className={className}>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={className}>{item.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicHeader() {
|
||||
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
|
||||
const currentLanguage = languageMeta[language]
|
||||
const availableLanguages = (['en', 'fr', 'ar'] as const)
|
||||
.filter((value) => value !== language)
|
||||
.map((value) => ({ value, ...languageMeta[value] }))
|
||||
const dict = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Sign in',
|
||||
createAccount: 'Create Agency Space',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
medium: 'Medium',
|
||||
dark: 'Dark',
|
||||
},
|
||||
fr: {
|
||||
home: 'Accueil',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Connexion',
|
||||
createAccount: "Créer Espace d'Agence",
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
medium: 'Moyen',
|
||||
dark: 'Sombre',
|
||||
},
|
||||
ar: {
|
||||
home: 'الرئيسية',
|
||||
marketplace: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createAccount: 'إنشاء مساحة الوكالة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
medium: 'متوسط',
|
||||
dark: 'داكن',
|
||||
},
|
||||
}[language]
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: dict.light },
|
||||
{ value: 'medium' as const, label: dict.medium },
|
||||
{ value: 'dark' as const, label: dict.dark },
|
||||
]
|
||||
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
|
||||
const localeMenuPositionClass =
|
||||
language === 'ar'
|
||||
? 'right-0 sm:right-0'
|
||||
: 'left-0 sm:left-auto sm:right-0'
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
|
||||
function toggleLocaleMenu() {
|
||||
setThemeMenuOpen(false)
|
||||
setLocaleMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
setLocaleMenuOpen(false)
|
||||
setThemeMenuOpen((open) => !open)
|
||||
}
|
||||
|
||||
const signInParams = new URLSearchParams()
|
||||
signInParams.set('lang', language)
|
||||
signInParams.set('theme', theme)
|
||||
const next = searchParams.get('next')
|
||||
const redirect = searchParams.get('redirect')
|
||||
if (next) signInParams.set('next', next)
|
||||
if (redirect) signInParams.set('redirect', redirect)
|
||||
const signInHref = `/sign-in?${signInParams.toString()}`
|
||||
|
||||
const signUpParams = new URLSearchParams()
|
||||
signUpParams.set('lang', language)
|
||||
signUpParams.set('theme', theme)
|
||||
const signUpHref = `/sign-up?${signUpParams.toString()}`
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-stone-800 dark:bg-stone-950 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
|
||||
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
priority
|
||||
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
|
||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
||||
<a href={marketplaceUrl} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.home}
|
||||
</a>
|
||||
<a href={`${marketplaceUrl}/explore`} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.marketplace}
|
||||
</a>
|
||||
<Link href={signInHref} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
<Link href={signUpHref} className="ml-1 rounded-full bg-stone-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
|
||||
{dict.createAccount}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-stone-700 dark:bg-stone-900/85 sm:self-auto sm:p-1">
|
||||
<div ref={localeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocaleMenu}
|
||||
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-stone-800 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
|
||||
{availableLanguages.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.shortLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
|
||||
<div ref={themeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThemeMenu}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-1.5 py-1 transition hover:bg-stone-100 dark:hover:bg-stone-800 sm:gap-2 sm:px-2 sm:py-1.5"
|
||||
aria-expanded={themeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="px-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400 sm:px-2 sm:text-[11px] sm:tracking-[0.2em]">
|
||||
{dict.theme}
|
||||
</span>
|
||||
<span className="rounded-full bg-stone-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-amber-400 dark:text-stone-950 sm:px-3.5 sm:py-1.5 sm:text-xs">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{themeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
|
||||
{themeOptions
|
||||
.filter((option) => option.value !== theme)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(option.value)
|
||||
setThemeMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,276 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown, Globe } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type DashboardLanguage,
|
||||
useDashboardI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const localeOptions: Array<{ value: DashboardLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'Global (English)' },
|
||||
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
|
||||
{ value: 'fr', label: 'Europ (French)' },
|
||||
]
|
||||
|
||||
function getFooterContent(language: DashboardLanguage): {
|
||||
primary: FooterItem[]
|
||||
secondary: FooterItem[]
|
||||
localeLabel: string
|
||||
rightsLabel: string
|
||||
} {
|
||||
switch (language) {
|
||||
case 'fr':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
|
||||
{ label: 'Conditions d’utilisation', href: `${marketplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'Sécurité', href: `${marketplaceUrl}/footer/security` },
|
||||
{ label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` },
|
||||
{ label: 'Politique de confidentialité', href: `${marketplaceUrl}/footer/privacy-policy` },
|
||||
{ label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
|
||||
{ label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'Nos bureaux', href: `${marketplaceUrl}/footer/our-offices` },
|
||||
],
|
||||
localeLabel: 'Europ (French)',
|
||||
rightsLabel: 'Tous droits réservés.',
|
||||
}
|
||||
case 'ar':
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'من نحن', href: `${marketplaceUrl}/footer/about-us` },
|
||||
{ label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'الأمان', href: `${marketplaceUrl}/footer/security` },
|
||||
{ label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` },
|
||||
{ label: 'سياسة الخصوصية', href: `${marketplaceUrl}/footer/privacy-policy` },
|
||||
{ label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` },
|
||||
{ label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'مكاتبنا', href: `${marketplaceUrl}/footer/our-offices` },
|
||||
],
|
||||
localeLabel: 'NorthAfrica (Arabic)',
|
||||
rightsLabel: 'جميع الحقوق محفوظة.',
|
||||
}
|
||||
case 'en':
|
||||
default:
|
||||
return {
|
||||
primary: [
|
||||
{ label: 'About Us', href: `${marketplaceUrl}/footer/about-us` },
|
||||
{ label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` },
|
||||
{ label: 'Security', href: `${marketplaceUrl}/footer/security` },
|
||||
{ label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` },
|
||||
{ label: 'Privacy Policy', href: `${marketplaceUrl}/footer/privacy-policy` },
|
||||
{ label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` },
|
||||
],
|
||||
secondary: [
|
||||
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
|
||||
{ label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` },
|
||||
{ label: 'Our Offices', href: `${marketplaceUrl}/footer/our-offices` },
|
||||
],
|
||||
localeLabel: 'Global (English)',
|
||||
rightsLabel: 'All rights reserved.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const dict = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Sign in',
|
||||
createAccount: 'Create account',
|
||||
preferences: 'Workspace preferences',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
medium: 'Medium',
|
||||
dark: 'Dark',
|
||||
},
|
||||
fr: {
|
||||
home: 'Accueil',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Connexion',
|
||||
createAccount: 'Créer un compte',
|
||||
preferences: 'Preferences espace',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
medium: 'Moyen',
|
||||
dark: 'Sombre',
|
||||
},
|
||||
ar: {
|
||||
home: 'الرئيسية',
|
||||
marketplace: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createAccount: 'إنشاء حساب',
|
||||
preferences: 'تفضيلات المساحة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
medium: 'متوسط',
|
||||
dark: 'داكن',
|
||||
},
|
||||
}[language]
|
||||
const footerContent = getFooterContent(language)
|
||||
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
import PublicFooter from '@/components/layout/PublicFooter'
|
||||
import PublicHeader from '@/components/layout/PublicHeader'
|
||||
|
||||
export default function PublicShell({
|
||||
children,
|
||||
embedded = false,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
embedded?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white/85 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/85">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8 lg:py-2">
|
||||
<a href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-md object-contain"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||
<a href={marketplaceUrl} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.home}
|
||||
</a>
|
||||
<a href={`${marketplaceUrl}/explore`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.marketplace}
|
||||
</a>
|
||||
<a href={`${marketplaceUrl}/sign-in`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
|
||||
{dict.signIn}
|
||||
</a>
|
||||
<Link href="/sign-up" className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300">
|
||||
{dict.createAccount}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'medium', 'dark'] as const).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 text-center px-0 sm:px-2 lg:px-0">
|
||||
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{footerContent.primary.map((item, index) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
{index < footerContent.primary.length - 1 ? (
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
|
||||
{footerContent.secondary.map((item) => (
|
||||
<div key={item.label} className="flex items-center">
|
||||
<FooterNavItem item={item} />
|
||||
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={localeMenuRef} className="relative px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocaleMenuOpen((open) => !open)}
|
||||
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{footerContent.localeLabel}</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
{availableLocaleOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
<div className="flex min-h-screen flex-col bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
|
||||
{embedded ? null : <PublicHeader />}
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
{embedded ? null : <PublicFooter />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterNavItem({ item }: { item: FooterItem }) {
|
||||
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<a href={item.href} className={className}>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={className}>{item.label}</span>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
@@ -131,9 +132,14 @@ export default function Sidebar() {
|
||||
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
|
||||
if (employee.role) setRole(employee.role)
|
||||
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
|
||||
// Only apply the server-side preference when the employee has no local preference
|
||||
// stored yet — avoids overriding a language the user manually switched to.
|
||||
const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
if (!existingPref) {
|
||||
setLanguage(employee.preferredLanguage)
|
||||
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
@@ -190,7 +196,7 @@ export default function Sidebar() {
|
||||
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
|
||||
].join(' ')}
|
||||
>
|
||||
<a href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
|
||||
<a href={marketplaceUrl} target="_top" className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
|
||||
{brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -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 { useState } from 'react'
|
||||
import { marketplacePost } from '@/lib/api'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
@@ -33,133 +30,24 @@ interface Dict {
|
||||
maintenance: string
|
||||
unavailableStatus: string
|
||||
from: string
|
||||
bookNow: string
|
||||
viewVehicle: string
|
||||
availableFrom: string
|
||||
reserveAfterDate: string
|
||||
unavailableNoDate: string
|
||||
details: string
|
||||
vehicleUnavailable: string
|
||||
listings: 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) {
|
||||
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) {
|
||||
if (!value) return null
|
||||
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)
|
||||
|
||||
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) {
|
||||
function getStatusCopy(vehicle: Vehicle, dict: Dict) {
|
||||
if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved
|
||||
if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance
|
||||
if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus
|
||||
@@ -167,8 +55,8 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
return dict.available
|
||||
}
|
||||
|
||||
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-stone-900">{dict.availableVehicles}</h2>
|
||||
@@ -191,7 +79,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<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)}
|
||||
{getStatusCopy(vehicle, dict)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
@@ -204,14 +92,12 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<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"
|
||||
<Link
|
||||
href={`/explore/${vehicle.company.slug}/vehicles/${vehicle.id}`}
|
||||
className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700"
|
||||
>
|
||||
{dict.bookNow}
|
||||
</button>
|
||||
</div>
|
||||
{dict.viewVehicle}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -221,184 +107,5 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
)}
|
||||
</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
|
||||
<img src={selected.photos[0]} alt="" className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
|
||||
)}
|
||||
<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>
|
||||
|
||||
{status === 'success' ? (
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-stone-900">{dict.successTitle}</h3>
|
||||
<p className="text-stone-500">{dict.successBody}</p>
|
||||
<button onClick={closeModal} className="mt-4 rounded-full bg-stone-900 px-8 py-3 text-sm font-semibold text-white">
|
||||
{dict.close}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
|
||||
{selected.availability === false && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
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>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone} <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={form.phone}
|
||||
onChange={set('phone')}
|
||||
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.notes}</label>
|
||||
<textarea
|
||||
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: {
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails de l’entreprise sont temporairement indisponibles.',
|
||||
unavailableBody: 'L’API marketplace fonctionne, mais la base de données n’est pas accessible dans cet environnement local.',
|
||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||
backToExplore: 'Retour à explorer',
|
||||
partner: 'Partenaire marketplace',
|
||||
vehicles: 'véhicules',
|
||||
@@ -52,7 +52,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
rating: 'Note',
|
||||
new: 'Nouveau',
|
||||
currentOffers: 'Offres actuelles',
|
||||
validUntil: 'Valable jusqu’au',
|
||||
validUntil: "Valable jusqu'au",
|
||||
noOffers: 'Aucune offre active pour le moment.',
|
||||
fleet: 'Flotte',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
|
||||
@@ -33,39 +33,76 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
en: {
|
||||
unavailable: ‘Marketplace unavailable’,
|
||||
unavailableTitle: ‘Vehicle details are temporarily unavailable.’,
|
||||
unavailableBody: ‘The marketplace frontend is running, but the backing database is not reachable right now.’,
|
||||
backToFleet: ‘Back to company fleet’,
|
||||
noPhotos: ‘No photos available.’,
|
||||
rentalCompany: ‘Rental company’,
|
||||
perDay: ‘/ day’,
|
||||
availableFrom: ‘Available from’,
|
||||
unavailableNoDate: ‘This vehicle is temporarily unavailable for new reservations.’,
|
||||
unavailable: 'Marketplace unavailable',
|
||||
unavailableTitle: 'Vehicle details are temporarily unavailable.',
|
||||
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
|
||||
backToFleet: 'Back to fleet',
|
||||
backToExplore: 'Back to explore',
|
||||
noPhotos: 'No photos available.',
|
||||
rentalCompany: 'Rental company',
|
||||
perDay: '/ day',
|
||||
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: {
|
||||
unavailable: ‘Marketplace indisponible’,
|
||||
unavailableTitle: ‘Les détails du véhicule sont temporairement indisponibles.’,
|
||||
unavailableBody: ‘Le frontend marketplace fonctionne, mais la base de données n’est pas accessible pour le moment.’,
|
||||
backToFleet: ‘Retour à la flotte’,
|
||||
noPhotos: ‘Aucune photo disponible.’,
|
||||
rentalCompany: ‘Société de location’,
|
||||
perDay: ‘/ jour’,
|
||||
availableFrom: ‘Disponible à partir du’,
|
||||
unavailableNoDate: ‘Ce véhicule est temporairement indisponible pour les nouvelles réservations.’,
|
||||
unavailable: 'Marketplace indisponible',
|
||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||
backToFleet: 'Retour à la flotte',
|
||||
backToExplore: 'Retour à explorer',
|
||||
noPhotos: 'Aucune photo disponible.',
|
||||
rentalCompany: 'Société de location',
|
||||
perDay: '/ jour',
|
||||
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: {
|
||||
unavailable: ‘السوق غير متاح’,
|
||||
unavailableTitle: ‘تفاصيل السيارة غير متاحة مؤقتاً.’,
|
||||
unavailableBody: ‘واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.’,
|
||||
backToFleet: ‘العودة إلى أسطول الشركة’,
|
||||
noPhotos: ‘لا توجد صور متاحة.’,
|
||||
rentalCompany: ‘شركة تأجير’,
|
||||
perDay: ‘/ يوم’,
|
||||
availableFrom: ‘متاح ابتداءً من’,
|
||||
unavailableNoDate: ‘هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.’,
|
||||
unavailable: 'السوق غير متاح',
|
||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
|
||||
backToFleet: 'العودة إلى الأسطول',
|
||||
backToExplore: 'العودة إلى الاستكشاف',
|
||||
noPhotos: 'لا توجد صور متاحة.',
|
||||
rentalCompany: 'شركة تأجير',
|
||||
perDay: '/ يوم',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
specs: 'المواصفات',
|
||||
year: 'السنة',
|
||||
category: 'الفئة',
|
||||
seats: 'المقاعد',
|
||||
transmission: 'ناقل الحركة',
|
||||
fuel: 'نوع الوقود',
|
||||
features: 'المميزات والإضافات',
|
||||
contact: 'تواصل مع الشركة',
|
||||
phone: 'الهاتف',
|
||||
whatsapp: 'واتساب',
|
||||
photos: 'الصور',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
||||
|
||||
if (!vehicle) {
|
||||
@@ -74,10 +111,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
<div className="shell">
|
||||
<section className="card p-8">
|
||||
<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>
|
||||
<p className="mt-3 text-stone-600">{dict.unavailableBody}</p>
|
||||
<div className="mt-6">
|
||||
<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 dark:text-stone-400">{dict.unavailableBody}</p>
|
||||
<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" 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>
|
||||
</section>
|
||||
</div>
|
||||
@@ -89,49 +127,151 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null
|
||||
|
||||
const [heroPhoto, ...galleryPhotos] = vehicle.photos
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
<div className="shell grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="card overflow-hidden">
|
||||
<main className="min-h-screen bg-stone-50 dark:bg-stone-950 py-10">
|
||||
<div className="shell space-y-8">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
<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 */}
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{galleryPhotos.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">{dict.photos}</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{galleryPhotos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="overflow-hidden rounded-2xl bg-stone-100 dark:bg-stone-800 aspect-[4/3]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo} alt={`${vehicle.make} ${vehicle.model} ${index + 2}`} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="card p-6 h-fit space-y-6">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specs */}
|
||||
<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>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Right — sticky booking panel */}
|
||||
<aside className="space-y-4">
|
||||
<div className="card p-6 space-y-4 lg:sticky lg:top-20">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-stone-500 dark:text-stone-400">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{!vehicle.availability && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<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>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicle.availability ? (
|
||||
{vehicle.availability && (
|
||||
<BookingForm
|
||||
vehicleId={vehicle.id}
|
||||
companySlug={params.slug}
|
||||
dailyRate={vehicle.dailyRate}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { marketplaceFetchOrDefault } from '@/lib/api'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import ExploreSearchForm from './ExploreSearchForm'
|
||||
import ExploreVehicleGrid from './ExploreVehicleGrid'
|
||||
|
||||
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> }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
const dict = {
|
||||
@@ -58,11 +72,16 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
kicker: 'Discovery only',
|
||||
title: 'Find your next rental from trusted local companies.',
|
||||
body: 'Browse, filter, and reserve — the company confirms and contacts you directly.',
|
||||
city: 'City or location',
|
||||
allCategories: 'All categories',
|
||||
anyTransmission: 'Any transmission',
|
||||
makePlaceholder: 'Make (e.g. Toyota)',
|
||||
modelPlaceholder: 'Model (e.g. Corolla)',
|
||||
searchTitle: 'Book a vehicle',
|
||||
pickupLocation: 'Pick-up location',
|
||||
dropoffLocation: 'Drop-off location',
|
||||
pickupDate: 'Start date',
|
||||
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',
|
||||
currentDeals: 'Current deals',
|
||||
featuredOffers: 'Featured marketplace offers',
|
||||
@@ -78,11 +97,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'In maintenance',
|
||||
unavailableStatus: 'Unavailable',
|
||||
from: 'From',
|
||||
bookNow: 'Reserve',
|
||||
viewVehicle: 'View vehicle',
|
||||
availableFrom: 'Available from',
|
||||
reserveAfterDate: 'This vehicle can only be reserved starting',
|
||||
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
details: 'Details',
|
||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||
browseByCategory: 'Browse by category',
|
||||
browseByCompany: 'Browse by company',
|
||||
@@ -92,36 +109,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'New',
|
||||
publishedVehicles: 'published vehicles',
|
||||
viewFleet: 'View fleet',
|
||||
categories: ['Economy', 'Compact', 'SUV', 'Luxury', 'Van', 'Truck', 'Electric'],
|
||||
// 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.',
|
||||
categories: ['Economy', 'Compact', 'Midsize', 'Full-size', 'SUV', 'Luxury', 'Van', 'Truck'],
|
||||
},
|
||||
fr: {
|
||||
kicker: 'Découverte uniquement',
|
||||
title: "Trouvez votre prochaine location auprès d'entreprises locales fiables.",
|
||||
body: "Parcourez, filtrez et réservez — l'entreprise confirme et vous contacte directement.",
|
||||
city: 'Ville ou emplacement',
|
||||
allCategories: 'Toutes les catégories',
|
||||
anyTransmission: 'Toute transmission',
|
||||
makePlaceholder: 'Marque (ex. Toyota)',
|
||||
modelPlaceholder: 'Modèle (ex. Corolla)',
|
||||
searchTitle: 'Réserver un véhicule',
|
||||
pickupLocation: 'Lieu de départ',
|
||||
dropoffLocation: "Lieu de retour",
|
||||
pickupDate: 'Date de départ',
|
||||
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',
|
||||
currentDeals: 'Offres du moment',
|
||||
featuredOffers: 'Offres marketplace mises en avant',
|
||||
@@ -137,11 +140,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'En maintenance',
|
||||
unavailableStatus: 'Indisponible',
|
||||
from: 'À partir de',
|
||||
bookNow: 'Réserver',
|
||||
viewVehicle: 'Voir le véhicule',
|
||||
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.',
|
||||
details: 'Détails',
|
||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||
browseByCategory: 'Parcourir par catégorie',
|
||||
browseByCompany: 'Parcourir par entreprise',
|
||||
@@ -151,35 +152,22 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'Nouveau',
|
||||
publishedVehicles: 'véhicules publiés',
|
||||
viewFleet: 'Voir la flotte',
|
||||
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
|
||||
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.',
|
||||
categories: ['Économie', 'Compacte', 'Intermédiaire', 'Grande berline', 'SUV', 'Luxe', 'Van', 'Camion'],
|
||||
},
|
||||
ar: {
|
||||
kicker: 'للاستكشاف فقط',
|
||||
title: 'اعثر على سيارتك القادمة من شركات محلية موثوقة.',
|
||||
body: 'تصفّح وابحث واحجز — تؤكد الشركة وتتواصل معك مباشرةً.',
|
||||
city: 'المدينة أو الموقع',
|
||||
allCategories: 'كل الفئات',
|
||||
anyTransmission: 'أي ناقل حركة',
|
||||
makePlaceholder: 'الماركة (مثل Toyota)',
|
||||
modelPlaceholder: 'الموديل (مثل Corolla)',
|
||||
searchTitle: 'احجز سيارة',
|
||||
pickupLocation: 'موقع الاستلام',
|
||||
dropoffLocation: 'موقع الإرجاع',
|
||||
pickupDate: 'تاريخ البدء',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
hour: 'الساعة',
|
||||
promoCode: 'لدي رمز ترويجي',
|
||||
myAge: 'عمري',
|
||||
ageDescription: 'نستخدم عمرك لتقديم تقدير أدق لسعر الإيجار. قد يتم تطبيق رسوم إضافية لبعض الفئات العمرية.',
|
||||
ageOptions: ['18+', '21+', '23+', '25+', '30+'],
|
||||
search: 'بحث',
|
||||
currentDeals: 'العروض الحالية',
|
||||
featuredOffers: 'عروض السوق المميزة',
|
||||
@@ -195,11 +183,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
maintenance: 'قيد الصيانة',
|
||||
unavailableStatus: 'غير متاح',
|
||||
from: 'ابتداءً من',
|
||||
bookNow: 'احجز',
|
||||
viewVehicle: 'عرض السيارة',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
|
||||
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
details: 'التفاصيل',
|
||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||
browseByCategory: 'تصفح حسب الفئة',
|
||||
browseByCompany: 'تصفح حسب الشركة',
|
||||
@@ -209,46 +195,41 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
new: 'جديد',
|
||||
publishedVehicles: 'سيارات منشورة',
|
||||
viewFleet: 'عرض الأسطول',
|
||||
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
||||
reserveVehicle: 'احجز هذه السيارة',
|
||||
pickupDate: 'تاريخ الاستلام',
|
||||
pickupTime: 'وقت الاستلام',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
dropoffTime: 'وقت الإرجاع',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'رقم الهاتف',
|
||||
notes: 'ملاحظات إضافية (اختياري)',
|
||||
cancel: 'إلغاء',
|
||||
submitRequest: 'إرسال طلب الحجز',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
successTitle: 'تم إرسال الطلب!',
|
||||
successBody: 'تم استلام طلب الحجز. تحقق من بريدك الإلكتروني — ستتواصل معك الشركة قريباً.',
|
||||
close: 'إغلاق',
|
||||
errorUnavailable: 'السيارة غير متاحة في التواريخ المحددة. يرجى اختيار تواريخ أخرى.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
categories: ['اقتصادية', 'مدمجة', 'متوسطة', 'كبيرة', 'SUV', 'فاخرة', 'فان', 'شاحنة'],
|
||||
},
|
||||
}[language]
|
||||
|
||||
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 transmission = typeof searchParams?.transmission === 'string' ? searchParams.transmission : ''
|
||||
const make = typeof searchParams?.make === 'string' ? searchParams.make : ''
|
||||
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()
|
||||
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 (transmission) query.set('transmission', transmission)
|
||||
if (make) query.set('make', make)
|
||||
if (model) query.set('model', model)
|
||||
query.set('pageSize', '24')
|
||||
|
||||
const [offers, vehicles, companies] = await Promise.all([
|
||||
const [offers, vehicles, companies, cities] = await Promise.all([
|
||||
marketplaceFetchOrDefault<Offer[]>('/marketplace/offers', []),
|
||||
marketplaceFetchOrDefault<Vehicle[]>(`/marketplace/search?${query.toString()}`, []),
|
||||
marketplaceFetchOrDefault<CompanyCard[]>('/marketplace/companies?pageSize=8', []),
|
||||
marketplaceFetchOrDefault<MarketplaceCity[]>('/marketplace/cities', []),
|
||||
])
|
||||
|
||||
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>
|
||||
<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>
|
||||
<form className="mt-8 grid gap-3 rounded-[1.5rem] bg-white/10 p-4 md:grid-cols-3 xl:grid-cols-6">
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<select name="category" defaultValue={category} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
||||
<option value="">{dict.allCategories}</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="transmission" defaultValue={transmission} className="rounded-2xl border border-white/10 bg-white/90 px-4 py-3 text-sm text-stone-900">
|
||||
<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>
|
||||
<ExploreSearchForm
|
||||
pickupLocation={pickupLocation}
|
||||
dropoffLocation={dropoffLocation}
|
||||
pickupDate={pickupDate}
|
||||
pickupTime={pickupTime}
|
||||
returnDate={returnDate}
|
||||
returnTime={returnTime}
|
||||
promoCode={promoCode}
|
||||
driverAge={driverAge}
|
||||
cities={cities}
|
||||
dict={dict}
|
||||
/>
|
||||
</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>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{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}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import MarketplaceShell from '@/components/MarketplaceShell'
|
||||
import { getMarketplaceLanguage } from '@/lib/i18n.server'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,10 +14,11 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const language = getMarketplaceLanguage()
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<MarketplaceShell>{children}</MarketplaceShell>
|
||||
<MarketplaceShell initialLanguage={language}>{children}</MarketplaceShell>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ const copy = {
|
||||
submit: 'Demander une réservation',
|
||||
submitting: 'Envoi…',
|
||||
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.',
|
||||
required: 'Veuillez remplir tous les champs obligatoires.',
|
||||
perDay: '/ jour',
|
||||
@@ -72,6 +72,7 @@ const copy = {
|
||||
|
||||
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
|
||||
const { language } = useMarketplacePreferences()
|
||||
|
||||
const t = copy[language]
|
||||
|
||||
const [firstName, setFirstName] = useState('')
|
||||
@@ -118,6 +119,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
|
||||
startDate: start.toISOString(),
|
||||
endDate: end.toISOString(),
|
||||
notes: notes.trim() || undefined,
|
||||
language,
|
||||
})
|
||||
setSuccess(true)
|
||||
} 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">
|
||||
© {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'
|
||||
|
||||
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 { usePathname, useRouter } from 'next/navigation'
|
||||
import MarketplaceFooter from '@/components/MarketplaceFooter'
|
||||
import MarketplaceHeader from '@/components/MarketplaceHeader'
|
||||
import { footerPageHref } from '@/lib/footerContent'
|
||||
import { MARKETPLACE_LANGUAGE_COOKIE, SHARED_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
@@ -30,7 +29,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
marketplace: 'Home',
|
||||
explore: 'Marketplace',
|
||||
signIn: 'Sign in',
|
||||
ownerSignIn: 'Create account',
|
||||
ownerSignIn: 'Create Agency Space',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
@@ -42,7 +41,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
marketplace: 'Accueil',
|
||||
explore: 'Marketplace',
|
||||
signIn: 'Connexion',
|
||||
ownerSignIn: 'Créer un compte',
|
||||
ownerSignIn: "Créer Espace d'Agence",
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
@@ -54,7 +53,7 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
|
||||
marketplace: 'الرئيسية',
|
||||
explore: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
ownerSignIn: 'إنشاء حساب',
|
||||
ownerSignIn: 'إنشاء مساحة الوكالة',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
@@ -74,20 +73,15 @@ type PreferencesContextValue = {
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
|
||||
|
||||
type FooterItem = {
|
||||
label: string
|
||||
href?: string
|
||||
}
|
||||
|
||||
const localeOptions: Array<{ value: MarketplaceLanguage; label: string }> = [
|
||||
{ value: 'en', label: 'Global (English)' },
|
||||
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
|
||||
{ value: 'fr', label: 'Europ (French)' },
|
||||
const localeOptions: Array<{ value: MarketplaceLanguage; 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: MarketplaceLanguage): {
|
||||
primary: FooterItem[]
|
||||
secondary: FooterItem[]
|
||||
primary: Array<{ label: string; href?: string }>
|
||||
secondary: Array<{ label: string; href?: string }>
|
||||
localeLabel: 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 {
|
||||
if (typeof navigator === 'undefined') return null
|
||||
const langs = Array.from(navigator.languages ?? [navigator.language])
|
||||
@@ -205,19 +160,50 @@ export function useMarketplacePreferences() {
|
||||
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 router = useRouter()
|
||||
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 [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
const [language, setLanguageState] = useState<MarketplaceLanguage>(initialLanguage)
|
||||
const [theme, setThemeState] = useState<Theme>('light')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>('en')
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>(initialLanguage)
|
||||
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() {
|
||||
const token = document.cookie
|
||||
@@ -249,36 +235,63 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
}
|
||||
}
|
||||
|
||||
function syncScopedPreferencesForSession() {
|
||||
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
|
||||
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
|
||||
|
||||
if (isMarketplaceLanguage(scopedLanguage) && scopedLanguage !== language) {
|
||||
setLanguage(scopedLanguage)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, language, ['marketplace-language'])
|
||||
function readSessionLanguage(): MarketplaceLanguage | null {
|
||||
try {
|
||||
const val = sessionStorage.getItem('marketplace-language')
|
||||
return isMarketplaceLanguage(val) ? val : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function syncScopedPreferencesForSession() {
|
||||
// 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 {
|
||||
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) {
|
||||
setTheme(scopedTheme)
|
||||
setThemeState(scopedTheme)
|
||||
} else {
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// sessionStorage is tab-local — check it first so a selection made earlier in
|
||||
// this tab is honoured even after a server refresh.
|
||||
const sessionLang = readSessionLanguage()
|
||||
if (sessionLang) {
|
||||
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 {
|
||||
const storedLanguage = readScopedPreference(SHARED_LANGUAGE_KEY, ['marketplace-language'])
|
||||
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['marketplace-theme']) as Theme | null
|
||||
|
||||
if (isMarketplaceLanguage(storedLanguage)) {
|
||||
setLanguage(storedLanguage)
|
||||
setLanguageState(storedLanguage)
|
||||
writeScopedPreference(SHARED_LANGUAGE_KEY, storedLanguage, ['marketplace-language'])
|
||||
} else {
|
||||
const detected = detectBrowserLanguage()
|
||||
if (isMarketplaceLanguage(detected)) setLanguage(detected)
|
||||
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') {
|
||||
setTheme(storedTheme)
|
||||
setThemeState(storedTheme)
|
||||
}
|
||||
|
||||
setHydrated(true)
|
||||
@@ -316,195 +329,77 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
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()
|
||||
}
|
||||
previousLanguage.current = language
|
||||
}, [hydrated, language, router, theme])
|
||||
}, [hydrated, language, pathname, router])
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark' || theme === 'medium')
|
||||
document.body.dataset.theme = theme
|
||||
writeScopedPreference(SHARED_THEME_KEY, theme, ['marketplace-theme'])
|
||||
}, [theme])
|
||||
|
||||
const dict = dictionaries[language]
|
||||
const footerContent = getFooterContent(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 isEmbeddedWorkspace = isRenterApp
|
||||
const hideHeader = isRenterApp
|
||||
const isAuthFrameRoute = pathname === '/sign-in'
|
||||
const value = useMemo(
|
||||
() => ({ language, theme, dict, setLanguage, setTheme }),
|
||||
() => ({ language, theme, dict, setLanguage: applyLanguage, setTheme: applyTheme }),
|
||||
[dict, language, theme],
|
||||
)
|
||||
|
||||
return (
|
||||
<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">
|
||||
{!hideHeader ? (
|
||||
<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">
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link href="/" className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
|
||||
<Image
|
||||
src="/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-md object-contain"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<nav className="flex items-center gap-1 overflow-x-auto">
|
||||
<Link
|
||||
href="/"
|
||||
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}
|
||||
</Link>
|
||||
<Link
|
||||
href="/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.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)
|
||||
<div className="flex min-h-screen flex-col bg-stone-50 text-stone-900 transition-colors dark:bg-stone-950 dark:text-stone-100">
|
||||
{isRenterApp || isAuthFrameRoute ? null : (
|
||||
<MarketplaceHeader
|
||||
dict={dict}
|
||||
theme={theme}
|
||||
setTheme={applyTheme}
|
||||
companyName={companyName}
|
||||
dashboardUrl={dashboardUrl}
|
||||
currentLanguage={{
|
||||
value: currentLocale.value,
|
||||
flag: currentLocale.flag,
|
||||
shortLabel: currentLocale.value.toUpperCase(),
|
||||
}}
|
||||
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>
|
||||
localeOptions={availableLocaleOptions.map((option) => ({
|
||||
value: option.value,
|
||||
flag: option.flag,
|
||||
shortLabel: option.value.toUpperCase(),
|
||||
}))}
|
||||
onSelectLanguage={applyLanguage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
{isRenterApp || isAuthFrameRoute ? null : (
|
||||
<MarketplaceFooter
|
||||
primaryItems={footerContent.primary}
|
||||
secondaryItems={footerContent.secondary}
|
||||
localeLabel={footerContent.localeLabel}
|
||||
rightsLabel={footerContent.rightsLabel}
|
||||
localeOptions={availableLocaleOptions}
|
||||
currentLocale={currentLocale}
|
||||
onSelectLanguage={applyLanguage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { useMarketplacePreferences } from './MarketplaceShell'
|
||||
|
||||
@@ -44,11 +45,13 @@ function buildFrameUrl(appUrl: string, path: string) {
|
||||
}
|
||||
|
||||
export default function WorkspaceFrame({ target }: { target: FrameId }) {
|
||||
const pathname = usePathname()
|
||||
const { language, theme } = useMarketplacePreferences()
|
||||
const [src, setSrc] = useState('')
|
||||
const [framePath, setFramePath] = useState(() => getDefaultFramePath(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 = {
|
||||
en: 'Loading',
|
||||
fr: 'Chargement de',
|
||||
@@ -59,6 +62,13 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
|
||||
setFramePath(getDefaultFramePath(target))
|
||||
}, [target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStandaloneAuthRoute || typeof window === 'undefined') return
|
||||
if (window.location.pathname !== '/sign-in') return
|
||||
|
||||
window.history.replaceState(window.history.state, '', '/')
|
||||
}, [isStandaloneAuthRoute])
|
||||
|
||||
useEffect(() => {
|
||||
function handleFrameMessage(event: MessageEvent) {
|
||||
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))
|
||||
nextUrl.searchParams.set('theme', theme)
|
||||
nextUrl.searchParams.set('lang', language)
|
||||
if (isStandaloneAuthRoute) {
|
||||
nextUrl.searchParams.delete('embedded')
|
||||
} else {
|
||||
nextUrl.searchParams.set('embedded', '1')
|
||||
}
|
||||
setSrc(nextUrl.toString())
|
||||
}, [config.appUrl, framePath, language, theme])
|
||||
}, [config.appUrl, framePath, isStandaloneAuthRoute, language, theme])
|
||||
|
||||
return (
|
||||
<main className="bg-stone-950" style={{ minHeight: frameHeight }}>
|
||||
|
||||
@@ -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)$).*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user