From 090fe7f63b6ca14df8ba461fe7adde1d277843b8 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 9 Oct 2025 13:24:56 +0000 Subject: [PATCH] feat: Add notification bell UI component to header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/layouts/AppShell/AppShell.tsx | 105 +++--- .../notifications/NotificationBell.tsx | 300 ++++++++++++++++++ maternal-web/hooks/useNotifications.ts | 159 ++++++++++ maternal-web/lib/api/notifications.ts | 72 +++++ 4 files changed, 585 insertions(+), 51 deletions(-) create mode 100644 maternal-web/components/notifications/NotificationBell.tsx create mode 100644 maternal-web/hooks/useNotifications.ts create mode 100644 maternal-web/lib/api/notifications.ts diff --git a/maternal-web/components/layouts/AppShell/AppShell.tsx b/maternal-web/components/layouts/AppShell/AppShell.tsx index 3607f98..f065c5c 100644 --- a/maternal-web/components/layouts/AppShell/AppShell.tsx +++ b/maternal-web/components/layouts/AppShell/AppShell.tsx @@ -24,6 +24,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth/AuthContext'; import Link from 'next/link'; +import { NotificationBell } from '@/components/notifications/NotificationBell'; interface AppShellProps { children: ReactNode; @@ -149,59 +150,62 @@ export const AppShell = ({ children }: AppShellProps) => { - {/* Right Side - User Menu Button with Status Indicator */} - - - - + + + + { - console.error('Avatar image failed to load:', user?.photoUrl?.substring(0, 50)); - e.target.style.display = 'none'; // Hide broken image, show fallback - } + minWidth: 44, + minHeight: 44, + position: 'relative', }} > - {user?.name?.charAt(0).toUpperCase() || 'U'} - - {/* Status Dot Indicator */} - - - + { + console.error('Avatar image failed to load:', user?.photoUrl?.substring(0, 50)); + e.target.style.display = 'none'; // Hide broken image, show fallback + } + }} + > + {user?.name?.charAt(0).toUpperCase() || 'U'} + + {/* Status Dot Indicator */} + + + + { {t('navigation.logout')} - = { + feeding: Restaurant, + sleep: Bedtime, + diaper: ChildCare, + medication: Medication, + milestone: EmojiEvents, + family: Group, + system: Settings, + reminder: NotificationsIcon, +}; + +export function NotificationBell({ variant = 'default' }: NotificationBellProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [anchorEl, setAnchorEl] = useState(null); + + const { + notifications, + unreadCount, + loading, + error, + refresh, + markAsRead, + markAllAsRead, + } = useNotifications({ limit: 10 }); + + const open = Boolean(anchorEl); + + const handleClick = (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleNotificationClick = async (notification: Notification) => { + if (!notification.isRead) { + await markAsRead(notification.id); + } + // TODO: Navigate to related content based on notification.data + }; + + const handleMarkAllAsRead = async () => { + await markAllAsRead(); + }; + + const getNotificationIcon = (type: Notification['type']) => { + const IconComponent = notificationIcons[type] || NotificationsIcon; + return ; + }; + + const formatTimestamp = (timestamp: string) => { + try { + return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); + } catch { + return 'Recently'; + } + }; + + const displayCount = unreadCount > 99 ? '99+' : unreadCount; + + return ( + <> + + 0 ? 'pulse 2s ease-in-out infinite' : 'none', + '@keyframes pulse': { + '0%, 100%': { + transform: 'scale(1)', + }, + '50%': { + transform: 'scale(1.1)', + }, + }, + }, + }} + > + + + + + + + + + Notifications + + {unreadCount > 0 && ( + + )} + + + + + {loading && notifications.length === 0 ? ( + + + + ) : error ? ( + + + {error} + + + + ) : notifications.length === 0 ? ( + + + + No notifications + + + You're all caught up! + + + ) : ( + + {notifications.map((notification, index) => ( + + {index > 0 && } + + handleNotificationClick(notification)} + sx={{ + px: 2, + py: 1.5, + backgroundColor: notification.isRead ? 'transparent' : 'action.hover', + '&:hover': { + backgroundColor: notification.isRead ? 'action.hover' : 'action.selected', + }, + }} + > + + + {getNotificationIcon(notification.type)} + + + + + {!notification.isRead && ( + + )} + + + {notification.title} + + + {notification.message} + + + {formatTimestamp(notification.createdAt)} + + + + + + + + + ))} + + )} + + + {notifications.length > 0 && ( + + + + )} + + + ); +} diff --git a/maternal-web/hooks/useNotifications.ts b/maternal-web/hooks/useNotifications.ts new file mode 100644 index 0000000..2591ec1 --- /dev/null +++ b/maternal-web/hooks/useNotifications.ts @@ -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; + markAsRead: (notificationId: string) => Promise; + markAllAsRead: () => Promise; + dismiss: (notificationId: string) => Promise; +} + +export function useNotifications(options?: { + limit?: number; + autoRefresh?: boolean; +}): UseNotificationsReturn { + const { limit = 10, autoRefresh = true } = options || {}; + + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const lastFetchRef = useRef(0); + const pollIntervalRef = useRef(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, + }; +} diff --git a/maternal-web/lib/api/notifications.ts b/maternal-web/lib/api/notifications.ts new file mode 100644 index 0000000..916b6d5 --- /dev/null +++ b/maternal-web/lib/api/notifications.ts @@ -0,0 +1,72 @@ +import { apiClient } from './client'; + +export interface Notification { + id: string; + userId: string; + type: 'feeding' | 'sleep' | 'diaper' | 'medication' | 'milestone' | 'family' | 'system' | 'reminder'; + title: string; + message: string; + data?: Record; + isRead: boolean; + isDismissed: boolean; + createdAt: string; + readAt?: string | null; + dismissedAt?: string | null; +} + +export interface GetNotificationsParams { + limit?: number; + offset?: number; + isRead?: boolean; + type?: Notification['type']; +} + +export interface GetNotificationsResponse { + notifications: Notification[]; + total: number; + unreadCount: number; +} + +export const notificationsApi = { + /** + * Get user notifications with optional filters + */ + async getNotifications(params?: GetNotificationsParams): Promise { + const { data } = await apiClient.get('/notifications', { params }); + return data; + }, + + /** + * Mark a notification as read + */ + async markAsRead(notificationId: string): Promise { + const { data } = await apiClient.patch(`/notifications/${notificationId}/read`); + return data; + }, + + /** + * Mark all notifications as read + */ + async markAllAsRead(): Promise<{ count: number }> { + const { data } = await apiClient.patch<{ count: number }>('/notifications/mark-all-read'); + return data; + }, + + /** + * Dismiss a notification + */ + async dismiss(notificationId: string): Promise { + const { data } = await apiClient.patch(`/notifications/${notificationId}/dismiss`); + return data; + }, + + /** + * Get unread notification count + */ + async getUnreadCount(): Promise { + const { data } = await apiClient.get('/notifications', { + params: { limit: 1, isRead: false }, + }); + return data.unreadCount; + }, +};