This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -20,6 +20,7 @@ import {
EntityMappers,
type DriverDTO,
type LeagueScoringConfigDTO,
Race,
} from '@gridpilot/racing';
import {
getLeagueRepository,
@@ -32,9 +33,10 @@ import {
getSeasonRepository,
getSponsorRepository,
getSeasonSponsorshipRepository,
getCompleteRaceUseCase,
} from '@/lib/di-container';
import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter';
import { Trophy, Star, ExternalLink } from 'lucide-react';
import { Trophy, Star, ExternalLink, Calendar, Users } from 'lucide-react';
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
@@ -62,6 +64,7 @@ export default function LeagueDetailPage() {
const [averageSOF, setAverageSOF] = useState<number | null>(null);
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
const [sponsors, setSponsors] = useState<SponsorInfo[]>([]);
const [runningRaces, setRunningRaces] = useState<Race[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
@@ -139,6 +142,11 @@ export default function LeagueDetailPage() {
setDrivers(driverDtos);
// Load all races for this league to find running ones
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
const runningRaces = leagueRaces.filter(r => r.status === 'running');
setRunningRaces(runningRaces);
// Load league stats including average SOF from application use case
await leagueStatsUseCase.execute({ leagueId });
const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel();
@@ -147,7 +155,6 @@ export default function LeagueDetailPage() {
setCompletedRacesCount(leagueStatsViewModel.completedRaces);
} else {
// Fallback: count completed races manually
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
const completedRaces = leagueRaces.filter(r => r.status === 'completed');
setCompletedRacesCount(completedRaces.length);
}
@@ -306,6 +313,88 @@ export default function LeagueDetailPage() {
/>
)}
{/* Live Race Card - Prominently show running races */}
{runningRaces.length > 0 && (
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
</div>
<div className="space-y-3">
{runningRaces.map((race) => (
<div
key={race.id}
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
<span className="text-sm font-semibold text-performance-green">LIVE</span>
</div>
<h3 className="text-lg font-semibold text-white">
{race.track} - {race.car}
</h3>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="primary"
onClick={() => router.push(`/races/${race.id}`)}
className="bg-performance-green hover:bg-performance-green/80 text-white"
>
View Live Race
</Button>
{membership?.role === 'admin' && (
<Button
variant="secondary"
onClick={async () => {
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload league data to reflect the completed race
await loadLeagueData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
>
End Race & Process Results
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>Started {new Date(race.scheduledAt).toLocaleDateString()}</span>
</div>
{race.registeredCount && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{race.registeredCount} drivers registered</span>
</div>
)}
{race.strengthOfField && (
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4" />
<span>SOF: {race.strengthOfField}</span>
</div>
)}
</div>
</div>
))}
</div>
</Card>
)}
{/* Action Card */}
{!membership && !isSponsor && (
<Card className="mb-6">

View File

@@ -9,8 +9,9 @@ import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import FileProtestModal from '@/components/races/FileProtestModal';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase } from '@/lib/di-container';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getMembership, isOwnerOrAdmin } from '@/lib/leagueMembership';
import type {
RaceDetailViewModel,
RaceDetailEntryViewModel,
@@ -49,6 +50,7 @@ export default function RaceDetailPage() {
const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false);
const [membership, setMembership] = useState<any>(null);
const currentDriverId = useEffectiveDriverId();
const isSponsorMode = useSponsorMode();
@@ -65,6 +67,13 @@ export default function RaceDetailPage() {
throw new Error('Race detail not available');
}
setViewModel(vm);
// Fetch league membership for admin controls
if (vm.league) {
const leagueMembership = getMembership(vm.league.id, currentDriverId);
setMembership(leagueMembership);
}
const userResultRatingChange = vm.userResult?.ratingChange ?? null;
setRatingChange(userResultRatingChange);
if (userResultRatingChange === null) {
@@ -529,7 +538,7 @@ export default function RaceDetailPage() {
{animatedRatingChange > 0 ? '+' : ''}
{animatedRatingChange}
</div>
<div className="text-xs text-gray-400 mt-0.5">iRating</div>
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
</div>
)}
@@ -717,11 +726,11 @@ export default function RaceDetailPage() {
className={`
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${
index === 0
race.status === 'completed' && index === 0
? 'bg-yellow-500/20 text-yellow-400'
: index === 1
: race.status === 'completed' && index === 1
? 'bg-gray-400/20 text-gray-300'
: index === 2
: race.status === 'completed' && index === 2
? 'bg-amber-600/20 text-amber-500'
: 'bg-iron-gray text-gray-500'
}
@@ -892,9 +901,55 @@ export default function RaceDetailPage() {
<Scale className="w-4 h-4" />
Stewarding
</Button>
{membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<>
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
);
if (!confirmed) return;
// TODO: Implement re-open race functionality
alert('Re-open race functionality not yet implemented');
}}
>
<PlayCircle className="w-4 h-4" />
Re-open Race
</Button>
</>
)}
</>
)}
{race.status === 'running' && membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload race data to reflect the completed race
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
>
<CheckCircle2 className="w-4 h-4" />
End Race & Process Results
</Button>
)}
{race.status === 'scheduled' && (
<Button
variant="secondary"

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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}`;

View File

@@ -138,6 +138,7 @@ import { GetDriversLeaderboardUseCase } from '@gridpilot/racing/application/use-
import { GetTeamsLeaderboardUseCase } from '@gridpilot/racing/application/use-cases/GetTeamsLeaderboardUseCase';
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
import { CompleteRaceUseCase } from '@gridpilot/racing/application/use-cases/CompleteRaceUseCase';
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
@@ -274,6 +275,21 @@ export function configureDIContainer(): void {
}
}
// For running races, add registrations (especially for league-5 demo)
const runningRaces = seedData.races.filter(r => r.status === 'running');
for (const race of runningRaces) {
// Add a good number of participants for running races
const participantCount = Math.floor(Math.random() * 8) + 12; // 12-20 participants
const shuffledDrivers = [...seedData.drivers].sort(() => Math.random() - 0.5).slice(0, participantCount);
for (const driver of shuffledDrivers) {
seedRaceRegistrations.push({
raceId: race.id,
driverId: driver.id,
registeredAt: new Date(Date.now() - Math.floor(Math.random() * 2) * 24 * 60 * 60 * 1000), // Recent registrations
});
}
}
container.registerInstance<IRaceRegistrationRepository>(
DI_TOKENS.RaceRegistrationRepository,
new InMemoryRaceRegistrationRepository(seedRaceRegistrations)
@@ -526,6 +542,22 @@ export function configureDIContainer(): void {
}
}
// Ensure driver-1 is an admin of league-5 (the demo league with running race)
const league5Membership = seededMemberships.find(
(m) => m.leagueId === 'league-5' && m.driverId === primaryDriverId,
);
if (league5Membership) {
league5Membership.role = 'admin';
} else {
seededMemberships.push({
leagueId: 'league-5',
driverId: primaryDriverId,
role: 'admin',
status: 'active',
joinedAt: new Date(),
});
}
// Ensure primary driver owns at least one league
const hasPrimaryOwnerMembership = seededMemberships.some(
(m) => m.driverId === primaryDriverId && m.role === 'owner',
@@ -606,6 +638,27 @@ export function configureDIContainer(): void {
});
}
// Ensure driver-1 is an admin of league-5 (the demo league with running race)
const league5 = seedData.leagues.find(l => l.id === 'league-5');
if (league5) {
const existing = seededMemberships.find(
(m) => m.leagueId === 'league-5' && m.driverId === primaryDriverId,
);
if (existing) {
if (existing.role !== 'owner') {
existing.role = 'admin';
}
} else {
seededMemberships.push({
leagueId: 'league-5',
driverId: primaryDriverId,
role: 'admin',
status: 'active',
joinedAt: new Date(),
});
}
}
// Seed pending join requests
const seededJoinRequests: JoinRequest[] = [];
const demoLeagues = seedData.leagues.slice(0, 6);
@@ -857,6 +910,17 @@ export function configureDIContainer(): void {
new CancelRaceUseCase(raceRepository)
);
container.registerInstance(
DI_TOKENS.CompleteRaceUseCase,
new CompleteRaceUseCase(
raceRepository,
raceRegistrationRepository,
resultRepository,
standingRepository,
driverRatingProvider
)
);
container.registerInstance(
DI_TOKENS.CreateLeagueWithSeasonAndScoringUseCase,
new CreateLeagueWithSeasonAndScoringUseCase(

View File

@@ -345,6 +345,11 @@ class DIContainer {
return getDIContainer().resolve<CancelRaceUseCase>(DI_TOKENS.CancelRaceUseCase);
}
get completeRaceUseCase(): import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase {
this.ensureInitialized();
return getDIContainer().resolve<import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase>(DI_TOKENS.CompleteRaceUseCase);
}
get importRaceResultsUseCase(): ImportRaceResultsUseCase {
this.ensureInitialized();
return getDIContainer().resolve<ImportRaceResultsUseCase>(DI_TOKENS.ImportRaceResultsUseCase);
@@ -702,6 +707,10 @@ export function getCancelRaceUseCase(): CancelRaceUseCase {
return DIContainer.getInstance().cancelRaceUseCase;
}
export function getCompleteRaceUseCase(): import('@gridpilot/racing/application/use-cases/CompleteRaceUseCase').CompleteRaceUseCase {
return DIContainer.getInstance().completeRaceUseCase;
}
export function getImportRaceResultsUseCase(): ImportRaceResultsUseCase {
return DIContainer.getInstance().importRaceResultsUseCase;
}

View File

@@ -44,6 +44,7 @@ export const DI_TOKENS = {
CreateLeagueWithSeasonAndScoringUseCase: Symbol.for('CreateLeagueWithSeasonAndScoringUseCase'),
TransferLeagueOwnershipUseCase: Symbol.for('TransferLeagueOwnershipUseCase'),
CancelRaceUseCase: Symbol.for('CancelRaceUseCase'),
CompleteRaceUseCase: Symbol.for('CompleteRaceUseCase'),
ImportRaceResultsUseCase: Symbol.for('ImportRaceResultsUseCase'),
// Queries - Dashboard