Files
maternal-app/maternal-web/components/notifications/NotificationBell.tsx
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

301 lines
9.4 KiB
TypeScript

'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>
</>
);
}