diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 6ece46c02..e8403be5d 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -344,8 +344,8 @@ export default function DriverDetailPage({ backLink = `/leagues/${leagueId}`; } else if (from === 'league-members' && leagueId) { backLink = `/leagues/${leagueId}`; - } else if (from === 'league-race' && leagueId && raceId) { - backLink = `/leagues/${leagueId}/races/${raceId}`; + } else if (from === 'league-race' && raceId) { + backLink = `/races/${raceId}`; } else { backLink = null; } diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 93aa36293..43f6723b7 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -1,17 +1,71 @@ -import React from 'react'; +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import LeagueHeader from '@/components/leagues/LeagueHeader'; -import { getLeagueRepository, getDriverRepository } from '@/lib/di-container'; +import { getLeagueRepository, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import type { League } from '@gridpilot/racing/domain/entities/League'; -export default async function LeagueLayout(props: { +export default function LeagueLayout({ + children, +}: { children: React.ReactNode; - params: Promise<{ id: string }>; }) { - const { children, params } = props; - const resolvedParams = await params; - const leagueRepo = getLeagueRepository(); - const driverRepo = getDriverRepository(); - const league = await leagueRepo.findById(resolvedParams.id); + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + + const [league, setLeague] = useState(null); + const [ownerName, setOwnerName] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadLeague() { + try { + const leagueRepo = getLeagueRepository(); + const driverRepo = getDriverRepository(); + const membershipRepo = getLeagueMembershipRepository(); + + const leagueData = await leagueRepo.findById(leagueId); + + if (!leagueData) { + setLoading(false); + return; + } + + setLeague(leagueData); + + const owner = await driverRepo.findById(leagueData.ownerId); + setOwnerName(owner ? owner.name : `${leagueData.ownerId.slice(0, 8)}...`); + + // Check if current user is admin + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } catch (error) { + console.error('Failed to load league:', error); + } finally { + setLoading(false); + } + } + + loadLeague(); + }, [leagueId, currentDriverId]); + + if (loading) { + return ( +
+
+
Loading league...
+
+
+ ); + } if (!league) { return ( @@ -23,8 +77,25 @@ export default async function LeagueLayout(props: { ); } - const owner = await driverRepo.findById(league.ownerId); - const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`; + // Define tab configuration + const baseTabs = [ + { label: 'Overview', href: `/leagues/${leagueId}`, exact: true }, + { label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false }, + { label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false }, + { label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false }, + ]; + + const adminTabs = [ + { label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false }, + { label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false }, + ]; + + const tabs = isAdmin ? [...baseTabs, ...adminTabs] : baseTabs; + + // Determine active tab + const activeTab = tabs.find(tab => + tab.exact ? pathname === tab.href : pathname.startsWith(tab.href) + ); return (
@@ -45,6 +116,25 @@ export default async function LeagueLayout(props: { ownerName={ownerName} /> + {/* Tab Navigation */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+
{children}
diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index ef0eed430..6297f334e 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -1,15 +1,10 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import { useRouter, useParams, useSearchParams } from 'next/navigation'; +import { useRouter, useParams } from 'next/navigation'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import JoinLeagueButton from '@/components/leagues/JoinLeagueButton'; -import LeagueMembers from '@/components/leagues/LeagueMembers'; -import LeagueSchedule from '@/components/leagues/LeagueSchedule'; -import LeagueAdmin from '@/components/leagues/LeagueAdmin'; -import StandingsTable from '@/components/leagues/StandingsTable'; -import LeagueScoringTab from '@/components/leagues/LeagueScoringTab'; import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; @@ -18,22 +13,21 @@ import { Driver, EntityMappers, type DriverDTO, - type LeagueDriverSeasonStatsDTO, type LeagueScoringConfigDTO, } from '@gridpilot/racing'; import { getLeagueRepository, getRaceRepository, getDriverRepository, - getGetLeagueDriverSeasonStatsQuery, getGetLeagueScoringConfigQuery, getDriverStats, getAllDriverRankings, getGetLeagueStatsQuery, } from '@/lib/di-container'; import { Zap, Users, Trophy, Calendar } from 'lucide-react'; -import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership'; +import { getMembership, getLeagueMembers } from '@/lib/leagueMembership'; import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { getLeagueRoleDisplay } from '@/lib/leagueRoles'; export default function LeagueDetailPage() { const router = useRouter(); @@ -42,22 +36,16 @@ export default function LeagueDetailPage() { const [league, setLeague] = useState(null); const [owner, setOwner] = useState(null); - const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); const [scoringConfig, setScoringConfig] = useState(null); const [averageSOF, setAverageSOF] = useState(null); const [completedRacesCount, setCompletedRacesCount] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState< - 'overview' | 'schedule' | 'standings' | 'scoring' | 'admin' - >('overview'); const [refreshKey, setRefreshKey] = useState(0); const currentDriverId = useEffectiveDriverId(); const membership = getMembership(leagueId, currentDriverId); - const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId); - const searchParams = useSearchParams(); const loadLeagueData = async () => { try { @@ -80,11 +68,6 @@ export default function LeagueDetailPage() { const ownerData = await driverRepo.findById(leagueData.ownerId); setOwner(ownerData); - // Load standings via rich season stats query - const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery(); - const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId }); - setStandings(leagueStandings); - // Load scoring configuration for the active season const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery(); const scoring = await getLeagueScoringConfigQuery.execute({ leagueId }); @@ -121,23 +104,6 @@ export default function LeagueDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [leagueId]); - useEffect(() => { - const initialTab = searchParams?.get('tab'); - if (!initialTab) { - return; - } - - if ( - initialTab === 'overview' || - initialTab === 'schedule' || - initialTab === 'standings' || - initialTab === 'scoring' || - initialTab === 'admin' - ) { - setActiveTab(initialTab as typeof activeTab); - } - }, [searchParams]); - const handleMembershipChange = () => { setRefreshKey(prev => prev + 1); loadLeagueData(); @@ -154,6 +120,7 @@ export default function LeagueDetailPage() { const leagueMemberships = getLeagueMembers(leagueId); const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null; const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin'); + const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward'); const buildDriverSummary = (driverId: string) => { const driverDto = driversById[driverId]; @@ -230,312 +197,181 @@ export default function LeagueDetailPage() { )} - {/* Overview section switcher (in-page, not primary tabs) */} -
-
- - - - - {isAdmin && ( - + {/* League Overview - Activity Center with Info Sidebar */} +
+ {/* Center - Activity Feed */} +
+ +

Recent Activity

+ +
+
+ + {/* Right Sidebar - League Info */} +
+ {/* League Info - Combined */} + +

About

+ + {/* Stats Grid */} +
+
+
{leagueMemberships.length}
+
Members
+
+
+
{completedRacesCount}
+
Races
+
+
+
{averageSOF ?? '—'}
+
Avg SOF
+
+
+ + {/* Details */} +
+
+ Structure + Solo • {league.settings.maxDrivers ?? 32} max +
+
+ Scoring + {scoringConfig?.scoringPresetName ?? 'Standard'} +
+
+ Created + + {new Date(league.createdAt).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric' + })} + +
+
+ + {league.socialLinks && ( +
+
+ {league.socialLinks.discordUrl && ( + + Discord + + )} + {league.socialLinks.youtubeUrl && ( + + YouTube + + )} + {league.socialLinks.websiteUrl && ( + + Website + + )} +
+
+ )} +
+ + {/* Management */} + {(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && ( + +

Management

+
+ {ownerMembership && (() => { + const driverDto = driversById[ownerMembership.driverId]; + const summary = buildDriverSummary(ownerMembership.driverId); + const roleDisplay = getLeagueRoleDisplay('owner'); + const meta = summary && summary.rating !== null + ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` + : null; + + return driverDto ? ( +
+
+ +
+ + {roleDisplay.text} + +
+ ) : null; + })()} + + {adminMemberships.slice(0, 3).map((membership) => { + const driverDto = driversById[membership.driverId]; + const summary = buildDriverSummary(membership.driverId); + const roleDisplay = getLeagueRoleDisplay('admin'); + const meta = summary && summary.rating !== null + ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` + : null; + + return driverDto ? ( +
+
+ +
+ + {roleDisplay.text} + +
+ ) : null; + })} + + {stewardMemberships.slice(0, 3).map((membership) => { + const driverDto = driversById[membership.driverId]; + const summary = buildDriverSummary(membership.driverId); + const roleDisplay = getLeagueRoleDisplay('steward'); + const meta = summary && summary.rating !== null + ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` + : null; + + return driverDto ? ( +
+
+ +
+ + {roleDisplay.text} + +
+ ) : null; + })} +
+
)}
- - {/* Tab Content */} - {activeTab === 'overview' && ( -
- {/* League Info */} - -

League Information

- -
-
- -

- {new Date(league.createdAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - })} -

-
- -
-

At a glance

- -
-
-

- Structure -

-

- - Solo • {league.settings.maxDrivers ?? 32} drivers -

-
-
-

- Schedule -

-

- - {completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'} -

-
-
-

- Scoring & drops -

-

- - {scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'} -

-
-
-

- Avg. Strength of Field -

-

- - {averageSOF ? averageSOF : '—'} -

-
-
-
- - {league.socialLinks && ( -
-

Community & Social

-
- {league.socialLinks.discordUrl && ( - - Discord - - )} - {league.socialLinks.youtubeUrl && ( - - YouTube - - )} - {league.socialLinks.websiteUrl && ( - - Website - - )} -
-
- )} - -
-

Management

-
- {ownerMembership && ( -
- - {buildDriverSummary(ownerMembership.driverId) ? ( - - ) : ( -

- {owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`} -

- )} -
- )} - - {adminMemberships.length > 0 && ( -
- -
- {adminMemberships.map((membership) => { - const driverDto = driversById[membership.driverId]; - const summary = buildDriverSummary(membership.driverId); - const meta = - summary && summary.rating !== null - ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` - : null; - - return ( -
- {driverDto ? ( - - ) : ( - Unknown admin - )} -
- ); - })} -
-
- )} - - {adminMemberships.length === 0 && !ownerMembership && ( -

- Management roles have not been configured for this league yet. -

- )} -
-
-
-
- - {/* Sidebar Container */} -
- {/* Quick Actions */} - -

Quick Actions

- -
- {membership ? ( - <> - - - - - ) : ( - - )} -
-
- - {/* Recent Activity */} - -

Recent Activity

- -
-
-
- )} - - {activeTab === 'schedule' && ( - - - - )} - - {activeTab === 'standings' && ( - -

Standings

- -
- )} - - {activeTab === 'scoring' && ( - -

Scoring

- -
- )} - - {activeTab === 'admin' && isAdmin && ( - - )} ); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/races/[raceId]/page.tsx b/apps/website/app/leagues/[id]/races/[raceId]/page.tsx deleted file mode 100644 index 9c9091a1f..000000000 --- a/apps/website/app/leagues/[id]/races/[raceId]/page.tsx +++ /dev/null @@ -1,417 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useRouter, useParams } from 'next/navigation'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip'; -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 { - getRaceRepository, - getLeagueRepository, - getDriverRepository, - getGetRaceRegistrationsQuery, - getIsDriverRegisteredForRaceQuery, - getRegisterForRaceUseCase, - getWithdrawFromRaceUseCase, -} from '@/lib/di-container'; -import { getMembership } from '@/lib/leagueMembership'; -import { useEffectiveDriverId } from '@/lib/currentDriver'; -import CompanionStatus from '@/components/alpha/CompanionStatus'; -import CompanionInstructions from '@/components/alpha/CompanionInstructions'; - -export default function LeagueRaceDetailPage() { - const router = useRouter(); - const params = useParams(); - const leagueId = params.id as string; - const raceId = params.raceId as string; - - const [race, setRace] = useState(null); - const [league, setLeague] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [cancelling, setCancelling] = useState(false); - const [registering, setRegistering] = useState(false); - const [entryList, setEntryList] = useState([]); - const [isUserRegistered, setIsUserRegistered] = useState(false); - const [canRegister, setCanRegister] = useState(false); - - const currentDriverId = useEffectiveDriverId(); - - const loadRaceData = async () => { - try { - const raceRepo = getRaceRepository(); - const leagueRepo = getLeagueRepository(); - - const raceData = await raceRepo.findById(raceId); - - if (!raceData) { - setError('Race not found'); - setLoading(false); - return; - } - - setRace(raceData); - - const leagueData = await leagueRepo.findById(raceData.leagueId); - setLeague(leagueData); - - await loadEntryList(raceData.id, raceData.leagueId); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load race'); - } finally { - setLoading(false); - } - }; - - const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => { - try { - const driverRepo = getDriverRepository(); - const raceRegistrationsQuery = getGetRaceRegistrationsQuery(); - const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: raceIdValue }); - - const drivers = await Promise.all( - registeredDriverIds.map((id: string) => driverRepo.findById(id)), - ); - setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null)); - - const isRegisteredQuery = getIsDriverRegisteredForRaceQuery(); - const userIsRegistered = await isRegisteredQuery.execute({ - raceId: raceIdValue, - driverId: currentDriverId, - }); - setIsUserRegistered(userIsRegistered); - - const membership = getMembership(leagueIdValue, currentDriverId); - const isUpcoming = race?.status === 'scheduled'; - setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming); - } catch (err) { - console.error('Failed to load entry list:', err); - } - }; - - useEffect(() => { - loadRaceData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [raceId]); - - const handleCancelRace = async () => { - if (!race || race.status !== 'scheduled') return; - - const confirmed = window.confirm( - 'Are you sure you want to cancel this race? This action cannot be undone.' - ); - - if (!confirmed) return; - - setCancelling(true); - try { - const raceRepo = getRaceRepository(); - const cancelledRace = race.cancel(); - await raceRepo.update(cancelledRace); - setRace(cancelledRace); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to cancel race'); - } finally { - setCancelling(false); - } - }; - - const handleRegister = async () => { - if (!race || !league) return; - - const confirmed = window.confirm( - `Register for ${race.track}?\n\nYou'll be added to the entry list for this race.` - ); - - if (!confirmed) return; - - setRegistering(true); - try { - const useCase = getRegisterForRaceUseCase(); - await useCase.execute({ - raceId: race.id, - leagueId: league.id, - driverId: currentDriverId, - }); - await loadEntryList(race.id, league.id); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to register for race'); - } finally { - setRegistering(false); - } - }; - - const handleWithdraw = async () => { - if (!race || !league) return; - - const confirmed = window.confirm( - 'Withdraw from this race?\n\nYou can register again later if you change your mind.' - ); - - if (!confirmed) return; - - setRegistering(true); - try { - const useCase = getWithdrawFromRaceUseCase(); - await useCase.execute({ - raceId: race.id, - driverId: currentDriverId, - }); - await loadEntryList(race.id, league.id); - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to withdraw from race'); - } finally { - setRegistering(false); - } - }; - - const formatDate = (date: Date) => { - return new Date(date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - - const formatTime = (date: Date) => { - return new Date(date).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); - }; - - const formatDateTime = (date: Date) => { - return new Date(date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); - }; - - const statusColors = { - scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', - completed: 'bg-green-500/20 text-green-400 border-green-500/30', - cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30', - } as const; - - if (loading) { - return ( -
Loading race details...
- ); - } - - if (error || !race) { - return ( - -
- {error || 'Race not found'} -
- -
- ); - } - - return ( -
-
- -
- - -
- -
-
- -
-
-
-

{race.track}

- {league && ( -

{league.name}

- )} -
- - {race.status.charAt(0).toUpperCase() + race.status.slice(1)} - -
-
- - {race.status === 'scheduled' && ( -
- -
- )} - -
- -

Race Details

- -
-
- -

- {formatDateTime(race.scheduledAt)} -

-
- {formatDate(race.scheduledAt)} - {formatTime(race.scheduledAt)} -
-
- -
- -

{race.track}

-
- -
- -

{race.car}

-
- -
- -

{race.sessionType}

-
-
-
- - -

Actions

- -
- {race.status === 'scheduled' && canRegister && !isUserRegistered && ( - - )} - - {race.status === 'scheduled' && isUserRegistered && ( -
-
- ✓ Registered -
- -
- )} - - {race.status === 'completed' && ( - - )} - - {race.status === 'scheduled' && ( - - )} - - -
-
-
- - {race.status === 'scheduled' && ( - -
-

Entry List

- - {entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered - -
- - {entryList.length === 0 ? ( -
-

No drivers registered yet

-

Be the first to register!

-
- ) : ( -
- {entryList.map((driver, index) => ( -
- router.push( - `/drivers/${driver.id}?from=league&leagueId=${leagueId}`, - ) - } - > -
- #{index + 1} -
-
- - {driver.name.charAt(0)} - -
-
-

{driver.name}

-

{driver.country}

-
- {driver.id === currentDriverId && ( - - You - - )} -
- ))} -
- )} -
- )} -
- ); -} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx new file mode 100644 index 000000000..d6af992fc --- /dev/null +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import { + getLeagueRepository, + getGetLeagueScoringConfigQuery +} from '@/lib/di-container'; +import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO'; +import type { League } from '@gridpilot/racing/domain/entities/League'; + +type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties'; + +export default function LeagueRulebookPage() { + const params = useParams(); + const leagueId = params.id as string; + + const [league, setLeague] = useState(null); + const [scoringConfig, setScoringConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [activeSection, setActiveSection] = useState('scoring'); + + useEffect(() => { + async function loadData() { + try { + const leagueRepo = getLeagueRepository(); + const scoringQuery = getGetLeagueScoringConfigQuery(); + + const leagueData = await leagueRepo.findById(leagueId); + if (!leagueData) { + setLoading(false); + return; + } + + setLeague(leagueData); + + const scoring = await scoringQuery.execute({ leagueId }); + setScoringConfig(scoring); + } catch (err) { + console.error('Failed to load scoring config:', err); + } finally { + setLoading(false); + } + } + + loadData(); + }, [leagueId]); + + if (loading) { + return ( + +
Loading rulebook...
+
+ ); + } + + if (!league || !scoringConfig) { + return ( + +
Unable to load rulebook
+
+ ); + } + + const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0]; + const positionPoints = primaryChampionship?.pointsPreview + .filter(p => p.sessionType === primaryChampionship.sessionTypes[0]) + .map(p => ({ position: p.position, points: p.points })) + .sort((a, b) => a.position - b.position) || []; + + const sections: { id: RulebookSection; label: string }[] = [ + { id: 'scoring', label: 'Scoring' }, + { id: 'conduct', label: 'Conduct' }, + { id: 'protests', label: 'Protests' }, + { id: 'penalties', label: 'Penalties' }, + ]; + + return ( +
+ {/* Header */} +
+
+

Rulebook

+

Official rules and regulations

+
+
+ {scoringConfig.scoringPresetName || 'Custom Rules'} +
+
+ + {/* Navigation Tabs */} +
+ {sections.map((section) => ( + + ))} +
+ + {/* Content Sections */} + {activeSection === 'scoring' && ( +
+ {/* Quick Stats */} +
+
+

Platform

+

{scoringConfig.gameName}

+
+
+

Championships

+

{scoringConfig.championships.length}

+
+
+

Sessions Scored

+

+ {primaryChampionship?.sessionTypes.join(', ') || 'Main'} +

+
+
+

Drop Policy

+

+ {scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} +

+
+
+ + {/* Points Table */} + +

Points Distribution

+
+ + + + + + + + + {positionPoints.map(({ position, points }) => ( + + + + + ))} + +
PositionPoints
+
+
+ {position} +
+ + {position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`} + +
+
+ {points} + pts +
+
+
+ + {/* Bonus Points */} + {primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && ( + +

Bonus Points

+
+ {primaryChampionship.bonusSummary.map((bonus, idx) => ( +
+
+ + +
+

{bonus}

+
+ ))} +
+
+ )} + + {/* Drop Policy */} + {!scoringConfig.dropPolicySummary.includes('All results count') && ( + +

Drop Policy

+

{scoringConfig.dropPolicySummary}

+

+ Drop rules are applied automatically when calculating championship standings. +

+
+ )} +
+ )} + + {activeSection === 'conduct' && ( + +

Driver Conduct

+
+
+

1. Respect

+

All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.

+
+
+

2. Clean Racing

+

Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.

+
+
+

3. Track Limits

+

Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.

+
+
+

4. Blue Flags

+

Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.

+
+
+

5. Communication

+

Drivers are expected to communicate respectfully in voice and text chat during sessions.

+
+
+
+ )} + + {activeSection === 'protests' && ( + +

Protest Process

+
+
+

Filing a Protest

+

Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.

+
+
+

Evidence

+

Video evidence is highly recommended but not required. Stewards will review available replay data.

+
+
+

Review Process

+

League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.

+
+
+

Outcomes

+

Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.

+
+
+
+ )} + + {activeSection === 'penalties' && ( + +

Penalty Guidelines

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
InfractionTypical Penalty
Causing avoidable contact5-10 second time penalty
Unsafe rejoin5 second time penalty
BlockingWarning or 3 second penalty
Repeated track limit violations5 second penalty
Intentional wreckingDisqualification
Unsportsmanlike conductPoints deduction or ban
+
+

+ Penalties are applied at steward discretion based on incident severity and driver history. +

+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/schedule/page.tsx b/apps/website/app/leagues/[id]/schedule/page.tsx new file mode 100644 index 000000000..33da040f7 --- /dev/null +++ b/apps/website/app/leagues/[id]/schedule/page.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import LeagueSchedule from '@/components/leagues/LeagueSchedule'; +import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm'; +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { getLeagueMembershipRepository } from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; + +export default function LeagueSchedulePage() { + const params = useParams(); + const router = useRouter(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + const [isAdmin, setIsAdmin] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + + useEffect(() => { + async function checkAdmin() { + const membershipRepo = getLeagueMembershipRepository(); + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } + checkAdmin(); + }, [leagueId, currentDriverId]); + + return ( +
+ +

Schedule

+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/scoring/page.tsx b/apps/website/app/leagues/[id]/scoring/page.tsx new file mode 100644 index 000000000..348ef15d7 --- /dev/null +++ b/apps/website/app/leagues/[id]/scoring/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +export default async function ScoringPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + redirect(`/leagues/${id}/rulebook`); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx new file mode 100644 index 000000000..8166b9d60 --- /dev/null +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { + getLeagueRepository, + getDriverRepository, + getGetLeagueFullConfigQuery, + getLeagueMembershipRepository, + getDriverStats, + getAllDriverRankings, + getListLeagueScoringPresetsQuery, + getTransferLeagueOwnershipUseCase +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection'; +import { LeagueDropSection } from '@/components/leagues/LeagueDropSection'; +import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo'; +import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; +import type { League } from '@gridpilot/racing/domain/entities/League'; +import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; +import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; +import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; +import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; +import { AlertTriangle, Settings, Trophy, Calendar, TrendingDown, Edit, Users, UserCog } from 'lucide-react'; + +export default function LeagueSettingsPage() { + const params = useParams(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + + const [league, setLeague] = useState(null); + const [configForm, setConfigForm] = useState(null); + const [presets, setPresets] = useState([]); + const [ownerDriver, setOwnerDriver] = useState(null); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [showTransferDialog, setShowTransferDialog] = useState(false); + const [selectedNewOwner, setSelectedNewOwner] = useState(''); + const [transferring, setTransferring] = useState(false); + const [allMembers, setAllMembers] = useState([]); + const router = useRouter(); + + useEffect(() => { + async function checkAdmin() { + const membershipRepo = getLeagueMembershipRepository(); + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } + checkAdmin(); + }, [leagueId, currentDriverId]); + + useEffect(() => { + async function loadSettings() { + setLoading(true); + try { + const leagueRepo = getLeagueRepository(); + const driverRepo = getDriverRepository(); + const query = getGetLeagueFullConfigQuery(); + const presetsQuery = getListLeagueScoringPresetsQuery(); + + const leagueData = await leagueRepo.findById(leagueId); + if (!leagueData) { + setLoading(false); + return; + } + + setLeague(leagueData); + + const form = await query.execute({ leagueId }); + setConfigForm(form); + + const presetsData = await presetsQuery.execute(); + setPresets(presetsData); + + const entity = await driverRepo.findById(leagueData.ownerId); + if (entity) { + setOwnerDriver(EntityMappers.toDriverDTO(entity)); + } + + const membershipRepo = getLeagueMembershipRepository(); + const memberships = await membershipRepo.getLeagueMembers(leagueId); + const memberDrivers: DriverDTO[] = []; + for (const m of memberships) { + if (m.driverId !== leagueData.ownerId && m.status === 'active') { + const d = await driverRepo.findById(m.driverId); + if (d) { + const dto = EntityMappers.toDriverDTO(d); + if (dto) { + memberDrivers.push(dto); + } + } + } + } + setAllMembers(memberDrivers); + } catch (err) { + console.error('Failed to load league settings:', err); + } finally { + setLoading(false); + } + } + + if (isAdmin) { + loadSettings(); + } + }, [leagueId, isAdmin]); + + const ownerSummary = useMemo(() => { + if (!ownerDriver) { + return null; + } + + const stats = getDriverStats(ownerDriver.id); + const allRankings = getAllDriverRankings(); + + let rating: number | null = stats?.rating ?? null; + let rank: number | null = null; + + if (stats) { + if (typeof stats.overallRank === 'number' && stats.overallRank > 0) { + rank = stats.overallRank; + } else { + const indexInGlobal = allRankings.findIndex( + (stat) => stat.driverId === stats.driverId, + ); + if (indexInGlobal !== -1) { + rank = indexInGlobal + 1; + } + } + + if (rating === null) { + const globalEntry = allRankings.find( + (stat) => stat.driverId === stats.driverId, + ); + if (globalEntry) { + rating = globalEntry.rating; + } + } + } + + return { + driver: ownerDriver, + rating, + rank, + }; + }, [ownerDriver]); + + const handleTransferOwnership = async () => { + if (!selectedNewOwner || !league) return; + + setTransferring(true); + try { + const useCase = getTransferLeagueOwnershipUseCase(); + await useCase.execute({ + leagueId, + currentOwnerId: currentDriverId, + newOwnerId: selectedNewOwner, + }); + + setShowTransferDialog(false); + router.refresh(); + } catch (err) { + console.error('Failed to transfer ownership:', err); + alert(err instanceof Error ? err.message : 'Failed to transfer ownership'); + } finally { + setTransferring(false); + } + }; + + if (!isAdmin) { + return ( + +
+
+ +
+

Admin Access Required

+

+ Only league admins can access settings. +

+
+
+ ); + } + + if (loading) { + return ( + +
Loading configuration…
+
+ ); + } + + if (!configForm || !league) { + return ( + +
+ Unable to load league configuration for this demo league. +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

League Settings

+

Manage your league configuration

+
+
+ + + + {/* READONLY INFORMATION SECTION - Compact */} +
+ + + {/* League Owner - Compact */} +
+

League Owner

+ {ownerSummary ? ( + + ) : ( +

Loading owner details...

+ )} +
+ + {/* Transfer Ownership - Owner Only */} + {league.ownerId === currentDriverId && allMembers.length > 0 && ( +
+
+ +

Transfer Ownership

+
+

+ Transfer league ownership to another active member. You will become an admin. +

+ + {!showTransferDialog ? ( + + ) : ( +
+ + +
+ + +
+
+ )} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 8e4b2b30e..687396e3d 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; import StandingsTable from '@/components/leagues/StandingsTable'; import { @@ -8,20 +9,32 @@ import { type DriverDTO, type LeagueDriverSeasonStatsDTO, } from '@gridpilot/racing'; -import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container'; +import { + getGetLeagueDriverSeasonStatsQuery, + getDriverRepository, + getLeagueMembershipRepository +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import type { MembershipRole, LeagueMembership } from '@/lib/leagueMembership'; -export default function LeagueStandingsPage({ params }: any) { - const leagueId = params.id; +export default function LeagueStandingsPage() { + const params = useParams(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); + const [memberships, setMemberships] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); - const loadData = async () => { + const loadData = useCallback(async () => { try { const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery(); const driverRepo = getDriverRepository(); + const membershipRepo = getLeagueMembershipRepository(); const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId }); setStandings(leagueStandings); @@ -31,17 +44,86 @@ export default function LeagueStandingsPage({ params }: any) { .map((driver) => EntityMappers.toDriverDTO(driver)) .filter((dto): dto is DriverDTO => dto !== null); setDrivers(driverDtos); + + // Load league memberships from repository (consistent with other data) + const allMemberships = await membershipRepo.getLeagueMembers(leagueId); + // Convert to the format expected by StandingsTable + const membershipData: LeagueMembership[] = allMemberships.map(m => ({ + leagueId: m.leagueId, + driverId: m.driverId, + role: m.role, + status: m.status, + joinedAt: m.joinedAt instanceof Date ? m.joinedAt.toISOString() : String(m.joinedAt), + })); + setMemberships(membershipData); + + // Check if current user is admin + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load standings'); } finally { setLoading(false); } - }; + }, [leagueId, currentDriverId]); useEffect(() => { loadData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [leagueId]); + }, [loadData]); + + const handleRemoveMember = async (driverId: string) => { + if (!confirm('Are you sure you want to remove this member?')) { + return; + } + + try { + const membershipRepo = getLeagueMembershipRepository(); + const performer = await membershipRepo.getMembership(leagueId, currentDriverId); + if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) { + throw new Error('Only owners or admins can remove members'); + } + + const membership = await membershipRepo.getMembership(leagueId, driverId); + if (!membership) { + throw new Error('Member not found'); + } + if (membership.role === 'owner') { + throw new Error('Cannot remove the league owner'); + } + + await membershipRepo.removeMembership(leagueId, driverId); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove member'); + } + }; + + const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => { + try { + const membershipRepo = getLeagueMembershipRepository(); + const performer = await membershipRepo.getMembership(leagueId, currentDriverId); + if (!performer || performer.role !== 'owner') { + throw new Error('Only the league owner can update roles'); + } + + const membership = await membershipRepo.getMembership(leagueId, driverId); + if (!membership) { + throw new Error('Member not found'); + } + if (membership.role === 'owner') { + throw new Error('Cannot change the owner role'); + } + + await membershipRepo.saveMembership({ + ...membership, + role: newRole, + }); + + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update role'); + } + }; if (loading) { return ( @@ -59,10 +141,68 @@ export default function LeagueStandingsPage({ params }: any) { ); } + const leader = standings[0]; + const totalRaces = Math.max(...standings.map(s => s.racesStarted), 0); + return ( - -

Standings

- -
+
+ {/* Championship Stats */} + {standings.length > 0 && ( +
+ +
+
+ 🏆 +
+
+

Championship Leader

+

{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}

+

{leader?.totalPoints || 0} points

+
+
+
+ + +
+
+ 🏁 +
+
+

Races Completed

+

{totalRaces}

+

Season in progress

+
+
+
+ + +
+
+ 👥 +
+
+

Active Drivers

+

{standings.length}

+

Competing for points

+
+
+
+
+ )} + + +

Championship Standings

+ +
+
); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx new file mode 100644 index 000000000..ee613dafa --- /dev/null +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -0,0 +1,508 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; +import { + getRaceRepository, + getProtestRepository, + getDriverRepository, + getLeagueMembershipRepository, + getReviewProtestUseCase, + getApplyPenaltyUseCase, + getPenaltyRepository +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import type { Protest } from '@gridpilot/racing/domain/entities/Protest'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; +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, ChevronRight, + Calendar, MapPin, AlertCircle, Video, Gavel +} from 'lucide-react'; + +interface RaceWithProtests { + race: Race; + pendingProtests: Protest[]; + resolvedProtests: Protest[]; + penalties: Penalty[]; +} + +export default function LeagueStewardingPage() { + const params = useParams(); + const leagueId = params.id as string; + const currentDriverId = useEffectiveDriverId(); + + const [races, setRaces] = useState([]); + const [protestsByRace, setProtestsByRace] = useState>({}); + const [penaltiesByRace, setPenaltiesByRace] = useState>({}); + const [driversById, setDriversById] = useState>({}); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); + const [selectedProtest, setSelectedProtest] = useState(null); + const [expandedRaces, setExpandedRaces] = useState>(new Set()); + + useEffect(() => { + async function checkAdmin() { + const membershipRepo = getLeagueMembershipRepository(); + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } + checkAdmin(); + }, [leagueId, currentDriverId]); + + useEffect(() => { + async function loadData() { + setLoading(true); + try { + const raceRepo = getRaceRepository(); + const protestRepo = getProtestRepository(); + const penaltyRepo = getPenaltyRepository(); + const driverRepo = getDriverRepository(); + + // Get all races for this league + const leagueRaces = await raceRepo.findByLeagueId(leagueId); + setRaces(leagueRaces); + + // Get protests and penalties for each race + const protestsMap: Record = {}; + const penaltiesMap: Record = {}; + const driverIds = new Set(); + + for (const race of leagueRaces) { + const raceProtests = await protestRepo.findByRaceId(race.id); + const racePenalties = await penaltyRepo.findByRaceId(race.id); + + protestsMap[race.id] = raceProtests; + penaltiesMap[race.id] = racePenalties; + + // Collect driver IDs + raceProtests.forEach((p) => { + driverIds.add(p.protestingDriverId); + driverIds.add(p.accusedDriverId); + }); + racePenalties.forEach((p) => { + driverIds.add(p.driverId); + }); + } + + setProtestsByRace(protestsMap); + setPenaltiesByRace(penaltiesMap); + + // 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); + + // Auto-expand races with pending protests + const racesWithPending = new Set(); + Object.entries(protestsMap).forEach(([raceId, protests]) => { + if (protests.some(p => p.status === 'pending' || p.status === 'under_review')) { + racesWithPending.add(raceId); + } + }); + setExpandedRaces(racesWithPending); + } catch (err) { + console.error('Failed to load data:', err); + } finally { + setLoading(false); + } + } + + if (isAdmin) { + loadData(); + } + }, [leagueId, isAdmin]); + + // Compute race data with protest/penalty info + const racesWithData = useMemo((): RaceWithProtests[] => { + return races.map(race => { + const protests = protestsByRace[race.id] || []; + const penalties = penaltiesByRace[race.id] || []; + return { + race, + pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'), + resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'), + penalties + }; + }).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime()); + }, [races, protestsByRace, penaltiesByRace]); + + // Filter races based on active tab + const filteredRaces = useMemo(() => { + if (activeTab === 'pending') { + return racesWithData.filter(r => r.pendingProtests.length > 0); + } + return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0); + }, [racesWithData, activeTab]); + + // Stats + const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0); + const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0); + const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0); + + const handleAcceptProtest = async ( + protestId: string, + penaltyType: PenaltyType, + penaltyValue: number, + stewardNotes: string + ) => { + const reviewUseCase = getReviewProtestUseCase(); + const penaltyUseCase = getApplyPenaltyUseCase(); + + await reviewUseCase.execute({ + protestId, + stewardId: currentDriverId, + decision: 'uphold', + decisionNotes: stewardNotes, + }); + + // Find the protest + let foundProtest: Protest | undefined; + Object.values(protestsByRace).forEach(protests => { + const p = protests.find(pr => pr.id === protestId); + if (p) foundProtest = p; + }); + + if (foundProtest) { + await penaltyUseCase.execute({ + raceId: foundProtest.raceId, + driverId: foundProtest.accusedDriverId, + stewardId: currentDriverId, + type: penaltyType, + value: penaltyValue, + reason: foundProtest.incident.description, + protestId, + notes: stewardNotes, + }); + } + }; + + const handleRejectProtest = async (protestId: string, stewardNotes: string) => { + const reviewUseCase = getReviewProtestUseCase(); + + await reviewUseCase.execute({ + protestId, + stewardId: currentDriverId, + decision: 'dismiss', + decisionNotes: stewardNotes, + }); + }; + + const handleProtestReviewed = () => { + setSelectedProtest(null); + window.location.reload(); + }; + + const toggleRaceExpanded = (raceId: string) => { + setExpandedRaces(prev => { + const next = new Set(prev); + if (next.has(raceId)) { + next.delete(raceId); + } else { + next.add(raceId); + } + return next; + }); + }; + + 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; + } + }; + + if (!isAdmin) { + return ( + +
+
+ +
+

Admin Access Required

+

+ Only league admins can access stewarding functions. +

+
+
+ ); + } + + return ( +
+ +
+
+

Stewarding

+

+ Quick overview of protests and penalties across all races +

+
+
+ + {/* Stats summary */} + {!loading && ( +
+
+
+ + Pending Review +
+
{totalPending}
+
+
+
+ + Resolved +
+
{totalResolved}
+
+
+
+ + Penalties +
+
{totalPenalties}
+
+
+ )} + + {/* Tab navigation */} +
+
+ + +
+
+ + {/* Content */} + {loading ? ( +
+
Loading stewarding data...
+
+ ) : filteredRaces.length === 0 ? ( +
+
+ +
+

+ {activeTab === 'pending' ? 'All Clear!' : 'No History Yet'} +

+

+ {activeTab === 'pending' + ? 'No pending protests to review' + : 'No resolved protests or penalties'} +

+
+ ) : ( +
+ {filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => { + const isExpanded = expandedRaces.has(race.id); + const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests; + + return ( +
+ {/* Race Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {displayProtests.length === 0 && penalties.length === 0 ? ( +

No items to display

+ ) : ( + <> + {displayProtests.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 && (protest.status === 'pending' || protest.status === 'under_review'); + + return ( +
+
+
+
+ + + {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} + + {getStatusBadge(protest.status)} + {isUrgent && ( + + + {daysSinceFiled}d old + + )} +
+
+ Lap {protest.incident.lap} + + Filed {new Date(protest.filedAt).toLocaleDateString()} + {protest.proofVideoUrl && ( + <> + + + + + )} +
+

+ {protest.incident.description} +

+ {protest.decisionNotes && ( +
+

+ Steward: {protest.decisionNotes} +

+
+ )} +
+ {(protest.status === 'pending' || protest.status === 'under_review') && ( + + + + )} +
+
+ ); + })} + + {activeTab === 'history' && penalties.map((penalty) => { + const driver = driversById[penalty.driverId]; + return ( +
+
+
+ +
+
+
+ {driver?.name || 'Unknown'} + + {penalty.type.replace('_', ' ')} + +
+

{penalty.reason}

+
+
+ + {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`} + +
+
+
+ ); + })} + + )} +
+ )} +
+ ); + })} +
+ )} +
+ + {selectedProtest && ( + setSelectedProtest(null)} + onAccept={handleAcceptProtest} + onReject={handleRejectProtest} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx new file mode 100644 index 000000000..6fca93123 --- /dev/null +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -0,0 +1,759 @@ +'use client'; + +import { useState, useEffect, useMemo } 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 { + getProtestRepository, + getRaceRepository, + getDriverRepository, + getLeagueMembershipRepository, + getReviewProtestUseCase, + getApplyPenaltyUseCase, + getRequestProtestDefenseUseCase, + getSendNotificationUseCase +} from '@/lib/di-container'; +import { useEffectiveDriverId } from '@/lib/currentDriver'; +import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; +import type { Protest } from '@gridpilot/racing/domain/entities/Protest'; +import type { Race } from '@gridpilot/racing/domain/entities/Race'; +import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO'; +import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty'; +import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers'; +import { + AlertCircle, + Video, + Clock, + Grid3x3, + TrendingDown, + XCircle, + CheckCircle, + ArrowLeft, + Flag, + AlertTriangle, + ShieldAlert, + Ban, + DollarSign, + FileWarning, + User, + Calendar, + MapPin, + MessageCircle, + Shield, + Gavel, + Send, + ChevronDown, + ExternalLink +} from 'lucide-react'; + +// Timeline event types +interface TimelineEvent { + id: string; + type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied'; + timestamp: Date; + actor: DriverDTO | null; + content: string; + metadata?: Record; +} + +const PENALTY_TYPES = [ + { + type: 'time_penalty' as PenaltyType, + label: 'Time Penalty', + description: 'Add seconds to race result', + icon: Clock, + color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', + requiresValue: true, + valueLabel: 'seconds', + defaultValue: 5 + }, + { + type: 'grid_penalty' as PenaltyType, + label: 'Grid Penalty', + description: 'Grid positions for next race', + icon: Grid3x3, + color: 'text-purple-400 bg-purple-500/10 border-purple-500/20', + requiresValue: true, + valueLabel: 'positions', + defaultValue: 3 + }, + { + type: 'points_deduction' as PenaltyType, + label: 'Points Deduction', + description: 'Deduct championship points', + icon: TrendingDown, + color: 'text-red-400 bg-red-500/10 border-red-500/20', + requiresValue: true, + valueLabel: 'points', + defaultValue: 5 + }, + { + type: 'disqualification' as PenaltyType, + label: 'Disqualification', + description: 'Disqualify from race', + icon: XCircle, + color: 'text-red-500 bg-red-500/10 border-red-500/20', + requiresValue: false, + valueLabel: '', + defaultValue: 0 + }, + { + type: 'warning' as PenaltyType, + label: 'Warning', + description: 'Official warning only', + icon: AlertTriangle, + color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20', + requiresValue: false, + valueLabel: '', + defaultValue: 0 + }, + { + type: 'license_points' as PenaltyType, + label: 'License Points', + description: 'Safety rating penalty', + icon: ShieldAlert, + color: 'text-orange-400 bg-orange-500/10 border-orange-500/20', + requiresValue: true, + valueLabel: 'points', + defaultValue: 2 + }, +]; + +export default function ProtestReviewPage() { + const params = useParams(); + const router = useRouter(); + const leagueId = params.id as string; + const protestId = params.protestId as string; + const currentDriverId = useEffectiveDriverId(); + + const [protest, setProtest] = useState(null); + const [race, setRace] = useState(null); + const [protestingDriver, setProtestingDriver] = useState(null); + const [accusedDriver, setAccusedDriver] = useState(null); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + + // Decision state + const [showDecisionPanel, setShowDecisionPanel] = useState(false); + const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null); + const [penaltyType, setPenaltyType] = useState('time_penalty'); + const [penaltyValue, setPenaltyValue] = useState(5); + const [stewardNotes, setStewardNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Comment state + const [newComment, setNewComment] = useState(''); + + useEffect(() => { + async function checkAdmin() { + const membershipRepo = getLeagueMembershipRepository(); + const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); + } + checkAdmin(); + }, [leagueId, currentDriverId]); + + useEffect(() => { + async function loadProtest() { + setLoading(true); + try { + const protestRepo = getProtestRepository(); + const raceRepo = getRaceRepository(); + const driverRepo = getDriverRepository(); + + const protestEntity = await protestRepo.findById(protestId); + if (!protestEntity) { + throw new Error('Protest not found'); + } + setProtest(protestEntity); + + const raceEntity = await raceRepo.findById(protestEntity.raceId); + if (!raceEntity) { + throw new Error('Race not found'); + } + setRace(raceEntity); + + const protestingDriverEntity = await driverRepo.findById(protestEntity.protestingDriverId); + const accusedDriverEntity = await driverRepo.findById(protestEntity.accusedDriverId); + + setProtestingDriver(protestingDriverEntity ? EntityMappers.toDriverDTO(protestingDriverEntity) : null); + setAccusedDriver(accusedDriverEntity ? EntityMappers.toDriverDTO(accusedDriverEntity) : null); + } catch (err) { + console.error('Failed to load protest:', err); + alert('Failed to load protest details'); + router.push(`/leagues/${leagueId}/stewarding`); + } finally { + setLoading(false); + } + } + + if (isAdmin) { + loadProtest(); + } + }, [protestId, leagueId, isAdmin, currentDriverId, router]); + + // Build timeline from protest data + const timeline = useMemo((): TimelineEvent[] => { + if (!protest) return []; + + const events: TimelineEvent[] = [ + { + id: 'filed', + type: 'protest_filed', + timestamp: new Date(protest.filedAt), + actor: protestingDriver, + content: protest.incident.description, + metadata: { + lap: protest.incident.lap, + comment: protest.comment + } + } + ]; + + // Add decision event if resolved + if (protest.status === 'upheld' || protest.status === 'dismissed') { + events.push({ + id: 'decision', + type: 'decision', + timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(), + actor: null, // Would need to load steward driver + content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'), + metadata: { + decision: protest.status + } + }); + } + + return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + }, [protest, protestingDriver]); + + const handleSubmitDecision = async () => { + if (!decision || !stewardNotes.trim() || !protest) return; + + setSubmitting(true); + try { + const reviewUseCase = getReviewProtestUseCase(); + const penaltyUseCase = getApplyPenaltyUseCase(); + + if (decision === 'uphold') { + await reviewUseCase.execute({ + protestId: protest.id, + stewardId: currentDriverId, + decision: 'uphold', + decisionNotes: stewardNotes, + }); + + const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType); + await penaltyUseCase.execute({ + raceId: protest.raceId, + driverId: protest.accusedDriverId, + stewardId: currentDriverId, + type: penaltyType, + value: selectedPenalty?.requiresValue ? penaltyValue : undefined, + reason: protest.incident.description, + protestId: protest.id, + notes: stewardNotes, + }); + } else { + await reviewUseCase.execute({ + protestId: protest.id, + stewardId: currentDriverId, + decision: 'dismiss', + decisionNotes: stewardNotes, + }); + } + + router.push(`/leagues/${leagueId}/stewarding`); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to submit decision'); + } finally { + setSubmitting(false); + } + }; + + const handleRequestDefense = async () => { + if (!protest) return; + + try { + const requestDefenseUseCase = getRequestProtestDefenseUseCase(); + const sendNotificationUseCase = getSendNotificationUseCase(); + + // Request defense + const result = await requestDefenseUseCase.execute({ + protestId: protest.id, + stewardId: currentDriverId, + }); + + // Send notification to accused driver + await sendNotificationUseCase.execute({ + recipientId: result.accusedDriverId, + type: 'protest_filed', + title: 'Defense Requested', + body: `A steward has requested your defense for a protest filed against you.`, + actionUrl: `/leagues/${leagueId}/stewarding/protests/${protest.id}`, + data: { + protestId: protest.id, + raceId: protest.raceId, + leagueId, + }, + }); + + // Reload page to show updated status + window.location.reload(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to request defense'); + } + }; + + const getStatusConfig = (status: string) => { + switch (status) { + case 'pending': + return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock }; + case 'under_review': + return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield }; + case 'awaiting_defense': + return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle }; + case 'upheld': + return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle }; + case 'dismissed': + return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle }; + default: + return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle }; + } + }; + + if (!isAdmin) { + return ( + +
+
+ +
+

Admin Access Required

+

+ Only league admins can review protests. +

+
+
+ ); + } + + if (loading || !protest || !race) { + return ( + +
+
Loading protest details...
+
+
+ ); + } + + const statusConfig = getStatusConfig(protest.status); + const StatusIcon = statusConfig.icon; + const isPending = protest.status === 'pending' || protest.status === 'under_review' || protest.status === 'awaiting_defense'; + const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)); + + return ( +
+ {/* Compact Header */} +
+
+ + + +
+

Protest Review

+
+ + {statusConfig.label} +
+ {daysSinceFiled > 2 && isPending && ( + + + {daysSinceFiled}d old + + )} +
+
+
+ + {/* Main Layout: Feed + Sidebar */} +
+ {/* Left Sidebar - Incident Info */} +
+ {/* Drivers Involved */} + +

Parties Involved

+ +
+ {/* Protesting Driver */} + +
+
+ +
+
+

Protesting

+

{protestingDriver?.name || 'Unknown'}

+
+ +
+ + + {/* Accused Driver */} + +
+
+ +
+
+

Accused

+

{accusedDriver?.name || 'Unknown'}

+
+ +
+ +
+
+ + {/* Race Info */} + +

Race Details

+ + +
+ {race.track} + +
+ + +
+
+ + {race.track} +
+
+ + {race.scheduledAt.toLocaleDateString()} +
+
+ + Lap {protest.incident.lap} +
+
+
+ + {/* Evidence */} + {protest.proofVideoUrl && ( + +

Evidence

+ + +
+ )} + + {/* Quick Stats */} + +

Timeline

+
+
+ Filed + {new Date(protest.filedAt).toLocaleDateString()} +
+
+ Age + 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days +
+ {protest.reviewedAt && ( +
+ Resolved + {new Date(protest.reviewedAt).toLocaleDateString()} +
+ )} +
+
+
+ + {/* Center - Discussion Feed */} +
+ {/* Timeline / Feed */} + +
+

Discussion

+
+ +
+ {/* Initial Protest Filing */} +
+
+
+ +
+
+
+ {protestingDriver?.name || 'Unknown'} + filed protest + + {new Date(protest.filedAt).toLocaleString()} +
+ +
+

{protest.incident.description}

+ + {protest.comment && ( +
+

Additional details:

+

{protest.comment}

+
+ )} +
+
+
+
+ + {/* Defense placeholder - will be populated when defense system is implemented */} + {protest.status === 'awaiting_defense' && ( +
+
+
+ +
+
+

Defense Requested

+

Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...

+
+
+
+ )} + + {/* Decision (if resolved) */} + {(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && ( +
+
+
+ +
+
+
+ Steward Decision + + {protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'} + + {protest.reviewedAt && ( + <> + + {new Date(protest.reviewedAt).toLocaleString()} + + )} +
+ +
+

{protest.decisionNotes}

+
+
+
+
+ )} +
+ + {/* Add Comment (future feature) */} + {isPending && ( +
+
+
+ +
+
+