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>
301 lines
9.4 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|