redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
'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="rdg-topbar relative z-[70] px-4 sm:px-6">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<p className="rdg-topbar-kicker hidden sm:block">RentalDriveGo</p>
|
||||
<h1 className="rdg-topbar-title truncate">{title}</h1>
|
||||
</div>
|
||||
<div className="rdg-topbar-search relative hidden w-[min(380px,34vw)] md:block">
|
||||
<Search className="pointer-events-none absolute start-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="rdg-search-field h-10 w-full ps-10 pe-4 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="rdg-system-status hidden xl:inline-flex">
|
||||
System operational
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifs(!showNotifs)}
|
||||
className="relative inline-flex h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)]"
|
||||
>
|
||||
<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="card absolute top-full z-[80] mt-2 w-80 p-4 [inset-inline-end:0]">
|
||||
<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 h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)] sm:inline-flex"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
<div className="rdg-avatar flex h-9 w-9 items-center justify-center text-xs font-semibold">
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user