fix notifications
Build & Push / Pipeline Tests (push) Failing after 1m8s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 52s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 46s
Test / API Integration Tests (push) Successful in 1m7s

This commit is contained in:
root
2026-07-22 22:31:39 -04:00
parent bcabd17220
commit f6fcd7ce54
26 changed files with 1171 additions and 361 deletions
@@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'
import { APPROVED_BASELINE_MENU_KEYS, getEmployeeLogoutRedirectUrl, hasRenderableMenuItems, shouldUseGeneratedMenu } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('keeps the safe fallback menu to the approved seven baseline items in order', () => {
it('keeps dashboard first, then sorts the remaining safe fallback items alphabetically', () => {
expect(APPROVED_BASELINE_MENU_KEYS).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'customers',
'fleet',
'reports',
'reservations',
'settings',
])
})
@@ -101,7 +101,13 @@ 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)
export const APPROVED_BASELINE_MENU_KEYS = [...NAV_ITEMS]
.sort((a, b) => {
if (a.key === 'dashboard') return -1
if (b.key === 'dashboard') return 1
return a.key.localeCompare(b.key)
})
.map((item) => item.key)
const ICON_MAP = {
LayoutDashboard,
@@ -152,6 +158,24 @@ export function getEmployeeLogoutRedirectUrl() {
return websiteUrl
}
function sortSidebarMenuItemsAlphabetically(
items: GeneratedMenuItem[],
resolveLabel: (item: GeneratedMenuItem) => string,
): GeneratedMenuItem[] {
return [...items]
.map((item) => ({
...item,
children: sortSidebarMenuItemsAlphabetically(item.children, resolveLabel),
}))
.sort((a, b) => {
if (a.systemKey === 'dashboard') return -1
if (b.systemKey === 'dashboard') return 1
const labelCompare = resolveLabel(a).localeCompare(resolveLabel(b), undefined, { sensitivity: 'base' })
if (labelCompare !== 0) return labelCompare
return (a.systemKey ?? a.id).localeCompare(b.systemKey ?? b.id)
})
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
@@ -172,6 +196,7 @@ export default function Sidebar() {
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [menuAccessLevel, setMenuAccessLevel] = useState<SubscriptionAccessLevel | null>(null)
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
@@ -276,6 +301,36 @@ export default function Sidebar() {
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {
setUnreadCount(0)
}
}
useEffect(() => {
refreshUnreadCount()
}, [pathname])
useEffect(() => {
const refresh = () => { void refreshUnreadCount() }
const refreshWhenVisible = () => {
if (document.visibilityState === 'visible') refresh()
}
window.addEventListener('notifications:updated', refresh)
window.addEventListener('online', refresh)
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshWhenVisible)
return () => {
window.removeEventListener('notifications:updated', refresh)
window.removeEventListener('online', refresh)
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}, [])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
@@ -328,6 +383,10 @@ export default function Sidebar() {
...generatedMenuItems,
...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))),
]
const sortedResolvedMenuItems = sortSidebarMenuItemsAlphabetically(
resolvedMenuItems,
(item) => item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label,
)
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
@@ -360,6 +419,9 @@ export default function Sidebar() {
}
const active = mounted && isGeneratedItemActive(item)
const badge = item.systemKey === 'notifications' && unreadCount > 0
? unreadCount > 99 ? '99+' : String(unreadCount)
: null
const className = [
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
paddingClass,
@@ -388,7 +450,12 @@ export default function Sidebar() {
return (
<Link key={item.id} href={href} className={className}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge ? (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-orange-500 px-1.5 text-[11px] font-bold leading-none text-white">
{badge}
</span>
) : null}
</Link>
)
})
@@ -454,7 +521,7 @@ export default function Sidebar() {
</a>
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{renderGeneratedMenu(resolvedMenuItems)}
{renderGeneratedMenu(sortedResolvedMenuItems)}
</nav>
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">