Files
gridpilot.gg/apps/website/components/notifications/ModalNotification.tsx
2026-01-15 17:12:24 +01:00

389 lines
14 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 { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import type { Notification, NotificationAction } from './notificationTypes';
import {
Bell,
AlertTriangle,
Shield,
Vote,
Trophy,
Users,
Flag,
AlertCircle,
Clock,
Star,
Medal,
X,
} from 'lucide-react';
import { Button } from '@/ui/Button';
interface ModalNotificationProps {
notification: Notification;
onAction: (notification: Notification, actionId?: string) => void;
onDismiss?: (notification: Notification) => void;
onNavigate?: (href: 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,
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 function ModalNotification({
notification,
onAction,
onDismiss,
onNavigate,
}: ModalNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
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 && onNavigate) {
onNavigate(action.href);
}
};
const handlePrimaryAction = () => {
onAction(notification, 'primary');
if (notification.actionUrl && onNavigate) {
onNavigate(notification.actionUrl);
}
};
const NotificationIcon = 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 provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
const ratingChange = provisionalRatingChange || finalRatingChange;
const protestId = getString(data.protestId);
return (
<Box
position="fixed"
inset="0"
zIndex={100}
display="flex"
alignItems="center"
justifyContent="center"
p={4}
transition
bg={isVisible ? 'bg-black/70' : 'bg-transparent'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isVisible ? 'backdrop-blur-sm' : ''}
hoverBg={isRaceNotification ? 'bg-gradient-to-br from-black/80 via-indigo-900/10 to-black/80' : undefined}
>
<Box
w="full"
maxWidth="lg"
transform
transition
opacity={isVisible ? 1 : 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isVisible ? 'scale-100' : 'scale-95'}
>
<Box
rounded="2xl"
border
borderWidth="2px"
borderColor={colors.border}
bg={colors.bg}
shadow={colors.glow}
overflow="hidden"
position={isRaceNotification ? 'relative' : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
className="backdrop-blur-md"
>
{/* Header with pulse animation */}
<Box
px={6}
py={4}
bg={colors.bg}
borderBottom
borderColor={colors.border}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isRaceNotification ? 'bg-gradient-to-r from-transparent via-yellow-500/10 to-transparent' : ''}
>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={4}>
<Box
p={3}
rounded="xl"
bg={colors.bg}
border
borderColor={colors.border}
shadow={isRaceNotification ? 'lg' : undefined}
>
<Icon icon={NotificationIcon} size={6} color={colors.text} />
</Box>
<Box>
<Text
size="xs"
weight="semibold"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
color={isRaceNotification ? 'text-yellow-400' : 'text-gray-400'}
>
{isRaceNotification ? (isPerformanceSummary ? '🏁 Race Complete!' : '🏆 Championship Update') : 'Action Required'}
</Text>
<Heading level={2} fontSize="xl" weight="bold" color="text-white">
{notification.title}
</Heading>
</Box>
</Box>
{/* X button for dismissible notifications */}
{onDismiss && !notification.requiresResponse && (
<IconButton
icon={X}
onClick={() => onDismiss(notification)}
variant="ghost"
size="md"
color="text-gray-400"
title="Dismiss notification"
/>
)}
</Box>
</Box>
{/* Body */}
<Box
px={6}
py={5}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}
>
<Text
leading="relaxed"
size={isRaceNotification ? 'lg' : 'base'}
weight={isRaceNotification ? 'medium' : 'normal'}
color={isRaceNotification ? 'text-white' : 'text-gray-300'}
block
>
{notification.message}
</Text>
{/* Race performance stats */}
{isRaceNotification && (
<Box display="grid" gridCols={2} gap={3} mt={4}>
<Box bg="bg-black/20" rounded="lg" p={3} border borderColor="border-yellow-400/20">
<Text size="xs" color="text-yellow-300" weight="medium" block mb={1}>POSITION</Text>
<Text size="2xl" weight="bold" color="text-white" block>
{notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
</Text>
</Box>
<Box bg="bg-black/20" rounded="lg" p={3} border borderColor="border-yellow-400/20">
<Text size="xs" color="text-yellow-300" weight="medium" block mb={1}>RATING CHANGE</Text>
<Text size="2xl" weight="bold" color={ratingChange >= 0 ? 'text-green-400' : 'text-red-400'} block>
{ratingChange >= 0 ? '+' : ''}
{ratingChange}
</Text>
</Box>
</Box>
)}
{/* Deadline warning */}
{hasDeadline && !isRaceNotification && (
<Box mt={4} display="flex" alignItems="center" gap={2} px={4} py={3} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30">
<Icon icon={Clock} size={5} color="text-warning-amber" />
<Box>
<Text size="sm" weight="medium" color="text-warning-amber" block>Response Required</Text>
<Text size="xs" color="text-gray-400" block>
Please respond by {deadline ? deadline.toLocaleDateString() : ''} at {deadline ? deadline.toLocaleTimeString() : ''}
</Text>
</Box>
</Box>
)}
{/* Additional context from data */}
{protestId && (
<Box mt={4} p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-500" block mb={1}>Related Protest</Text>
<Text size="sm" color="text-gray-300" font="mono" block>
{protestId}
</Text>
</Box>
)}
</Box>
{/* Actions */}
<Box
px={6}
py={4}
borderTop
borderColor={isRaceNotification ? (isPerformanceSummary ? 'border-yellow-400/60' : 'border-purple-400/60') : 'border-charcoal-outline'}
bg={isRaceNotification ? undefined : 'bg-iron-gray/30'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isRaceNotification ? (isPerformanceSummary ? 'bg-gradient-to-r from-yellow-500/10 to-orange-500/10' : 'bg-gradient-to-r from-purple-500/10 to-pink-500/10') : ''}
>
<Box display="flex" flexWrap="wrap" gap={3} justifyContent="end">
{notification.actions && notification.actions.length > 0 ? (
notification.actions.map((action, index) => (
<Button
key={index}
variant={action.type === 'primary' ? 'primary' : 'secondary'}
onClick={() => handleAction(action)}
bg={action.type === 'danger' ? 'bg-red-500' : undefined}
color={action.type === 'danger' ? 'text-white' : undefined}
shadow={isRaceNotification ? 'lg' : undefined}
>
{action.label}
</Button>
))
) : (
isRaceNotification ? (
<>
<Button
variant="secondary"
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
shadow="lg"
>
Dismiss
</Button>
<Button
variant="secondary"
onClick={() => handleAction({ id: 'share', label: 'Share Achievement', type: 'secondary' })}
shadow="lg"
>
🎉 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>
)
)}
</Box>
</Box>
{/* Cannot dismiss warning */}
{notification.requiresResponse && !isRaceNotification && (
<Box px={6} py={2} bg="bg-red-500/10" borderTop borderColor="border-red-500/20">
<Text size="xs" color="text-red-400" textAlign="center" block>
This notification requires your action and cannot be dismissed
</Text>
</Box>
)}
</Box>
</Box>
</Box>
);
}