From fb15883f8eb9105016940e7e1694a3b76b2244d4 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 14:52:26 -0400 Subject: [PATCH] update website home and dashboard --- apps/admin/src/components/I18nProvider.tsx | 21 +- apps/admin/src/components/PublicFooter.tsx | 47 ++ apps/admin/src/components/PublicHeader.tsx | 49 +++ apps/admin/src/components/PublicShell.tsx | 57 +-- apps/api/src/index.ts | 1 + apps/api/src/lib/emailTranslations.ts | 290 ++++++++++++ apps/api/src/routes/analytics.ts | 4 +- apps/api/src/routes/auth.company.ts | 34 +- apps/api/src/routes/auth.employee.ts | 40 +- apps/api/src/routes/marketplace.ts | 71 ++- apps/api/src/routes/reservations.ts | 31 +- .../app/(dashboard)/dashboard/fleet/page.tsx | 15 +- .../src/app/(dashboard)/dashboard/page.tsx | 47 +- .../dashboard/subscription/page.tsx | 42 +- .../ForgotPasswordPageClient.tsx | 143 ++++++ .../src/app/forgot-password/page.tsx | 134 +----- .../ResetPasswordPageClient.tsx | 214 +++++++++ .../dashboard/src/app/reset-password/page.tsx | 207 +-------- .../[[...sign-in]]/SignInPageClient.tsx | 416 ++++++++++++++++++ .../src/app/sign-in/[[...sign-in]]/page.tsx | 400 +---------------- .../src/app/sign-up/[[...sign-up]]/page.tsx | 34 +- .../dashboard/src/components/I18nProvider.tsx | 63 ++- .../src/components/layout/PublicFooter.tsx | 181 ++++++++ .../src/components/layout/PublicHeader.tsx | 217 +++++++++ .../src/components/layout/PublicShell.tsx | 282 +----------- .../src/components/layout/Sidebar.tsx | 12 +- .../src/app/explore/ExploreSearchForm.tsx | 251 +++++++++++ .../src/app/explore/ExploreVehicleGrid.tsx | 399 +++-------------- .../src/app/explore/[slug]/page.tsx | 6 +- .../app/explore/[slug]/vehicles/[id]/page.tsx | 266 ++++++++--- apps/marketplace/src/app/explore/page.tsx | 179 ++++---- apps/marketplace/src/app/layout.tsx | 6 +- .../src/components/BookingForm.tsx | 4 +- .../src/components/MarketplaceFooter.tsx | 123 ++++++ .../src/components/MarketplaceHeader.tsx | 216 +++++++++ .../src/components/MarketplaceShell.tsx | 389 ++++++---------- .../src/components/WorkspaceFrame.tsx | 19 +- apps/marketplace/src/middleware.ts | 55 +++ 38 files changed, 3043 insertions(+), 1922 deletions(-) create mode 100644 apps/admin/src/components/PublicFooter.tsx create mode 100644 apps/admin/src/components/PublicHeader.tsx create mode 100644 apps/api/src/lib/emailTranslations.ts create mode 100644 apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx create mode 100644 apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx create mode 100644 apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx create mode 100644 apps/dashboard/src/components/layout/PublicFooter.tsx create mode 100644 apps/dashboard/src/components/layout/PublicHeader.tsx create mode 100644 apps/marketplace/src/app/explore/ExploreSearchForm.tsx create mode 100644 apps/marketplace/src/components/MarketplaceFooter.tsx create mode 100644 apps/marketplace/src/components/MarketplaceHeader.tsx create mode 100644 apps/marketplace/src/middleware.ts diff --git a/apps/admin/src/components/I18nProvider.tsx b/apps/admin/src/components/I18nProvider.tsx index fb215e4..5aaca08 100644 --- a/apps/admin/src/components/I18nProvider.tsx +++ b/apps/admin/src/components/I18nProvider.tsx @@ -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 = { }, } +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(null) export function AdminI18nProvider({ children }: { children: React.ReactNode }) { const [language, setLanguage] = useState('en') const [theme, setTheme] = useState('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() { {dict.language} {(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => { const active = value === language + const meta = languageMeta[value] return ( ) })} diff --git a/apps/admin/src/components/PublicFooter.tsx b/apps/admin/src/components/PublicFooter.tsx new file mode 100644 index 0000000..fc5af98 --- /dev/null +++ b/apps/admin/src/components/PublicFooter.tsx @@ -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 ( +
+
+
+

{dict.preferences}

+ + + {currentLanguage.shortLabel} + +
+
+ + +
+
+
+ ) +} diff --git a/apps/admin/src/components/PublicHeader.tsx b/apps/admin/src/components/PublicHeader.tsx new file mode 100644 index 0000000..6004564 --- /dev/null +++ b/apps/admin/src/components/PublicHeader.tsx @@ -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 ( +
+
+ + RentalDriveGo + {dict.admin} + + +
+
+ ) +} diff --git a/apps/admin/src/components/PublicShell.tsx b/apps/admin/src/components/PublicShell.tsx index 879453e..50fa1a4 100644 --- a/apps/admin/src/components/PublicShell.tsx +++ b/apps/admin/src/components/PublicShell.tsx @@ -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 ( -
-
-
- - RentalDriveGo - {dict.admin} - - -
-
-
{children}
-
-
-

- {dict.preferences} -

-
- - -
-
-
+
+ +
{children}
+
) } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1820d68..bca5b3c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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' }, diff --git a/apps/api/src/lib/emailTranslations.ts b/apps/api/src/lib/emailTranslations.ts new file mode 100644 index 0000000..f683baa --- /dev/null +++ b/apps/api/src/lib/emailTranslations.ts @@ -0,0 +1,290 @@ +export type Lang = 'en' | 'fr' | 'ar' + +function t(map: Record, lang: Lang): T { + return map[lang] ?? map['fr'] +} + +const dateLocale: Record = { 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: `
+

Hi ${firstName},

+

You requested a password reset for your RentalDriveGo workspace account.

+

Reset your password

+

This link expires in ${expireMinutes} minutes. If you did not request this, you can safely ignore this email.

+

RentalDriveGo

+
`, + fr: `
+

Bonjour ${firstName},

+

Vous avez demandé la réinitialisation du mot de passe de votre compte RentalDriveGo.

+

Réinitialiser mon mot de passe

+

Ce lien expire dans ${expireMinutes} minutes. Si vous n'avez pas fait cette demande, ignorez simplement cet e-mail.

+

RentalDriveGo

+
`, + ar: `
+

مرحباً ${firstName}،

+

طلبت إعادة تعيين كلمة مرور حساب RentalDriveGo الخاص بك.

+

إعادة تعيين كلمة المرور

+

ينتهي صلاحية هذا الرابط خلال ${expireMinutes} دقيقة. إذا لم تطلب ذلك، يمكنك تجاهل هذا البريد الإلكتروني بأمان.

+

RentalDriveGo

+
`, + }, 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 ` +
+

${l.greeting}

+

${l.intro}

+
+

${opts.vehicleYear} ${opts.vehicleMake} ${opts.vehicleModel}

+

${l.company}: ${opts.companyName}

+

${l.pickup}: ${startStr}

+

${l.returnD}: ${endStr}

+

${l.duration}: ${opts.totalDays} ${l.days}

+

${l.daily}: ${opts.rateDisplay} MAD

+

${l.total}: ${opts.totalDisplay} MAD

+
+

${l.footer}

+
+ ` + }, + + 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 ${opts.companyName}. We hope you enjoyed your ${opts.vehicleLabel}.`, + 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 ${opts.companyName}. Nous espérons que vous avez apprécié votre ${opts.vehicleLabel}.`, + 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: `شكراً لاستئجارك مع ${opts.companyName}. نأمل أنك استمتعت بـ ${opts.vehicleLabel}.`, + body2: 'تساعد ملاحظاتك الشركة على التحسين وتساعد العملاء الآخرين على اتخاذ قرارات مستنيرة. لا يستغرق الأمر سوى 30 ثانية.', + cta: 'اترك تقييماً', + disclaimer: 'هذا الرابط فريد لحجزك ولا يمكن استخدامه إلا مرة واحدة. إذا لم تستأجر مركبة مؤخراً، يمكنك تجاهل هذا البريد الإلكتروني.', + }, + }, lang) + + return ` +
+

${l.greeting}

+

${l.body1}

+

${l.body2}

+ +

${l.disclaimer}

+
+ ` + }, + + 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), +} diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 0821aa7..6705dc1 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -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 const accountingSettings = await prisma.accountingSettings.findUnique({ where: { companyId: req.companyId } }) diff --git a/apps/api/src/routes/auth.company.ts b/apps/api/src/routes/auth.company.ts index a41365c..0c810a7 100644 --- a/apps/api/src/routes/auth.company.ts +++ b/apps/api/src/routes/auth.company.ts @@ -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[0]): Promise { @@ -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({ diff --git a/apps/api/src/routes/auth.employee.ts b/apps/api/src/routes/auth.employee.ts index 35497d2..9e10535 100644 --- a/apps/api/src/routes/auth.employee.ts +++ b/apps/api/src/routes/auth.employee.ts @@ -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 ` -

Hi ${firstName},

-

You requested a password reset for your RentalDriveGo workspace account.

-

Reset your password

-

This link expires in ${RESET_TOKEN_TTL_MINUTES} minutes. If you did not request this, you can safely ignore this email.

-

RentalDriveGo

- ` -} 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({ diff --git a/apps/api/src/routes/marketplace.ts b/apps/api/src/routes/marketplace.ts index cb967a0..0b88c74 100644 --- a/apps/api/src/routes/marketplace.ts +++ b/apps/api/src/routes/marketplace.ts @@ -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()), + ) + .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 @@ -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: ` -
-

Hi ${body.firstName},

-

Your reservation request has been received and is pending company confirmation.

-
-

${vehicle.year} ${vehicle.make} ${vehicle.model}

-

Company: ${companyName}

-

Pick-up date: ${startStr}

-

Return date: ${endStr}

-

Duration: ${totalDays} day${totalDays > 1 ? 's' : ''}

-

Daily rate: ${rateDisplay} MAD

-

Estimated total: ${totalDisplay} MAD

-
-

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}` : ''}.

-
- `, - 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 diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts index d08ef70..52ebcfb 100644 --- a/apps/api/src/routes/reservations.ts +++ b/apps/api/src/routes/reservations.ts @@ -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: ` -
-

Hi ${customer.firstName},

-

Thank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.

-

Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.

- -

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.

-
- `, - 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 }) diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx index 17c0a00..f77314e 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/page.tsx @@ -648,18 +648,9 @@ export default function FleetPage() { {filtered.map((vehicle) => ( -
-
- {vehicle.primaryPhoto ? ( - {`${vehicle.make} - ) : ( -
{f.noPhoto}
- )} -
-
-

{vehicle.make} {vehicle.model}

-

{vehicle.year} · {vehicle.licensePlate}

-
+
+

{vehicle.make} {vehicle.model}

+

{vehicle.year} · {vehicle.licensePlate}

diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx index 2c4eb22..8de7be0 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/page.tsx @@ -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 = { CONFIRMED: 'badge-blue', PENDING: 'badge-amber', @@ -108,12 +112,22 @@ export default function DashboardPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [error, setError] = useState<{ code: string; message: string } | null>(null) + const [employeeRole, setEmployeeRole] = useState(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('/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 (
-

{d.failedToLoad}

-

{error}

+

{errorMsg}

) } 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 */} -
+
- + {canViewRevenue ? ( + + ) : null} = { TRIALING: 'bg-sky-100 text-sky-700', ACTIVE: 'bg-green-100 text-green-700', @@ -52,11 +57,13 @@ const INVOICE_STATUS: Record = { const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] export default function SubscriptionPage() { + const router = useRouter() const { language } = useDashboardI18n() const [subscription, setSubscription] = useState(null) const [invoices, setInvoices] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [canViewPage, setCanViewPage] = useState(null) const [selectedPlan, setSelectedPlan] = useState('STARTER') const [billingPeriod, setBillingPeriod] = useState('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('/subscriptions/me'), apiFetch('/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) diff --git a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx new file mode 100644 index 0000000..671b910 --- /dev/null +++ b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx @@ -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(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 ( + +
+
+
+
+ + RentalDriveGo + +
+

{dict.title}

+

{dict.subtitle}

+
+ +
+ {sent ? ( +
+
+ + + +
+

{dict.successTitle}

+

{dict.successBody}

+
+ ) : ( +
+ {error && ( +
{error}
+ )} +
+ + setEmail(e.target.value)} + placeholder="owner@company.com" + className="input-field" + /> +
+ +
+ )} + +
+ + {dict.backToLogin} + +
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/forgot-password/page.tsx b/apps/dashboard/src/app/forgot-password/page.tsx index c887e24..31b1d5f 100644 --- a/apps/dashboard/src/app/forgot-password/page.tsx +++ b/apps/dashboard/src/app/forgot-password/page.tsx @@ -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> +}) { + 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(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 ( -
-
-
- - RentalDriveGo - -

{dict.title}

-

{dict.subtitle}

-
- -
- {sent ? ( -
-
- - - -
-

{dict.successTitle}

-

{dict.successBody}

-
- ) : ( -
- {error && ( -
{error}
- )} -
- - setEmail(e.target.value)} - placeholder="owner@company.com" - className="input-field" - /> -
- -
- )} - -
- - {dict.backToLogin} - -
-
-
-
- ) + return } diff --git a/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx new file mode 100644 index 0000000..1696ce7 --- /dev/null +++ b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx @@ -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 ( + + + + ) +} + +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(null) + + async function handleSubmit(e: React.FormEvent) { + 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 ( + +

{dict.noToken}

+
+ {dict.requestNew} +
+
+ ) + } + + if (done) { + return ( + +
+
+ + + +
+

{dict.successTitle}

+

{dict.successBody}

+ {dict.signIn} +
+
+ ) + } + + return ( + +
+ {error && ( +
{error}
+ )} +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="input-field" + /> +
+
+ + setConfirm(e.target.value)} + placeholder="••••••••" + className="input-field" + /> +
+ +
+ +
+ + {dict.backToLogin} + +
+
+ ) +} + +function ResetShell({ + embedded = false, + title, + subtitle, + children, +}: { + embedded?: boolean + title: string + subtitle?: string + children: React.ReactNode +}) { + return ( + +
+
+
+ RentalDriveGo +

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {children} +
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/reset-password/page.tsx b/apps/dashboard/src/app/reset-password/page.tsx index bcc578b..9dbfe8e 100644 --- a/apps/dashboard/src/app/reset-password/page.tsx +++ b/apps/dashboard/src/app/reset-password/page.tsx @@ -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> +}) { + const params = await searchParams + const embedded = params.embedded === '1' -export default function ResetPasswordPage() { - return ( - - - - ) -} - -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(null) - - async function handleSubmit(e: React.FormEvent) { - 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 ( - -

{dict.noToken}

-
- {dict.requestNew} -
-
- ) - } - - if (done) { - return ( - -
-
- - - -
-

{dict.successTitle}

-

{dict.successBody}

- {dict.signIn} -
-
- ) - } - - return ( - -
- {error && ( -
{error}
- )} -
- - setPassword(e.target.value)} - placeholder="••••••••" - className="input-field" - /> -
-
- - setConfirm(e.target.value)} - placeholder="••••••••" - className="input-field" - /> -
- -
- -
- - {dict.backToLogin} - -
-
- ) -} - -function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { - return ( -
-
-
- RentalDriveGo -

{title}

- {subtitle &&

{subtitle}

} -
-
- {children} -
-
-
- ) + return } diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx new file mode 100644 index 0000000..81631cc --- /dev/null +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx @@ -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) { + 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 ( + +
+
+
+
+ + RentalDriveGo + +
+

+ {dict.brandName} +

+

+ {dict.subtitle} +

+
+ {(['en', 'fr', 'ar'] as const).map((lang) => ( + + ))} +
+
+ +
+ +
+
+
+
+ ) +} + +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(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 ? ( +
+ {error} +
+ ) : null} + + {step === 'credentials' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="owner@company.com" + className="input-field" + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + className="input-field pr-10" + /> + +
+
+ + + +
+ + {dict.forgotPassword} + +
+
+ ) : ( +
+
+ {dict.enterCode} +
+ +
+ + setTotpCode(e.target.value.replace(/\D/g, ''))} + placeholder="000000" + className="input-field text-center text-xl tracking-[0.45em]" + /> +
+ + + + +
+ )} + + ) +} diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx index caa08ed..cd7822d 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx @@ -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) { - 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 ( -
-
-
-
- RentalDriveGo -
- - RentalDriveGo - -

- {dict.title} -

-

- {dict.subtitle} -

-
- -
- -
-
-
- ) -} - -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> }) { - 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(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 ? ( -
- {error} -
- ) : null} - - {step === 'credentials' ? ( -
-
- - setEmail(e.target.value)} - placeholder="owner@company.com" - className="input-field" - /> -
- -
- -
- setPassword(e.target.value)} - placeholder="••••••••" - className="input-field pr-10" - /> - -
-
- - - -
- - {dict.forgotPassword} - -
-
- ) : ( -
-
- {dict.enterCode} -
- -
- - setTotpCode(e.target.value.replace(/\D/g, ''))} - placeholder="000000" - className="input-field text-center text-xl tracking-[0.45em]" - /> -
- - - - -
- )} - - ) + return } diff --git a/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx b/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx index e197cce..d270284 100644 --- a/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx @@ -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() {
- {dict.brand} +
+ + RentalDriveGo + +
+ {dict.brand}

{dict.workspaceReady}

{completedSignup.companyName} {dict.workspaceReadyBody} @@ -420,9 +434,9 @@ export default function SignUpPage() { {dict.enterDashboard} - + {dict.signInAnotherDevice} - +

@@ -435,7 +449,19 @@ export default function SignUpPage() {
- {dict.brand} +
+ + RentalDriveGo + +
+ {dict.brand}

{dict.pageTitle}

{dict.pageSubtitle}

diff --git a/apps/dashboard/src/components/I18nProvider.tsx b/apps/dashboard/src/components/I18nProvider.tsx index 022de03..6e9af2c 100644 --- a/apps/dashboard/src/components/I18nProvider.tsx +++ b/apps/dashboard/src/components/I18nProvider.tsx @@ -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 = { }, 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 = { }, 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 = { }, dashboardPage: { failedToLoad: 'فشل تحميل لوحة التحكم', + forbidden: 'ليس لديك صلاحية الوصول إلى هذه الصفحة. يلزم دور المدير أو أعلى.', kpiTotalBookings: 'إجمالي الحجوزات', kpiActiveVehicles: 'المركبات النشطة', kpiMonthlyRevenue: 'الإيرادات الشهرية', @@ -1368,33 +1373,61 @@ export function DashboardI18nProvider({ children: React.ReactNode initialLanguage?: DashboardLanguage }) { - const [language, setLanguage] = useState(initialLanguage) - const [theme, setTheme] = useState('light') + const [language, setLanguageState] = useState(initialLanguage) + const [theme, setThemeState] = useState('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 {children} @@ -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 (
@@ -1486,7 +1527,7 @@ export function DashboardLanguageSwitcher() { + {localeMenuOpen ? ( +
+ {availableLocaleOptions.map((option) => ( + + ))} +
+ ) : null} +
+
+ +

+ © {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel} +

+
+ + ) +} + +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 ( + + {item.label} + + ) + } + + return {item.label} +} diff --git a/apps/dashboard/src/components/layout/PublicHeader.tsx b/apps/dashboard/src/components/layout/PublicHeader.tsx new file mode 100644 index 0000000..e8078bf --- /dev/null +++ b/apps/dashboard/src/components/layout/PublicHeader.tsx @@ -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(null) + const [localeMenuOpen, setLocaleMenuOpen] = useState(false) + const themeMenuRef = useRef(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 ( +
+
+ + RentalDriveGo + RentalDriveGo + + +
+ +
+
+
+ + {localeMenuOpen ? ( +
+ {availableLanguages.map((option) => ( + + ))} +
+ ) : null} +
+
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/components/layout/PublicShell.tsx b/apps/dashboard/src/components/layout/PublicShell.tsx index 5316db2..c3f98a4 100644 --- a/apps/dashboard/src/components/layout/PublicShell.tsx +++ b/apps/dashboard/src/components/layout/PublicShell.tsx @@ -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(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 ( -
-
-
- - RentalDriveGo - RentalDriveGo - - -
- -
-
- - {dict.theme} - - {(['light', 'medium', 'dark'] as const).map((value) => { - const active = value === theme - return ( - - ) - })} -
-
-
-
-
-
{children}
-
-
- - -
- {footerContent.secondary.map((item) => ( -
- - -
- ))} -
- - {localeMenuOpen ? ( -
- {availableLocaleOptions.map((option) => ( - - ))} -
- ) : null} -
-
- -

- © {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel} -

-
-
+
+ {embedded ? null : } +
{children}
+ {embedded ? null : }
) } - -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 ( - - {item.label} - - ) - } - - return {item.label} -} diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index d1ba22a..95f330a 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -23,6 +23,7 @@ import { import { useDashboardI18n } from '@/components/I18nProvider' import { marketplaceUrl } from '@/lib/urls' import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api' +import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences' interface BrandSettings { displayName: string @@ -131,8 +132,13 @@ export default function Sidebar() { setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth)) if (employee.role) setRole(employee.role) if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { - setLanguage(employee.preferredLanguage) - document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax` + // Only apply the server-side preference when the employee has no local preference + // stored yet — avoids overriding a language the user manually switched to. + const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY) + if (!existingPref) { + setLanguage(employee.preferredLanguage) + document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax` + } } }) .catch(() => { @@ -190,7 +196,7 @@ export default function Sidebar() { open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'), ].join(' ')} > - +
{brand?.logoUrl ? ( // eslint-disable-next-line @next/next/no-img-element diff --git a/apps/marketplace/src/app/explore/ExploreSearchForm.tsx b/apps/marketplace/src/app/explore/ExploreSearchForm.tsx new file mode 100644 index 0000000..cd0568f --- /dev/null +++ b/apps/marketplace/src/app/explore/ExploreSearchForm.tsx @@ -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) { + 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 ( +
+

{dict.searchTitle}

+
+
+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
+ ) +} diff --git a/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx b/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx index f223445..f2b80d4 100644 --- a/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx +++ b/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx @@ -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,372 +30,82 @@ 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) +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 + if (vehicle.availability === false) return dict.checkAvailability + return dict.available +} export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) { - const [selected, setSelected] = useState(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) => - setForm((prev) => { - if (field !== 'startDate') return { ...prev, [field]: e.target.value } - const nextStart = e.target.value - const nextEnd = !prev.endDate || prev.endDate <= nextStart - ? new Date(new Date(nextStart).getTime() + 86_400_000).toISOString().slice(0, 10) - : prev.endDate - return { ...prev, startDate: nextStart, endDate: nextEnd } - }) - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!selected) return - const minStart = selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today() - if (form.startDate < minStart) { - setStatus('error') - setErrorMsg(`${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt) ?? minStart}.`) - return - } - setStatus('submitting') - setErrorMsg('') - try { - await marketplacePost('/marketplace/reservations', { - vehicleId: selected.id, - companySlug: selected.company.slug, - firstName: form.firstName, - lastName: form.lastName, - email: form.email, - phone: form.phone, - startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(), - endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(), - notes: form.notes || undefined, - }) - setStatus('success') - } catch (err: any) { - setStatus('error') - if (err?.code === 'unavailable') { - setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable) - } else { - setErrorMsg(err?.message ?? dict.errorGeneric) - } - } - } - - const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0 - const estimatedTotal = selected ? selected.dailyRate * days : 0 - - function getStatusCopy(vehicle: Vehicle) { - if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved - if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance - if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus - if (vehicle.availability === false) return dict.checkAvailability - return dict.available - } - return ( - <> -
-
-

{dict.availableVehicles}

-

{vehicles.length} {dict.listings}

-
-
- {vehicles.map((vehicle) => ( -
-
- {vehicle.photos[0] ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${vehicle.make} - ) : null} -
-
-
-
-

{vehicle.company.brand?.displayName ?? dict.rentalCompany}

-

{vehicle.make} {vehicle.model}

-

{vehicle.year} · {vehicle.category}

-
- - {getStatusCopy(vehicle)} - -
- {vehicle.availability === false && ( -

- {vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate} -

- )} -
-
-

{dict.from}

-

{formatCents(vehicle.dailyRate)}

-
-
- -
-
-
-
- ))} - {vehicles.length === 0 && ( -
{dict.vehicleUnavailable}
- )} -
-
- - {selected && ( -
-
e.stopPropagation()} - > - {/* Header */} -
- {selected.photos[0] && ( +
+
+

{dict.availableVehicles}

+

{vehicles.length} {dict.listings}

+
+
+ {vehicles.map((vehicle) => ( +
+
+ {vehicle.photos[0] ? ( // eslint-disable-next-line @next/next/no-img-element - - )} -
-

{selected.company.brand?.displayName ?? dict.rentalCompany}

-

{selected.year} {selected.make} {selected.model}

-

{selected.category} · {formatCents(selected.dailyRate)}/day

-
- + {`${vehicle.make} + ) : null}
- - {status === 'success' ? ( -
-
- +
+
+
+

{vehicle.company.brand?.displayName ?? dict.rentalCompany}

+

{vehicle.make} {vehicle.model}

+

{vehicle.year} · {vehicle.category}

-

{dict.successTitle}

-

{dict.successBody}

- + + {getStatusCopy(vehicle, dict)} +
- ) : ( -
-

{dict.reserveVehicle}

- {selected.availability === false && ( -
- {selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate} -
- )} - - {/* Dates + Times */} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - {/* Estimated total */} - {days > 0 && ( -
-

{days} day{days > 1 ? 's' : ''} × {formatCents(selected.dailyRate)}

-

{formatCents(estimatedTotal)}

-
- )} - - {/* Customer info */} -
-
- - -
-
- - -
-
- + {vehicle.availability === false && ( +

+ {vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate} +

+ )} +
- - +

{dict.from}

+

{formatCents(vehicle.dailyRate)}

- -
- - -
- -
- -