75a1d39050
Build & Deploy / Build & Push Docker Image (push) Successful in 2m48s
Test / Type Check (all packages) (push) Successful in 52s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 1m9s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 47s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s
498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
'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<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: '/', 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<string, number> = { 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<string, unknown>) {
|
|
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<BrandSettings | null>(null)
|
|
const [user, setUser] = useState<SidebarUser>({
|
|
displayName: dict.workspaceUser,
|
|
subtitle: dict.localAuth,
|
|
initials: 'W',
|
|
})
|
|
const [role, setRole] = useState<string>('AGENT')
|
|
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
|
|
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(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<BrandSettings>
|
|
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<BrandSettings | null>('/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<CompanySummary>('/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<EmployeeProfile>
|
|
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 <div key={item.id} className="mx-3 my-3 h-px bg-gradient-to-r from-transparent via-blue-400/20 to-transparent" />
|
|
}
|
|
|
|
if (item.itemType === 'SECTION_LABEL') {
|
|
return (
|
|
<p key={item.id} className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
{label}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
if (item.itemType === 'PARENT_MENU') {
|
|
return (
|
|
<div key={item.id} className="space-y-1">
|
|
<div className={`flex items-center gap-3 rounded-lg px-3.5 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-300 ${paddingClass}`}>
|
|
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
|
|
{label}
|
|
</div>
|
|
<div className="space-y-0.5">{renderGeneratedMenu(item.children, depth + 1)}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<a
|
|
key={item.id}
|
|
href={item.routeOrUrl}
|
|
target={item.openInNewTab ? '_blank' : '_self'}
|
|
rel={item.openInNewTab ? 'noreferrer noopener' : undefined}
|
|
className={className}
|
|
>
|
|
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
|
|
{label}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
|
|
|
|
return (
|
|
<Link key={item.id} href={href} className={className}>
|
|
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
|
|
{label}
|
|
</Link>
|
|
)
|
|
})
|
|
}
|
|
|
|
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 && (
|
|
<div
|
|
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
|
|
{!open && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
aria-label="Open sidebar"
|
|
className={[
|
|
'fixed top-1/2 z-50 flex h-14 w-6 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
|
|
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
|
|
].join(' ')}
|
|
>
|
|
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)}
|
|
|
|
<aside
|
|
className={[
|
|
'fixed inset-y-0 z-40 flex w-64 flex-col border-r border-blue-200/70 bg-white/90 text-blue-950 shadow-[8px_0_32px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-[transform,background-color,border-color,color,box-shadow] duration-300 dark:border-blue-400/10 dark:bg-[#0a0f1a]/95 dark:text-slate-100 dark:shadow-[8px_0_32px_rgba(0,0,0,0.22)]',
|
|
isRtl ? 'right-0' : 'left-0',
|
|
'lg:translate-x-0',
|
|
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
|
|
].join(' ')}
|
|
>
|
|
<a
|
|
href={storefrontUrl}
|
|
target="_top"
|
|
className="flex items-center gap-3 border-b border-blue-200/70 px-5 py-5 transition-colors dark:border-blue-400/10"
|
|
>
|
|
<div className="flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 shadow-[0_4px_14px_rgba(59,130,246,0.34)]">
|
|
{brand?.logoUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
|
|
) : (
|
|
<Car className="h-4 w-4 text-white" />
|
|
)}
|
|
</div>
|
|
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
|
|
{brand?.displayName ?? 'RentalDriveGo'}
|
|
</span>
|
|
</a>
|
|
|
|
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
|
|
{renderGeneratedMenu(resolvedMenuItems)}
|
|
</nav>
|
|
|
|
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
|
|
<div className="rounded-xl border border-blue-200/70 bg-blue-50/75 p-3 backdrop-blur dark:border-blue-400/10 dark:bg-blue-500/[0.06]">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
|
|
{user.initials}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-blue-950 dark:text-white">{user.displayName}</p>
|
|
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{user.subtitle}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={signOut}
|
|
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-white"
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{dict.signOut}
|
|
</button>
|
|
<div className="mt-3 flex items-center gap-2 border-t border-blue-200/70 pt-3 dark:border-blue-400/10">
|
|
<DashboardLanguageSwitcher />
|
|
<DashboardThemeSwitcher />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
aria-label="Close sidebar"
|
|
className={[
|
|
'absolute top-1/2 z-10 flex h-14 w-5 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
|
|
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
|
|
].join(' ')}
|
|
>
|
|
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</aside>
|
|
</>
|
|
)
|
|
}
|