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:
@@ -24,6 +24,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { NotificationBell } from '@/components/notifications/NotificationBell';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -149,59 +150,62 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right Side - User Menu Button with Status Indicator */}
|
{/* Right Side - Notifications & User Menu */}
|
||||||
<Box sx={{ width: 80, display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Tooltip title={isConnected ? t('connection.syncActive') : t('connection.syncDisconnected')}>
|
<NotificationBell />
|
||||||
<IconButton
|
|
||||||
onClick={handleMenuOpen}
|
<Tooltip title={isConnected ? t('connection.syncActive') : t('connection.syncDisconnected')}>
|
||||||
size="medium"
|
<IconButton
|
||||||
aria-label="user menu"
|
onClick={handleMenuOpen}
|
||||||
aria-controls={anchorEl ? 'user-menu' : undefined}
|
size="medium"
|
||||||
aria-haspopup="true"
|
aria-label="user menu"
|
||||||
aria-expanded={anchorEl ? 'true' : undefined}
|
aria-controls={anchorEl ? 'user-menu' : undefined}
|
||||||
sx={{
|
aria-haspopup="true"
|
||||||
minWidth: 44,
|
aria-expanded={anchorEl ? 'true' : undefined}
|
||||||
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
|
|
||||||
sx={{
|
sx={{
|
||||||
width: 36,
|
minWidth: 44,
|
||||||
height: 36,
|
minHeight: 44,
|
||||||
bgcolor: 'primary.main',
|
position: 'relative',
|
||||||
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
|
||||||
</Avatar>
|
src={user?.photoUrl || undefined}
|
||||||
{/* Status Dot Indicator */}
|
alt={user?.name ? `${user.name}'s profile photo` : 'User profile photo'}
|
||||||
<Box
|
key={user?.photoUrl || 'no-photo'} // Force re-render when photoUrl changes
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
width: 36,
|
||||||
bottom: 2,
|
height: 36,
|
||||||
right: 2,
|
bgcolor: 'primary.main',
|
||||||
width: 12,
|
fontSize: '0.875rem',
|
||||||
height: 12,
|
}}
|
||||||
borderRadius: '50%',
|
imgProps={{
|
||||||
bgcolor: isConnected ? '#4caf50' : '#9e9e9e',
|
onError: (e: any) => {
|
||||||
border: '2px solid',
|
console.error('Avatar image failed to load:', user?.photoUrl?.substring(0, 50));
|
||||||
borderColor: 'background.paper',
|
e.target.style.display = 'none'; // Hide broken image, show fallback
|
||||||
boxShadow: 1,
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={isConnected ? 'Online' : 'Offline'}
|
>
|
||||||
/>
|
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||||
</IconButton>
|
</Avatar>
|
||||||
</Tooltip>
|
{/* 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
|
<Menu
|
||||||
id="user-menu"
|
id="user-menu"
|
||||||
@@ -248,7 +252,6 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<ListItemText>{t('navigation.logout')}</ListItemText>
|
<ListItemText>{t('navigation.logout')}</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
maternal-web/hooks/useNotifications.ts
Normal file
159
maternal-web/hooks/useNotifications.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
72
maternal-web/lib/api/notifications.ts
Normal file
72
maternal-web/lib/api/notifications.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user