diff --git a/app/(landing)/notifications/page.tsx b/app/(landing)/notifications/page.tsx
index 067a7187..c1d1ee0b 100644
--- a/app/(landing)/notifications/page.tsx
+++ b/app/(landing)/notifications/page.tsx
@@ -1,152 +1,5 @@
-'use client';
+import { redirect } from 'next/navigation';
-import { useState } from 'react';
-import { useNotifications } from '@/hooks/useNotifications';
-import { useNotificationPolling } from '@/hooks/use-notification-polling';
-import { NotificationList } from '@/components/notifications/NotificationList';
-import { Button } from '@/components/ui/button';
-import { Skeleton } from '@/components/ui/skeleton';
-import { toast } from 'sonner';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
-import { AuthGuard } from '@/components/auth';
-import Loading from '@/components/Loading';
-
-export default function NotificationsPage() {
- const [page, setPage] = useState(1);
- const limit = 20;
-
- const notificationsHook = useNotifications({
- page,
- limit,
- autoFetch: true,
- });
-
- const {
- notifications,
- loading,
- error,
- total,
- unreadCount,
- markAllAsRead,
- markNotificationAsRead,
- setCurrentPage,
- } = notificationsHook;
-
- // Enable polling for real-time updates
- useNotificationPolling(notificationsHook, {
- interval: 30000,
- enabled: true,
- });
-
- const totalPages = Math.ceil(total / limit);
-
- const handleMarkAllAsRead = async () => {
- try {
- await markAllAsRead();
- toast.success('All notifications marked as read');
- } catch {
- toast.error('Failed to mark all as read');
- }
- };
-
- const handlePageChange = (newPage: number) => {
- setCurrentPage(newPage);
- setPage(newPage);
- window.scrollTo({ top: 0, behavior: 'smooth' });
- };
-
- if (error) {
- return (
-
-
-
- Error loading notifications
-
-
{error.message}
-
-
-
- );
- }
-
- return (
- }>
-
-
-
-
Notifications
-
- {loading ? (
-
- ) : (
- <>
- {unreadCount} unread of {total} total
- >
- )}
-
-
- {unreadCount > 0 && !loading && (
-
- )}
-
-
-
{
- if (!notification.read) {
- markNotificationAsRead([notification.id]).catch(() => {
- // Silently handle error - user feedback already provided
- });
- }
- }}
- onMarkAsRead={id => {
- markNotificationAsRead([id]).catch(() => {
- // Silently handle error - user feedback already provided
- });
- }}
- />
-
- {totalPages > 1 && !loading && (
-
-
-
- Page {page} of {totalPages}
-
-
-
- )}
-
-
- );
+export default function LegacyNotificationsPage() {
+ redirect('/me/notifications');
}
diff --git a/app/me/notifications/page.tsx b/app/me/notifications/page.tsx
new file mode 100644
index 00000000..08415021
--- /dev/null
+++ b/app/me/notifications/page.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import { toast } from 'sonner';
+import { NotificationList } from '@/components/notifications/NotificationList';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import BoundlessSheet from '@/components/sheet/boundless-sheet';
+import { useNotificationCenter } from '@/components/providers/notification-provider';
+import type { Notification } from '@/types/notifications';
+
+const formatMetadataValue = (value: unknown): string => {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ if (typeof value === 'string' || typeof value === 'number') {
+ return String(value);
+ }
+ if (typeof value === 'boolean') {
+ return value ? 'Yes' : 'No';
+ }
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+};
+
+export default function MeNotificationsPage() {
+ const {
+ notifications,
+ unreadCount,
+ loading,
+ error,
+ markAllAsRead,
+ markNotificationAsRead,
+ refetch,
+ } = useNotificationCenter();
+
+ const [selectedNotification, setSelectedNotification] =
+ useState(null);
+ const [sheetOpen, setSheetOpen] = useState(false);
+
+ const metadataEntries = useMemo(() => {
+ if (!selectedNotification) {
+ return [];
+ }
+
+ return Object.entries(selectedNotification.data).filter(
+ ([, value]) => value !== undefined && value !== null && value !== ''
+ );
+ }, [selectedNotification]);
+
+ const handleMarkAllAsRead = async () => {
+ try {
+ await markAllAsRead();
+ toast.success('All notifications marked as read');
+ } catch {
+ toast.error('Failed to mark all as read');
+ }
+ };
+
+ const handleOpenDetails = (notification: Notification) => {
+ setSelectedNotification(notification);
+ setSheetOpen(true);
+
+ if (!notification.read) {
+ markNotificationAsRead([notification.id]).catch(() => {
+ toast.error('Failed to update notification state');
+ });
+ }
+ };
+
+ if (error) {
+ return (
+
+
+
+ Error loading notifications
+
+
{error.message}
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
Notifications
+
+ {loading ? (
+
+ ) : (
+ <>
+ {unreadCount} unread of {notifications.length} loaded
+ >
+ )}
+
+
+
+
+ {unreadCount > 0 && !loading && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {selectedNotification ? (
+
+
+
+ {selectedNotification.message}
+
+
+ {new Date(selectedNotification.createdAt).toLocaleString()}
+
+
+ Type: {selectedNotification.type}
+
+
+
+
+
Metadata
+ {metadataEntries.length === 0 ? (
+
+ No additional metadata provided.
+
+ ) : (
+
+ {metadataEntries.map(([key, value]) => (
+
+
- {key}
+ -
+ {formatMetadataValue(value)}
+
+
+ ))}
+
+ )}
+
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/app/providers.tsx b/app/providers.tsx
index b1630424..39e6e51b 100644
--- a/app/providers.tsx
+++ b/app/providers.tsx
@@ -4,6 +4,7 @@ import { ReactNode } from 'react';
import { AuthProvider } from '@/components/providers/auth-provider';
import { SocketProvider } from '@/components/providers/socket-provider';
import { WalletProvider } from '@/components/providers/wallet-provider';
+import { NotificationProvider } from '@/components/providers/notification-provider';
import { TrustlessWorkProvider } from '@/lib/providers/TrustlessWorkProvider';
import { EscrowProvider } from '@/lib/providers/EscrowProvider';
interface ProvidersProps {
@@ -13,13 +14,15 @@ interface ProvidersProps {
export function Providers({ children }: ProvidersProps) {
return (
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
);
}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index ad72176a..1b25a630 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -26,6 +26,7 @@ import {
} from '@/components/ui/sidebar';
import Image from 'next/image';
import Link from 'next/link';
+import { useNotificationCenter } from '@/components/providers/notification-provider';
const navigationData = {
main: [
@@ -88,7 +89,6 @@ const navigationData = {
title: 'Notifications',
url: '/me/notifications',
icon: IconBell,
- badge: '5',
},
],
};
@@ -101,6 +101,13 @@ export function AppSidebar({
user,
...props
}: { user: userData } & React.ComponentProps) {
+ const { unreadCount } = useNotificationCenter();
+ const accountItems = navigationData.account.map(item =>
+ item.title === 'Notifications'
+ ? { ...item, badge: unreadCount > 0 ? String(unreadCount) : undefined }
+ : item
+ );
+
return (
@@ -115,7 +122,6 @@ export function AppSidebar({
- {/* Header with Logo */}
@@ -140,15 +146,13 @@ export function AppSidebar({
- {/* Main Content */}
-
+
- {/* Footer with User */}
diff --git a/components/nav-main.tsx b/components/nav-main.tsx
index 0bec9fcb..0d7256c6 100644
--- a/components/nav-main.tsx
+++ b/components/nav-main.tsx
@@ -23,7 +23,7 @@ export function NavMain({
title: string;
url: string;
icon?: Icon;
- badge?: string;
+ badge?: string | number;
badgeVariant?: 'default' | 'destructive' | 'outline';
}[];
label?: string;
@@ -43,6 +43,11 @@ export function NavMain({
const isActive =
pathname === item.url || pathname?.startsWith(`${item.url}/`);
+ const hasBadge =
+ item.badge !== undefined &&
+ item.badge !== null &&
+ Number(item.badge) > 0;
+
return (
)}
{item.title}
- {item.badge && (
+ {hasBadge && (
{
const [isOpen, setIsOpen] = useState(false);
-
- // Get userId from auth session
const { data: session } = authClient.useSession();
const userId = session?.user?.id;
- // Use WebSocket-based notifications hook (only connect if userId is available)
- const { notifications, unreadCount, isConnected, loading, markAllAsRead } =
- useNotifications(userId || undefined);
+ const {
+ notifications,
+ unreadCount,
+ isConnected,
+ loading,
+ markAllAsRead,
+ markNotificationAsRead,
+ } = useNotificationCenter();
- // Wrapper for markAllAsRead that uses WebSocket
const handleMarkAllAsRead = async () => {
markAllAsRead();
};
- const handleNotificationClick = () => {
- // Notification click is handled in NotificationDropdown
+ const handleNotificationClick = (
+ notification: (typeof notifications)[number]
+ ) => {
+ if (!notification.read) {
+ markNotificationAsRead([notification.id]).catch(() => {});
+ }
};
return (
diff --git a/components/notifications/NotificationDropdown.tsx b/components/notifications/NotificationDropdown.tsx
index 12032373..3c8cf467 100644
--- a/components/notifications/NotificationDropdown.tsx
+++ b/components/notifications/NotificationDropdown.tsx
@@ -10,7 +10,6 @@ import {
import { NotificationItem } from './NotificationItem';
import { Notification } from '@/types/notifications';
import { Skeleton } from '@/components/ui/skeleton';
-import { markAsRead } from '@/lib/api/notifications';
import { toast } from 'sonner';
interface NotificationDropdownProps {
@@ -82,45 +81,24 @@ export const NotificationDropdown = ({
onNotificationClick(notification);
}
- // Auto-mark as read on click
- if (!notification.read) {
- try {
- await markAsRead({ ids: [notification.id] });
- } catch {
- // Silently handle error - user feedback already provided
- }
- }
-
- // Navigate to relevant page based on priority
- // Organization notifications
if (notification.data.organizationId) {
router.push(`/organizations/${notification.data.organizationId}`);
- }
- // Hackathon notifications (prefer slug over ID)
- else if (notification.data.hackathonId) {
+ } else if (notification.data.hackathonId) {
if (notification.data.hackathonSlug) {
router.push(`/hackathons/${notification.data.hackathonSlug}`);
} else {
router.push(`/hackathons/${notification.data.hackathonId}`);
}
- }
- // Team invitation notifications (navigate to project if available)
- else if (
+ } else if (
notification.data.teamInvitationId &&
notification.data.projectId
) {
router.push(`/projects/${notification.data.projectId}`);
- }
- // Project notifications
- else if (notification.data.projectId) {
+ } else if (notification.data.projectId) {
router.push(`/projects/${notification.data.projectId}`);
- }
- // Comment notifications
- else if (notification.data.commentId) {
+ } else if (notification.data.commentId) {
router.push(`/comments/${notification.data.commentId}`);
- }
- // Milestone notifications
- else if (notification.data.milestoneId) {
+ } else if (notification.data.milestoneId) {
router.push(`/milestones/${notification.data.milestoneId}`);
}
@@ -219,7 +197,7 @@ export const NotificationDropdown = ({
)}
diff --git a/components/notifications/NotificationItem.tsx b/components/notifications/NotificationItem.tsx
index 3fc8b549..41a72176 100644
--- a/components/notifications/NotificationItem.tsx
+++ b/components/notifications/NotificationItem.tsx
@@ -5,12 +5,14 @@ import Link from 'next/link';
import { Notification } from '@/types/notifications';
import { getNotificationIcon } from './NotificationIcon';
import { cn } from '@/lib/utils';
+import { AnimatePresence, motion } from 'framer-motion';
interface NotificationItemProps {
notification: Notification;
onMarkAsRead?: () => void;
showUnreadIndicator?: boolean;
className?: string;
+ disableNavigation?: boolean;
}
export const NotificationItem = ({
@@ -18,16 +20,15 @@ export const NotificationItem = ({
onMarkAsRead,
showUnreadIndicator = true,
className,
+ disableNavigation = false,
}: NotificationItemProps) => {
const Icon = getNotificationIcon(notification.type);
const getNotificationLink = (): string => {
- // Organization notifications
if (notification.data.organizationId) {
return `/organizations/${notification.data.organizationId}`;
}
- // Hackathon notifications (prefer slug over ID)
if (notification.data.hackathonId) {
if (notification.data.hackathonSlug) {
return `/hackathons/${notification.data.hackathonSlug}`;
@@ -35,22 +36,18 @@ export const NotificationItem = ({
return `/hackathons/${notification.data.hackathonId}`;
}
- // Team invitation notifications (navigate to project if available)
if (notification.data.teamInvitationId && notification.data.projectId) {
return `/projects/${notification.data.projectId}`;
}
- // Project notifications
if (notification.data.projectId) {
return `/projects/${notification.data.projectId}`;
}
- // Comment notifications
if (notification.data.commentId) {
return `/comments/${notification.data.commentId}`;
}
- // Milestone notifications
if (notification.data.milestoneId) {
return `/milestones/${notification.data.milestoneId}`;
}
@@ -59,7 +56,7 @@ export const NotificationItem = ({
};
const handleClick = () => {
- if (!notification.read && onMarkAsRead) {
+ if (onMarkAsRead) {
onMarkAsRead();
}
};
@@ -68,7 +65,11 @@ export const NotificationItem = ({
const isClickable = link !== '#';
const content = (
-
- {!notification.read && showUnreadIndicator && (
-
- )}
-
+
+ {!notification.read && showUnreadIndicator && (
+
+ )}
+
+
);
- if (isClickable) {
+ if (isClickable && !disableNavigation) {
return (
{content}
@@ -138,5 +148,14 @@ export const NotificationItem = ({
);
}
- return {content}
;
+ return (
+
+ );
};
diff --git a/components/notifications/NotificationList.tsx b/components/notifications/NotificationList.tsx
index fbad14be..e2a66760 100644
--- a/components/notifications/NotificationList.tsx
+++ b/components/notifications/NotificationList.tsx
@@ -1,6 +1,7 @@
'use client';
-import { format, isToday, isYesterday, isThisWeek } from 'date-fns';
+import { isBefore, subDays } from 'date-fns';
+import { AnimatePresence, motion } from 'framer-motion';
import { NotificationItem } from './NotificationItem';
import { Notification } from '@/types/notifications';
import { Skeleton } from '@/components/ui/skeleton';
@@ -12,34 +13,68 @@ interface NotificationListProps {
onMarkAsRead?: (id: string) => void;
}
-const groupNotificationsByDate = (
- notifications: Notification[]
-): Record => {
- const groups: Record = {};
+const ACTIVE_RETENTION_DAYS = 30;
+
+const isArchivedNotification = (notification: Notification): boolean => {
+ const archivedFlag = Boolean(
+ notification.data.archivedBy ||
+ notification.data['archived'] ||
+ notification.data['isArchived']
+ );
+
+ if (archivedFlag) {
+ return true;
+ }
+
+ const cutoffDate = subDays(new Date(), ACTIVE_RETENTION_DAYS);
+ return isBefore(new Date(notification.createdAt), cutoffDate);
+};
+
+const groupNotifications = (notifications: Notification[]) => {
+ const groups = {
+ new: [] as Notification[],
+ earlier: [] as Notification[],
+ archived: [] as Notification[],
+ };
notifications.forEach(notification => {
- const date = new Date(notification.createdAt);
- let groupKey: string;
-
- if (isToday(date)) {
- groupKey = 'Today';
- } else if (isYesterday(date)) {
- groupKey = 'Yesterday';
- } else if (isThisWeek(date)) {
- groupKey = format(date, 'EEEE'); // Day name
- } else {
- groupKey = format(date, 'MMMM d, yyyy'); // Full date
+ if (isArchivedNotification(notification)) {
+ groups.archived.push(notification);
+ return;
}
- if (!groups[groupKey]) {
- groups[groupKey] = [];
+ if (!notification.read) {
+ groups.new.push(notification);
+ return;
}
- groups[groupKey].push(notification);
+
+ groups.earlier.push(notification);
});
return groups;
};
+const SECTION_CONFIG = [
+ {
+ key: 'new',
+ title: 'New',
+ emptyTitle: 'No new notifications',
+ emptyDescription: 'You are all caught up for now.',
+ },
+ {
+ key: 'earlier',
+ title: 'Earlier',
+ emptyTitle: 'No earlier notifications',
+ emptyDescription: 'Read notifications from this cycle will appear here.',
+ },
+ {
+ key: 'archived',
+ title: 'Archived',
+ emptyTitle: 'No archived notifications',
+ emptyDescription: 'Older or dismissed notifications will collect here.',
+ },
+] as const;
+
export const NotificationList = ({
notifications,
loading = false,
@@ -56,47 +91,70 @@ export const NotificationList = ({
);
}
- if (notifications.length === 0) {
- return (
-
-
No notifications
-
- You're all caught up! New notifications will appear here.
-
-
- );
- }
-
- const groupedNotifications = groupNotificationsByDate(notifications);
+ const groupedNotifications = groupNotifications(notifications);
return (
-
- {Object.entries(groupedNotifications).map(([date, dateNotifications]) => (
-
-
-
- {date}
-
-
-
- {dateNotifications.map(notification => (
- {
- if (onMarkAsRead) {
- onMarkAsRead(notification.id);
- }
- if (onNotificationClick) {
- onNotificationClick(notification);
- }
- }}
- showUnreadIndicator={true}
- />
- ))}
-
-
- ))}
+
+ {SECTION_CONFIG.map(section => {
+ const sectionItems = groupedNotifications[section.key];
+
+ return (
+
+
+
+ {section.title}
+
+
+
+ {sectionItems.length === 0 ? (
+
+
+ {section.emptyTitle}
+
+
+ {section.emptyDescription}
+
+
+ ) : (
+
+
+ {sectionItems.map(notification => (
+
+ {
+ if (onMarkAsRead) {
+ onMarkAsRead(notification.id);
+ }
+ if (onNotificationClick) {
+ onNotificationClick(notification);
+ }
+ }}
+ showUnreadIndicator={true}
+ disableNavigation={true}
+ />
+
+ ))}
+
+
+ )}
+
+ );
+ })}
);
};
diff --git a/components/providers/notification-provider.tsx b/components/providers/notification-provider.tsx
new file mode 100644
index 00000000..bea01341
--- /dev/null
+++ b/components/providers/notification-provider.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { createContext, useContext, useMemo, type ReactNode } from 'react';
+import { authClient } from '@/lib/auth-client';
+import {
+ useNotifications,
+ type UseNotificationsReturn,
+} from '@/hooks/useNotifications';
+import { useNotificationPolling } from '@/hooks/use-notification-polling';
+
+const NotificationContext = createContext
(null);
+
+export function NotificationProvider({ children }: { children: ReactNode }) {
+ const { data: session } = authClient.useSession();
+ const userId = session?.user?.id;
+
+ const notificationsHook = useNotifications({
+ userId,
+ limit: 50,
+ autoFetch: Boolean(userId),
+ });
+
+ useNotificationPolling(notificationsHook, {
+ interval: 30000,
+ enabled: Boolean(userId),
+ });
+
+ const value = useMemo(() => notificationsHook, [notificationsHook]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNotificationCenter(): UseNotificationsReturn {
+ const context = useContext(NotificationContext);
+ if (!context) {
+ throw new Error(
+ 'useNotificationCenter must be used within a NotificationProvider'
+ );
+ }
+ return context;
+}
diff --git a/hooks/use-notification-polling.ts b/hooks/use-notification-polling.ts
index 210c1294..68ac2052 100644
--- a/hooks/use-notification-polling.ts
+++ b/hooks/use-notification-polling.ts
@@ -6,15 +6,11 @@ interface UseNotificationPollingOptions {
enabled?: boolean;
}
-/**
- * Hook to poll for new notifications at a specified interval
- * Only polls when component is mounted and enabled is true
- */
export const useNotificationPolling = (
notificationsHook: UseNotificationsReturn,
options: UseNotificationPollingOptions = {}
): void => {
- const { interval = 900000000000000, enabled = false } = options;
+ const { interval = 30000, enabled = false } = options;
const intervalRef = useRef | null>(null);
const { refetch } = notificationsHook;
@@ -23,11 +19,8 @@ export const useNotificationPolling = (
return;
}
- // Poll for new notifications at specified interval
intervalRef.current = setInterval(() => {
- refetch().catch(() => {
- // Silently fail polling errors to avoid disrupting UX
- });
+ refetch().catch(() => {});
}, interval);
return () => {
diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts
index e9eb1812..8542a355 100644
--- a/hooks/useNotifications.ts
+++ b/hooks/useNotifications.ts
@@ -4,6 +4,7 @@ import { getNotifications } from '@/lib/api/notifications';
import { Notification } from '@/types/notifications';
interface UseNotificationsOptions {
+ userId?: string;
page?: number;
limit?: number;
autoFetch?: boolean;
@@ -28,14 +29,16 @@ export interface UseNotificationsReturn {
export function useNotifications(
input?: string | UseNotificationsOptions
): UseNotificationsReturn {
- // Handle overloaded arguments
- const userId = typeof input === 'string' ? input : undefined;
+ const userId =
+ typeof input === 'string' ? input : (input?.userId as string | undefined);
const options = typeof input === 'object' ? input : {};
const { page: initialPage = 1, limit = 10, autoFetch = true } = options;
+ const shouldConnectSocket = Boolean(userId);
const { socket, isConnected } = useSocket({
namespace: '/notifications',
userId,
+ autoConnect: shouldConnectSocket,
});
const [notifications, setNotifications] = useState([]);
@@ -45,7 +48,6 @@ export function useNotifications(
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(initialPage);
- // Fetch notifications with pagination
const fetchNotifications = useCallback(async () => {
try {
setLoading(true);
@@ -53,7 +55,6 @@ export function useNotifications(
const response = await getNotifications(currentPage, limit);
if (response && Array.isArray(response.notifications)) {
- // Sort notifications by createdAt desc to ensure correct order
const sorted = [...response.notifications].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
@@ -71,27 +72,33 @@ export function useNotifications(
}
}, [currentPage, limit]);
- // Initial fetch
useEffect(() => {
if (autoFetch) {
fetchNotifications();
}
}, [fetchNotifications, autoFetch]);
- // Request initial unread count when socket connects
+ useEffect(() => {
+ if (!userId) {
+ setNotifications([]);
+ setUnreadCount(0);
+ setTotal(0);
+ setError(null);
+ setLoading(false);
+ }
+ }, [userId]);
+
useEffect(() => {
if (socket && isConnected) {
socket.emit('get-unread-count');
}
}, [socket, isConnected]);
- // Set up event listeners
useEffect(() => {
if (!socket) {
return;
}
- // Listen for new notifications
const handleNotification = (notification: any) => {
const normalizedNotification: Notification = {
...notification,
@@ -103,15 +110,11 @@ export function useNotifications(
};
setNotifications(prev => {
- // Avoid duplicates
const exists = prev.some(n => n.id === normalizedNotification.id);
if (exists) {
return prev;
}
- // Add new notification and resort
- // Note: For pagination, real-time updates might be tricky.
- // We typically add it to the top if we are on page 1.
if (currentPage === 1) {
return [normalizedNotification, ...prev];
}
@@ -120,12 +123,10 @@ export function useNotifications(
setUnreadCount(prev => prev + 1);
};
- // Listen for unread count updates
const handleUnreadCount = (data: { count: number }) => {
setUnreadCount(data.count);
};
- // Listen for notification updates
const handleNotificationUpdated = (data: any) => {
const id = data.notificationId || data.id || data._id;
if (id) {
@@ -135,7 +136,6 @@ export function useNotifications(
}
};
- // Listen for all notifications read
const handleAllRead = () => {
setNotifications(prev => prev.map(notif => ({ ...notif, read: true })));
setUnreadCount(0);
@@ -161,10 +161,20 @@ export function useNotifications(
}, [socket, currentPage]);
const markAsRead = (notificationId: string) => {
+ const didChangeUnread = notifications.some(
+ notification => notification.id === notificationId && !notification.read
+ );
setNotifications(prev =>
- prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
+ prev.map(n => {
+ if (n.id === notificationId) {
+ return { ...n, read: true };
+ }
+ return n;
+ })
);
- setUnreadCount(prev => Math.max(0, prev - 1));
+ if (didChangeUnread) {
+ setUnreadCount(prev => Math.max(0, prev - 1));
+ }
if (socket && isConnected) {
socket.emit('mark-read', { notificationId });
@@ -172,11 +182,24 @@ export function useNotifications(
};
const markNotificationAsRead = async (ids: string[]) => {
- // Optimistic
+ const unreadNotificationIds = new Set(
+ notifications.filter(notification => !notification.read).map(n => n.id)
+ );
+ const changedUnreadCount = ids.filter(id =>
+ unreadNotificationIds.has(id)
+ ).length;
+
setNotifications(prev =>
- prev.map(n => (ids.includes(n.id) ? { ...n, read: true } : n))
+ prev.map(n => {
+ if (ids.includes(n.id)) {
+ return { ...n, read: true };
+ }
+ return n;
+ })
);
- // Note: unread count update is approximate here, ideally we wait for socket update
+ if (changedUnreadCount > 0) {
+ setUnreadCount(prev => Math.max(0, prev - changedUnreadCount));
+ }
if (socket && isConnected) {
ids.forEach(id => socket.emit('mark-read', { notificationId: id }));