'use client' import Link from 'next/link' import { usePathname } from 'next/navigation' import type { ReactNode } from 'react' import { useEffect, useLayoutEffect, useState } from 'react' import { LayoutDashboard, Car, Calendar, Globe, Users, Tag, UserPlus, BarChart2, CreditCard, Bell, Settings, LogOut, FileText, ChevronLeft, ChevronRight, Star, AlertTriangle, } from 'lucide-react' import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider' import { storefrontUrl } from '@/lib/urls' import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' import { toDashboardAppPath } from '@/lib/dashboardPaths' import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences' interface BrandSettings { displayName: string logoUrl: string | null } interface CompanySummary { name: string brand?: { displayName?: string | null logoUrl?: string | null } | null } interface SidebarUser { displayName: string subtitle: string initials: string } interface EmployeeProfile { email: string firstName: string lastName: string role: string preferredLanguage?: string } interface GeneratedMenuItem { id: string systemKey: string | null label: string itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' routeOrUrl: string | null icon: string | null parentId: string | null openInNewTab: boolean displayOrder: number children: GeneratedMenuItem[] } type SubscriptionAccessLevel = 'full' | 'limited' | 'read_only' | 'none' function computeInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean) if (parts.length === 0) return 'U' if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase() return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase() } function toSidebarUser(profile: Partial, 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: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' }, { href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' }, { href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' }, { href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' }, { href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' }, { href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' }, { href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' }, ] as const const OWNER_SYSTEM_NAV_ITEMS = [ { href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' }, ] as const export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key) const ICON_MAP = { LayoutDashboard, Car, Calendar, Globe, Users, Tag, UserPlus, BarChart2, CreditCard, Bell, Settings, FileText, Star, AlertTriangle, } as const const SIDEBAR_BRAND_KEY = 'dashboard_brand' const ROLE_RANK: Record = { OWNER: 3, MANAGER: 2, AGENT: 1 } function hasMinRole(employeeRole: string, minRole: string): boolean { return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0) } export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boolean { if (!items?.length) return false return items.some((item) => { if (item.itemType === 'DIVIDER' || item.itemType === 'SECTION_LABEL') return false if (item.itemType === 'PARENT_MENU') return hasRenderableMenuItems(item.children) return Boolean(item.routeOrUrl) }) } export function shouldUseGeneratedMenu( menuLoadState: 'loading' | 'loaded' | 'failed', items: GeneratedMenuItem[] | null, subscriptionAccessLevel: SubscriptionAccessLevel | null = null, ): boolean { if (menuLoadState !== 'loaded') return false if (subscriptionAccessLevel === 'none') return true return hasRenderableMenuItems(items) } function notifyParent(message: Record) { if (typeof window === 'undefined' || window.parent === window) return window.parent.postMessage(message, '*') } export default function Sidebar() { const { dict, language, setLanguage } = useDashboardI18n() const isRtl = language === 'ar' const pathname = usePathname() const appPath = toDashboardAppPath(pathname) const [brand, setBrand] = useState(null) const [user, setUser] = useState({ displayName: dict.workspaceUser, subtitle: dict.localAuth, initials: 'W', }) const [role, setRole] = useState('AGENT') const [menuItems, setMenuItems] = useState(null) const [menuAccessLevel, setMenuAccessLevel] = useState(null) const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading') const [open, setOpen] = useState(false) const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) useLayoutEffect(() => { try { const cached = window.localStorage.getItem(SIDEBAR_BRAND_KEY) if (!cached) return const parsed = JSON.parse(cached) as Partial if (typeof parsed.displayName === 'string' && parsed.displayName.trim()) { setBrand({ displayName: parsed.displayName, logoUrl: typeof parsed.logoUrl === 'string' ? parsed.logoUrl : null, }) } } catch {} }, []) useEffect(() => { let cancelled = false async function loadBrand() { try { const brandData = await apiFetch('/companies/me/brand') if (cancelled) return if (brandData?.displayName?.trim()) { setBrand(brandData) window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(brandData)) return } } catch {} try { const company = await apiFetch('/companies/me') if (cancelled) return const resolvedBrand: BrandSettings = { displayName: company.brand?.displayName?.trim() || company.name, logoUrl: company.brand?.logoUrl ?? null, } setBrand(resolvedBrand) window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(resolvedBrand)) } catch {} } loadBrand() return () => { cancelled = true } }, []) useEffect(() => { const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY) if (cached) { try { const profile = JSON.parse(cached) as Partial setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth)) if (profile.role) setRole(profile.role) } catch {} } let cancelled = false Promise.all([ apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'), apiFetch<{ items: GeneratedMenuItem[]; subscriptionAccessLevel?: SubscriptionAccessLevel }>('/auth/employee/menu').catch(() => null), ]) .then(([{ employee }, menu]) => { if (cancelled) return window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth)) if (employee.role) setRole(employee.role) if (menu) { setMenuItems(menu.items) setMenuAccessLevel(menu.subscriptionAccessLevel ?? null) setMenuLoadState('loaded') } else { setMenuAccessLevel(null) setMenuLoadState('failed') } if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { // Only apply the server-side preference when the employee has no local preference // stored yet — avoids overriding a language the user manually switched to. const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY) if (!existingPref) { setLanguage(employee.preferredLanguage) document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax` } } }) .catch(() => { if (cancelled) return if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth)) setMenuAccessLevel(null) setMenuLoadState('failed') }) return () => { cancelled = true } }, [dict.localAuth, dict.workspaceUser]) // Close sidebar when navigating on mobile useEffect(() => { setOpen(false) }, [pathname]) const isActive = (item: typeof NAV_ITEMS[number]) => { if ('exact' in item && item.exact) return appPath === item.href return appPath.startsWith(item.href) } const isGeneratedItemActive = (item: GeneratedMenuItem) => { if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false const route = toDashboardAppPath(item.routeOrUrl) if (route === '/') return appPath === '/' return appPath === route || appPath.startsWith(`${route}/`) } const fallbackMenuItems = NAV_ITEMS .filter((item) => !mounted || hasMinRole(role, item.minRole)) .map((item) => ({ id: item.href, systemKey: item.key, label: dict.nav[item.key] ?? item.key, itemType: 'INTERNAL_PAGE' as const, routeOrUrl: item.href, icon: item.icon.name, parentId: null, openInNewTab: false, displayOrder: 0, children: [], })) const useGeneratedMenu = shouldUseGeneratedMenu(menuLoadState, menuItems, menuAccessLevel) const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS .filter((item) => !mounted || hasMinRole(role, item.minRole)) .map((item) => ({ id: `system:${item.href}`, systemKey: item.key, label: dict.nav[item.key] ?? item.key, itemType: 'INTERNAL_PAGE' as const, routeOrUrl: item.href, icon: item.icon, parentId: null, openInNewTab: false, displayOrder: 0, children: [], })) const generatedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems const generatedRoutes = new Set( generatedMenuItems .filter((item) => item.itemType === 'INTERNAL_PAGE' && item.routeOrUrl) .map((item) => toDashboardAppPath(item.routeOrUrl)), ) const resolvedMenuItems = [ ...generatedMenuItems, ...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))), ] function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode { return items.map((item) => { const label = item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label const paddingClass = depth > 0 ? 'pl-6' : '' const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null if (item.itemType === 'DIVIDER') { return
} if (item.itemType === 'SECTION_LABEL') { return (

{label}

) } if (item.itemType === 'PARENT_MENU') { return (
{Icon ? : null} {label}
{renderGeneratedMenu(item.children, depth + 1)}
) } const active = mounted && isGeneratedItemActive(item) const className = [ 'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200', paddingClass, active ? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50' : 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50', ].join(' ') if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) { return ( {Icon ? : null} {label} ) } const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/') return ( {Icon ? : null} {label} ) }) } function signOut() { localStorage.removeItem(EMPLOYEE_PROFILE_KEY) void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' }) window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) notifyParent({ type: 'rentaldrivego:employee-logout' }) window.location.href = storefrontUrl } return ( <> {/* Mobile backdrop */} {open && (
setOpen(false)} /> )} {/* Tab to open sidebar — only visible when sidebar is closed on mobile */} {!open && ( )} ) }