Files
carmanagement/apps/dashboard/src/components/layout/TopBar.tsx
T
root 3b142ca4c7
Build & Deploy / Build & Push Docker Image (push) Successful in 48s
Test / API Unit Tests (push) Successful in 9m48s
Test / Marketplace Unit Tests (push) Successful in 9m38s
Test / Admin Unit Tests (push) Successful in 9m33s
Build & Deploy / Deploy to VPS (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
chore: bulk commit of pre-existing changes
Includes:
- Admin dashboard layout and page updates
- API account auth module (routes, schemas, service)
- Dashboard sign-up form extraction, middleware refactor
- Marketplace proxy and middleware updates
- CORS origins expansion for dev environment
- Seed email domain correction (.com → .ma)
- Docs: create-account guide, fleet page, signup plan
- Various UI component and layout refinements
2026-06-25 00:57:59 -04:00

288 lines
10 KiB
TypeScript

'use client'
import Link from 'next/link'
import { Bell, Search, Settings } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
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 resolveSocketOrigin(): string | null {
if (typeof window === 'undefined') return null
const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL
if (configuredApiUrl) {
try {
return new URL(configuredApiUrl, window.location.origin).origin
} catch {}
}
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:4000'
}
return window.location.origin
}
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const [socketEnabled, setSocketEnabled] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
if (appPath.startsWith('/contracts/')) return dict.titles['/contracts'] ?? dict.titles['/']
if (appPath.startsWith('/reservations/')) return dict.titles['/reservations'] ?? dict.titles['/']
return dict.titles['/']
})()
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
setSocketEnabled(true)
} catch {
setUnreadCount(0)
setSocketEnabled(false)
}
}
useEffect(() => {
refreshUnreadCount()
}, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!socketEnabled) return
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
withCredentials: true,
transports: ['polling', 'websocket'],
})
const handleNotification = () => {
setUnreadCount((prev) => prev + 1)
window.dispatchEvent(new Event('notifications:updated'))
}
socket.on('notification', handleNotification)
const connectTimer = window.setTimeout(() => {
socket.connect()
}, 0)
return () => {
window.clearTimeout(connectTimer)
socket.off('notification', handleNotification)
socket.disconnect()
}
}, [socketEnabled])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
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<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/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))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/notifications')
}
return (
<header className="relative z-[70] flex h-16 items-center justify-between border-b border-blue-200/70 bg-white/82 px-6 text-blue-950 backdrop-blur-xl transition-colors dark:border-blue-400/10 dark:bg-[#0a0f1a]/90 dark:text-slate-100">
<div className="flex items-center gap-4 min-w-0">
<h1 className="shrink-0 text-lg font-semibold text-blue-950 dark:text-slate-50">{title}</h1>
<div className="relative hidden w-[min(380px,38vw)] sm:block">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Search vehicles, bookings, customers..."
className="h-10 w-full rounded-lg border border-blue-200/80 bg-blue-50/70 pl-10 pr-4 text-sm text-blue-950 outline-none transition-all placeholder:text-slate-500 focus:border-blue-400/45 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-100 dark:focus:bg-blue-500/10"
/>
</div>
<div className="hidden items-center gap-2 text-sm text-slate-400 xl:flex">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_0_5px_rgba(16,185,129,0.10)]" />
System operational
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifs(!showNotifs)}
className="relative rounded-lg p-2 text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-100"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-orange-500 px-1 text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#0a0f1a]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{showNotifs ? (
<div className="absolute right-0 top-full z-[80] mt-2 w-80 rounded-xl border border-blue-200/80 bg-white/95 p-4 shadow-[0_20px_60px_rgba(15,23,42,0.14)] backdrop-blur-xl dark:border-blue-400/15 dark:bg-[#0d1728]/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-blue-950 dark:text-slate-100">{dict.notifications}</p>
<button
type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-orange-700 hover:text-blue-950 dark:text-orange-300 dark:hover:text-white"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-blue-500/10',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-blue-950 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
<Link
href="/settings"
className="hidden rounded-lg p-2 text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-100 sm:block"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</Link>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
</div>
</header>
)
}