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
@@ -25,15 +25,15 @@ type PreferenceItem = {
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'NEW_BOOKING',
'BOOKING_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'VEHICLE_MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const COMPANY_CHANNELS = ['EMAIL', 'IN_APP']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
@@ -45,9 +45,12 @@ const CHANNEL_BADGE: Record<string, string> = {
const STATUS_BADGE: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
QUEUED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
SKIPPED: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
DEAD_LETTER: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
}
@@ -89,14 +92,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
channels: { EMAIL: 'Email', IN_APP: 'In-app' } as Record<string, string>,
statuses: { PENDING: 'Pending', QUEUED: 'Queued', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', SKIPPED: 'Skipped', DEAD_LETTER: 'Dead letter', READ: 'Read' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
NEW_BOOKING: 'New booking',
BOOKING_CANCELLED: 'Booking cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
@@ -125,14 +127,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de l\'enregistrement des préférences',
noHistory: 'Aucun historique de notification trouvé.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
channels: { EMAIL: 'Email', IN_APP: 'Dans l\'application' } as Record<string, string>,
statuses: { PENDING: 'En attente', QUEUED: 'En file', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', SKIPPED: 'Ignoré', DEAD_LETTER: 'Échec final', READ: 'Lu' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
NEW_BOOKING: 'Nouvelle réservation',
BOOKING_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
@@ -161,14 +162,13 @@ export default function DashboardNotificationsPage() {
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
channels: { EMAIL: 'البريد', IN_APP: 'داخل التطبيق' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', QUEUED: 'في قائمة الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', SKIPPED: 'تم التجاوز', DEAD_LETTER: 'فشل نهائي', READ: 'مقروء' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
NEW_BOOKING: 'حجز جديد',
BOOKING_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
@@ -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">