218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
/**
|
|
* @ai-summary Bell icon with dropdown for in-app notifications
|
|
* @ai-context Displays in header with unread count badge
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
IconButton,
|
|
Badge,
|
|
Popover,
|
|
Box,
|
|
Typography,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
Button,
|
|
Divider,
|
|
CircularProgress,
|
|
} from '@mui/material';
|
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
import CheckIcon from '@mui/icons-material/Check';
|
|
import { useInAppNotifications } from '../hooks/useInAppNotifications';
|
|
|
|
// Helper function for relative time
|
|
function formatTimeAgo(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMins < 1) return 'just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
export const NotificationBell: React.FC = () => {
|
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
|
const {
|
|
notifications,
|
|
unreadCount,
|
|
isLoading,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
deleteNotification,
|
|
} = useInAppNotifications();
|
|
|
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
setAnchorEl(event.currentTarget);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setAnchorEl(null);
|
|
};
|
|
|
|
const handleMarkAsRead = async (id: string) => {
|
|
try {
|
|
await markAsRead(id);
|
|
} catch (error) {
|
|
console.error('Failed to mark as read:', error);
|
|
}
|
|
};
|
|
|
|
const handleMarkAllAsRead = async () => {
|
|
try {
|
|
await markAllAsRead();
|
|
} catch (error) {
|
|
console.error('Failed to mark all as read:', error);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await deleteNotification(id);
|
|
} catch (error) {
|
|
console.error('Failed to delete notification:', error);
|
|
}
|
|
};
|
|
|
|
const open = Boolean(anchorEl);
|
|
|
|
return (
|
|
<>
|
|
<IconButton
|
|
onClick={handleClick}
|
|
sx={{
|
|
color: 'text.secondary',
|
|
minWidth: 44,
|
|
minHeight: 44,
|
|
}}
|
|
aria-label="notifications"
|
|
>
|
|
<Badge badgeContent={unreadCount} color="error" max={99}>
|
|
<NotificationsIcon />
|
|
</Badge>
|
|
</IconButton>
|
|
|
|
<Popover
|
|
open={open}
|
|
anchorEl={anchorEl}
|
|
onClose={handleClose}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
slotProps={{
|
|
paper: {
|
|
sx: {
|
|
width: { xs: '100vw', sm: 360 },
|
|
maxWidth: 360,
|
|
maxHeight: 480
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>Notifications</Typography>
|
|
{unreadCount > 0 && (
|
|
<Button size="small" onClick={handleMarkAllAsRead}>
|
|
Mark all read
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
<Divider />
|
|
|
|
{isLoading ? (
|
|
<Box sx={{ p: 3, display: 'flex', justifyContent: 'center' }}>
|
|
<CircularProgress size={24} />
|
|
</Box>
|
|
) : notifications.length === 0 ? (
|
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
|
<NotificationsIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
|
|
<Typography color="text.secondary" fontWeight={500}>No notifications</Typography>
|
|
<Typography variant="caption" color="text.disabled">
|
|
You're all caught up
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<List sx={{ py: 0, maxHeight: 360, overflow: 'auto' }}>
|
|
{notifications.map((notification) => (
|
|
<ListItem
|
|
key={notification.id}
|
|
sx={{
|
|
bgcolor: notification.isRead ? 'transparent' : 'action.hover',
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
py: 1.5,
|
|
px: 2,
|
|
alignItems: 'flex-start',
|
|
}}
|
|
secondaryAction={
|
|
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
|
|
{!notification.isRead && (
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleMarkAsRead(notification.id)}
|
|
title="Mark as read"
|
|
sx={{ minWidth: 36, minHeight: 36 }}
|
|
>
|
|
<CheckIcon fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleDelete(notification.id)}
|
|
title="Delete"
|
|
sx={{ minWidth: 36, minHeight: 36 }}
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
}
|
|
>
|
|
<ListItemText
|
|
sx={{ pr: 8 }}
|
|
primary={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: notification.isRead ? 400 : 600,
|
|
mb: 0.5
|
|
}}
|
|
>
|
|
{notification.title}
|
|
</Typography>
|
|
}
|
|
secondary={
|
|
<>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden',
|
|
mb: 0.5
|
|
}}
|
|
>
|
|
{notification.message}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.disabled">
|
|
{formatTimeAgo(notification.createdAt)}
|
|
</Typography>
|
|
</>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Popover>
|
|
</>
|
|
);
|
|
};
|