Files
gridpilot.gg/apps/website/components/notifications/NotificationCenter.tsx
2025-12-24 21:44:58 +01:00

212 lines
7.9 KiB
TypeScript

'use client';
import {
AlertTriangle,
Bell,
CheckCheck,
ExternalLink,
Flag,
Shield,
Trophy,
Users,
Vote
} from 'lucide-react';
import { useRouter } from 'next/navigation';
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';
export default function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const router = useRouter();
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) {
router.push(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 (
<div className="relative" ref={panelRef}>
{/* Bell button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
relative p-2 rounded-lg transition-colors
${isOpen
? 'bg-primary-blue/10 text-primary-blue'
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
}
`}
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold bg-red-500 text-white rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Notification panel */}
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-96 bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-primary-blue" />
<span className="font-semibold text-white">Notifications</span>
{unreadCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{unreadCount} new
</span>
)}
</div>
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</button>
)}
</div>
{/* Notifications list */}
<div className="max-h-[400px] overflow-y-auto">
{notifications.length === 0 ? (
<div className="py-12 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Bell className="w-6 h-6 text-gray-500" />
</div>
<p className="text-sm text-gray-400">No notifications yet</p>
<p className="text-xs text-gray-500 mt-1">
You'll be notified about protests, races, and more
</p>
</div>
) : (
<div className="divide-y divide-charcoal-outline/50">
{notifications.map((notification) => {
const Icon = notificationIcons[notification.type] || Bell;
const colorClass = notificationColors[notification.type] || 'text-gray-400 bg-gray-400/10';
return (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`
w-full text-left px-4 py-3 transition-colors hover:bg-iron-gray/30
${!notification.read ? 'bg-primary-blue/5' : ''}
`}
>
<div className="flex gap-3">
<div className={`p-2 rounded-lg flex-shrink-0 ${colorClass}`}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm font-medium truncate ${
!notification.read ? 'text-white' : 'text-gray-300'
}`}>
{notification.title}
</p>
{!notification.read && (
<span className="w-2 h-2 bg-primary-blue rounded-full flex-shrink-0 mt-1.5" />
)}
</div>
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] text-gray-600">
{formatTime(notification.createdAt)}
</span>
{notification.actionUrl && (
<span className="flex items-center gap-0.5 text-[10px] text-primary-blue">
<ExternalLink className="w-2.5 h-2.5" />
View
</span>
)}
</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-charcoal-outline bg-iron-gray/20">
<p className="text-[10px] text-gray-500 text-center">
Showing {notifications.length} notification{notifications.length !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
</div>
);
}