diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 3a737fc59..8238b064b 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -334,7 +334,7 @@ export default function LeagueDetailPage() {

- {league.settings.pointsSystem.toUpperCase()} + {scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'}

diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 8d77062fb..905679368 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; import { User, Trophy, @@ -352,10 +353,14 @@ function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistrib // ============================================================================ export default function ProfilePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const tabParam = searchParams.get('tab') as ProfileTab | null; + const [driver, setDriver] = useState(null); const [loading, setLoading] = useState(true); const [editMode, setEditMode] = useState(false); - const [activeTab, setActiveTab] = useState('overview'); + const [activeTab, setActiveTab] = useState(tabParam || 'overview'); const [teamData, setTeamData] = useState(null); const [allTeamMemberships, setAllTeamMemberships] = useState([]); const [friends, setFriends] = useState([]); @@ -413,6 +418,27 @@ export default function ProfilePage() { void loadData(); }, [effectiveDriverId]); + // Update URL when tab changes + useEffect(() => { + if (tabParam !== activeTab) { + const params = new URLSearchParams(searchParams.toString()); + if (activeTab === 'overview') { + params.delete('tab'); + } else { + params.set('tab', activeTab); + } + const query = params.toString(); + router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false }); + } + }, [activeTab, tabParam, searchParams, router]); + + // Sync tab from URL on mount and param change + useEffect(() => { + if (tabParam && tabParam !== activeTab) { + setActiveTab(tabParam); + } + }, [tabParam]); + const handleSaveSettings = async (updates: Partial) => { if (!driver) return; @@ -497,7 +523,7 @@ export default function ProfilePage() { } return ( -
+
{/* Hero Header Section */}
{/* Background Pattern */} @@ -1000,13 +1026,13 @@ export default function ProfilePage() { )} - {activeTab === 'history' && ( + {activeTab === 'history' && driver && (

Race History

- +
)} diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 4073c755e..88f074e72 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -7,9 +7,11 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import FileProtestModal from '@/components/races/FileProtestModal'; import type { Race } from '@gridpilot/racing/domain/entities/Race'; import type { League } from '@gridpilot/racing/domain/entities/League'; import type { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import type { Result } from '@gridpilot/racing/domain/entities/Result'; import { getRaceRepository, getLeagueRepository, @@ -18,12 +20,13 @@ import { getIsDriverRegisteredForRaceQuery, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, - getTrackRepository, - getCarRepository, getGetRaceWithSOFQuery, + getResultRepository, + getImageService, } from '@/lib/di-container'; import { getMembership } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { getDriverStats } from '@/lib/di-container'; import { Calendar, Clock, @@ -45,8 +48,9 @@ import { ArrowLeft, ExternalLink, Award, + Scale, } from 'lucide-react'; -import { getDriverStats, getAllDriverRankings } from '@/lib/di-container'; +import { getAllDriverRankings } from '@/lib/di-container'; export default function RaceDetailPage() { const router = useRouter(); @@ -63,6 +67,10 @@ export default function RaceDetailPage() { const [isUserRegistered, setIsUserRegistered] = useState(false); const [canRegister, setCanRegister] = useState(false); const [raceSOF, setRaceSOF] = useState(null); + const [userResult, setUserResult] = useState(null); + const [ratingChange, setRatingChange] = useState(null); + const [animatedRatingChange, setAnimatedRatingChange] = useState(0); + const [showProtestModal, setShowProtestModal] = useState(false); const currentDriverId = useEffectiveDriverId(); @@ -94,6 +102,26 @@ export default function RaceDetailPage() { // Load entry list await loadEntryList(raceData.id, raceData.leagueId); + + // Load user's result if race is completed + if (raceData.status === 'completed') { + const resultRepo = getResultRepository(); + const results = await resultRepo.findByRaceId(raceData.id); + const result = results.find(r => r.driverId === currentDriverId); + setUserResult(result || null); + + // Get rating change from driver stats (mock based on position) + if (result) { + const stats = getDriverStats(currentDriverId); + if (stats) { + // Calculate rating change based on position - simplified domain logic + const baseChange = result.position <= 3 ? 25 : result.position <= 10 ? 10 : -5; + const positionBonus = Math.max(0, (20 - result.position) * 2); + const change = baseChange + positionBonus; + setRatingChange(change); + } + } + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load race'); } finally { @@ -134,6 +162,31 @@ export default function RaceDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [raceId]); + // Animate rating change when it changes + useEffect(() => { + if (ratingChange !== null) { + let start = 0; + const end = ratingChange; + const duration = 1000; + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3); + const current = Math.round(start + (end - start) * eased); + setAnimatedRatingChange(current); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + } + }, [ratingChange]); + const handleCancelRace = async () => { if (!race || race.status !== 'scheduled') return; @@ -329,6 +382,15 @@ export default function RaceDetailPage() { { label: race.track }, ]; + // Country code to flag emoji converter + const getCountryFlag = (countryCode: string): string => { + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); + }; + // Build driver rankings for entry list display const getDriverRank = (driverId: string): { rating: number | null; rank: number | null } => { const stats = getDriverStats(driverId); @@ -348,7 +410,7 @@ export default function RaceDetailPage() { return (
-
+
{/* Navigation Row: Breadcrumbs left, Back button right */}
@@ -362,6 +424,141 @@ export default function RaceDetailPage() {
+ {/* User Result - Premium Achievement Card */} + {userResult && ( +
+
+ {/* Decorative elements */} +
+
+ + {/* Victory confetti effect for P1 */} + {userResult.position === 1 && ( +
+
+
+
+
+
+ )} + +
+ {/* Main content grid */} +
+ {/* Left: Position and achievement */} +
+ {/* Giant position badge */} +
+ {userResult.position === 1 && ( + + )} + P{userResult.position} +
+ + {/* Achievement text */} +
+

+ {userResult.position === 1 ? '🏆 VICTORY!' : + userResult.position === 2 ? '🥈 Second Place' : + userResult.position === 3 ? '🥉 Podium Finish' : + userResult.position <= 5 ? '⭐ Top 5 Finish' : + userResult.position <= 10 ? 'Points Finish' : + `P${userResult.position} Finish`} +

+
+ Started P{userResult.startPosition} + + + {userResult.incidents}x incidents + {userResult.isClean() && ' ✨'} + +
+
+
+ + {/* Right: Stats cards */} +
+ {/* Position change */} + {userResult.getPositionChange() !== 0 && ( +
0 + ? 'bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40' + : 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'} + `}> +
0 ? 'text-performance-green' : 'text-red-400'} + `}> + {userResult.getPositionChange() > 0 ? ( + + + + ) : ( + + + + )} + {Math.abs(userResult.getPositionChange())} +
+
+ {userResult.getPositionChange() > 0 ? 'Gained' : 'Lost'} +
+
+ )} + + {/* Rating change */} + {ratingChange !== null && ( +
0 + ? 'bg-gradient-to-br from-warning-amber/30 to-warning-amber/10 border border-warning-amber/40' + : 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40'} + `}> +
0 ? 'text-warning-amber' : 'text-red-400'} + `}> + {animatedRatingChange > 0 ? '+' : ''}{animatedRatingChange} +
+
iRating
+
+ )} + + {/* Clean race bonus */} + {userResult.isClean() && ( +
+
+
Clean Race
+
+ )} +
+
+
+
+
+ )} + {/* Hero Header */}
{/* Live indicator */} @@ -407,43 +604,38 @@ export default function RaceDetailPage() { {race.car} - {raceSOF && ( - - - SOF {raceSOF} - - )}
-
- - {/* League Banner */} - {league && ( - -
-
-
-
- + {/* Prominent SOF Badge - Electric Design */} + {raceSOF && ( +
+
+ {/* Glow effect */} +
+ +
+ {/* Electric bolt with animation */} +
+ +
+
-

Part of

-

- {league.name} -

+
+ Strength of Field +
+
+ + {raceSOF} + + SOF +
-
- View League - -
- - )} + )} +
{/* Main Content */} @@ -503,53 +695,139 @@ export default function RaceDetailPage() {
- {entryList.length === 0 ? ( -
-
- + {(() => { + const imageService = getImageService(); + return entryList.length === 0 ? ( +
+
+ +
+

No drivers registered yet

+

Be the first to sign up!

-

No drivers registered yet

-

Be the first to sign up!

-
- ) : ( -
+ ) : ( +
{entryList.map((driver, index) => { const driverRankInfo = getDriverRank(driver.id); + const isCurrentUser = driver.id === currentDriverId; + const avatarUrl = imageService.getDriverAvatar(driver.id); + const countryFlag = getCountryFlag(driver.country); + return (
router.push(`/drivers/${driver.id}`)} - className="flex items-center gap-2 p-2 bg-deep-graphite rounded-lg hover:bg-charcoal-outline/50 cursor-pointer transition-colors" + className={` + flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200 + ${isCurrentUser + ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40 shadow-lg shadow-primary-blue/10' + : 'bg-deep-graphite hover:bg-charcoal-outline/50 border border-transparent'} + `} > - #{index + 1} -
- - {driver.name.charAt(0)} - + {/* Position number */} +
+ {index + 1}
+ + {/* Avatar with nation flag */} +
+ {driver.name} + {/* Nation flag */} +
+ {countryFlag} +
+
+ + {/* Driver info */}
-

{driver.name}

+
+

+ {driver.name} +

+ {isCurrentUser && ( + + You + + )} +
+

{driver.country}

+ + {/* Rating badge */} {driverRankInfo.rating && ( - - {driverRankInfo.rating} - - )} - {driver.id === currentDriverId && ( - - You - +
+ + + {driverRankInfo.rating} + +
)}
); })}
- )} + ); + })()}
- {/* Sidebar - Actions */} + {/* Sidebar */}
+ {/* League Card - Premium Design */} + {league && ( + +
+
+ {league.name} +
+
+

League

+

{league.name}

+
+
+ + {league.description && ( +

{league.description}

+ )} + +
+
+

Max Drivers

+

{league.settings.maxDrivers ?? 32}

+
+
+

Format

+

{league.settings.qualifyingFormat ?? 'Open'}

+
+
+ + + View League + + +
+ )} + {/* Quick Actions Card */}

Actions

@@ -587,14 +865,26 @@ export default function RaceDetailPage() { )} {race.status === 'completed' && ( - + <> + + {userResult && ( + + )} + )} {race.status === 'scheduled' && ( @@ -658,6 +948,16 @@ export default function RaceDetailPage() {
+ + {/* Protest Filing Modal */} + setShowProtestModal(false)} + raceId={race.id} + leagueId={league?.id} + protestingDriverId={currentDriverId} + participants={entryList} + />
); } \ No newline at end of file diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index 5a1a39a46..320e4231e 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -11,6 +11,7 @@ import { Race } from '@gridpilot/racing/domain/entities/Race'; import { League } from '@gridpilot/racing/domain/entities/League'; import { Result } from '@gridpilot/racing/domain/entities/Result'; import { Driver } from '@gridpilot/racing/domain/entities/Driver'; +import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty'; import { getRaceRepository, getLeagueRepository, @@ -18,7 +19,14 @@ import { getStandingRepository, getDriverRepository, getGetRaceWithSOFQuery, + getGetRacePenaltiesQuery, } from '@/lib/di-container'; + +interface PenaltyData { + driverId: string; + type: PenaltyType; + value?: number; +} import { ArrowLeft, Zap, Trophy, Users, Clock, Calendar } from 'lucide-react'; export default function RaceResultsPage() { @@ -30,7 +38,9 @@ export default function RaceResultsPage() { const [league, setLeague] = useState(null); const [results, setResults] = useState([]); const [drivers, setDrivers] = useState([]); + const [currentDriverId, setCurrentDriverId] = useState(undefined); const [raceSOF, setRaceSOF] = useState(null); + const [penalties, setPenalties] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); @@ -71,6 +81,26 @@ export default function RaceResultsPage() { // Load drivers const driversData = await driverRepo.findAll(); setDrivers(driversData); + + // Get current driver (first driver in demo mode) + if (driversData.length > 0) { + setCurrentDriverId(driversData[0].id); + } + + // Load penalties for this race + try { + const penaltiesQuery = getGetRacePenaltiesQuery(); + const penaltiesData = await penaltiesQuery.execute(raceId); + // Map the DTO to the PenaltyData interface expected by ResultsTable + setPenalties(penaltiesData.map(p => ({ + driverId: p.driverId, + type: p.type, + value: p.value, + }))); + } catch (penaltyErr) { + console.error('Failed to load penalties:', penaltyErr); + // Don't fail the whole page if penalties fail to load + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load race data'); } finally { @@ -268,6 +298,8 @@ export default function RaceResultsPage() { drivers={drivers} pointsSystem={getPointsSystem()} fastestLapTime={getFastestLapTime()} + penalties={penalties} + currentDriverId={currentDriverId} /> ) : ( <> diff --git a/apps/website/components/drivers/ProfileRaceHistory.tsx b/apps/website/components/drivers/ProfileRaceHistory.tsx index 33edd99d3..6b6b4a9ef 100644 --- a/apps/website/components/drivers/ProfileRaceHistory.tsx +++ b/apps/website/components/drivers/ProfileRaceHistory.tsx @@ -1,43 +1,74 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Card from '../ui/Card'; import Button from '../ui/Button'; +import RaceResultCard from '../races/RaceResultCard'; +import { getRaceRepository, getLeagueRepository, getResultRepository } from '@/lib/di-container'; +import { Race } from '@gridpilot/racing/domain/entities/Race'; +import { Result } from '@gridpilot/racing/domain/entities/Result'; +import { League } from '@gridpilot/racing/domain/entities/League'; -interface RaceResult { - id: string; - date: string; - track: string; - car: string; - position: number; - startPosition: number; - incidents: number; - league: string; +interface RaceHistoryProps { + driverId: string; } -const mockRaceHistory: RaceResult[] = [ - { id: '1', date: '2024-11-28', track: 'Spa-Francorchamps', car: 'Porsche 911 GT3 R', position: 1, startPosition: 3, incidents: 0, league: 'GridPilot Championship' }, - { id: '2', date: '2024-11-21', track: 'Nürburgring GP', car: 'Porsche 911 GT3 R', position: 4, startPosition: 5, incidents: 2, league: 'GridPilot Championship' }, - { id: '3', date: '2024-11-14', track: 'Monza', car: 'Ferrari 488 GT3', position: 2, startPosition: 1, incidents: 1, league: 'GT3 Sprint Series' }, - { id: '4', date: '2024-11-07', track: 'Silverstone', car: 'Audi R8 LMS GT3', position: 7, startPosition: 12, incidents: 0, league: 'GridPilot Championship' }, - { id: '5', date: '2024-10-31', track: 'Interlagos', car: 'Mercedes-AMG GT3', position: 3, startPosition: 4, incidents: 1, league: 'GT3 Sprint Series' }, - { id: '6', date: '2024-10-24', track: 'Road Atlanta', car: 'Porsche 911 GT3 R', position: 5, startPosition: 8, incidents: 2, league: 'GridPilot Championship' }, - { id: '7', date: '2024-10-17', track: 'Watkins Glen', car: 'BMW M4 GT3', position: 1, startPosition: 2, incidents: 0, league: 'GT3 Sprint Series' }, - { id: '8', date: '2024-10-10', track: 'Brands Hatch', car: 'Porsche 911 GT3 R', position: 6, startPosition: 7, incidents: 3, league: 'GridPilot Championship' }, - { id: '9', date: '2024-10-03', track: 'Suzuka', car: 'McLaren 720S GT3', position: 2, startPosition: 6, incidents: 1, league: 'GT3 Sprint Series' }, - { id: '10', date: '2024-09-26', track: 'Bathurst', car: 'Porsche 911 GT3 R', position: 8, startPosition: 10, incidents: 0, league: 'GridPilot Championship' }, - { id: '11', date: '2024-09-19', track: 'Laguna Seca', car: 'Ferrari 488 GT3', position: 3, startPosition: 5, incidents: 2, league: 'GT3 Sprint Series' }, - { id: '12', date: '2024-09-12', track: 'Imola', car: 'Audi R8 LMS GT3', position: 1, startPosition: 1, incidents: 0, league: 'GridPilot Championship' }, -]; - -export default function ProfileRaceHistory() { +export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) { const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all'); const [page, setPage] = useState(1); + const [races, setRaces] = useState([]); + const [results, setResults] = useState([]); + const [leagues, setLeagues] = useState>(new Map()); + const [loading, setLoading] = useState(true); const resultsPerPage = 10; - const filteredResults = mockRaceHistory.filter(result => { - if (filter === 'wins') return result.position === 1; - if (filter === 'podiums') return result.position <= 3; + useEffect(() => { + async function loadRaceHistory() { + try { + const resultRepo = getResultRepository(); + const raceRepo = getRaceRepository(); + const leagueRepo = getLeagueRepository(); + + const driverResults = await resultRepo.findByDriverId(driverId); + const allRaces = await raceRepo.findAll(); + const allLeagues = await leagueRepo.findAll(); + + // Filter races to only those where driver has results + const raceIds = new Set(driverResults.map(r => r.raceId)); + const driverRaces = allRaces + .filter(race => raceIds.has(race.id) && race.status === 'completed') + .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()); + + const leagueMap = new Map(); + allLeagues.forEach(league => leagueMap.set(league.id, league)); + + setRaces(driverRaces); + setResults(driverResults); + setLeagues(leagueMap); + } catch (err) { + console.error('Failed to load race history:', err); + } finally { + setLoading(false); + } + } + + loadRaceHistory(); + }, [driverId]); + + const raceHistory = races.map(race => { + const result = results.find(r => r.raceId === race.id); + const league = leagues.get(race.leagueId); + return { + race, + result, + league, + }; + }).filter(item => item.result); + + const filteredResults = raceHistory.filter(item => { + if (!item.result) return false; + if (filter === 'wins') return item.result.position === 1; + if (filter === 'podiums') return item.result.position <= 3; return true; }); @@ -47,6 +78,34 @@ export default function ProfileRaceHistory() { page * resultsPerPage ); + if (loading) { + return ( +
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ +
+ ); + } + + if (raceHistory.length === 0) { + return ( + +

No race history yet

+

Complete races to build your racing record

+
+ ); + } + return (
@@ -75,53 +134,19 @@ export default function ProfileRaceHistory() {
- {paginatedResults.map((result) => ( -
-
-
-
- P{result.position} -
-
-
{result.track}
-
{result.car}
-
-
-
-
- {new Date(result.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - })} -
-
{result.league}
-
-
-
- Started P{result.startPosition} - - 2 ? 'text-red-400' : ''}> - {result.incidents}x incidents - - {result.position < result.startPosition && ( - <> - - +{result.startPosition - result.position} positions - - )} -
-
- ))} + {paginatedResults.map(({ race, result, league }) => { + if (!result) return null; + + return ( + + ); + })}
{totalPages > 1 && ( diff --git a/apps/website/components/leagues/LeagueAdmin.tsx b/apps/website/components/leagues/LeagueAdmin.tsx index 2be312475..551e3f62f 100644 --- a/apps/website/components/leagues/LeagueAdmin.tsx +++ b/apps/website/components/leagues/LeagueAdmin.tsx @@ -13,6 +13,8 @@ import { getAllDriverRankings, getDriverRepository, getGetLeagueFullConfigQuery, + getRaceRepository, + getProtestRepository, } from '@/lib/di-container'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import { LeagueBasicsSection } from './LeagueBasicsSection'; @@ -27,6 +29,9 @@ import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappe import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import Modal from '@/components/ui/Modal'; +import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User } from 'lucide-react'; +import type { Protest } from '@gridpilot/racing/domain/entities/Protest'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; interface JoinRequest { id: string; @@ -51,10 +56,14 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps const [ownerDriver, setOwnerDriver] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'disputes'>('members'); + const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings' | 'protests'>('members'); const [rejectReason, setRejectReason] = useState(''); const [configForm, setConfigForm] = useState(null); const [configLoading, setConfigLoading] = useState(false); + const [protests, setProtests] = useState([]); + const [protestRaces, setProtestRaces] = useState>({}); + const [protestDriversById, setProtestDriversById] = useState>({}); + const [protestsLoading, setProtestsLoading] = useState(false); const loadJoinRequests = useCallback(async () => { setLoading(true); @@ -119,6 +128,62 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps loadConfig(); }, [league.id]); + // Load protests for this league's races + useEffect(() => { + async function loadProtests() { + setProtestsLoading(true); + try { + const raceRepo = getRaceRepository(); + const protestRepo = getProtestRepository(); + const driverRepo = getDriverRepository(); + + // Get all races for this league + const leagueRaces = await raceRepo.findByLeagueId(league.id); + + // Get protests for each race + const allProtests: Protest[] = []; + const racesById: Record = {}; + + for (const race of leagueRaces) { + racesById[race.id] = race; + const raceProtests = await protestRepo.findByRaceId(race.id); + allProtests.push(...raceProtests); + } + + setProtests(allProtests); + setProtestRaces(racesById); + + // Load driver info for all protesters and accused + const driverIds = new Set(); + allProtests.forEach((p) => { + driverIds.add(p.protestingDriverId); + driverIds.add(p.accusedDriverId); + }); + + const driverEntities = await Promise.all( + Array.from(driverIds).map((id) => driverRepo.findById(id)), + ); + const driverDtos = driverEntities + .map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null)) + .filter((dto): dto is DriverDTO => dto !== null); + + const byId: Record = {}; + for (const dto of driverDtos) { + byId[dto.id] = dto; + } + setProtestDriversById(byId); + } catch (err) { + console.error('Failed to load protests:', err); + } finally { + setProtestsLoading(false); + } + } + + if (activeTab === 'protests') { + loadProtests(); + } + }, [league.id, activeTab]); + const handleApproveRequest = async (requestId: string) => { try { const membershipRepo = getLeagueMembershipRepository(); @@ -341,14 +406,19 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps Create Race +
+ )} +
+ + {protest.comment && ( +
+ + Driver comment: "{protest.comment}" + +
+ )} +
+ ); + })} +
+ +
+

+ Alpha Note: Protest review and penalty application is demonstration-only. + In the full product, stewards can review evidence, apply penalties, and manage appeals. +

+
+
+ )} )} diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx index 795be0bd4..b8915bbfc 100644 --- a/apps/website/components/leagues/LeagueHeader.tsx +++ b/apps/website/components/leagues/LeagueHeader.tsx @@ -5,7 +5,6 @@ import Link from 'next/link'; import Image from 'next/image'; import MembershipStatus from '@/components/leagues/MembershipStatus'; import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; -import { getLeagueCoverClasses } from '@/lib/leagueCovers'; import { getDriverRepository, getDriverStats, @@ -32,7 +31,6 @@ export default function LeagueHeader({ ownerName, }: LeagueHeaderProps) { const imageService = getImageService(); - const coverUrl = imageService.getLeagueCover(leagueId); const logoUrl = imageService.getLeagueLogo(leagueId); const [ownerDriver, setOwnerDriver] = useState(null); @@ -100,35 +98,27 @@ export default function LeagueHeader({ return (
-
-