Files
motovaultpro/frontend/src/features/notifications/components/NotificationBell.tsx
2026-02-13 20:02:56 -06:00

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>
</>
);
};