fix notification and add billing page and contract

This commit is contained in:
root
2026-05-13 00:09:39 -04:00
committed by Administrator
parent 1a39aa8433
commit 89621a163b
52 changed files with 5631 additions and 1110 deletions
+93 -31
View File
@@ -1,9 +1,10 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
export type DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'dark'
export type DashboardTheme = 'light' | 'medium' | 'dark'
type FleetDict = {
statusLabels: Record<'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE', string>
@@ -294,6 +295,7 @@ type DashboardDictionary = {
language: string
theme: string
light: string
medium: string
dark: string
fleet: FleetDict
vehicleDetail: VehicleDetailDict
@@ -307,26 +309,31 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
nav: {
dashboard: 'Dashboard',
fleet: 'Fleet',
reservations: 'Reservations',
reservations: 'Booking',
onlineReservations: 'Online Reservations',
customers: 'Customers',
offers: 'Offers',
team: 'Team',
reports: 'Reports',
billing: 'Billing',
subscription: 'Subscription',
billing: 'Rent Car Billing',
contracts: 'Contracts',
notifications: 'Notifications',
settings: 'Settings',
},
titles: {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Reservations',
'/dashboard/reservations': 'Booking',
'/dashboard/reservations/new': 'Book Car',
'/dashboard/online-reservations': 'Online Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/billing': 'Billing',
'/dashboard/subscription': 'Subscription',
'/dashboard/billing': 'Rent Car Billing',
'/dashboard/contracts': 'Contracts',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Settings',
},
@@ -339,6 +346,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'Language',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
fleet: {
statusLabels: { AVAILABLE: 'Available', RENTED: 'Rented', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Out of Service' },
@@ -572,11 +580,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: "You're on a free trial.",
pastDueMsg: 'Your payment is past due. Please update your billing information.',
suspendedMsg: 'Your account has been suspended. Please contact support or renew your subscription.',
manageBilling: 'Manage billing →',
manageBilling: 'Manage subscription →',
statusLabels: { CONFIRMED: 'Confirmed', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' },
},
reservations: {
heading: 'Reservations',
heading: 'Booking',
subtitle: 'All booking sources, including dashboard, public site, and marketplace.',
colCustomer: 'Customer',
colVehicle: 'Vehicle',
@@ -650,7 +658,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
offers: 'Offres',
team: 'Équipe',
reports: 'Rapports',
billing: 'Facturation',
subscription: 'Abonnement',
billing: 'Facturation location',
contracts: 'Contrats',
notifications: 'Notifications',
settings: 'Paramètres',
},
@@ -658,12 +668,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'Tableau de bord',
'/dashboard/fleet': 'Gestion de flotte',
'/dashboard/reservations': 'Réservations',
'/dashboard/reservations/new': 'Réserver une voiture',
'/dashboard/online-reservations': 'Réservations en ligne',
'/dashboard/customers': 'Clients',
'/dashboard/offers': 'Offres',
'/dashboard/team': 'Équipe',
'/dashboard/reports': 'Rapports',
'/dashboard/billing': 'Facturation',
'/dashboard/subscription': 'Abonnement',
'/dashboard/billing': 'Facturation location',
'/dashboard/contracts': 'Contrats',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Paramètres',
},
@@ -676,6 +689,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'Langue',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
fleet: {
statusLabels: { AVAILABLE: 'Disponible', RENTED: 'Loué', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Hors service' },
@@ -909,7 +923,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: "Vous êtes en période d'essai.",
pastDueMsg: 'Votre paiement est en retard. Veuillez mettre à jour vos informations de facturation.',
suspendedMsg: 'Votre compte a été suspendu. Veuillez contacter le support ou renouveler votre abonnement.',
manageBilling: 'Gérer la facturation →',
manageBilling: 'Gérer labonnement →',
statusLabels: { CONFIRMED: 'Confirmé', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' },
},
reservations: {
@@ -987,7 +1001,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
offers: 'العروض',
team: 'الفريق',
reports: 'التقارير',
billing: 'الفوترة',
subscription: 'الاشتراك',
billing: 'فوترة تأجير السيارات',
contracts: 'العقود',
notifications: 'الإشعارات',
settings: 'الإعدادات',
},
@@ -995,12 +1011,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'لوحة التحكم',
'/dashboard/fleet': 'إدارة الأسطول',
'/dashboard/reservations': 'الحجوزات',
'/dashboard/reservations/new': 'حجز سيارة',
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
'/dashboard/customers': 'العملاء',
'/dashboard/offers': 'العروض',
'/dashboard/team': 'الفريق',
'/dashboard/reports': 'التقارير',
'/dashboard/billing': 'الفوترة',
'/dashboard/subscription': 'الاشتراك',
'/dashboard/billing': 'فوترة تأجير السيارات',
'/dashboard/contracts': 'العقود',
'/dashboard/notifications': 'الإشعارات',
'/dashboard/settings': 'الإعدادات',
},
@@ -1013,6 +1032,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
fleet: {
statusLabels: { AVAILABLE: 'متاح', RENTED: 'مؤجر', MAINTENANCE: 'صيانة', OUT_OF_SERVICE: 'خارج الخدمة' },
@@ -1246,7 +1266,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: 'أنت في فترة تجريبية مجانية.',
pastDueMsg: 'دفعتك متأخرة. يرجى تحديث معلومات الفوترة.',
suspendedMsg: 'تم تعليق حسابك. يرجى التواصل مع الدعم أو تجديد اشتراكك.',
manageBilling: 'إدارة الفوترة ←',
manageBilling: 'إدارة الاشتراك ←',
statusLabels: { CONFIRMED: 'مؤكد', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' },
},
reservations: {
@@ -1325,25 +1345,38 @@ type I18nContextValue = {
}
const I18nContext = createContext<I18nContextValue | null>(null)
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<DashboardLanguage>('en')
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
export function DashboardI18nProvider({
children,
initialLanguage = 'en',
}: {
children: React.ReactNode
initialLanguage?: DashboardLanguage
}) {
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
const [theme, setTheme] = useState<DashboardTheme>('light')
const themeInitialized = useRef(false)
useEffect(() => {
const stored = window.localStorage.getItem('dashboard-language')
const storedTheme = window.localStorage.getItem('dashboard-theme')
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
if (stored === null) return
const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next)
}, [])
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
useEffect(() => {
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
if (storedTheme !== theme) setTheme(storedTheme)
return
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
setTheme('dark')
}
}, [])
@@ -1351,17 +1384,46 @@ export function DashboardI18nProvider({ children }: { children: React.ReactNode
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
window.localStorage.setItem('dashboard-language', language)
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; samesite=lax`
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${language}; path=/; max-age=31536000; samesite=lax`
}, [language])
useEffect(() => {
if (!themeInitialized.current) {
// Skip first run: inline script already applied the correct class; writing 'light' here would flash and corrupt storage.
themeInitialized.current = true
return
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.documentElement.classList.add(theme === 'light' ? 'light' : 'dark')
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = theme
window.localStorage.setItem('dashboard-theme', theme)
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
}, [theme])
useEffect(() => {
function handleAuthChanged() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if (scopedLanguage && normalizeLanguage(scopedLanguage) !== language) {
setLanguage(normalizeLanguage(scopedLanguage))
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
}
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
if (scopedTheme !== theme) setTheme(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
}
}
window.addEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
return () => window.removeEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
}, [language, theme])
const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
[language, theme],
@@ -1426,7 +1488,7 @@ export function DashboardThemeSwitcher() {
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{dict.theme}
</span>
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
{(['light', 'medium', 'dark'] as DashboardTheme[]).map((value) => {
const active = value === theme
return (
<button
@@ -1439,7 +1501,7 @@ export function DashboardThemeSwitcher() {
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{value === 'light' ? dict.light : dict.dark}
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
</button>
)
})}
@@ -1,66 +1,280 @@
'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 {
DashboardLanguageSwitcher,
DashboardThemeSwitcher,
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
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', href: marketplaceUrl },
{ label: 'CLUF' },
{ label: "Conditions d'utilisation" },
{ label: 'Sécurité' },
{ label: 'Conformité' },
{ label: 'Politique de confidentialité' },
{ label: 'Politique de cookies' },
{ label: "Programme d'affiliation" },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contacter les ventes', href: '/sign-up' },
{ label: 'Nos bureaux', href: marketplaceUrl },
],
localeLabel: 'Europ (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: marketplaceUrl },
{ label: 'اتفاقية الترخيص' },
{ label: 'شروط الخدمة' },
{ label: 'الأمان' },
{ label: 'الامتثال' },
{ label: 'سياسة الخصوصية' },
{ label: 'سياسة ملفات الارتباط' },
{ label: 'برنامج الشركاء' },
],
secondary: [
{ label: 'النشرة البريدية' },
{ label: 'تواصل مع المبيعات', href: '/sign-up' },
{ label: 'مكاتبنا', href: marketplaceUrl },
],
localeLabel: 'NorthAfrica (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About us', href: marketplaceUrl },
{ label: 'EULA' },
{ label: 'Terms of service' },
{ label: 'Security' },
{ label: 'Compliance' },
{ label: 'Privacy policy' },
{ label: 'Cookie policy' },
{ label: 'Affiliate program' },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contact sales', href: '/sign-up' },
{ label: 'Our offices', href: marketplaceUrl },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useDashboardI18n()
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const dict = {
en: {
workspace: 'Company Workspace',
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createWorkspace: 'Create workspace',
createAccount: 'Create account',
preferences: 'Workspace preferences',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
},
fr: {
workspace: 'Espace entreprise',
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createWorkspace: 'Créer un espace',
createAccount: 'Créer un compte',
preferences: 'Preferences espace',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
},
ar: {
workspace: 'مساحة الشركة',
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createWorkspace: 'إنشاء مساحة',
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)
}, [])
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/85">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900 dark:text-slate-100">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-slate-500 dark:text-slate-400 sm:inline">{dict.workspace}</span>
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white/85 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/85">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8 lg:py-2">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
className="h-9 w-9 rounded-md object-contain"
/>
<span>RentalDriveGo</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/sign-in" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100">
{dict.signIn}
</Link>
<Link href="/sign-up" className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200">
{dict.createWorkspace}
</Link>
</nav>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link href={marketplaceUrl} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.home}
</Link>
<Link href={`${marketplaceUrl}/explore`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.marketplace}
</Link>
<Link href={`${marketplaceUrl}/sign-in`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.signIn}
</Link>
<Link href="/sign-up" className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300">
{dict.createAccount}
</Link>
</nav>
<div className="shrink-0">
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{dict.theme}
</span>
{(['light', 'medium', 'dark'] as const).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
}`}
>
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
</button>
)
})}
</div>
</div>
</div>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 text-center px-0 sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<Globe className="h-4 w-4" />
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
</div>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -16,12 +16,13 @@ import {
Bell,
Settings,
LogOut,
FileText,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
@@ -33,6 +34,12 @@ interface SidebarUser {
subtitle: string
initials: string
}
interface EmployeeProfile {
email: string
firstName: string
lastName: string
role: string
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
@@ -41,6 +48,16 @@ function computeInitials(name: string): string {
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
const role = profile.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
const subtitle = email || role || fallbackSubtitle
const initials = computeInitials(fullName || email || fallbackName)
return { displayName, subtitle, initials }
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
@@ -50,7 +67,9 @@ const NAV_ITEMS = [
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
{ href: '/dashboard/subscription', key: 'subscription', icon: CreditCard },
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
{ href: '/dashboard/contracts', key: 'contracts', icon: FileText },
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
] as const
@@ -83,33 +102,25 @@ export default function Sidebar() {
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
} catch {}
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
role: string
}
}>('/auth/employee/me')
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
const role = employee.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
const subtitle = email || role || dict.localAuth
const initials = computeInitials(fullName || email || dict.workspaceUser)
setUser({ displayName, subtitle, initials })
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
})
.catch(() => {
if (cancelled) return
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
})
return () => { cancelled = true }
@@ -125,6 +136,7 @@ export default function Sidebar() {
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
router.push('/sign-in')
}
@@ -3,7 +3,7 @@
import { Bell } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
function computeInitials(name: string): string {
@@ -29,7 +29,12 @@ export default function TopBar() {
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
const title = (() => {
if (dict.titles[pathname]) return dict.titles[pathname]
if (pathname.startsWith('/dashboard/contracts/')) return dict.titles['/dashboard/contracts'] ?? dict.titles['/dashboard']
if (pathname.startsWith('/dashboard/reservations/')) return dict.titles['/dashboard/reservations'] ?? dict.titles['/dashboard']
return dict.titles['/dashboard']
})()
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
@@ -87,6 +92,16 @@ export default function TopBar() {
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { email?: string; firstName?: string; lastName?: string }
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
} catch {}
}
let cancelled = false
apiFetch<{
@@ -98,6 +113,7 @@ export default function TopBar() {
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
@@ -74,19 +74,27 @@ export default function DamageInspectionCard({
setSaving(true)
setError(null)
try {
const payload = {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
}
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify({
mileage: inspection.mileage,
fuelLevel: inspection.fuelLevel,
fuelCharge: inspection.fuelCharge,
generalCondition: inspection.generalCondition,
employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType, x, y, damageType, severity, description, isPreExisting,
})),
}),
body: JSON.stringify(payload),
})
setInspection(result)
onSaved(result)
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
member: TeamMember | null
@@ -13,14 +14,81 @@ interface Props {
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
const copy = {
en: {
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
role: 'Role',
cancel: 'Cancel',
saveChanges: 'Save changes',
saving: 'Saving…',
working: 'Working…',
deactivate: 'Deactivate',
reactivate: 'Reactivate',
remove: 'Remove',
removePermanently: 'Remove permanently',
confirmDeactivateTitle: 'Deactivate this member?',
confirmRemoveTitle: 'Permanently remove this member?',
confirmReactivateTitle: 'Reactivate this member?',
confirmDeactivateDesc: "They'll immediately lose dashboard access. You can reactivate anytime.",
confirmRemoveDesc: "This will delete the employee record and revoke their dashboard access. This cannot be undone.",
confirmReactivateDesc: "They'll regain dashboard access with their current role.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
],
},
fr: {
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
role: 'Rôle',
cancel: 'Annuler',
saveChanges: 'Enregistrer',
saving: 'Enregistrement…',
working: 'En cours…',
deactivate: 'Désactiver',
reactivate: 'Réactiver',
remove: 'Supprimer',
removePermanently: 'Supprimer définitivement',
confirmDeactivateTitle: 'Désactiver ce membre ?',
confirmRemoveTitle: 'Supprimer définitivement ce membre ?',
confirmReactivateTitle: 'Réactiver ce membre ?',
confirmDeactivateDesc: "Il perdra immédiatement l'accès au tableau de bord. Vous pouvez le réactiver à tout moment.",
confirmRemoveDesc: "Cela supprimera le compte et révoquera son accès au tableau de bord. Cette action est irréversible.",
confirmReactivateDesc: "Il retrouvera l'accès au tableau de bord avec son rôle actuel.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Opérations complètes — sans facturation' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Réservations et enregistrements' },
],
},
ar: {
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
role: 'الدور',
cancel: 'إلغاء',
saveChanges: 'حفظ التغييرات',
saving: 'جارٍ الحفظ…',
working: 'جارٍ التنفيذ…',
deactivate: 'تعطيل',
reactivate: 'إعادة التفعيل',
remove: 'حذف',
removePermanently: 'حذف نهائياً',
confirmDeactivateTitle: 'تعطيل هذا العضو؟',
confirmRemoveTitle: 'حذف هذا العضو نهائياً؟',
confirmReactivateTitle: 'إعادة تفعيل هذا العضو؟',
confirmDeactivateDesc: 'سيفقد الوصول إلى لوحة التحكم فوراً. يمكنك إعادة التفعيل في أي وقت.',
confirmRemoveDesc: 'سيتم حذف سجل الموظف وإلغاء صلاحياته. هذا الإجراء لا يمكن التراجع عنه.',
confirmReactivateDesc: 'سيستعيد الوصول إلى لوحة التحكم بدوره الحالي.',
roleOptions: [
{ value: 'MANAGER' as const, label: 'مدير', description: 'عمليات كاملة — بدون فوترة' },
{ value: 'AGENT' as const, label: 'وكيل', description: 'الحجوزات والاستلام' },
],
},
}
export default function EditMemberModal({
member,
open,
@@ -30,6 +98,10 @@ export default function EditMemberModal({
onReactivate,
onRemove,
}: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
@@ -48,6 +120,8 @@ export default function EditMemberModal({
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
const handleSave = async () => {
if (role === member.role) { onClose(); return }
@@ -101,15 +175,16 @@ export default function EditMemberModal({
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
@@ -123,35 +198,36 @@ export default function EditMemberModal({
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
{t.pendingInvite}
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
{t.deactivated}
</span>
)}
</div>
</div>
{/* Confirm panel */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
{confirm === 'deactivate' && t.confirmDeactivateTitle}
{confirm === 'remove' && t.confirmRemoveTitle}
{confirm === 'reactivate' && t.confirmReactivateTitle}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their dashboard access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
{confirm === 'deactivate' && t.confirmDeactivateDesc}
{confirm === 'remove' && t.confirmRemoveDesc}
{confirm === 'reactivate' && t.confirmReactivateDesc}
</p>
<div className="flex gap-2">
<div className={`flex gap-2 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
{t.cancel}
</button>
<button
onClick={
@@ -167,23 +243,24 @@ export default function EditMemberModal({
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
{busy ? t.working : (
confirm === 'deactivate' ? t.deactivate :
confirm === 'reactivate' ? t.reactivate :
t.removePermanently
)}
</button>
</div>
</div>
)}
{/* Role selector — only for active, non-pending members */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -209,15 +286,16 @@ export default function EditMemberModal({
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex gap-2 mr-auto">
{/* Actions footer */}
<div className={`flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-2 ${isRtl ? 'ml-auto' : 'mr-auto'}`}>
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
{t.deactivate}
</button>
)}
{!isActive && (
@@ -226,7 +304,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
{t.reactivate}
</button>
)}
<button
@@ -234,7 +312,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
{t.remove}
</button>
</div>
@@ -243,7 +321,7 @@ export default function EditMemberModal({
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
{t.cancel}
</button>
{isActive && !isPending && (
<button
@@ -251,7 +329,7 @@ export default function EditMemberModal({
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
{actionState === 'saving' ? t.saving : t.saveChanges}
</button>
)}
</div>
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
open: boolean
@@ -9,22 +10,95 @@ interface Props {
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
const copy = {
en: {
title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your dashboard",
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
sendInvite: 'Send invite →',
failedInvite: 'Failed to send invitation',
more: (n: number) => `+${n} more`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
fr: {
title: 'Inviter un membre',
subtitle: "Il recevra un email d'invitation pour rejoindre votre tableau de bord",
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse email',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
sendInvite: "Envoyer l'invitation →",
failedInvite: "Échec de l'envoi de l'invitation",
more: (n: number) => `+${n} de plus`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Opérations complètes — sans facturation ni paramètres équipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Opérations quotidiennes uniquement',
permissions: ['Voir la flotte', 'Réservations', 'Départ / Retour', 'Voir les clients'],
},
],
},
]
ar: {
title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
sendInvite: '← إرسال الدعوة',
failedInvite: 'فشل إرسال الدعوة',
more: (n: number) => `+${n} أكثر`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'مدير',
description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
},
{
value: 'AGENT' as const,
label: 'وكيل',
description: 'العمليات اليومية فقط',
permissions: ['عرض الأسطول', 'الحجوزات', 'الاستلام / الإرجاع', 'عرض العملاء'],
},
],
},
}
export default function InviteModal({ open, onClose, onInvite }: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
@@ -48,12 +122,11 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
setError(err.message ?? t.failedInvite)
} finally {
setLoading(false)
}
@@ -66,19 +139,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They&apos;ll receive an email invitation to join your dashboard
</p>
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">{t.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t.subtitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
{t.firstName}
</label>
<input
type="text"
@@ -91,7 +165,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
{t.lastName}
</label>
<input
type="text"
@@ -106,7 +180,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
{t.email}
</label>
<input
type="email"
@@ -120,10 +194,10 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -161,7 +235,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
{t.more(option.permissions.length - 3)}
</span>
)}
</div>
@@ -176,20 +250,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
)}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<div className={`flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
{t.cancel}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending…' : 'Send invite →'}
{loading ? t.sending : t.sendInvite}
</button>
</div>
</form>
@@ -1,9 +1,10 @@
'use client'
import { useDashboardI18n } from '@/components/I18nProvider'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
@@ -11,36 +12,138 @@ interface Feature {
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
const copy = {
en: {
heading: 'Role permissions',
subtitle: 'What each role can do across the dashboard. Owners have full access to everything.',
colFeature: 'Feature',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Full',
noAccess: 'No access',
limited: 'Limited',
featureLabels: [
'View fleet & vehicles',
'Add / edit vehicles',
'Publish / unpublish vehicle',
'Upload vehicle photos',
'View reservations',
'Create reservations',
'Cancel reservations',
'Check-in / check-out',
'Damage inspection',
'Customer CRM — view',
'Customer CRM — edit',
'Approve driver licenses',
'Flag / blacklist customer',
'Offers & promotions',
'Analytics & reports',
'Contract & invoice PDF',
'Billing & subscription',
'Invite / remove staff',
'Brand & site settings',
'Payment settings',
],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
},
fr: {
heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans le tableau de bord. Les propriétaires ont un accès complet.',
colFeature: 'Fonctionnalité',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Complet',
noAccess: 'Aucun accès',
limited: 'Limité',
featureLabels: [
'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule',
'Télécharger des photos de véhicule',
'Voir les réservations',
'Créer des réservations',
'Annuler des réservations',
'Départ / retour',
'Inspection des dommages',
'CRM clients — consultation',
'CRM clients — modification',
'Approuver les permis de conduire',
'Signaler / blacklister un client',
'Offres et promotions',
'Analyses et rapports',
'Contrat et facture PDF',
'Facturation et abonnement',
'Inviter / supprimer du personnel',
'Paramètres marque et site',
'Paramètres de paiement',
],
agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
},
ar: {
heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.',
colFeature: 'الميزة',
colManager: 'المدير',
colAgent: 'الوكيل',
full: 'كامل',
noAccess: 'لا وصول',
limited: 'محدود',
featureLabels: [
'عرض الأسطول والمركبات',
'إضافة / تعديل المركبات',
'نشر / إلغاء نشر المركبة',
'رفع صور المركبة',
'عرض الحجوزات',
'إنشاء الحجوزات',
'إلغاء الحجوزات',
'الاستلام / الإرجاع',
'فحص الأضرار',
'CRM العملاء — عرض',
'CRM العملاء — تعديل',
'الموافقة على رخص القيادة',
'تعليم / حظر العميل',
'العروض والترويجات',
'التحليلات والتقارير',
'العقد وفاتورة PDF',
'الفوترة والاشتراك',
'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع',
'إعدادات الدفع',
],
agentNotes: ['', '', '', '', '', '', 'حجوزاته فقط', '', '', '', 'الملاحظات فقط', '', '', '', '', '', '', '', '', ''],
},
}
function AccessCell({ access, label, limitedNote }: { access: Access; label: { full: string; noAccess: string; limited: string }; limitedNote: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
<span className="text-xs text-zinc-700 dark:text-zinc-300">{label.full}</span>
</div>
)
}
@@ -50,7 +153,7 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
<span className="text-xs text-amber-700 dark:text-amber-400">{limitedNote || label.limited}</span>
</div>
)
}
@@ -59,37 +162,38 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
<span className="text-xs text-zinc-400 dark:text-zinc-600">{label.noAccess}</span>
</div>
)
}
export default function PermissionsMatrix() {
const { language } = useDashboardI18n()
const t = copy[language]
return (
<div>
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
<h2 className="text-base font-medium text-zinc-900 dark:text-white">{t.heading}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{t.subtitle}</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
{t.colFeature}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
{t.colManager}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
{t.colAgent}
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={feature.label}
key={i}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
@@ -97,13 +201,21 @@ export default function PermissionsMatrix() {
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
{t.featureLabels[i]}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
<AccessCell
access={feature.manager}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={feature.managerNote ?? ''}
/>
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
<AccessCell
access={feature.agent}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={t.agentNotes[i] ?? ''}
/>
</div>
</div>
))}