274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Stack } from '@/ui/Stack';
|
|
import { Text } from '@/ui/Text';
|
|
import {
|
|
AlertTriangle,
|
|
Bell,
|
|
CheckCheck,
|
|
ExternalLink,
|
|
Flag,
|
|
Shield,
|
|
Trophy,
|
|
Users,
|
|
Vote
|
|
} from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const notificationIcons: Record<string, typeof Bell> = {
|
|
protest_filed: AlertTriangle,
|
|
protest_defense_requested: Shield,
|
|
protest_vote_required: Vote,
|
|
penalty_issued: AlertTriangle,
|
|
race_results_posted: Trophy,
|
|
league_invite: Users,
|
|
race_reminder: Flag,
|
|
};
|
|
|
|
const notificationColors: Record<string, string> = {
|
|
protest_filed: 'text-red-400 bg-red-400/10',
|
|
protest_defense_requested: 'text-warning-amber bg-warning-amber/10',
|
|
protest_vote_required: 'text-primary-blue bg-primary-blue/10',
|
|
penalty_issued: 'text-red-400 bg-red-400/10',
|
|
race_results_posted: 'text-performance-green bg-performance-green/10',
|
|
league_invite: 'text-primary-blue bg-primary-blue/10',
|
|
race_reminder: 'text-warning-amber bg-warning-amber/10',
|
|
};
|
|
|
|
import { useNotifications } from './NotificationProvider';
|
|
import type { Notification } from './notificationTypes';
|
|
|
|
interface NotificationCenterProps {
|
|
onNavigate?: (href: string) => void;
|
|
}
|
|
|
|
export function NotificationCenter({ onNavigate }: NotificationCenterProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
|
|
|
|
// Close panel when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const handleNotificationClick = (notification: Notification) => {
|
|
markAsRead(notification.id);
|
|
|
|
if (notification.actionUrl && onNavigate) {
|
|
onNavigate(notification.actionUrl);
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const formatTime = (date: Date) => {
|
|
const now = new Date();
|
|
const diff = now.getTime() - new Date(date).getTime();
|
|
|
|
const minutes = Math.floor(diff / (1000 * 60));
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
|
|
if (minutes < 1) return 'Just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
if (hours < 24) return `${hours}h ago`;
|
|
if (days < 7) return `${days}d ago`;
|
|
|
|
return new Date(date).toLocaleDateString();
|
|
};
|
|
|
|
return (
|
|
<Stack position="relative" ref={panelRef}>
|
|
{/* Bell button */}
|
|
<Stack
|
|
as="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
p={2}
|
|
rounded="lg"
|
|
transition
|
|
bg={isOpen ? 'bg-primary-blue/10' : undefined}
|
|
color={isOpen ? 'text-primary-blue' : 'text-gray-400'}
|
|
hoverBg={!isOpen ? 'bg-iron-gray/50' : undefined}
|
|
hoverTextColor={!isOpen ? 'text-white' : undefined}
|
|
position="relative"
|
|
>
|
|
<Icon icon={Bell} size={5} />
|
|
{unreadCount > 0 && (
|
|
<Stack
|
|
position="absolute"
|
|
top="-0.5"
|
|
right="-0.5"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
minWidth="18px"
|
|
height="18px"
|
|
px={1}
|
|
bg="bg-red-500"
|
|
color="text-white"
|
|
rounded="full"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px', fontWeight: 'bold' }}
|
|
>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Notification panel */}
|
|
{isOpen && (
|
|
<Stack
|
|
position="absolute"
|
|
right="0"
|
|
top="full"
|
|
mt={2}
|
|
w="96"
|
|
bg="bg-deep-graphite"
|
|
border
|
|
borderColor="border-charcoal-outline"
|
|
rounded="xl"
|
|
shadow="2xl"
|
|
overflow="hidden"
|
|
zIndex={50}
|
|
>
|
|
{/* Header */}
|
|
<Stack display="flex" alignItems="center" justifyContent="between" px={4} py={3} borderBottom borderColor="border-charcoal-outline">
|
|
<Stack display="flex" alignItems="center" gap={2}>
|
|
<Icon icon={Bell} size={4} color="text-primary-blue" />
|
|
<Text weight="semibold" color="text-white">Notifications</Text>
|
|
{unreadCount > 0 && (
|
|
<Stack px={2} py={0.5} bg="bg-red-500/20" rounded="full">
|
|
<Text size="xs" weight="medium" color="text-red-400">
|
|
{unreadCount} new
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
{unreadCount > 0 && (
|
|
<Stack
|
|
as="button"
|
|
onClick={markAllAsRead}
|
|
display="flex"
|
|
alignItems="center"
|
|
gap={1}
|
|
cursor="pointer"
|
|
transition
|
|
hoverTextColor="text-white"
|
|
>
|
|
<Icon icon={CheckCheck} size={3.5} color="text-gray-400" />
|
|
<Text size="xs" color="text-gray-400">Mark all read</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Notifications list */}
|
|
<Stack maxHeight="400px" overflow="auto">
|
|
{notifications.length === 0 ? (
|
|
<Stack py={12} textAlign="center">
|
|
<Stack w="12" h="12" mx="auto" mb={3} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
|
|
<Icon icon={Bell} size={6} color="text-gray-500" />
|
|
</Stack>
|
|
<Text size="sm" color="text-gray-400" block>No notifications yet</Text>
|
|
<Text size="xs" color="text-gray-500" block mt={1}>
|
|
You'll be notified about protests, races, and more
|
|
</Text>
|
|
</Stack>
|
|
) : (
|
|
<Stack gap={0}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className="divide-y divide-charcoal-outline/50"
|
|
>
|
|
{notifications.map((notification) => {
|
|
const NotificationIcon = notificationIcons[notification.type] || Bell;
|
|
const colorClass = notificationColors[notification.type] || 'text-gray-400 bg-gray-400/10';
|
|
|
|
return (
|
|
<Stack
|
|
key={notification.id}
|
|
as="button"
|
|
onClick={() => handleNotificationClick(notification)}
|
|
w="full"
|
|
textAlign="left"
|
|
px={4}
|
|
py={3}
|
|
transition
|
|
hoverBg="bg-iron-gray/30"
|
|
bg={!notification.read ? 'bg-primary-blue/5' : undefined}
|
|
>
|
|
<Stack display="flex" gap={3}>
|
|
<Stack p={2} rounded="lg" flexShrink={0}
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
className={colorClass}
|
|
>
|
|
<Icon icon={NotificationIcon} size={4} />
|
|
</Stack>
|
|
<Stack flexGrow={1} minWidth="0">
|
|
<Stack display="flex" alignItems="start" justifyContent="between" gap={2}>
|
|
<Text size="sm" weight="medium" truncate color={!notification.read ? 'text-white' : 'text-gray-300'} block>
|
|
{notification.title}
|
|
</Text>
|
|
{!notification.read && (
|
|
<Stack w="2" h="2" bg="bg-primary-blue" rounded="full" flexShrink={0} mt={1.5} />
|
|
)}
|
|
</Stack>
|
|
<Text size="xs" color="text-gray-500" lineClamp={2} mt={0.5} block>
|
|
{notification.message}
|
|
</Text>
|
|
<Stack display="flex" alignItems="center" gap={2} mt={1.5}>
|
|
<Text color="text-gray-600"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
>
|
|
{formatTime(notification.createdAt)}
|
|
</Text>
|
|
{notification.actionUrl && (
|
|
<Stack display="flex" alignItems="center" gap={0.5}>
|
|
<Icon icon={ExternalLink} size={2.5} color="text-primary-blue" />
|
|
<Text color="text-primary-blue"
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
>
|
|
View
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
|
|
{/* Footer */}
|
|
{notifications.length > 0 && (
|
|
<Stack px={4} py={2} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
|
<Text color="text-gray-500" textAlign="center" block
|
|
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
style={{ fontSize: '10px' }}
|
|
>
|
|
Showing {notifications.length} notification{notifications.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|