wip
This commit is contained in:
@@ -21,9 +21,11 @@ import {
|
||||
Building2,
|
||||
LogOut,
|
||||
LogIn,
|
||||
TrendingUp,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
|
||||
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required';
|
||||
type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||
type DemoUrgency = 'silent' | 'toast' | 'modal';
|
||||
|
||||
interface NotificationOption {
|
||||
@@ -63,6 +65,20 @@ const notificationOptions: NotificationOption[] = [
|
||||
icon: Vote,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_performance_summary',
|
||||
label: 'Race Performance Summary',
|
||||
description: 'Immediate results after main race',
|
||||
icon: TrendingUp,
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
type: 'race_final_results',
|
||||
label: 'Race Final Results',
|
||||
description: 'Final results after stewarding closes',
|
||||
icon: Award,
|
||||
color: 'text-warning-amber',
|
||||
},
|
||||
];
|
||||
|
||||
const urgencyOptions: UrgencyOption[] = [
|
||||
@@ -81,7 +97,7 @@ const urgencyOptions: UrgencyOption[] = [
|
||||
{
|
||||
urgency: 'modal',
|
||||
label: 'Modal',
|
||||
description: 'Shows blocking modal (must respond)',
|
||||
description: 'Shows blocking modal (may require response)',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
];
|
||||
@@ -193,7 +209,7 @@ export default function DevToolbar() {
|
||||
|
||||
let title: string;
|
||||
let body: string;
|
||||
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
|
||||
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required' | 'race_performance_summary' | 'race_final_results';
|
||||
let actionUrl: string;
|
||||
|
||||
switch (selectedType) {
|
||||
@@ -224,14 +240,38 @@ export default function DevToolbar() {
|
||||
actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues';
|
||||
break;
|
||||
}
|
||||
case 'race_performance_summary': {
|
||||
const raceId = primaryRace?.id;
|
||||
const leagueId = primaryLeague?.id;
|
||||
title = '🏁 Race Complete: Performance Summary';
|
||||
body =
|
||||
'Your Monza Grand Prix race is finished! You finished P1 with 0 incidents. Provisional rating: +25 points. View full results and standings.';
|
||||
notificationType = 'race_performance_summary';
|
||||
actionUrl = raceId ? `/races/${raceId}` : '/races';
|
||||
break;
|
||||
}
|
||||
case 'race_final_results': {
|
||||
const leagueId = primaryLeague?.id;
|
||||
title = '🏆 Final Results: Monza Grand Prix';
|
||||
body =
|
||||
'Stewarding is now closed. Your final result: P1 (+25 rating). No penalties were applied. View championship standings.';
|
||||
notificationType = 'race_final_results';
|
||||
actionUrl = leagueId ? `/leagues/${leagueId}/standings` : '/leagues';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const actions =
|
||||
selectedUrgency === 'modal'
|
||||
? [
|
||||
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||
]
|
||||
? selectedType.startsWith('race_')
|
||||
? [
|
||||
{ label: selectedType === 'race_performance_summary' ? '🏁 View Race Results' : '🏆 View Standings', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
{ label: '🎉 Share Achievement', type: 'secondary' as const, actionId: 'share' },
|
||||
]
|
||||
: [
|
||||
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||
]
|
||||
: [];
|
||||
|
||||
await sendNotification.execute({
|
||||
@@ -241,13 +281,25 @@ export default function DevToolbar() {
|
||||
body,
|
||||
actionUrl,
|
||||
urgency: selectedUrgency as NotificationUrgency,
|
||||
requiresResponse: selectedUrgency === 'modal',
|
||||
requiresResponse: selectedUrgency === 'modal' && !selectedType.startsWith('race_'),
|
||||
actions,
|
||||
data: {
|
||||
protestId: `demo-protest-${Date.now()}`,
|
||||
...(selectedType.startsWith('protest_') ? {
|
||||
protestId: `demo-protest-${Date.now()}`,
|
||||
} : {}),
|
||||
...(selectedType.startsWith('race_') ? {
|
||||
raceEventId: `demo-race-event-${Date.now()}`,
|
||||
sessionId: `demo-session-${Date.now()}`,
|
||||
position: 1,
|
||||
positionChange: 0,
|
||||
incidents: 0,
|
||||
provisionalRatingChange: 25,
|
||||
finalRatingChange: 25,
|
||||
hadPenaltiesApplied: false,
|
||||
} : {}),
|
||||
raceId: primaryRace?.id ?? '',
|
||||
leagueId: primaryLeague?.id ?? '',
|
||||
...(notificationDeadline ? { deadline: notificationDeadline } : {}),
|
||||
...(notificationDeadline && selectedType.startsWith('protest_') ? { deadline: notificationDeadline } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,7 +367,7 @@ export default function DevToolbar() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{notificationOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedType === option.type;
|
||||
@@ -436,7 +488,7 @@ export default function DevToolbar() {
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
||||
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (requires action)
|
||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
100
apps/website/components/leagues/EndRaceModal.tsx
Normal file
100
apps/website/components/leagues/EndRaceModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface EndRaceModalProps {
|
||||
raceId: string;
|
||||
raceName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function EndRaceModal({ raceId, raceName, onConfirm, onCancel }: EndRaceModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
||||
<TestTube className="w-6 h-6 text-warning-amber" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">Development Test Function</h2>
|
||||
<p className="text-sm text-gray-400">End Race & Process Results</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-warning-amber mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">Development Only Feature</h3>
|
||||
<p className="text-sm text-gray-300 leading-relaxed">
|
||||
This is a development/testing function to simulate ending a race and processing results.
|
||||
It will generate realistic race results, update driver ratings, and calculate final standings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-performance-green/10 border border-performance-green/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-performance-green mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">What This Does</h3>
|
||||
<ul className="text-sm text-gray-300 space-y-1">
|
||||
<li>• Marks the race as completed</li>
|
||||
<li>• Generates realistic finishing positions</li>
|
||||
<li>• Updates driver ratings based on performance</li>
|
||||
<li>• Calculates championship points</li>
|
||||
<li>• Updates league standings</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
Race: <span className="text-white font-medium">{raceName}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
ID: {raceId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
className="flex-1 bg-performance-green hover:bg-performance-green/80"
|
||||
>
|
||||
<TestTube className="w-4 h-4 mr-2" />
|
||||
Run Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
This action cannot be undone. Use only for testing purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/website/components/leagues/QuickPenaltyModal.tsx
Normal file
188
apps/website/components/leagues/QuickPenaltyModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getQuickPenaltyUseCase } from '@/lib/di-container';
|
||||
import type { Driver } from '@gridpilot/racing/application';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
||||
|
||||
interface QuickPenaltyModalProps {
|
||||
raceId: string;
|
||||
drivers: Driver[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const INFRACTION_TYPES = [
|
||||
{ value: 'track_limits', label: 'Track Limits', icon: Flag },
|
||||
{ value: 'unsafe_rejoin', label: 'Unsafe Rejoin', icon: AlertTriangle },
|
||||
{ value: 'aggressive_driving', label: 'Aggressive Driving', icon: Zap },
|
||||
{ value: 'false_start', label: 'False Start', icon: Clock },
|
||||
{ value: 'other', label: 'Other', icon: AlertTriangle },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_LEVELS = [
|
||||
{ value: 'warning', label: 'Warning', description: 'Official warning only' },
|
||||
{ value: 'minor', label: 'Minor', description: 'Light penalty' },
|
||||
{ value: 'major', label: 'Major', description: 'Significant penalty' },
|
||||
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
|
||||
] as const;
|
||||
|
||||
export default function QuickPenaltyModal({ raceId, drivers, onClose }: QuickPenaltyModalProps) {
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>('');
|
||||
const [infractionType, setInfractionType] = useState<string>('');
|
||||
const [severity, setSeverity] = useState<string>('');
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedDriver || !infractionType || !severity) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const useCase = getQuickPenaltyUseCase();
|
||||
await useCase.execute({
|
||||
raceId,
|
||||
driverId: selectedDriver,
|
||||
adminId: 'driver-1', // TODO: Get from current user context
|
||||
infractionType: infractionType as any,
|
||||
severity: severity as any,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
// Refresh the page to show updated results
|
||||
router.refresh();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Driver Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Driver
|
||||
</label>
|
||||
<select
|
||||
value={selectedDriver}
|
||||
onChange={(e) => setSelectedDriver(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="">Select driver...</option>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Infraction Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Infraction Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{INFRACTION_TYPES.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setInfractionType(value)}
|
||||
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
|
||||
infractionType === value
|
||||
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
|
||||
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Severity
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SEVERITY_LEVELS.map(({ value, label, description }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
severity === value
|
||||
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
|
||||
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-xs opacity-75">{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Additional details..."
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={loading || !selectedDriver || !infractionType || !severity}
|
||||
>
|
||||
{loading ? 'Applying...' : 'Apply Penalty'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,20 @@ import {
|
||||
Flag,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Award,
|
||||
Star,
|
||||
Medal,
|
||||
Target,
|
||||
Zap,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface ModalNotificationProps {
|
||||
notification: Notification;
|
||||
onAction: (notification: Notification, actionId?: string) => void;
|
||||
onDismiss?: (notification: Notification) => void;
|
||||
}
|
||||
|
||||
const notificationIcons: Record<string, typeof Bell> = {
|
||||
@@ -27,6 +35,8 @@ const notificationIcons: Record<string, typeof Bell> = {
|
||||
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,
|
||||
};
|
||||
@@ -50,17 +60,30 @@ const notificationColors: Record<string, { bg: string; border: string; text: str
|
||||
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',
|
||||
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();
|
||||
@@ -71,6 +94,18 @@ export default function ModalNotification({
|
||||
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.actionId);
|
||||
if (action.href) {
|
||||
@@ -97,18 +132,25 @@ export default function ModalNotification({
|
||||
const deadline = notification.data?.deadline;
|
||||
const hasDeadline = deadline instanceof Date;
|
||||
|
||||
// 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';
|
||||
|
||||
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
|
||||
@@ -116,38 +158,71 @@ export default function ModalNotification({
|
||||
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}`}>
|
||||
{/* Animated pulse ring */}
|
||||
<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} animate-ping opacity-20`} />
|
||||
<div className={`absolute inset-0 rounded-full ${colors.bg} opacity-10`} />
|
||||
</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 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">
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
<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.body}
|
||||
</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 ${(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? '+' : ''}
|
||||
{notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deadline warning */}
|
||||
{hasDeadline && (
|
||||
{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>
|
||||
@@ -168,10 +243,11 @@ export default function ModalNotification({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 bg-iron-gray/30 border-t border-charcoal-outline">
|
||||
<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) => (
|
||||
@@ -179,23 +255,48 @@ export default function ModalNotification({
|
||||
key={index}
|
||||
variant={action.type === 'primary' ? 'primary' : 'secondary'}
|
||||
onClick={() => handleAction(action)}
|
||||
className={action.type === 'danger' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
|
||||
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 justify-end">
|
||||
<Button variant="primary" onClick={handlePrimaryAction}>
|
||||
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-3 justify-end">
|
||||
{isRaceNotification ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onDismiss ? onDismiss(notification) : handleAction(notification, 'dismiss')}
|
||||
className="shadow-lg hover:shadow-yellow-400/30"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleAction({ label: 'Share Achievement', type: 'secondary', actionId: 'share' })}
|
||||
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 && (
|
||||
{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
|
||||
|
||||
@@ -18,6 +18,7 @@ interface NotificationContextValue {
|
||||
markAsRead: (notification: Notification) => Promise<void>;
|
||||
dismissToast: (notification: Notification) => void;
|
||||
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
|
||||
dismissModal: (notification: Notification) => Promise<void>;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
@@ -132,6 +133,25 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissModal = useCallback(async (notification: Notification) => {
|
||||
try {
|
||||
// Dismiss the notification
|
||||
const repo = getNotificationRepository();
|
||||
const updated = notification.dismiss();
|
||||
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 dismiss notification:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
|
||||
|
||||
const value: NotificationContextValue = {
|
||||
@@ -142,6 +162,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
||||
markAsRead,
|
||||
dismissToast,
|
||||
respondToModal,
|
||||
dismissModal,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -165,6 +186,7 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
||||
<ModalNotification
|
||||
notification={modalNotification}
|
||||
onAction={respondToModal}
|
||||
onDismiss={dismissModal}
|
||||
/>
|
||||
)}
|
||||
</NotificationContext.Provider>
|
||||
|
||||
@@ -11,7 +11,7 @@ type ButtonAsLink = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
};
|
||||
|
||||
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,9 @@ export default function Button({
|
||||
const variantStyles = {
|
||||
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
|
||||
secondary: 'bg-iron-gray text-white border border-charcoal-outline shadow-[0_0_10px_rgba(25,140,255,0.2)] hover:shadow-[0_0_20px_rgba(25,140,255,0.4)] hover:border-primary-blue focus-visible:outline-primary-blue',
|
||||
danger: 'bg-red-600 text-white shadow-[0_0_15px_rgba(248,113,113,0.4)] hover:shadow-[0_0_25px_rgba(248,113,113,0.6)] active:ring-2 active:ring-red-600 focus-visible:outline-red-600'
|
||||
danger: 'bg-red-600 text-white shadow-[0_0_15px_rgba(248,113,113,0.4)] hover:shadow-[0_0_25px_rgba(248,113,113,0.6)] active:ring-2 active:ring-red-600 focus-visible:outline-red-600',
|
||||
'race-performance': 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white shadow-[0_0_15px_rgba(251,191,36,0.4)] hover:shadow-[0_0_25px_rgba(251,191,36,0.6)] hover:from-yellow-500 hover:to-orange-600 active:ring-2 active:ring-yellow-400 focus-visible:outline-yellow-400',
|
||||
'race-final': 'bg-gradient-to-r from-purple-400 to-pink-500 text-white shadow-[0_0_15px_rgba(168,85,247,0.4)] hover:shadow-[0_0_25px_rgba(168,85,247,0.6)] hover:from-purple-500 hover:to-pink-600 active:ring-2 active:ring-purple-400 focus-visible:outline-purple-400'
|
||||
} as const;
|
||||
|
||||
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;
|
||||
|
||||
Reference in New Issue
Block a user