feat: Add notification bell UI component to header
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
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
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>
This commit is contained in:
159
maternal-web/hooks/useNotifications.ts
Normal file
159
maternal-web/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user