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
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user