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
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:
300
maternal-web/components/notifications/NotificationBell.tsx
Normal file
300
maternal-web/components/notifications/NotificationBell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user