Files
maternal-app/maternal-web/hooks/useNotifications.ts
Andrei 090fe7f63b
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
feat: Add notification bell UI component to header
Implements Phase 1 of notification bell feature with:
- NotificationBell component with badge counter (99+ max)
- useNotifications hook with 30s polling and caching
- Notifications API client for backend integration
- Integration into AppShell header next to user avatar
- Responsive dropdown (400px desktop, full-width mobile)
- Empty state, loading, and error handling
- Optimistic UI updates for mark as read
- Animated badge with pulse effect
- Icon mapping for notification types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 13:24:56 +00:00

160 lines
4.5 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { notificationsApi, Notification } from '@/lib/api/notifications';
const POLL_INTERVAL = 30000; // 30 seconds
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface UseNotificationsReturn {
notifications: Notification[];
unreadCount: number;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
markAsRead: (notificationId: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
dismiss: (notificationId: string) => Promise<void>;
}
export function useNotifications(options?: {
limit?: number;
autoRefresh?: boolean;
}): UseNotificationsReturn {
const { limit = 10, autoRefresh = true } = options || {};
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const lastFetchRef = useRef<number>(0);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const fetchNotifications = useCallback(async (force = false) => {
const now = Date.now();
// Use cache if data is fresh and not forced
if (!force && now - lastFetchRef.current < CACHE_DURATION) {
return;
}
try {
setError(null);
const response = await notificationsApi.getNotifications({
limit,
offset: 0,
});
setNotifications(response.notifications);
setUnreadCount(response.unreadCount);
lastFetchRef.current = now;
} catch (err: any) {
console.error('❌ Failed to fetch notifications:', err);
setError(err.response?.data?.message || 'Failed to load notifications');
} finally {
setLoading(false);
}
}, [limit]);
const refresh = useCallback(async () => {
setLoading(true);
await fetchNotifications(true);
}, [fetchNotifications]);
const markAsRead = useCallback(async (notificationId: string) => {
try {
// Optimistic update
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, isRead: true, readAt: new Date().toISOString() } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
// API call
await notificationsApi.markAsRead(notificationId);
} catch (err: any) {
console.error('❌ Failed to mark notification as read:', err);
// Revert optimistic update on error
await refresh();
}
}, [refresh]);
const markAllAsRead = useCallback(async () => {
try {
// Optimistic update
setNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
);
setUnreadCount(0);
// API call
await notificationsApi.markAllAsRead();
} catch (err: any) {
console.error('❌ Failed to mark all notifications as read:', err);
// Revert optimistic update on error
await refresh();
}
}, [refresh]);
const dismiss = useCallback(async (notificationId: string) => {
try {
// Optimistic update
setNotifications((prev) => prev.filter((n) => n.id !== notificationId));
setUnreadCount((prev) => {
const notification = notifications.find((n) => n.id === notificationId);
return notification && !notification.isRead ? Math.max(0, prev - 1) : prev;
});
// API call
await notificationsApi.dismiss(notificationId);
} catch (err: any) {
console.error('❌ Failed to dismiss notification:', err);
// Revert optimistic update on error
await refresh();
}
}, [notifications, refresh]);
// Initial fetch
useEffect(() => {
fetchNotifications(true);
}, [fetchNotifications]);
// Setup polling
useEffect(() => {
if (!autoRefresh) return;
pollIntervalRef.current = setInterval(() => {
fetchNotifications(false);
}, POLL_INTERVAL);
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [autoRefresh, fetchNotifications]);
// Refresh on tab focus
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
fetchNotifications(false);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [fetchNotifications]);
return {
notifications,
unreadCount,
loading,
error,
refresh,
markAsRead,
markAllAsRead,
dismiss,
};
}