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 { 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
|
||||
|
||||
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