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

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:
Andrei
2025-10-09 13:24:56 +00:00
parent 2dbfb79e72
commit 090fe7f63b
4 changed files with 585 additions and 51 deletions

View File

@@ -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) => {
</Box>
</Box>
{/* Right Side - User Menu Button with Status Indicator */}
<Box sx={{ width: 80, display: 'flex', justifyContent: 'flex-end' }}>
<Tooltip title={isConnected ? t('connection.syncActive') : t('connection.syncDisconnected')}>
<IconButton
onClick={handleMenuOpen}
size="medium"
aria-label="user menu"
aria-controls={anchorEl ? 'user-menu' : undefined}
aria-haspopup="true"
aria-expanded={anchorEl ? 'true' : undefined}
sx={{
minWidth: 44,
minHeight: 44,
position: 'relative',
}}
>
<Avatar
src={user?.photoUrl || undefined}
alt={user?.name ? `${user.name}'s profile photo` : 'User profile photo'}
key={user?.photoUrl || 'no-photo'} // Force re-render when photoUrl changes
{/* Right Side - Notifications & User Menu */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<NotificationBell />
<Tooltip title={isConnected ? t('connection.syncActive') : t('connection.syncDisconnected')}>
<IconButton
onClick={handleMenuOpen}
size="medium"
aria-label="user menu"
aria-controls={anchorEl ? 'user-menu' : undefined}
aria-haspopup="true"
aria-expanded={anchorEl ? 'true' : undefined}
sx={{
width: 36,
height: 36,
bgcolor: 'primary.main',
fontSize: '0.875rem',
}}
imgProps={{
onError: (e: any) => {
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'}
</Avatar>
{/* Status Dot Indicator */}
<Box
sx={{
position: 'absolute',
bottom: 2,
right: 2,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? '#4caf50' : '#9e9e9e',
border: '2px solid',
borderColor: 'background.paper',
boxShadow: 1,
}}
aria-label={isConnected ? 'Online' : 'Offline'}
/>
</IconButton>
</Tooltip>
<Avatar
src={user?.photoUrl || undefined}
alt={user?.name ? `${user.name}'s profile photo` : 'User profile photo'}
key={user?.photoUrl || 'no-photo'} // Force re-render when photoUrl changes
sx={{
width: 36,
height: 36,
bgcolor: 'primary.main',
fontSize: '0.875rem',
}}
imgProps={{
onError: (e: any) => {
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'}
</Avatar>
{/* Status Dot Indicator */}
<Box
sx={{
position: 'absolute',
bottom: 2,
right: 2,
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? '#4caf50' : '#9e9e9e',
border: '2px solid',
borderColor: 'background.paper',
boxShadow: 1,
}}
aria-label={isConnected ? 'Online' : 'Offline'}
/>
</IconButton>
</Tooltip>
</Box>
<Menu
id="user-menu"
@@ -248,7 +252,6 @@ export const AppShell = ({ children }: AppShellProps) => {
<ListItemText>{t('navigation.logout')}</ListItemText>
</MenuItem>
</Menu>
</Box>
</Box>
<Container

View File

@@ -0,0 +1,300 @@
'use client';
import { useState, MouseEvent } from 'react';
import {
IconButton,
Badge,
Popover,
Box,
Typography,
List,
ListItem,
ListItemButton,
ListItemText,
Button,
CircularProgress,
Alert,
Divider,
useTheme,
useMediaQuery,
} from '@mui/material';
import {
Notifications as NotificationsIcon,
Circle,
Restaurant,
Bedtime,
ChildCare,
Medication,
EmojiEvents,
Group,
Settings,
NotificationsOff,
} from '@mui/icons-material';
import { formatDistanceToNow } from 'date-fns';
import { useNotifications } from '@/hooks/useNotifications';
import { Notification } from '@/lib/api/notifications';
interface NotificationBellProps {
variant?: 'default' | 'compact';
}
const notificationIcons: Record<Notification['type'], React.ComponentType> = {
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 | HTMLElement>(null);
const {
notifications,
unreadCount,
loading,
error,
refresh,
markAsRead,
markAllAsRead,
} = useNotifications({ limit: 10 });
const open = Boolean(anchorEl);
const handleClick = (event: MouseEvent<HTMLElement>) => {
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 <IconComponent fontSize="small" />;
};
const formatTimestamp = (timestamp: string) => {
try {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
} catch {
return 'Recently';
}
};
const displayCount = unreadCount > 99 ? '99+' : unreadCount;
return (
<>
<IconButton
onClick={handleClick}
sx={{
color: open ? 'primary.main' : 'inherit',
transition: 'all 0.2s',
'&:hover': {
backgroundColor: 'action.hover',
},
}}
aria-label={`${unreadCount} unread notifications`}
>
<Badge
badgeContent={displayCount}
color="error"
max={99}
sx={{
'& .MuiBadge-badge': {
animation: unreadCount > 0 ? 'pulse 2s ease-in-out infinite' : 'none',
'@keyframes pulse': {
'0%, 100%': {
transform: 'scale(1)',
},
'50%': {
transform: 'scale(1.1)',
},
},
},
}}
>
<NotificationsIcon />
</Badge>
</IconButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
slotProps={{
paper: {
sx: {
width: isMobile ? '100vw' : 400,
maxHeight: 600,
mt: 1,
borderRadius: isMobile ? 0 : 2,
boxShadow: theme.shadows[8],
},
},
}}
>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" fontWeight="600">
Notifications
</Typography>
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllAsRead}>
Mark all read
</Button>
)}
</Box>
</Box>
<Box sx={{ maxHeight: 480, overflowY: 'auto' }}>
{loading && notifications.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={32} />
</Box>
) : error ? (
<Box sx={{ p: 2 }}>
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
<Button onClick={refresh} fullWidth sx={{ mt: 1 }}>
Retry
</Button>
</Box>
) : notifications.length === 0 ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 4,
textAlign: 'center',
}}
>
<NotificationsOff sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="body1" color="text.secondary" fontWeight="500">
No notifications
</Typography>
<Typography variant="body2" color="text.disabled" sx={{ mt: 0.5 }}>
You're all caught up!
</Typography>
</Box>
) : (
<List sx={{ p: 0 }}>
{notifications.map((notification, index) => (
<Box key={notification.id}>
{index > 0 && <Divider />}
<ListItem disablePadding>
<ListItemButton
onClick={() => handleNotificationClick(notification)}
sx={{
px: 2,
py: 1.5,
backgroundColor: notification.isRead ? 'transparent' : 'action.hover',
'&:hover': {
backgroundColor: notification.isRead ? 'action.hover' : 'action.selected',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, width: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: '50%',
backgroundColor: 'primary.light',
color: 'primary.main',
flexShrink: 0,
}}
>
{getNotificationIcon(notification.type)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{!notification.isRead && (
<Circle sx={{ fontSize: 8, color: 'primary.main', mt: 0.75 }} />
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={notification.isRead ? 400 : 600}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{notification.title}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
mt: 0.5,
}}
>
{notification.message}
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ mt: 0.5, display: 'block' }}>
{formatTimestamp(notification.createdAt)}
</Typography>
</Box>
</Box>
</Box>
</Box>
</ListItemButton>
</ListItem>
</Box>
))}
</List>
)}
</Box>
{notifications.length > 0 && (
<Box sx={{ p: 1.5, borderTop: 1, borderColor: 'divider', textAlign: 'center' }}>
<Button fullWidth size="small" href="/notifications" onClick={handleClose}>
View all notifications
</Button>
</Box>
)}
</Popover>
</>
);
}

View 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,
};
}

View File

@@ -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<string, any>;
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<GetNotificationsResponse> {
const { data } = await apiClient.get<GetNotificationsResponse>('/notifications', { params });
return data;
},
/**
* Mark a notification as read
*/
async markAsRead(notificationId: string): Promise<Notification> {
const { data } = await apiClient.patch<Notification>(`/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<Notification> {
const { data } = await apiClient.patch<Notification>(`/notifications/${notificationId}/dismiss`);
return data;
},
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const { data } = await apiClient.get<GetNotificationsResponse>('/notifications', {
params: { limit: 1, isRead: false },
});
return data.unreadCount;
},
};