Files
gridpilot.gg/apps/website/components/notifications/NotificationCenter.tsx
2026-01-18 23:24:30 +01:00

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&apos;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>
);
}