diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 047a620d7..258b303fe 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -522,40 +522,6 @@ export default async function DashboardPage() { )} - - {/* Quick Links */} - -

- - Quick Links -

-
- - - Leaderboards - - - - - Teams - - - - - Create League - - -
-
diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 5cf147ca2..1a48bd943 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -9,6 +9,8 @@ import { AlphaNav } from '@/components/alpha/AlphaNav'; import AlphaBanner from '@/components/alpha/AlphaBanner'; import AlphaFooter from '@/components/alpha/AlphaFooter'; import { AuthProvider } from '@/lib/auth/AuthContext'; +import NotificationProvider from '@/components/notifications/NotificationProvider'; +import DevToolbar from '@/components/dev/DevToolbar'; export const dynamic = 'force-dynamic'; @@ -60,12 +62,15 @@ export default async function RootLayout({ - - -
- {children} -
- + + + +
+ {children} +
+ + +
diff --git a/apps/website/app/profile/settings/page.tsx b/apps/website/app/profile/settings/page.tsx new file mode 100644 index 000000000..973643119 --- /dev/null +++ b/apps/website/app/profile/settings/page.tsx @@ -0,0 +1,202 @@ +import { Bell, Shield, Eye, Volume2 } from 'lucide-react'; + +export default function SettingsPage() { + return ( +
+
+

Settings

+ +
+ {/* Notification Settings */} +
+
+ +

Notifications

+
+ +
+
+
+

Protest Filed Against You

+

Get notified when someone files a protest involving you

+
+ +
+ +
+
+

Vote Requested

+

Get notified when your vote is needed on a protest

+
+ +
+ +
+
+

Defense Required

+

Get notified when you need to submit a defense

+
+ +
+ +
+
+

Penalty Issued

+

Get notified when you receive a penalty

+
+ +
+ +
+
+

Race Starting Soon

+

Reminder before scheduled races begin

+
+ +
+ +
+
+

League Announcements

+

Updates from league administrators

+
+ +
+
+
+ + {/* Display Settings */} +
+
+ +

Display

+
+ +
+
+
+

Toast Duration

+

How long toast notifications stay visible

+
+ +
+ +
+
+

Toast Position

+

Where toast notifications appear on screen

+
+ +
+
+
+ + {/* Sound Settings */} +
+
+ +

Sound

+
+ +
+
+
+

Notification Sounds

+

Play sounds for new notifications

+
+ +
+ +
+
+

Urgent Notification Sound

+

Special sound for modal notifications

+
+ +
+
+
+ + {/* Privacy Settings */} +
+
+ +

Privacy

+
+ +
+
+
+

Show Online Status

+

Let others see when you're online

+
+ +
+ +
+
+

Public Profile

+

Allow non-league members to view your profile

+
+ +
+
+
+ + {/* Save Button */} +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 88f074e72..fc9d6a662 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -884,6 +884,14 @@ export default function RaceDetailPage() { File Protest )} + )} @@ -913,38 +921,6 @@ export default function RaceDetailPage() { - - {/* Quick Links */} - -

Quick Links

-
- - - All Races - - {league && ( - - - {league.name} - - )} - {league && ( - - - League Standings - - )} -
-
diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx new file mode 100644 index 000000000..2fd41749b --- /dev/null +++ b/apps/website/app/races/[id]/stewarding/page.tsx @@ -0,0 +1,525 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import { + getRaceRepository, + getLeagueRepository, + getProtestRepository, + getDriverRepository, + getPenaltyRepository, + getLeagueMembershipRepository, + getReviewProtestUseCase, + getApplyPenaltyUseCase, +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; +import type { League } from '@gridpilot/racing/domain/entities/League'; +import type { Protest } from '@gridpilot/racing/domain/entities/Protest'; +import type { Penalty, PenaltyType } from '@gridpilot/racing/domain/entities/Penalty'; +import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; +import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; +import { + AlertTriangle, + Clock, + CheckCircle, + Flag, + Calendar, + MapPin, + AlertCircle, + Video, + Gavel, + ArrowLeft, + Scale, + ChevronRight, + Users, + Trophy, +} from 'lucide-react'; + +export default function RaceStewardingPage() { + const params = useParams(); + const router = useRouter(); + const raceId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + + const [race, setRace] = useState(null); + const [league, setLeague] = useState(null); + const [protests, setProtests] = useState([]); + const [penalties, setPenalties] = useState([]); + const [driversById, setDriversById] = useState>({}); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [activeTab, setActiveTab] = useState<'pending' | 'resolved' | 'penalties'>('pending'); + + useEffect(() => { + async function loadData() { + setLoading(true); + try { + const raceRepo = getRaceRepository(); + const leagueRepo = getLeagueRepository(); + const protestRepo = getProtestRepository(); + const penaltyRepo = getPenaltyRepository(); + const driverRepo = getDriverRepository(); + const membershipRepo = getLeagueMembershipRepository(); + + // Get race + const raceData = await raceRepo.findById(raceId); + if (!raceData) { + setLoading(false); + return; + } + setRace(raceData); + + // Get league + const leagueData = await leagueRepo.findById(raceData.leagueId); + setLeague(leagueData); + + // Check admin status + if (leagueData) { + const membership = await membershipRepo.getMembership(leagueData.id, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } + + // Get protests for this race + const raceProtests = await protestRepo.findByRaceId(raceId); + setProtests(raceProtests); + + // Get penalties for this race + const racePenalties = await penaltyRepo.findByRaceId(raceId); + setPenalties(racePenalties); + + // Collect driver IDs + const driverIds = new Set(); + raceProtests.forEach((p) => { + driverIds.add(p.protestingDriverId); + driverIds.add(p.accusedDriverId); + }); + racePenalties.forEach((p) => { + driverIds.add(p.driverId); + }); + + // Load driver info + const driverEntities = await Promise.all( + Array.from(driverIds).map((id) => driverRepo.findById(id)) + ); + const byId: Record = {}; + driverEntities.forEach((driver) => { + if (driver) { + const dto = EntityMappers.toDriverDTO(driver); + if (dto) { + byId[dto.id] = dto; + } + } + }); + setDriversById(byId); + } catch (err) { + console.error('Failed to load data:', err); + } finally { + setLoading(false); + } + } + + loadData(); + }, [raceId, currentDriverId]); + + const pendingProtests = protests.filter( + (p) => p.status === 'pending' || p.status === 'under_review' + ); + const resolvedProtests = protests.filter( + (p) => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn' + ); + + const getStatusBadge = (status: string) => { + switch (status) { + case 'pending': + case 'under_review': + return ( + + Pending + + ); + case 'upheld': + return ( + + Upheld + + ); + case 'dismissed': + return ( + + Dismissed + + ); + case 'withdrawn': + return ( + + Withdrawn + + ); + default: + return null; + } + }; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (!race) { + return ( +
+
+ +
+
+ +
+
+

Race not found

+

+ The race you're looking for doesn't exist. +

+
+ +
+
+
+
+ ); + } + + const breadcrumbItems = [ + { label: 'Races', href: '/races' }, + { label: race.track, href: `/races/${race.id}` }, + { label: 'Stewarding' }, + ]; + + return ( +
+
+ {/* Navigation */} +
+ + +
+ + {/* Header */} + +
+
+ +
+
+

Stewarding

+

+ {race.track} • {formatDate(race.scheduledAt)} +

+
+
+ + {/* Stats */} +
+
+
+ + Pending +
+
{pendingProtests.length}
+
+
+
+ + Resolved +
+
{resolvedProtests.length}
+
+
+
+ + Penalties +
+
{penalties.length}
+
+
+
+ + {/* Tab Navigation */} +
+
+ + + +
+
+ + {/* Content */} + {activeTab === 'pending' && ( +
+ {pendingProtests.length === 0 ? ( + +
+ +
+

All Clear!

+

No pending protests to review

+
+ ) : ( + pendingProtests.map((protest) => { + const protester = driversById[protest.protestingDriverId]; + const accused = driversById[protest.accusedDriverId]; + const daysSinceFiled = Math.floor( + (Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24) + ); + const isUrgent = daysSinceFiled > 2; + + return ( + +
+
+
+ + + {protester?.name || 'Unknown'} + + vs + + {accused?.name || 'Unknown'} + + {getStatusBadge(protest.status)} + {isUrgent && ( + + + {daysSinceFiled}d old + + )} +
+
+ Lap {protest.incident.lap} + + Filed {formatDate(protest.filedAt)} + {protest.proofVideoUrl && ( + <> + + + + + )} +
+

{protest.incident.description}

+
+ {isAdmin && league && ( + + + + )} +
+
+ ); + }) + )} +
+ )} + + {activeTab === 'resolved' && ( +
+ {resolvedProtests.length === 0 ? ( + +
+ +
+

No Resolved Protests

+

+ Resolved protests will appear here +

+
+ ) : ( + resolvedProtests.map((protest) => { + const protester = driversById[protest.protestingDriverId]; + const accused = driversById[protest.accusedDriverId]; + + return ( + +
+
+
+ + + {protester?.name || 'Unknown'} + + vs + + {accused?.name || 'Unknown'} + + {getStatusBadge(protest.status)} +
+
+ Lap {protest.incident.lap} + + Filed {formatDate(protest.filedAt)} +
+

+ {protest.incident.description} +

+ {protest.decisionNotes && ( +
+

+ Steward Decision +

+

{protest.decisionNotes}

+
+ )} +
+
+
+ ); + }) + )} +
+ )} + + {activeTab === 'penalties' && ( +
+ {penalties.length === 0 ? ( + +
+ +
+

No Penalties

+

+ Penalties issued for this race will appear here +

+
+ ) : ( + penalties.map((penalty) => { + const driver = driversById[penalty.driverId]; + return ( + +
+
+ +
+
+
+ + {driver?.name || 'Unknown'} + + + {penalty.type.replace('_', ' ')} + +
+

{penalty.reason}

+ {penalty.notes && ( +

{penalty.notes}

+ )} +
+
+ + {penalty.type === 'time_penalty' && `+${penalty.value}s`} + {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} + {penalty.type === 'points_deduction' && `-${penalty.value} pts`} + {penalty.type === 'disqualification' && 'DSQ'} + {penalty.type === 'warning' && 'Warning'} + {penalty.type === 'license_points' && `${penalty.value} LP`} + +
+
+
+ ); + }) + )} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/alpha/AlphaNav.tsx b/apps/website/components/alpha/AlphaNav.tsx index 593e45857..c1fe34961 100644 --- a/apps/website/components/alpha/AlphaNav.tsx +++ b/apps/website/components/alpha/AlphaNav.tsx @@ -4,6 +4,7 @@ import React from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import UserPill from '@/components/profile/UserPill'; +import NotificationCenter from '@/components/notifications/NotificationCenter'; import { useAuth } from '@/lib/auth/AuthContext'; type AlphaNavProps = Record; @@ -66,6 +67,7 @@ export function AlphaNav({}: AlphaNavProps) {
+
diff --git a/apps/website/components/dev/DevToolbar.tsx b/apps/website/components/dev/DevToolbar.tsx new file mode 100644 index 000000000..784a6684a --- /dev/null +++ b/apps/website/components/dev/DevToolbar.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useState } from 'react'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { getSendNotificationUseCase } from '@/lib/di-container'; +import type { NotificationUrgency } from '@gridpilot/notifications/application'; +import { + Bell, + AlertTriangle, + Vote, + Shield, + ChevronDown, + ChevronUp, + Wrench, + X, + MessageSquare, + AlertCircle, + BellRing, +} from 'lucide-react'; + +type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required'; +type DemoUrgency = 'silent' | 'toast' | 'modal'; + +interface NotificationOption { + type: DemoNotificationType; + label: string; + description: string; + icon: typeof Bell; + color: string; +} + +interface UrgencyOption { + urgency: DemoUrgency; + label: string; + description: string; + icon: typeof Bell; +} + +const notificationOptions: NotificationOption[] = [ + { + type: 'protest_filed', + label: 'Protest Against You', + description: 'A protest was filed against you', + icon: AlertTriangle, + color: 'text-red-400', + }, + { + type: 'defense_requested', + label: 'Defense Requested', + description: 'A steward requests your defense', + icon: Shield, + color: 'text-warning-amber', + }, + { + type: 'vote_required', + label: 'Vote Required', + description: 'You need to vote on a protest', + icon: Vote, + color: 'text-primary-blue', + }, +]; + +const urgencyOptions: UrgencyOption[] = [ + { + urgency: 'silent', + label: 'Silent', + description: 'Only shows in notification center', + icon: Bell, + }, + { + urgency: 'toast', + label: 'Toast', + description: 'Shows a temporary popup', + icon: BellRing, + }, + { + urgency: 'modal', + label: 'Modal', + description: 'Shows blocking modal (must respond)', + icon: AlertCircle, + }, +]; + +export default function DevToolbar() { + const [isExpanded, setIsExpanded] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + const [selectedType, setSelectedType] = useState('protest_filed'); + const [selectedUrgency, setSelectedUrgency] = useState('toast'); + const [sending, setSending] = useState(false); + const [lastSent, setLastSent] = useState(null); + + const currentDriverId = useEffectiveDriverId(); + + // Only show in development + if (process.env.NODE_ENV === 'production') { + return null; + } + + const handleSendNotification = async () => { + setSending(true); + try { + const sendNotification = getSendNotificationUseCase(); + + let title: string; + let body: string; + let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required'; + let actionUrl: string; + + switch (selectedType) { + case 'protest_filed': + title = '🚨 Protest Filed Against You'; + body = 'Max Verstappen has filed a protest against you for unsafe rejoining at Turn 3, Lap 12 during the Spa-Francorchamps race.'; + notificationType = 'protest_filed'; + actionUrl = '/races/race-1/stewarding'; + break; + case 'defense_requested': + title = '⚖️ Defense Requested'; + body = 'A steward has requested your defense regarding the incident at Turn 1 in the Monza race. Please provide your side of the story within 48 hours.'; + notificationType = 'protest_defense_requested'; + actionUrl = '/races/race-2/stewarding'; + break; + case 'vote_required': + title = '🗳️ Your Vote Required'; + body = 'As a league steward, you are required to vote on the protest: Driver A vs Driver B - Causing a collision at Eau Rouge.'; + notificationType = 'protest_vote_required'; + actionUrl = '/leagues/league-1/stewarding'; + break; + } + + // For modal urgency, add actions + const actions = selectedUrgency === 'modal' ? [ + { label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' }, + { label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' }, + ] : undefined; + + await sendNotification.execute({ + recipientId: currentDriverId, + type: notificationType, + title, + body, + actionUrl, + urgency: selectedUrgency as NotificationUrgency, + requiresResponse: selectedUrgency === 'modal', + actions, + data: { + protestId: `demo-protest-${Date.now()}`, + raceId: 'race-1', + leagueId: 'league-1', + deadline: selectedUrgency === 'modal' ? new Date(Date.now() + 48 * 60 * 60 * 1000) : undefined, + }, + }); + + setLastSent(`${selectedType}-${selectedUrgency}`); + setTimeout(() => setLastSent(null), 3000); + } catch (error) { + console.error('Failed to send demo notification:', error); + } finally { + setSending(false); + } + }; + + if (isMinimized) { + return ( + + ); + } + + return ( +
+ {/* Header */} +
+
+ + Dev Toolbar + + DEMO + +
+
+ + +
+
+ + {/* Content */} + {isExpanded && ( +
+ {/* Notification Type Section */} +
+
+ + + Notification Type + +
+ +
+ {notificationOptions.map((option) => { + const Icon = option.icon; + const isSelected = selectedType === option.type; + + return ( + + ); + })} +
+
+ + {/* Urgency Section */} +
+
+ + + Urgency Level + +
+ +
+ {urgencyOptions.map((option) => { + const Icon = option.icon; + const isSelected = selectedUrgency === option.urgency; + + return ( + + ); + })} +
+

+ {urgencyOptions.find(o => o.urgency === selectedUrgency)?.description} +

+
+ + {/* Send Button */} + + + {/* Info */} +
+

+ Silent: Notification center only
+ Toast: Temporary popup (auto-dismisses)
+ Modal: Blocking popup (requires action) +

+
+
+ )} + + {/* Collapsed state hint */} + {!isExpanded && ( +
+ Click ↑ to expand notification demo tools +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index bf7ca406d..17b4f8531 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -15,6 +15,7 @@ import { AlertCircle, Sparkles, Check, + Scale, } from 'lucide-react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -42,6 +43,7 @@ import { } from './LeagueScoringSection'; import { LeagueDropSection } from './LeagueDropSection'; import { LeagueTimingsSection } from './LeagueTimingsSection'; +import { LeagueStewardingSection } from './LeagueStewardingSection'; // ============================================================================ // LOCAL STORAGE PERSISTENCE @@ -99,9 +101,9 @@ function getHighestStep(): number { } } -type Step = 1 | 2 | 3 | 4 | 5 | 6; +type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7; -type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'review'; +type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review'; interface CreateLeagueWizardProps { stepName: StepName; @@ -120,8 +122,10 @@ function stepNameToStep(stepName: StepName): Step { return 4; case 'scoring': return 5; - case 'review': + case 'stewarding': return 6; + case 'review': + return 7; } } @@ -138,6 +142,8 @@ function stepToStepName(step: Step): StepName { case 5: return 'scoring'; case 6: + return 'stewarding'; + case 7: return 'review'; } } @@ -198,6 +204,17 @@ function createDefaultForm(): LeagueConfigFormModel { timezoneId: 'UTC', seasonStartDate: getDefaultSeasonStartDate(), }, + stewarding: { + decisionMode: 'admin_only', + requiredVotes: 2, + requireDefense: false, + defenseTimeLimit: 48, + voteTimeLimit: 72, + protestDeadlineHours: 48, + stewardingClosesHours: 168, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }, }; } @@ -287,7 +304,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea if (!validateStep(step)) { return; } - const nextStep = (step < 6 ? ((step + 1) as Step) : step); + const nextStep = (step < 7 ? ((step + 1) as Step) : step); saveHighestStep(nextStep); setHighestCompletedStep((prev) => Math.max(prev, nextStep)); onStepChange(stepToStepName(nextStep)); @@ -353,7 +370,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea { id: 3 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' }, { id: 4 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' }, { id: 5 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' }, - { id: 6 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' }, + { id: 6 as Step, label: 'Stewarding', icon: Scale, shortLabel: 'Rules' }, + { id: 7 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' }, ]; const getStepTitle = (currentStep: Step): string => { @@ -369,6 +387,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea case 5: return 'Scoring & championships'; case 6: + return 'Stewarding & protests'; + case 7: return 'Review & create'; default: return ''; @@ -388,6 +408,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea case 5: return 'Select a scoring preset, enable championships, and set drop rules.'; case 6: + return 'Configure how protests are handled and penalties decided.'; + case 7: return 'Everything looks good? Launch your new league!'; default: return ''; @@ -629,6 +651,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea )} {step === 6 && ( +
+ +
+ )} + + {step === 7 && (
{errors.submit && ( @@ -669,7 +701,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea ))}
- {step < 6 ? ( + {step < 7 ? ( + ))} +
+ + + {/* Vote Requirements (conditional) */} + {selectedMode?.requiresVotes && ( +
+

+ + Voting Configuration +

+ +
+
+ + +
+ +
+ + +
+
+
+ )} + + {/* Defense Settings */} +
+

+ + Defense Requirements +

+

+ Should accused drivers be required to submit a defense? +

+ +
+ + + +
+ + {stewarding.requireDefense && ( +
+ + +

+ After this time, the decision can proceed without a defense +

+
+ )} +
+ + {/* Deadlines */} +
+

+ + Deadlines +

+

+ Set time limits for filing protests and closing stewarding +

+ +
+
+ + +

+ Drivers cannot file protests after this time +

+
+ +
+ + +

+ All stewarding must be concluded by this time +

+
+
+
+ + {/* Notifications */} +
+

+ + Notifications +

+

+ Configure automatic notifications for involved parties +

+ +
+ + + +
+
+ + {/* Warning about strict settings */} + {stewarding.requireDefense && stewarding.decisionMode !== 'admin_only' && ( +
+ +
+

Strict settings enabled

+

+ Requiring defense and voting may delay penalty decisions. Make sure your stewards/members + are active enough to meet the deadlines. +

+
+
+ )} + + ); +} \ No newline at end of file diff --git a/apps/website/components/notifications/ModalNotification.tsx b/apps/website/components/notifications/ModalNotification.tsx new file mode 100644 index 000000000..a43e012fe --- /dev/null +++ b/apps/website/components/notifications/ModalNotification.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import type { Notification, NotificationAction } from '@gridpilot/notifications/application'; +import { + Bell, + AlertTriangle, + Shield, + Vote, + Trophy, + Users, + Flag, + AlertCircle, + Clock, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface ModalNotificationProps { + notification: Notification; + onAction: (notification: Notification, actionId?: string) => void; +} + +const notificationIcons: Record = { + protest_filed: AlertTriangle, + protest_defense_requested: Shield, + protest_vote_required: Vote, + penalty_issued: AlertTriangle, + race_results_posted: Trophy, + league_invite: Users, + race_reminder: Flag, +}; + +const notificationColors: Record = { + protest_filed: { + bg: 'bg-red-500/10', + border: 'border-red-500/50', + text: 'text-red-400', + glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]', + }, + protest_defense_requested: { + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/50', + text: 'text-warning-amber', + glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]', + }, + protest_vote_required: { + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/50', + text: 'text-primary-blue', + glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]', + }, + penalty_issued: { + bg: 'bg-red-500/10', + border: 'border-red-500/50', + text: 'text-red-400', + glow: 'shadow-[0_0_60px_rgba(239,68,68,0.3)]', + }, +}; + +export default function ModalNotification({ + notification, + onAction, +}: ModalNotificationProps) { + const [isVisible, setIsVisible] = useState(false); + const router = useRouter(); + + useEffect(() => { + // Animate in + const timeout = setTimeout(() => setIsVisible(true), 10); + return () => clearTimeout(timeout); + }, []); + + const handleAction = (action: NotificationAction) => { + onAction(notification, action.actionId); + if (action.href) { + router.push(action.href); + } + }; + + const handlePrimaryAction = () => { + onAction(notification, 'primary'); + if (notification.actionUrl) { + router.push(notification.actionUrl); + } + }; + + const Icon = notificationIcons[notification.type] || AlertCircle; + const colors = notificationColors[notification.type] || { + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/50', + text: 'text-warning-amber', + glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]', + }; + + // Check if there's a deadline + const deadline = notification.data?.deadline; + const hasDeadline = deadline instanceof Date; + + return ( +
+
+
+ {/* Header with pulse animation */} +
+ {/* Animated pulse ring */} +
+
+
+ +
+
+ +
+
+

+ Action Required +

+

+ {notification.title} +

+
+
+
+ + {/* Body */} +
+

+ {notification.body} +

+ + {/* Deadline warning */} + {hasDeadline && ( +
+ +
+

Response Required

+

+ Please respond by {deadline.toLocaleDateString()} at {deadline.toLocaleTimeString()} +

+
+
+ )} + + {/* Additional context from data */} + {notification.data?.protestId && ( +
+

Related Protest

+

+ {notification.data.protestId} +

+
+ )} +
+ + {/* Actions */} +
+ {notification.actions && notification.actions.length > 0 ? ( +
+ {notification.actions.map((action, index) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+ + {/* Cannot dismiss warning */} + {notification.requiresResponse && ( +
+

+ ⚠️ This notification requires your action and cannot be dismissed +

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/notifications/NotificationCenter.tsx b/apps/website/components/notifications/NotificationCenter.tsx new file mode 100644 index 000000000..fe3f5b8b2 --- /dev/null +++ b/apps/website/components/notifications/NotificationCenter.tsx @@ -0,0 +1,271 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { + getNotificationRepository, + getMarkNotificationReadUseCase, +} from '@/lib/di-container'; +import type { Notification } from '@gridpilot/notifications/application'; +import { + Bell, + AlertTriangle, + Shield, + Vote, + Trophy, + Users, + Flag, + X, + Check, + CheckCheck, + ExternalLink, +} from 'lucide-react'; + +const notificationIcons: Record = { + protest_filed: AlertTriangle, + protest_defense_requested: Shield, + protest_vote_required: Vote, + penalty_issued: AlertTriangle, + race_results_posted: Trophy, + league_invite: Users, + race_reminder: Flag, +}; + +const notificationColors: Record = { + protest_filed: 'text-red-400 bg-red-400/10', + protest_defense_requested: 'text-warning-amber bg-warning-amber/10', + protest_vote_required: 'text-primary-blue bg-primary-blue/10', + penalty_issued: 'text-red-400 bg-red-400/10', + race_results_posted: 'text-performance-green bg-performance-green/10', + league_invite: 'text-primary-blue bg-primary-blue/10', + race_reminder: 'text-warning-amber bg-warning-amber/10', +}; + +export default function NotificationCenter() { + const [isOpen, setIsOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); + const panelRef = useRef(null); + const router = useRouter(); + const currentDriverId = useEffectiveDriverId(); + + // Polling for new notifications + useEffect(() => { + const loadNotifications = async () => { + try { + const repo = getNotificationRepository(); + const allNotifications = await repo.findByRecipientId(currentDriverId); + setNotifications(allNotifications); + } catch (error) { + console.error('Failed to load notifications:', error); + } + }; + + loadNotifications(); + + // Poll every 5 seconds + const interval = setInterval(loadNotifications, 5000); + return () => clearInterval(interval); + }, [currentDriverId]); + + // Close panel when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const unreadCount = notifications.filter((n) => n.isUnread()).length; + + const handleMarkAsRead = async (notification: Notification) => { + if (!notification.isUnread()) return; + + try { + const markRead = getMarkNotificationReadUseCase(); + await markRead.execute({ + notificationId: notification.id, + recipientId: currentDriverId, + }); + + // Update local state + setNotifications((prev) => + prev.map((n) => (n.id === notification.id ? n.markAsRead() : n)) + ); + } catch (error) { + console.error('Failed to mark notification as read:', error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + const repo = getNotificationRepository(); + await repo.markAllAsReadByRecipientId(currentDriverId); + + // Update local state + setNotifications((prev) => prev.map((n) => n.markAsRead())); + } catch (error) { + console.error('Failed to mark all as read:', error); + } + }; + + const handleNotificationClick = async (notification: Notification) => { + await handleMarkAsRead(notification); + + if (notification.actionUrl) { + router.push(notification.actionUrl); + setIsOpen(false); + } + }; + + const formatTime = (date: Date) => { + const now = new Date(); + const diff = now.getTime() - new Date(date).getTime(); + + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + return new Date(date).toLocaleDateString(); + }; + + return ( +
+ {/* Bell button */} + + + {/* Notification panel */} + {isOpen && ( +
+ {/* Header */} +
+
+ + Notifications + {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+ {unreadCount > 0 && ( + + )} +
+ + {/* Notifications list */} +
+ {notifications.length === 0 ? ( +
+
+ +
+

No notifications yet

+

+ You'll be notified about protests, races, and more +

+
+ ) : ( +
+ {notifications.map((notification) => { + const Icon = notificationIcons[notification.type] || Bell; + const colorClass = notificationColors[notification.type] || 'text-gray-400 bg-gray-400/10'; + + return ( + + ); + })} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+

+ Showing {notifications.length} notification{notifications.length !== 1 ? 's' : ''} +

+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/notifications/NotificationProvider.tsx b/apps/website/components/notifications/NotificationProvider.tsx new file mode 100644 index 000000000..e969352a6 --- /dev/null +++ b/apps/website/components/notifications/NotificationProvider.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { + getNotificationRepository, + getMarkNotificationReadUseCase, +} from '@/lib/di-container'; +import type { Notification } from '@gridpilot/notifications/application'; +import ToastNotification from './ToastNotification'; +import ModalNotification from './ModalNotification'; + +interface NotificationContextValue { + notifications: Notification[]; + unreadCount: number; + toastNotifications: Notification[]; + modalNotification: Notification | null; + markAsRead: (notification: Notification) => Promise; + dismissToast: (notification: Notification) => void; + respondToModal: (notification: Notification, actionId?: string) => Promise; +} + +const NotificationContext = createContext(null); + +export function useNotifications() { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotifications must be used within NotificationProvider'); + } + return context; +} + +interface NotificationProviderProps { + children: ReactNode; +} + +export default function NotificationProvider({ children }: NotificationProviderProps) { + const [notifications, setNotifications] = useState([]); + const [toastNotifications, setToastNotifications] = useState([]); + const [modalNotification, setModalNotification] = useState(null); + const [seenNotificationIds, setSeenNotificationIds] = useState>(new Set()); + + const currentDriverId = useEffectiveDriverId(); + + // Poll for new notifications + useEffect(() => { + const loadNotifications = async () => { + try { + const repo = getNotificationRepository(); + const allNotifications = await repo.findByRecipientId(currentDriverId); + setNotifications(allNotifications); + + // Check for new notifications that need toast/modal display + allNotifications.forEach((notification) => { + if (notification.isUnread() && !seenNotificationIds.has(notification.id)) { + // Mark as seen to prevent duplicate displays + setSeenNotificationIds((prev) => new Set([...prev, notification.id])); + + // Handle based on urgency + if (notification.isModal()) { + // Modal takes priority - show immediately + setModalNotification(notification); + } else if (notification.isToast()) { + // Add to toast queue + setToastNotifications((prev) => [...prev, notification]); + } + // Silent notifications just appear in the notification center + } + }); + } catch (error) { + console.error('Failed to load notifications:', error); + } + }; + + loadNotifications(); + + // Poll every 2 seconds for responsiveness + const interval = setInterval(loadNotifications, 2000); + return () => clearInterval(interval); + }, [currentDriverId, seenNotificationIds]); + + const markAsRead = useCallback(async (notification: Notification) => { + try { + const markRead = getMarkNotificationReadUseCase(); + await markRead.execute({ + notificationId: notification.id, + recipientId: currentDriverId, + }); + + setNotifications((prev) => + prev.map((n) => (n.id === notification.id ? n.markAsRead() : n)) + ); + } catch (error) { + console.error('Failed to mark notification as read:', error); + } + }, [currentDriverId]); + + const dismissToast = useCallback((notification: Notification) => { + setToastNotifications((prev) => prev.filter((n) => n.id !== notification.id)); + }, []); + + const respondToModal = useCallback(async (notification: Notification, actionId?: string) => { + try { + // Mark as responded + const repo = getNotificationRepository(); + const updated = notification.markAsResponded(actionId); + await repo.update(updated); + + // Update local state + setNotifications((prev) => + prev.map((n) => (n.id === notification.id ? updated : n)) + ); + + // Clear modal + setModalNotification(null); + } catch (error) { + console.error('Failed to respond to notification:', error); + } + }, []); + + const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length; + + const value: NotificationContextValue = { + notifications, + unreadCount, + toastNotifications, + modalNotification, + markAsRead, + dismissToast, + respondToModal, + }; + + return ( + + {children} + + {/* Toast notifications container */} +
+ {toastNotifications.map((notification) => ( + + ))} +
+ + {/* Modal notification */} + {modalNotification && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/notifications/ToastNotification.tsx b/apps/website/components/notifications/ToastNotification.tsx new file mode 100644 index 000000000..336b4414c --- /dev/null +++ b/apps/website/components/notifications/ToastNotification.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import type { Notification } from '@gridpilot/notifications/application'; +import { + Bell, + AlertTriangle, + Shield, + Vote, + Trophy, + Users, + Flag, + X, + ExternalLink, +} from 'lucide-react'; + +interface ToastNotificationProps { + notification: Notification; + onDismiss: (notification: Notification) => void; + onRead: (notification: Notification) => void; + autoHideDuration?: number; +} + +const notificationIcons: Record = { + protest_filed: AlertTriangle, + protest_defense_requested: Shield, + protest_vote_required: Vote, + penalty_issued: AlertTriangle, + race_results_posted: Trophy, + league_invite: Users, + race_reminder: Flag, +}; + +const notificationColors: Record = { + protest_filed: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' }, + protest_defense_requested: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' }, + protest_vote_required: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' }, + penalty_issued: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' }, + race_results_posted: { bg: 'bg-performance-green/10', border: 'border-performance-green/30', text: 'text-performance-green' }, + league_invite: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' }, + race_reminder: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' }, +}; + +export default function ToastNotification({ + notification, + onDismiss, + onRead, + autoHideDuration = 5000, +}: ToastNotificationProps) { + const [isVisible, setIsVisible] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const router = useRouter(); + + useEffect(() => { + // Animate in + const showTimeout = setTimeout(() => setIsVisible(true), 10); + + // Auto-hide + const hideTimeout = setTimeout(() => { + handleDismiss(); + }, autoHideDuration); + + return () => { + clearTimeout(showTimeout); + clearTimeout(hideTimeout); + }; + }, [autoHideDuration]); + + const handleDismiss = () => { + setIsExiting(true); + setTimeout(() => { + onDismiss(notification); + }, 300); + }; + + const handleClick = () => { + onRead(notification); + if (notification.actionUrl) { + router.push(notification.actionUrl); + } + handleDismiss(); + }; + + const Icon = notificationIcons[notification.type] || Bell; + const colors = notificationColors[notification.type] || { + bg: 'bg-gray-500/10', + border: 'border-gray-500/30', + text: 'text-gray-400', + }; + + return ( +
+
+ {/* Progress bar */} +
+
+
+ +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+

+ {notification.title} +

+ +
+

+ {notification.body} +

+ {notification.actionUrl && ( + + )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx index 3ad017234..89e38c9a2 100644 --- a/apps/website/components/profile/UserPill.tsx +++ b/apps/website/components/profile/UserPill.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; -import { LogOut, Star } from 'lucide-react'; +import { LogOut, Settings, Star } from 'lucide-react'; import { useAuth } from '@/lib/auth/AuthContext'; import { getDriverStats, @@ -153,6 +153,14 @@ export default function UserPill() { > Manage leagues + setIsMenuOpen(false)} + > + + Settings +
diff --git a/packages/notifications/application/index.ts b/packages/notifications/application/index.ts index 86ec5e1c9..920afcfb5 100644 --- a/packages/notifications/application/index.ts +++ b/packages/notifications/application/index.ts @@ -14,7 +14,14 @@ export * from './use-cases/NotificationPreferencesUseCases'; export * from './ports/INotificationGateway'; // Re-export domain types for convenience -export type { Notification, NotificationProps, NotificationStatus, NotificationData } from '../domain/entities/Notification'; +export type { + Notification, + NotificationProps, + NotificationStatus, + NotificationData, + NotificationUrgency, + NotificationAction, +} from '../domain/entities/Notification'; export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference'; export type { NotificationType } from '../domain/value-objects/NotificationType'; export type { NotificationChannel } from '../domain/value-objects/NotificationChannel'; diff --git a/packages/notifications/application/use-cases/SendNotificationUseCase.ts b/packages/notifications/application/use-cases/SendNotificationUseCase.ts index 7aa368c55..eb5679858 100644 --- a/packages/notifications/application/use-cases/SendNotificationUseCase.ts +++ b/packages/notifications/application/use-cases/SendNotificationUseCase.ts @@ -21,6 +21,17 @@ export interface SendNotificationCommand { body: string; data?: NotificationData; actionUrl?: string; + /** How urgently to display this notification (default: 'silent') */ + urgency?: 'silent' | 'toast' | 'modal'; + /** Whether this notification requires a response before dismissal */ + requiresResponse?: boolean; + /** Action buttons for modal notifications */ + actions?: Array<{ + label: string; + type: 'primary' | 'secondary' | 'danger'; + href?: string; + actionId?: string; + }>; /** Override channels (skip preference check) */ forceChannels?: NotificationChannel[]; } @@ -91,8 +102,11 @@ export class SendNotificationUseCase { title: command.title, body: command.body, channel, + urgency: command.urgency, data: command.data, actionUrl: command.actionUrl, + actions: command.actions, + requiresResponse: command.requiresResponse, }); // Save to repository (in_app channel) or attempt delivery (external channels) diff --git a/packages/notifications/domain/entities/Notification.ts b/packages/notifications/domain/entities/Notification.ts index 113b5ce55..5b4d35b78 100644 --- a/packages/notifications/domain/entities/Notification.ts +++ b/packages/notifications/domain/entities/Notification.ts @@ -1,6 +1,6 @@ /** * Domain Entity: Notification - * + * * Represents a notification sent to a user. * Immutable entity with factory methods and domain validation. */ @@ -8,7 +8,15 @@ import type { NotificationType } from '../value-objects/NotificationType'; import type { NotificationChannel } from '../value-objects/NotificationChannel'; -export type NotificationStatus = 'unread' | 'read' | 'dismissed'; +export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required'; + +/** + * Notification urgency determines how the notification is displayed + * - silent: Only appears in notification center (default) + * - toast: Shows a temporary toast notification + * - modal: Shows a blocking modal that requires user action (cannot be ignored) + */ +export type NotificationUrgency = 'silent' | 'toast' | 'modal'; export interface NotificationData { /** Reference to related protest */ @@ -29,6 +37,20 @@ export interface NotificationData { [key: string]: unknown; } +/** + * Configuration for action buttons shown on modal notifications + */ +export interface NotificationAction { + /** Button label */ + label: string; + /** Action type - determines styling */ + type: 'primary' | 'secondary' | 'danger'; + /** URL to navigate to when clicked */ + href?: string; + /** Custom action identifier (for handling in code) */ + actionId?: string; +} + export interface NotificationProps { id: string; /** Driver who receives this notification */ @@ -43,22 +65,31 @@ export interface NotificationProps { channel: NotificationChannel; /** Current status */ status: NotificationStatus; + /** How urgently to display this notification */ + urgency: NotificationUrgency; /** Structured data for linking/context */ data?: NotificationData; - /** Optional action URL */ + /** Optional action URL (for simple click-through) */ actionUrl?: string; + /** Action buttons for modal notifications */ + actions?: NotificationAction[]; + /** Whether this notification requires a response before it can be dismissed */ + requiresResponse?: boolean; /** When the notification was created */ createdAt: Date; /** When the notification was read (if applicable) */ readAt?: Date; + /** When the notification was responded to (for action_required status) */ + respondedAt?: Date; } export class Notification { private constructor(private readonly props: NotificationProps) {} - static create(props: Omit & { + static create(props: Omit & { status?: NotificationStatus; createdAt?: Date; + urgency?: NotificationUrgency; }): Notification { if (!props.id) throw new Error('Notification ID is required'); if (!props.recipientId) throw new Error('Recipient ID is required'); @@ -67,9 +98,13 @@ export class Notification { if (!props.body?.trim()) throw new Error('Notification body is required'); if (!props.channel) throw new Error('Notification channel is required'); + // Modal notifications that require response start with action_required status + const defaultStatus = props.requiresResponse ? 'action_required' : 'unread'; + return new Notification({ ...props, - status: props.status ?? 'unread', + status: props.status ?? defaultStatus, + urgency: props.urgency ?? 'silent', createdAt: props.createdAt ?? new Date(), }); } @@ -81,10 +116,14 @@ export class Notification { get body(): string { return this.props.body; } get channel(): NotificationChannel { return this.props.channel; } get status(): NotificationStatus { return this.props.status; } + get urgency(): NotificationUrgency { return this.props.urgency; } get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; } get actionUrl(): string | undefined { return this.props.actionUrl; } + get actions(): NotificationAction[] | undefined { return this.props.actions ? [...this.props.actions] : undefined; } + get requiresResponse(): boolean { return this.props.requiresResponse ?? false; } get createdAt(): Date { return this.props.createdAt; } get readAt(): Date | undefined { return this.props.readAt; } + get respondedAt(): Date | undefined { return this.props.respondedAt; } isUnread(): boolean { return this.props.status === 'unread'; @@ -98,6 +137,29 @@ export class Notification { return this.props.status === 'dismissed'; } + isActionRequired(): boolean { + return this.props.status === 'action_required'; + } + + isSilent(): boolean { + return this.props.urgency === 'silent'; + } + + isToast(): boolean { + return this.props.urgency === 'toast'; + } + + isModal(): boolean { + return this.props.urgency === 'modal'; + } + + /** + * Check if this notification can be dismissed without responding + */ + canDismiss(): boolean { + return !this.props.requiresResponse || this.props.status !== 'action_required'; + } + /** * Mark the notification as read */ @@ -112,6 +174,19 @@ export class Notification { }); } + /** + * Mark that the user has responded to an action_required notification + */ + markAsResponded(actionId?: string): Notification { + return new Notification({ + ...this.props, + status: 'read', + readAt: this.props.readAt ?? new Date(), + respondedAt: new Date(), + data: actionId ? { ...this.props.data, responseActionId: actionId } : this.props.data, + }); + } + /** * Dismiss the notification */ @@ -119,6 +194,10 @@ export class Notification { if (this.props.status === 'dismissed') { return this; // Already dismissed } + // Cannot dismiss action_required notifications without responding + if (this.props.requiresResponse && this.props.status === 'action_required') { + throw new Error('Cannot dismiss notification that requires response'); + } return new Notification({ ...this.props, status: 'dismissed', diff --git a/packages/racing/application/dto/LeagueConfigFormDTO.ts b/packages/racing/application/dto/LeagueConfigFormDTO.ts index 5a8a15573..8a2bef42b 100644 --- a/packages/racing/application/dto/LeagueConfigFormDTO.ts +++ b/packages/racing/application/dto/LeagueConfigFormDTO.ts @@ -1,4 +1,5 @@ import type { LeagueVisibilityType } from '../../domain/value-objects/LeagueVisibility'; +import type { StewardingDecisionMode } from '../../domain/entities/League'; export type LeagueStructureMode = 'solo' | 'fixedTeams'; @@ -57,6 +58,49 @@ export interface LeagueTimingsFormDTO { monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday; } +/** + * Stewarding configuration for protests and penalties. + */ +export interface LeagueStewardingFormDTO { + /** + * How protest decisions are made + */ + decisionMode: StewardingDecisionMode; + /** + * Number of votes required to uphold/reject a protest + * Used with steward_vote, member_vote, steward_veto, member_veto modes + */ + requiredVotes?: number; + /** + * Whether to require a defense from the accused before deciding + */ + requireDefense: boolean; + /** + * Time limit (hours) for accused to submit defense + */ + defenseTimeLimit: number; + /** + * Time limit (hours) for voting to complete + */ + voteTimeLimit: number; + /** + * Time limit (hours) after race ends when protests can be filed + */ + protestDeadlineHours: number; + /** + * Time limit (hours) after race ends when stewarding is closed + */ + stewardingClosesHours: number; + /** + * Whether to notify the accused when a protest is filed + */ + notifyAccusedOnProtest: boolean; + /** + * Whether to notify eligible voters when a vote is required + */ + notifyOnVoteRequired: boolean; +} + export interface LeagueConfigFormModel { leagueId?: string; // present for admin, omitted for create basics: { @@ -80,6 +124,7 @@ export interface LeagueConfigFormModel { scoring: LeagueScoringFormDTO; dropPolicy: LeagueDropPolicyFormDTO; timings: LeagueTimingsFormDTO; + stewarding: LeagueStewardingFormDTO; } /** diff --git a/packages/racing/application/index.ts b/packages/racing/application/index.ts index 592b0e885..e79cd33c9 100644 --- a/packages/racing/application/index.ts +++ b/packages/racing/application/index.ts @@ -77,4 +77,5 @@ export type { LeagueDropPolicyFormDTO, LeagueStructureMode, LeagueTimingsFormDTO, + LeagueStewardingFormDTO, } from './dto/LeagueConfigFormDTO'; \ No newline at end of file diff --git a/packages/racing/domain/entities/League.ts b/packages/racing/domain/entities/League.ts index 84098fd5e..bba1199e1 100644 --- a/packages/racing/domain/entities/League.ts +++ b/packages/racing/domain/entities/League.ts @@ -5,6 +5,56 @@ * Immutable entity with factory methods and domain validation. */ +/** + * Stewarding decision mode for protests + */ +export type StewardingDecisionMode = + | 'admin_only' // Only admins can decide + | 'steward_vote' // X stewards must vote to uphold + | 'member_vote' // X members must vote to uphold + | 'steward_veto' // Upheld unless X stewards vote against + | 'member_veto'; // Upheld unless X members vote against + +export interface StewardingSettings { + /** + * How protest decisions are made + */ + decisionMode: StewardingDecisionMode; + /** + * Number of votes required to uphold/reject a protest + * Used with steward_vote, member_vote, steward_veto, member_veto modes + */ + requiredVotes?: number; + /** + * Whether to require a defense from the accused before deciding + */ + requireDefense?: boolean; + /** + * Time limit (hours) for accused to submit defense + */ + defenseTimeLimit?: number; + /** + * Time limit (hours) for voting to complete + */ + voteTimeLimit?: number; + /** + * Time limit (hours) after race ends when protests can be filed + */ + protestDeadlineHours?: number; + /** + * Time limit (hours) after race ends when stewarding is closed (no more decisions) + */ + stewardingClosesHours?: number; + /** + * Whether to notify the accused when a protest is filed + */ + notifyAccusedOnProtest?: boolean; + /** + * Whether to notify eligible voters when a vote is required + */ + notifyOnVoteRequired?: boolean; +} + export interface LeagueSettings { pointsSystem: 'f1-2024' | 'indycar' | 'custom'; sessionDuration?: number; @@ -15,6 +65,10 @@ export interface LeagueSettings { * Used for simple capacity display on the website. */ maxDrivers?: number; + /** + * Stewarding settings for protest handling + */ + stewarding?: StewardingSettings; } export interface LeagueSocialLinks { @@ -64,11 +118,23 @@ export class League { }): League { this.validate(props); + const defaultStewardingSettings: StewardingSettings = { + decisionMode: 'admin_only', + requireDefense: false, + defenseTimeLimit: 48, + voteTimeLimit: 72, + protestDeadlineHours: 48, + stewardingClosesHours: 168, // 7 days + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }; + const defaultSettings: LeagueSettings = { pointsSystem: 'f1-2024', sessionDuration: 60, qualifyingFormat: 'open', maxDrivers: 32, + stewarding: defaultStewardingSettings, }; return new League({