chore: bulk commit of pre-existing changes
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
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
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
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
|
||||
|
||||
describe('DashboardAccessGuard route helpers', () => {
|
||||
it('normalizes dashboard-prefixed menu routes before checking access', () => {
|
||||
const routes = flattenInternalRoutes([
|
||||
{
|
||||
id: 'dashboard',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/dashboard',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/dashboard/fleet',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'external',
|
||||
itemType: 'EXTERNAL_LINK',
|
||||
routeOrUrl: '/dashboard/dashboard',
|
||||
children: [],
|
||||
},
|
||||
])
|
||||
|
||||
expect(routes).toEqual(['/', '/fleet'])
|
||||
expect(isAllowedRoute('/', routes)).toBe(true)
|
||||
expect(isAllowedRoute('/fleet/123', routes)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not redirect forever when an authenticated employee has no visible menu routes', () => {
|
||||
expect(resolveAccessRedirect('/', [])).toBeNull()
|
||||
expect(resolveAccessRedirect('/fleet', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('redirects disallowed routes to the first visible internal route', () => {
|
||||
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
|
||||
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
|
||||
type GeneratedMenuItem = {
|
||||
id: string
|
||||
@@ -16,27 +16,35 @@ type EmployeeMenuResponse = {
|
||||
items: GeneratedMenuItem[]
|
||||
}
|
||||
|
||||
function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
|
||||
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
|
||||
const routes: string[] = []
|
||||
|
||||
const walk = (entry: GeneratedMenuItem) => {
|
||||
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
|
||||
routes.push(entry.routeOrUrl)
|
||||
routes.push(toDashboardAppPath(entry.routeOrUrl))
|
||||
}
|
||||
entry.children.forEach(walk)
|
||||
}
|
||||
|
||||
items.forEach(walk)
|
||||
return routes
|
||||
return Array.from(new Set(routes))
|
||||
}
|
||||
|
||||
function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
|
||||
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
|
||||
return allowedRoutes.some((route) => {
|
||||
if (route === '/') return currentPath === '/'
|
||||
return currentPath === route || currentPath.startsWith(`${route}/`)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveAccessRedirect(currentPath: string, allowedRoutes: string[]) {
|
||||
if (allowedRoutes.length === 0) return null
|
||||
if (isAllowedRoute(currentPath, allowedRoutes)) return null
|
||||
|
||||
const fallbackRoute = allowedRoutes[0] ?? '/'
|
||||
return fallbackRoute === currentPath ? null : fallbackRoute
|
||||
}
|
||||
|
||||
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
@@ -53,10 +61,10 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
|
||||
if (cancelled) return
|
||||
|
||||
const allowedRoutes = flattenInternalRoutes(menu.items)
|
||||
const fallbackRoute = allowedRoutes[0] ?? '/'
|
||||
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
|
||||
|
||||
if (!isAllowedRoute(currentPath, allowedRoutes)) {
|
||||
router.replace(toPublicDashboardPath(fallbackRoute))
|
||||
if (redirectPath) {
|
||||
router.replace(redirectPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,7 +73,7 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
|
||||
if (cancelled) return
|
||||
|
||||
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
|
||||
router.replace(toPublicDashboardPath('/'))
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
@@ -102,7 +101,7 @@ export default function PublicHeader() {
|
||||
const signUpParams = new URLSearchParams()
|
||||
signUpParams.set('lang', language)
|
||||
signUpParams.set('theme', theme)
|
||||
const signUpHref = `/sign-up?${signUpParams.toString()}`
|
||||
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
|
||||
@@ -130,9 +129,9 @@ export default function PublicHeader() {
|
||||
<a href={signInHref} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.signIn}
|
||||
</a>
|
||||
<Link href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
|
||||
<a href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
|
||||
{dict.createAccount}
|
||||
</Link>
|
||||
</a>
|
||||
</nav>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasRenderableMenuItems } from './Sidebar'
|
||||
|
||||
describe('Sidebar menu rendering helpers', () => {
|
||||
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
|
||||
expect(hasRenderableMenuItems(null)).toBe(false)
|
||||
expect(hasRenderableMenuItems([])).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores structural-only menu responses without clickable entries', () => {
|
||||
expect(hasRenderableMenuItems([
|
||||
{
|
||||
id: 'section',
|
||||
systemKey: null,
|
||||
label: 'Workspace',
|
||||
itemType: 'SECTION_LABEL',
|
||||
routeOrUrl: null,
|
||||
icon: null,
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
displayOrder: 1,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
systemKey: null,
|
||||
label: '',
|
||||
itemType: 'DIVIDER',
|
||||
routeOrUrl: null,
|
||||
icon: null,
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
displayOrder: 2,
|
||||
children: [],
|
||||
},
|
||||
])).toBe(false)
|
||||
})
|
||||
|
||||
it('detects nested clickable menu entries', () => {
|
||||
expect(hasRenderableMenuItems([
|
||||
{
|
||||
id: 'parent',
|
||||
systemKey: null,
|
||||
label: 'Operations',
|
||||
itemType: 'PARENT_MENU',
|
||||
routeOrUrl: null,
|
||||
icon: null,
|
||||
parentId: null,
|
||||
openInNewTab: false,
|
||||
displayOrder: 1,
|
||||
children: [
|
||||
{
|
||||
id: 'fleet',
|
||||
systemKey: 'fleet',
|
||||
label: 'Fleet',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '/fleet',
|
||||
icon: 'Car',
|
||||
parentId: 'parent',
|
||||
openInNewTab: false,
|
||||
displayOrder: 1,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -128,13 +128,23 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
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, theme } = useDashboardI18n()
|
||||
const { dict, language, setLanguage } = useDashboardI18n()
|
||||
const isRtl = language === 'ar'
|
||||
const pathname = usePathname()
|
||||
const appPath = toDashboardAppPath(pathname)
|
||||
@@ -248,8 +258,9 @@ export default function Sidebar() {
|
||||
|
||||
const isGeneratedItemActive = (item: GeneratedMenuItem) => {
|
||||
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
|
||||
if (item.routeOrUrl === '/') return appPath === '/'
|
||||
return appPath.startsWith(item.routeOrUrl)
|
||||
const route = toDashboardAppPath(item.routeOrUrl)
|
||||
if (route === '/') return appPath === '/'
|
||||
return appPath === route || appPath.startsWith(`${route}/`)
|
||||
}
|
||||
|
||||
const fallbackMenuItems = NAV_ITEMS
|
||||
@@ -267,7 +278,8 @@ export default function Sidebar() {
|
||||
children: [],
|
||||
}))
|
||||
|
||||
const resolvedMenuItems = menuItems ?? fallbackMenuItems
|
||||
const useGeneratedMenu = hasRenderableMenuItems(menuItems)
|
||||
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
|
||||
|
||||
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
|
||||
return items.map((item) => {
|
||||
@@ -276,7 +288,7 @@ export default function Sidebar() {
|
||||
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="my-3 border-t border-blue-900/50" />
|
||||
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') {
|
||||
@@ -290,7 +302,7 @@ export default function Sidebar() {
|
||||
if (item.itemType === 'PARENT_MENU') {
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<div className={`flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-slate-300 ${paddingClass}`}>
|
||||
<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>
|
||||
@@ -301,11 +313,11 @@ export default function Sidebar() {
|
||||
|
||||
const active = mounted && isGeneratedItemActive(item)
|
||||
const className = [
|
||||
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
|
||||
paddingClass,
|
||||
active
|
||||
? 'bg-orange-500 text-white shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
? '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) {
|
||||
@@ -323,8 +335,10 @@ export default function Sidebar() {
|
||||
)
|
||||
}
|
||||
|
||||
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
|
||||
|
||||
return (
|
||||
<Link key={item.id} href={item.routeOrUrl || '/'} className={className}>
|
||||
<Link key={item.id} href={href} className={className}>
|
||||
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
|
||||
{label}
|
||||
</Link>
|
||||
@@ -353,6 +367,7 @@ export default function Sidebar() {
|
||||
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
|
||||
{!open && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
className={[
|
||||
@@ -366,10 +381,7 @@ export default function Sidebar() {
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col border-r transition-colors duration-300',
|
||||
theme === 'dark'
|
||||
? 'border-blue-900/50 bg-blue-950/80 backdrop-blur-xl'
|
||||
: 'border-stone-200/80 bg-white/92 backdrop-blur-xl shadow-[4px_0_20px_rgba(0,0,0,0.04)]',
|
||||
'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'),
|
||||
@@ -378,9 +390,9 @@ export default function Sidebar() {
|
||||
<a
|
||||
href={marketplaceUrl}
|
||||
target="_top"
|
||||
className="flex items-center gap-3 border-b px-6 py-5 transition-colors border-blue-900/50 dark:border-blue-900/50"
|
||||
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-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-orange-400 to-orange-500 shadow-sm">
|
||||
<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" />
|
||||
@@ -388,15 +400,13 @@ export default function Sidebar() {
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`truncate text-sm font-bold tracking-wide ${
|
||||
theme === 'dark' ? 'text-white' : 'text-blue-950'
|
||||
}`}>
|
||||
<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-0.5 overflow-y-auto px-3 py-4">
|
||||
{menuItems ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
|
||||
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
|
||||
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = mounted && isActive(item)
|
||||
return (
|
||||
@@ -404,37 +414,48 @@ export default function Sidebar() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
|
||||
active
|
||||
? 'bg-orange-500 text-white shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
? '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(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
|
||||
{dict.nav[item.key]}
|
||||
{item.key === 'dashboard' ? (
|
||||
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
|
||||
Live
|
||||
</span>
|
||||
) : null}
|
||||
{item.key === 'notifications' && active ? (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-blue-900/50 px-3 py-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl px-3 py-2">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white">
|
||||
<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-white">{user.displayName}</p>
|
||||
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
|
||||
<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-2xl px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white"
|
||||
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-900/50 pt-3">
|
||||
<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>
|
||||
@@ -442,6 +463,7 @@ export default function Sidebar() {
|
||||
|
||||
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className={[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Bell } from 'lucide-react'
|
||||
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'
|
||||
@@ -48,6 +49,7 @@ export default function TopBar() {
|
||||
createdAt: string
|
||||
}>>([])
|
||||
const [loadingNotifs, setLoadingNotifs] = useState(false)
|
||||
const [socketEnabled, setSocketEnabled] = useState(false)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
@@ -63,8 +65,10 @@ export default function TopBar() {
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
setSocketEnabled(true)
|
||||
} catch {
|
||||
setUnreadCount(0)
|
||||
setSocketEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,8 @@ export default function TopBar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socketEnabled) return
|
||||
|
||||
const socketOrigin = resolveSocketOrigin()
|
||||
if (!socketOrigin) return
|
||||
|
||||
@@ -106,7 +112,7 @@ export default function TopBar() {
|
||||
socket.off('notification', handleNotification)
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [])
|
||||
}, [socketEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
@@ -188,52 +194,54 @@ export default function TopBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="relative z-[70] flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/78 px-6 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/76">
|
||||
<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="text-lg font-semibold text-blue-950 dark:text-stone-50 shrink-0">{title}</h1>
|
||||
<div className="hidden sm:block relative max-w-xs w-full">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400 dark:text-stone-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<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…"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-stone-50/60 pl-9 pr-4 py-2 text-sm text-stone-900 placeholder:text-stone-400 transition-colors focus:border-orange-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-orange-500/20 dark:border-blue-800 dark:bg-blue-950/30 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:border-orange-400 dark:focus:bg-blue-950/50"
|
||||
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-2xl p-2 text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-stone-400 dark:hover:bg-blue-900/40 dark:hover:text-stone-100"
|
||||
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-red-500 px-1 text-[10px] font-bold text-white">
|
||||
<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-[1.75rem] border border-stone-200/80 bg-white/95 p-4 shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<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-stone-100">{dict.notifications}</p>
|
||||
<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-900 dark:text-orange-300 dark:hover:text-white"
|
||||
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-stone-500 dark:text-stone-400">Loading…</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Loading…</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
) : (
|
||||
@@ -244,14 +252,14 @@ export default function TopBar() {
|
||||
type="button"
|
||||
onClick={() => openNotificationsInbox(notification.id)}
|
||||
className={[
|
||||
'w-full rounded-2xl px-3 py-2 text-left transition-colors hover:bg-stone-100 dark:hover:bg-blue-900/40',
|
||||
'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-stone-100">
|
||||
<p className="truncate text-sm font-medium text-blue-950 dark:text-slate-100">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-stone-500 dark:text-stone-400">
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{notification.body}
|
||||
</p>
|
||||
</button>
|
||||
@@ -262,7 +270,15 @@ export default function TopBar() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-400 text-xs font-semibold text-blue-950">
|
||||
<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>
|
||||
|
||||
@@ -36,16 +36,16 @@ export default function StatCard({
|
||||
const isNegative = change !== undefined && change < 0
|
||||
|
||||
return (
|
||||
<div className={`card p-6 ${pulse ? 'pulse-glow' : ''}`}>
|
||||
<div className={`card p-5 ${pulse ? 'pulse-glow' : ''}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-stone-500 dark:text-stone-400">{title}</p>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{title}</p>
|
||||
<p
|
||||
className={[
|
||||
'mt-1 text-2xl font-bold',
|
||||
'mt-2 text-[34px] font-light leading-none tracking-normal',
|
||||
valueGradient
|
||||
? 'bg-gradient-to-r from-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-blue-400 dark:to-orange-300'
|
||||
: 'text-blue-950 dark:text-stone-50',
|
||||
? 'bg-gradient-to-br from-blue-950 via-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-slate-100 dark:via-blue-200 dark:to-orange-300'
|
||||
: 'bg-gradient-to-br from-blue-950 to-slate-700 bg-clip-text text-transparent dark:from-slate-100 dark:to-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{value}
|
||||
@@ -58,7 +58,7 @@ export default function StatCard({
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||
{Math.round(progressPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -68,16 +68,16 @@ export default function StatCard({
|
||||
<span
|
||||
className={[
|
||||
'text-xs font-medium',
|
||||
isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-stone-500 dark:text-stone-400',
|
||||
isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-slate-500 dark:text-slate-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 dark:text-stone-500">{vsLastMonthLabel}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-500">{vsLastMonthLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-[1.25rem] ${iconBg}`}>
|
||||
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${iconBg}`}>
|
||||
<Icon className={`h-6 w-6 ${iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user