Files
gridpilot.gg/apps/website/components/notifications/ModalNotification.tsx
2026-01-14 23:46:04 +01:00

341 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import type { Notification, NotificationAction } from './notificationTypes';
import {
Bell,
AlertTriangle,
Shield,
Vote,
Trophy,
Users,
Flag,
AlertCircle,
Clock,
TrendingUp,
Award,
Star,
Medal,
Target,
Zap,
X,
} from 'lucide-react';
import Button from '@/ui/Button';
interface ModalNotificationProps {
notification: Notification;
onAction: (notification: Notification, actionId?: string) => void;
onDismiss?: (notification: Notification) => 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,
race_performance_summary: Medal,
race_final_results: Star,
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)]',
},
race_performance_summary: {
bg: 'bg-gradient-to-br from-yellow-400/20 via-orange-500/20 to-red-500/20',
border: 'border-yellow-400/60',
text: 'text-yellow-400',
glow: 'shadow-[0_0_80px_rgba(251,191,36,0.4)]',
},
race_final_results: {
bg: 'bg-gradient-to-br from-purple-500/20 via-pink-500/20 to-indigo-500/20',
border: 'border-purple-400/60',
text: 'text-purple-400',
glow: 'shadow-[0_0_80px_rgba(168,85,247,0.4)]',
},
};
export default function ModalNotification({
notification,
onAction,
onDismiss,
}: ModalNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
const router = useRouter();
useEffect(() => {
// Animate in
const timeout = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timeout);
}, []);
// Handle ESC key to dismiss
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && onDismiss && !notification.requiresResponse) {
onDismiss(notification);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [notification, onDismiss]);
const handleAction = (action: NotificationAction) => {
onAction(notification, action.id);
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)]',
};
const data: Record<string, unknown> = notification.data ?? {};
const getNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const getString = (value: unknown): string | null => {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
};
const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
// Check if there's a deadline
const deadlineValue = data.deadline;
const deadline: Date | null =
isValidDate(deadlineValue)
? deadlineValue
: typeof deadlineValue === 'string' || typeof deadlineValue === 'number'
? new Date(deadlineValue)
: null;
const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime());
// Special celebratory styling for race notifications
const isRaceNotification = notification.type.startsWith('race_');
const isPerformanceSummary = notification.type === 'race_performance_summary';
const isFinalResults = notification.type === 'race_final_results';
const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
const ratingChange = provisionalRatingChange || finalRatingChange;
const protestId = getString(data.protestId);
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'}
${isRaceNotification ? 'bg-gradient-to-br from-black/80 via-indigo-900/10 to-black/80' : ''}
`}
>
<div
className={`
w-full max-w-lg transform transition-all duration-300
${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
${isRaceNotification ? '' : ''}
`}
>
<div
className={`
rounded-2xl border-2 ${colors.border} ${colors.bg}
backdrop-blur-md ${colors.glow}
overflow-hidden
${isRaceNotification ? 'relative' : ''}
`}
>
{/* Header with pulse animation */}
<div className={`relative px-6 py-4 ${colors.bg} border-b ${colors.border} ${isRaceNotification ? 'bg-gradient-to-r from-transparent via-yellow-500/10 to-transparent' : ''}`}>
{/* Subtle pulse ring */}
<div className="absolute top-4 left-6 w-12 h-12">
<div className={`absolute inset-0 rounded-full ${colors.bg} opacity-10`} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`relative p-3 rounded-xl ${colors.bg} border ${colors.border} ${isRaceNotification ? 'shadow-lg' : ''}`}>
<Icon className={`w-6 h-6 ${colors.text}`} />
</div>
<div>
<p className={`text-xs font-semibold uppercase tracking-wide ${isRaceNotification ? 'text-yellow-400' : 'text-gray-400'}`}>
{isRaceNotification ? (isPerformanceSummary ? '🏁 Race Complete!' : '🏆 Championship Update') : 'Action Required'}
</p>
<h2 className={`text-xl font-bold ${isRaceNotification ? 'text-white' : 'text-white'}`}>
{notification.title}
</h2>
</div>
</div>
{/* X button for dismissible notifications */}
{onDismiss && !notification.requiresResponse && (
<button
onClick={() => onDismiss(notification)}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-5 h-5 text-gray-400 hover:text-white" />
</button>
)}
</div>
</div>
{/* Body */}
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
{notification.message}
</p>
{/* Race performance stats */}
{isRaceNotification && (
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
<div className="text-xs text-yellow-300 font-medium mb-1">POSITION</div>
<div className="text-2xl font-bold text-white">
{notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
</div>
</div>
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
<div className="text-xs text-yellow-300 font-medium mb-1">RATING CHANGE</div>
<div className={`text-2xl font-bold ${ratingChange >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{ratingChange >= 0 ? '+' : ''}
{ratingChange}
</div>
</div>
</div>
)}
{/* Deadline warning */}
{hasDeadline && !isRaceNotification && (
<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 ? deadline.toLocaleDateString() : ''} at {deadline ? deadline.toLocaleTimeString() : ''}
</p>
</div>
</div>
)}
{/* Additional context from 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">
{protestId}
</p>
</div>
)}
</div>
{/* Actions */}
<div className={`px-6 py-4 border-t ${isRaceNotification ? (isPerformanceSummary ? 'border-yellow-400/60 bg-gradient-to-r from-yellow-500/10 to-orange-500/10' : 'border-purple-400/60 bg-gradient-to-r from-purple-500/10 to-pink-500/10') : 'border-charcoal-outline bg-iron-gray/30'}`}>
{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' : ''} ${isRaceNotification ? 'shadow-lg hover:shadow-yellow-400/30' : ''}`}
>
{action.label}
</Button>
))}
</div>
) : (
<div className="flex flex-wrap gap-3 justify-end">
{isRaceNotification ? (
<>
<Button
variant="secondary"
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
className="shadow-lg hover:shadow-yellow-400/30"
>
Dismiss
</Button>
<Button
variant="secondary"
onClick={() => handleAction({ id: 'share', label: 'Share Achievement', type: 'secondary' })}
className="shadow-lg hover:shadow-yellow-400/30"
>
🎉 Share
</Button>
<Button
variant={isPerformanceSummary ? 'race-performance' : 'race-final'}
onClick={handlePrimaryAction}
>
{isPerformanceSummary ? '🏁 View Race Results' : '🏆 View Standings'}
</Button>
</>
) : (
<Button variant="primary" onClick={handlePrimaryAction}>
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
</Button>
)}
</div>
)}
</div>
{/* Cannot dismiss warning */}
{notification.requiresResponse && !isRaceNotification && (
<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>
);
}