212 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|