Files
gridpilot.gg/apps/website/components/notifications/NotificationCenter.tsx
2025-12-11 00:57:32 +01:00

271 lines
9.7 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import {
getNotificationRepository,
getMarkNotificationReadUseCase,
} from '@/lib/di-container';
import type { Notification } from '@gridpilot/notifications/application';
import {
Bell,
AlertTriangle,
Shield,
Vote,
Trophy,
Users,
Flag,
X,
Check,
CheckCheck,
ExternalLink,
} from 'lucide-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',
};
export default function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
// Polling for new notifications
useEffect(() => {
const loadNotifications = async () => {
try {
const repo = getNotificationRepository();
const allNotifications = await repo.findByRecipientId(currentDriverId);
setNotifications(allNotifications);
} catch (error) {
console.error('Failed to load notifications:', error);
}
};
loadNotifications();
// Poll every 5 seconds
const interval = setInterval(loadNotifications, 5000);
return () => clearInterval(interval);
}, [currentDriverId]);
// 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 unreadCount = notifications.filter((n) => n.isUnread()).length;
const handleMarkAsRead = async (notification: Notification) => {
if (!notification.isUnread()) return;
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
const repo = getNotificationRepository();
await repo.markAllAsReadByRecipientId(currentDriverId);
// Update local state
setNotifications((prev) => prev.map((n) => n.markAsRead()));
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
const handleNotificationClick = async (notification: Notification) => {
await handleMarkAsRead(notification);
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={handleMarkAllAsRead}
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.isUnread() ? '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.isUnread() ? 'text-white' : 'text-gray-300'
}`}>
{notification.title}
</p>
{notification.isUnread() && (
<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.body}
</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>
);
}