This commit is contained in:
2025-12-09 22:45:03 +01:00
parent 3adf2e5e94
commit 3659d25e52
20 changed files with 2537 additions and 85 deletions

View File

@@ -0,0 +1,209 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import type { Notification, NotificationAction } from '@gridpilot/notifications/application';
import {
Bell,
AlertTriangle,
Shield,
Vote,
Trophy,
Users,
Flag,
AlertCircle,
Clock,
} from 'lucide-react';
import Button from '@/components/ui/Button';
interface ModalNotificationProps {
notification: Notification;
onAction: (notification: Notification, actionId?: string) => void;
}
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, { bg: string; border: string; text: string; glow: string }> = {
protest_filed: {
bg: 'bg-red-500/10',
border: 'border-red-500/50',
text: 'text-red-400',
glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]',
},
protest_defense_requested: {
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/50',
text: 'text-warning-amber',
glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]',
},
protest_vote_required: {
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/50',
text: 'text-primary-blue',
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
},
penalty_issued: {
bg: 'bg-red-500/10',
border: 'border-red-500/50',
text: 'text-red-400',
glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]',
},
};
export default function ModalNotification({
notification,
onAction,
}: ModalNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
const router = useRouter();
useEffect(() => {
// Animate in
const timeout = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timeout);
}, []);
const handleAction = (action: NotificationAction) => {
onAction(notification, action.actionId);
if (action.href) {
router.push(action.href);
}
};
const handlePrimaryAction = () => {
onAction(notification, 'primary');
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
};
const Icon = notificationIcons[notification.type] || AlertCircle;
const colors = notificationColors[notification.type] || {
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/50',
text: 'text-warning-amber',
glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]',
};
// Check if there's a deadline
const deadline = notification.data?.deadline;
const hasDeadline = deadline instanceof Date;
return (
<div
className={`
fixed inset-0 z-[100] flex items-center justify-center p-4
transition-all duration-300
${isVisible ? 'bg-black/70 backdrop-blur-sm' : 'bg-transparent'}
`}
>
<div
className={`
w-full max-w-lg transform transition-all duration-300
${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
`}
>
<div
className={`
rounded-2xl border-2 ${colors.border} ${colors.bg}
backdrop-blur-md ${colors.glow}
overflow-hidden
`}
>
{/* Header with pulse animation */}
<div className={`relative px-6 py-4 ${colors.bg} border-b ${colors.border}`}>
{/* Animated pulse ring */}
<div className="absolute top-4 left-6 w-12 h-12">
<div className={`absolute inset-0 rounded-full ${colors.bg} animate-ping opacity-20`} />
</div>
<div className="flex items-center gap-4">
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border}`}>
<Icon className={`w-6 h-6 ${colors.text}`} />
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
Action Required
</p>
<h2 className="text-xl font-bold text-white">
{notification.title}
</h2>
</div>
</div>
</div>
{/* Body */}
<div className="px-6 py-5">
<p className="text-gray-300 leading-relaxed">
{notification.body}
</p>
{/* Deadline warning */}
{hasDeadline && (
<div className="mt-4 flex items-center gap-2 px-4 py-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<Clock className="w-5 h-5 text-warning-amber" />
<div>
<p className="text-sm font-medium text-warning-amber">Response Required</p>
<p className="text-xs text-gray-400">
Please respond by {deadline.toLocaleDateString()} at {deadline.toLocaleTimeString()}
</p>
</div>
</div>
)}
{/* Additional context from data */}
{notification.data?.protestId && (
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-500 mb-1">Related Protest</p>
<p className="text-sm text-gray-300 font-mono">
{notification.data.protestId}
</p>
</div>
)}
</div>
{/* Actions */}
<div className="px-6 py-4 bg-iron-gray/30 border-t border-charcoal-outline">
{notification.actions && notification.actions.length > 0 ? (
<div className="flex flex-wrap gap-3 justify-end">
{notification.actions.map((action, index) => (
<Button
key={index}
variant={action.type === 'primary' ? 'primary' : 'secondary'}
onClick={() => handleAction(action)}
className={action.type === 'danger' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
>
{action.label}
</Button>
))}
</div>
) : (
<div className="flex justify-end">
<Button variant="primary" onClick={handlePrimaryAction}>
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
</Button>
</div>
)}
</div>
{/* Cannot dismiss warning */}
{notification.requiresResponse && (
<div className="px-6 py-2 bg-red-500/10 border-t border-red-500/20">
<p className="text-xs text-red-400 text-center">
This notification requires your action and cannot be dismissed
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { 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>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import {
getNotificationRepository,
getMarkNotificationReadUseCase,
} from '@/lib/di-container';
import type { Notification } from '@gridpilot/notifications/application';
import ToastNotification from './ToastNotification';
import ModalNotification from './ModalNotification';
interface NotificationContextValue {
notifications: Notification[];
unreadCount: number;
toastNotifications: Notification[];
modalNotification: Notification | null;
markAsRead: (notification: Notification) => Promise<void>;
dismissToast: (notification: Notification) => void;
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
export function useNotifications() {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotifications must be used within NotificationProvider');
}
return context;
}
interface NotificationProviderProps {
children: ReactNode;
}
export default function NotificationProvider({ children }: NotificationProviderProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [toastNotifications, setToastNotifications] = useState<Notification[]>([]);
const [modalNotification, setModalNotification] = useState<Notification | null>(null);
const [seenNotificationIds, setSeenNotificationIds] = useState<Set<string>>(new Set());
const currentDriverId = useEffectiveDriverId();
// Poll for new notifications
useEffect(() => {
const loadNotifications = async () => {
try {
const repo = getNotificationRepository();
const allNotifications = await repo.findByRecipientId(currentDriverId);
setNotifications(allNotifications);
// Check for new notifications that need toast/modal display
allNotifications.forEach((notification) => {
if (notification.isUnread() && !seenNotificationIds.has(notification.id)) {
// Mark as seen to prevent duplicate displays
setSeenNotificationIds((prev) => new Set([...prev, notification.id]));
// Handle based on urgency
if (notification.isModal()) {
// Modal takes priority - show immediately
setModalNotification(notification);
} else if (notification.isToast()) {
// Add to toast queue
setToastNotifications((prev) => [...prev, notification]);
}
// Silent notifications just appear in the notification center
}
});
} catch (error) {
console.error('Failed to load notifications:', error);
}
};
loadNotifications();
// Poll every 2 seconds for responsiveness
const interval = setInterval(loadNotifications, 2000);
return () => clearInterval(interval);
}, [currentDriverId, seenNotificationIds]);
const markAsRead = useCallback(async (notification: Notification) => {
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}, [currentDriverId]);
const dismissToast = useCallback((notification: Notification) => {
setToastNotifications((prev) => prev.filter((n) => n.id !== notification.id));
}, []);
const respondToModal = useCallback(async (notification: Notification, actionId?: string) => {
try {
// Mark as responded
const repo = getNotificationRepository();
const updated = notification.markAsResponded(actionId);
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to respond to notification:', error);
}
}, []);
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
const value: NotificationContextValue = {
notifications,
unreadCount,
toastNotifications,
modalNotification,
markAsRead,
dismissToast,
respondToModal,
};
return (
<NotificationContext.Provider value={value}>
{children}
{/* Toast notifications container */}
<div className="fixed top-20 right-4 z-50 space-y-3">
{toastNotifications.map((notification) => (
<ToastNotification
key={notification.id}
notification={notification}
onDismiss={dismissToast}
onRead={markAsRead}
/>
))}
</div>
{/* Modal notification */}
{modalNotification && (
<ModalNotification
notification={modalNotification}
onAction={respondToModal}
/>
)}
</NotificationContext.Provider>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import type { Notification } from '@gridpilot/notifications/application';
import {
Bell,
AlertTriangle,
Shield,
Vote,
Trophy,
Users,
Flag,
X,
ExternalLink,
} from 'lucide-react';
interface ToastNotificationProps {
notification: Notification;
onDismiss: (notification: Notification) => void;
onRead: (notification: Notification) => void;
autoHideDuration?: number;
}
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, { bg: string; border: string; text: string }> = {
protest_filed: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
protest_defense_requested: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
protest_vote_required: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
penalty_issued: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
race_results_posted: { bg: 'bg-performance-green/10', border: 'border-performance-green/30', text: 'text-performance-green' },
league_invite: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
race_reminder: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
};
export default function ToastNotification({
notification,
onDismiss,
onRead,
autoHideDuration = 5000,
}: ToastNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
useEffect(() => {
// Animate in
const showTimeout = setTimeout(() => setIsVisible(true), 10);
// Auto-hide
const hideTimeout = setTimeout(() => {
handleDismiss();
}, autoHideDuration);
return () => {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
};
}, [autoHideDuration]);
const handleDismiss = () => {
setIsExiting(true);
setTimeout(() => {
onDismiss(notification);
}, 300);
};
const handleClick = () => {
onRead(notification);
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
handleDismiss();
};
const Icon = notificationIcons[notification.type] || Bell;
const colors = notificationColors[notification.type] || {
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
text: 'text-gray-400',
};
return (
<div
className={`
transform transition-all duration-300 ease-out
${isVisible && !isExiting ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
`}
>
<div
className={`
w-96 rounded-xl border ${colors.border} ${colors.bg}
backdrop-blur-md shadow-2xl overflow-hidden
`}
>
{/* Progress bar */}
<div className="h-1 bg-iron-gray/50 overflow-hidden">
<div
className={`h-full ${colors.text.replace('text-', 'bg-')} animate-toast-progress`}
style={{ animationDuration: `${autoHideDuration}ms` }}
/>
</div>
<div className="p-4">
<div className="flex gap-3">
{/* Icon */}
<div className={`p-2 rounded-lg ${colors.bg} flex-shrink-0`}>
<Icon className={`w-5 h-5 ${colors.text}`} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-white truncate">
{notification.title}
</p>
<button
onClick={(e) => {
e.stopPropagation();
handleDismiss();
}}
className="p-1 rounded hover:bg-charcoal-outline transition-colors flex-shrink-0"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<p className="text-xs text-gray-400 line-clamp-2 mt-1">
{notification.body}
</p>
{notification.actionUrl && (
<button
onClick={handleClick}
className={`mt-2 flex items-center gap-1 text-xs font-medium ${colors.text} hover:underline`}
>
View details
<ExternalLink className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}