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 */}
+
+
+
+
-
= {
+ 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;
+ },
+};