This commit is contained in:
2025-12-09 22:22:06 +01:00
parent e34a11ae7c
commit 3adf2e5e94
62 changed files with 6079 additions and 998 deletions

View File

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

View File

@@ -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<League | null>(null);
const [ownerName, setOwnerName] = useState<string>('');
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 (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading league...</div>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
@@ -45,6 +116,25 @@ export default async function LeagueLayout(props: {
ownerName={ownerName}
/>
{/* Tab Navigation */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.href}
onClick={() => router.push(tab.href)}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
(tab.exact ? pathname === tab.href : pathname.startsWith(tab.href))
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div>{children}</div>
</div>
</div>

View File

@@ -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<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [averageSOF, setAverageSOF] = useState<number | null>(null);
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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() {
</Card>
)}
{/* Overview section switcher (in-page, not primary tabs) */}
<div className="mb-6">
<div className="inline-flex flex-wrap gap-2 rounded-full bg-iron-gray/60 px-2 py-1">
<button
onClick={() => setActiveTab('overview')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'overview'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'schedule'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Schedule
</button>
<button
onClick={() => setActiveTab('standings')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'standings'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Standings
</button>
<button
onClick={() => setActiveTab('scoring')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'scoring'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Scoring
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'admin'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Admin
</button>
{/* League Overview - Activity Center with Info Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Center - Activity Feed */}
<div className="lg:col-span-2 space-y-6">
<Card>
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
<LeagueActivityFeed leagueId={leagueId} limit={20} />
</Card>
</div>
{/* Right Sidebar - League Info */}
<div className="space-y-6">
{/* League Info - Combined */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{leagueMemberships.length}</div>
<div className="text-xs text-gray-500">Members</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{completedRacesCount}</div>
<div className="text-xs text-gray-500">Races</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-warning-amber">{averageSOF ?? '—'}</div>
<div className="text-xs text-gray-500">Avg SOF</div>
</div>
</div>
{/* Details */}
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Structure</span>
<span className="text-white">Solo {league.settings.maxDrivers ?? 32} max</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Scoring</span>
<span className="text-white">{scoringConfig?.scoringPresetName ?? 'Standard'}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-gray-500">Created</span>
<span className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
})}
</span>
</div>
</div>
{league.socialLinks && (
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<div className="flex flex-wrap gap-2">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
Discord
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
>
YouTube
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
>
Website
</a>
)}
</div>
</div>
)}
</Card>
{/* Management */}
{(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
<div className="space-y-2">
{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 ? (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${ownerMembership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : 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 ? (
<div key={membership.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : 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 ? (
<div key={membership.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
</div>
) : null;
})}
</div>
</Card>
)}
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">At a glance</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Structure
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Users className="w-4 h-4 text-gray-500" />
Solo {league.settings.maxDrivers ?? 32} drivers
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Schedule
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Calendar className="w-4 h-4 text-gray-500" />
{completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Scoring & drops
</h4>
<p className="text-gray-200 flex items-center gap-1.5">
<Trophy className="w-4 h-4 text-gray-500" />
{scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
Avg. Strength of Field
</h4>
<p className="text-warning-amber font-medium flex items-center gap-1.5">
<Zap className="w-4 h-4" />
{averageSOF ? averageSOF : '—'}
</p>
</div>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Community & Social</h3>
<div className="flex flex-wrap gap-3 text-sm">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<span>Discord</span>
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
>
<span>YouTube</span>
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
>
<span>Website</span>
</a>
)}
</div>
</div>
)}
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Management</h3>
<div className="space-y-4">
{ownerMembership && (
<div>
<label className="text-sm text-gray-500 block mb-2">Owner</label>
{buildDriverSummary(ownerMembership.driverId) ? (
<DriverSummaryPill
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
/>
) : (
<p className="text-sm text-gray-500">
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
</p>
)}
</div>
)}
{adminMemberships.length > 0 && (
<div>
<label className="text-sm text-gray-500 block mb-2">Admins</label>
<div className="space-y-2">
{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 (
<div
key={membership.driverId}
className="flex items-center justify-between gap-3"
>
{driverDto ? (
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
contextLabel="Admin"
meta={meta}
size="sm"
/>
) : (
<span className="text-sm text-gray-400">Unknown admin</span>
)}
</div>
);
})}
</div>
</div>
)}
{adminMemberships.length === 0 && !ownerMembership && (
<p className="text-sm text-gray-500">
Management roles have not been configured for this league yet.
</p>
)}
</div>
</div>
</div>
</Card>
{/* Sidebar Container */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
{membership ? (
<>
<Button
variant="primary"
className="w-full"
onClick={() => setActiveTab('schedule')}
>
View Schedule
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => setActiveTab('standings')}
>
View Standings
</Button>
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</>
) : (
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
)}
</div>
</Card>
{/* Recent Activity */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
<LeagueActivityFeed leagueId={leagueId} limit={8} />
</Card>
</div>
</div>
)}
{activeTab === 'schedule' && (
<Card>
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'standings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
</Card>
)}
{activeTab === 'scoring' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
<LeagueScoringTab
scoringConfig={scoringConfig}
practiceMinutes={20}
qualifyingMinutes={30}
sprintRaceMinutes={20}
mainRaceMinutes={
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: 40
}
/>
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}
onLeagueUpdate={handleMembershipChange}
key={refreshKey}
/>
)}
</>
);
}

View File

@@ -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<Race | null>(null);
const [league, setLeague] = useState<League | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false);
const [entryList, setEntryList] = useState<Driver[]>([]);
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 (
<div className="text-center text-gray-400">Loading race details...</div>
);
}
if (error || !race) {
return (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'Race not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push(`/leagues/${leagueId}`)}
>
Back to League
</Button>
</Card>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<button
onClick={() => router.push(`/leagues/${leagueId}`)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to League
</button>
</div>
<FeatureLimitationTooltip message="Companion automation available in production">
<div className="mb-6">
<CompanionStatus />
</div>
</FeatureLimitationTooltip>
<div className="mb-8">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
{league && (
<p className="text-gray-400">{league.name}</p>
)}
</div>
<span
className={`px-3 py-1 text-sm font-medium rounded border ${
statusColors[race.status as keyof typeof statusColors]
}`}
>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
</div>
{race.status === 'scheduled' && (
<div className="mb-6">
<CompanionInstructions race={race} leagueName={league?.name} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
<div className="space-y-6">
<div>
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
<p className="text-white text-lg font-medium">
{formatDateTime(race.scheduledAt)}
</p>
<div className="flex gap-4 mt-2 text-sm">
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<label className="text-sm text-gray-500 block mb-1">Track</label>
<p className="text-white">{race.track}</p>
</div>
<div>
<label className="text-sm text-gray-500 block mb-1">Car</label>
<p className="text-white">{race.car}</p>
</div>
<div>
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
<p className="text-white capitalize">{race.sessionType}</p>
</div>
</div>
</Card>
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
<div className="space-y-3">
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
<Button
variant="primary"
className="w-full"
onClick={handleRegister}
disabled={registering}
>
{registering ? 'Registering...' : 'Register for Race'}
</Button>
)}
{race.status === 'scheduled' && isUserRegistered && (
<div className="space-y-2">
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
Registered
</div>
<Button
variant="secondary"
className="w-full"
onClick={handleWithdraw}
disabled={registering}
>
{registering ? 'Withdrawing...' : 'Withdraw'}
</Button>
</div>
)}
{race.status === 'completed' && (
<Button
variant="primary"
className="w-full"
onClick={() => router.push(`/races/${race.id}/results`)}
>
View Results
</Button>
)}
{race.status === 'scheduled' && (
<Button
variant="secondary"
className="w-full"
onClick={handleCancelRace}
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Cancel Race'}
</Button>
)}
<Button
variant="secondary"
className="w-full"
onClick={() => router.push(`/leagues/${leagueId}`)}
>
Back to League
</Button>
</div>
</Card>
</div>
{race.status === 'scheduled' && (
<Card className="mt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Entry List</h2>
<span className="text-sm text-gray-400">
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
</span>
</div>
{entryList.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No drivers registered yet</p>
<p className="text-sm text-gray-500">Be the first to register!</p>
</div>
) : (
<div className="space-y-2">
{entryList.map((driver, index) => (
<div
key={driver.id}
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
onClick={() =>
router.push(
`/drivers/${driver.id}?from=league&leagueId=${leagueId}`,
)
}
>
<div className="w-8 text-center text-gray-400 font-mono text-sm">
#{index + 1}
</div>
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-gray-500">
{driver.name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="text-white font-medium">{driver.name}</p>
<p className="text-sm text-gray-400">{driver.country}</p>
</div>
{driver.id === currentDriverId && (
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
You
</span>
)}
</div>
))}
</div>
)}
</Card>
)}
</div>
);
}

View File

@@ -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<League | null>(null);
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<RulebookSection>('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 (
<Card>
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
</Card>
);
}
if (!league || !scoringConfig) {
return (
<Card>
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
</Card>
);
}
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
<span className="text-sm font-medium text-primary-blue">{scoringConfig.scoringPresetName || 'Custom Rules'}</span>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
{sections.map((section) => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeSection === section.id
? 'bg-iron-gray text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
}`}
>
{section.label}
</button>
))}
</div>
{/* Content Sections */}
{activeSection === 'scoring' && (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
<p className="text-lg font-semibold text-white">{scoringConfig.gameName}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
<p className="text-lg font-semibold text-white">{scoringConfig.championships.length}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
<p className="text-lg font-semibold text-white capitalize">
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={scoringConfig.dropPolicySummary}>
{scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
</p>
</div>
</div>
{/* Points Table */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{positionPoints.map(({ position, points }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 text-right">
<span className="text-white font-semibold tabular-nums">{points}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
<div className="space-y-2">
{primaryChampionship.bonusSummary.map((bonus, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
>
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-sm font-bold">+</span>
</div>
<p className="text-sm text-gray-300">{bonus}</p>
</div>
))}
</div>
</Card>
)}
{/* Drop Policy */}
{!scoringConfig.dropPolicySummary.includes('All results count') && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
<p className="text-sm text-gray-300">{scoringConfig.dropPolicySummary}</p>
<p className="text-xs text-gray-500 mt-3">
Drop rules are applied automatically when calculating championship standings.
</p>
</Card>
)}
</div>
)}
{activeSection === 'conduct' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">1. Respect</h3>
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">5. Communication</h3>
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
</div>
</div>
</Card>
)}
{activeSection === 'protests' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Evidence</h3>
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Review Process</h3>
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Outcomes</h3>
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
</div>
</div>
</Card>
)}
{activeSection === 'penalties' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
<div className="space-y-4 text-sm text-gray-300">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
</tr>
</thead>
<tbody className="divide-y divide-charcoal-outline/50">
<tr>
<td className="py-3">Causing avoidable contact</td>
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
</tr>
<tr>
<td className="py-3">Unsafe rejoin</td>
<td className="py-3 text-warning-amber">5 second time penalty</td>
</tr>
<tr>
<td className="py-3">Blocking</td>
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
</tr>
<tr>
<td className="py-3">Repeated track limit violations</td>
<td className="py-3 text-warning-amber">5 second penalty</td>
</tr>
<tr>
<td className="py-3">Intentional wrecking</td>
<td className="py-3 text-red-400">Disqualification</td>
</tr>
<tr>
<td className="py-3">Unsportsmanlike conduct</td>
<td className="py-3 text-red-400">Points deduction or ban</td>
</tr>
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4">
Penalties are applied at steward discretion based on incident severity and driver history.
</p>
</div>
</Card>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
<LeagueSchedule leagueId={leagueId} />
</Card>
</div>
);
}

View File

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

View File

@@ -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<League | null>(null);
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [showTransferDialog, setShowTransferDialog] = useState(false);
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
const [transferring, setTransferring] = useState(false);
const [allMembers, setAllMembers] = useState<DriverDTO[]>([]);
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 (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can access settings.
</p>
</div>
</Card>
);
}
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
</Card>
);
}
if (!configForm || !league) {
return (
<Card>
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={league} configForm={configForm} />
{/* League Owner - Compact */}
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
/>
) : (
<p className="text-sm text-gray-500">Loading owner details...</p>
)}
</div>
{/* Transfer Ownership - Owner Only */}
{league.ownerId === currentDriverId && allMembers.length > 0 && (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<div className="flex items-center gap-2 mb-3">
<UserCog className="w-4 h-4 text-gray-400" />
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
</div>
<p className="text-xs text-gray-500 mb-4">
Transfer league ownership to another active member. You will become an admin.
</p>
{!showTransferDialog ? (
<Button
variant="secondary"
onClick={() => setShowTransferDialog(true)}
>
Transfer Ownership
</Button>
) : (
<div className="space-y-3">
<select
value={selectedNewOwner}
onChange={(e) => setSelectedNewOwner(e.target.value)}
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
>
<option value="">Select new owner...</option>
{allMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.name}
</option>
))}
</select>
<div className="flex gap-2">
<Button
variant="primary"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || transferring}
>
{transferring ? 'Transferring...' : 'Confirm Transfer'}
</Button>
<Button
variant="secondary"
onClick={() => {
setShowTransferDialog(false);
setSelectedNewOwner('');
}}
disabled={transferring}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
</Card>
<div className="space-y-6">
{/* Championship Stats */}
{standings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<span className="text-2xl">🏆</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
<span className="text-2xl">🏁</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
<p className="text-2xl font-bold text-white">{totalRaces}</p>
<p className="text-sm text-gray-400">Season in progress</p>
</div>
</div>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
<p className="text-2xl font-bold text-white">{standings.length}</p>
<p className="text-sm text-gray-400">Competing for points</p>
</div>
</div>
</Card>
</div>
)}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
<StandingsTable
standings={standings}
drivers={drivers}
leagueId={leagueId}
memberships={memberships}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
/>
</Card>
</div>
);
}

View File

@@ -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<Race[]>([]);
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(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<string, Protest[]> = {};
const penaltiesMap: Record<string, Penalty[]> = {};
const driverIds = new Set<string>();
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<string, DriverDTO> = {};
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<string>();
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 <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can access stewarding functions.
</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
{!loading && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-warning-amber mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Pending Review</span>
</div>
<div className="text-2xl font-bold text-white">{totalPending}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-performance-green mb-1">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Resolved</span>
</div>
<div className="text-2xl font-bold text-white">{totalResolved}</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-center gap-2 text-red-400 mb-1">
<Gavel className="w-4 h-4" />
<span className="text-xs font-medium uppercase">Penalties</span>
</div>
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
</div>
</div>
)}
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading stewarding data...</div>
</div>
) : filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{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 (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = driversById[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{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`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
</div>
);
}

View File

@@ -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<string, unknown>;
}
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<Protest | null>(null);
const [race, setRace] = useState<Race | null>(null);
const [protestingDriver, setProtestingDriver] = useState<DriverDTO | null>(null);
const [accusedDriver, setAccusedDriver] = useState<DriverDTO | null>(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<PenaltyType>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(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 (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can review protests.
</p>
</div>
</Card>
);
}
if (loading || !protest || !race) {
return (
<Card>
<div className="text-center py-12">
<div className="animate-pulse text-gray-400">Loading protest details...</div>
</div>
</Card>
);
}
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 (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.track}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.scheduledAt.toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
</div>
</Card>
{/* Evidence */}
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.filedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.filedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.incident.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
<div className="grid grid-cols-2 gap-1.5">
{PENALTY_TYPES.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import Card from '@/components/ui/Card';
interface BonusPointsCardProps {
bonusSummary: string[];
}
export function BonusPointsCard({ bonusSummary }: BonusPointsCardProps) {
if (!bonusSummary || bonusSummary.length === 0) {
return null;
}
return (
<Card>
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Bonus Points</h3>
<p className="text-sm text-gray-400 mt-1">Additional points for special achievements</p>
</div>
<div className="space-y-3">
{bonusSummary.map((bonus, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-4 bg-deep-graphite rounded-lg border border-charcoal-outline transition-colors hover:border-primary-blue/30"
>
<div className="w-10 h-10 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-lg font-bold">+</span>
</div>
<p className="text-sm text-gray-300 flex-1">{bonus}</p>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,95 @@
import Card from '@/components/ui/Card';
import type { LeagueScoringChampionshipDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
interface ChampionshipCardProps {
championship: LeagueScoringChampionshipDTO;
}
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
const getTypeLabel = (type: string): string => {
switch (type) {
case 'driver':
return 'Driver Championship';
case 'team':
return 'Team Championship';
case 'nations':
return 'Nations Championship';
case 'trophy':
return 'Trophy Championship';
default:
return 'Championship';
}
};
const getTypeBadgeStyle = (type: string): string => {
switch (type) {
case 'driver':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/20';
case 'team':
return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
case 'nations':
return 'bg-performance-green/10 text-performance-green border-performance-green/20';
case 'trophy':
return 'bg-warning-amber/10 text-warning-amber border-warning-amber/20';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/20';
}
};
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-white">{championship.name}</h3>
<span className={`inline-block mt-2 px-2.5 py-1 text-xs font-medium rounded border ${getTypeBadgeStyle(championship.type)}`}>
{getTypeLabel(championship.type)}
</span>
</div>
</div>
<div className="space-y-4">
{/* Session Types */}
{championship.sessionTypes.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Scored Sessions</h4>
<div className="flex flex-wrap gap-2">
{championship.sessionTypes.map((session, idx) => (
<span
key={idx}
className="px-3 py-1.5 rounded bg-deep-graphite text-gray-300 text-sm font-medium border border-charcoal-outline capitalize"
>
{session}
</span>
))}
</div>
</div>
)}
{/* Points Preview */}
{championship.pointsPreview.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
{championship.pointsPreview.slice(0, 6).map((preview, idx) => (
<div key={idx} className="text-center">
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Drop Policy */}
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
</div>
<p className="text-sm text-gray-300">{championship.dropPolicyDescription}</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,31 @@
import Card from '@/components/ui/Card';
interface DropRulesExplanationProps {
dropPolicyDescription: string;
}
export function DropRulesExplanation({ dropPolicyDescription }: DropRulesExplanationProps) {
// Don't show if all results count
const hasDropRules = !dropPolicyDescription.toLowerCase().includes('all results count');
if (!hasDropRules) {
return null;
}
return (
<Card>
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Drop Score Rules</h3>
<p className="text-sm text-gray-400 mt-1">How your worst results are handled</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
</div>
<p className="mt-4 text-xs text-gray-500">
Drop rules are applied automatically when calculating championship standings. Focus on racing the system handles the rest.
</p>
</Card>
);
}

View File

@@ -407,18 +407,13 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
</button>
<button
onClick={() => setActiveTab('protests')}
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'protests'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Protests
{protests.length > 0 && (
<span className="px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{protests.filter(p => p.status === 'pending').length || protests.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('settings')}
@@ -526,7 +521,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
<ScheduleRaceForm
preSelectedLeagueId={league.id}
onSuccess={(race) => {
router.push(`/leagues/${league.id}/races/${race.id}`);
router.push(`/races/${race.id}`);
}}
/>
</Card>
@@ -604,6 +599,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
const statusConfig = {
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', icon: Clock, label: 'Pending Review' },
under_review: { color: 'text-primary-blue', bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: Flag, label: 'Under Review' },
awaiting_defense: { color: 'text-purple-400', bg: 'bg-purple-500/10', border: 'border-purple-500/30', icon: Clock, label: 'Awaiting Defense' },
upheld: { color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertTriangle, label: 'Upheld' },
dismissed: { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/30', icon: XCircle, label: 'Dismissed' },
withdrawn: { color: 'text-gray-500', bg: 'bg-gray-600/10', border: 'border-gray-600/30', icon: XCircle, label: 'Withdrawn' },

View File

@@ -120,30 +120,6 @@ export default function LeagueHeader({
)}
</div>
</div>
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Alpha: Single League
</span>
</FeatureLimitationTooltip>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Owner:</span>
{ownerSummary ? (
<DriverSummaryPill
driver={ownerSummary.driver}
rating={ownerSummary.rating}
rank={ownerSummary.rank}
href={`/drivers/${ownerSummary.driver.id}?from=league&leagueId=${leagueId}`}
/>
) : (
<Link
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
className="text-sm text-primary-blue hover:underline"
>
{ownerName}
</Link>
)}
</div>
</div>
);

View File

@@ -201,7 +201,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/leagues/${leagueId}/races/${race.id}`)}
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">

View File

@@ -1,6 +1,7 @@
'use client';
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
interface LeagueScoringTabProps {
scoringConfig: LeagueScoringConfigDTO | null;
@@ -19,8 +20,14 @@ export default function LeagueScoringTab({
}: LeagueScoringTabProps) {
if (!scoringConfig) {
return (
<div className="text-sm text-gray-400 py-6">
Scoring configuration is not available for this league yet.
<div className="p-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-purple-500/10 flex items-center justify-center">
<Target className="w-8 h-8 text-purple-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">No Scoring System</h3>
<p className="text-sm text-gray-400">
Scoring configuration is not available for this league yet
</p>
</div>
);
}
@@ -37,49 +44,63 @@ export default function LeagueScoringTab({
return (
<div className="space-y-6">
<div className="border-b border-charcoal-outline pb-4 space-y-3">
<h2 className="text-xl font-semibold text-white mb-1">
Scoring overview
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{scoringConfig.scoringPresetName
? `${scoringConfig.scoringPresetName}`
: '• Custom scoring'}{' '}
{scoringConfig.dropPolicySummary}
</p>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-blue/10 flex items-center justify-center">
<Trophy className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">
Scoring overview
</h2>
<p className="text-sm text-gray-400">
{scoringConfig.gameName}{' '}
{scoringConfig.scoringPresetName
? `${scoringConfig.scoringPresetName}`
: '• Custom scoring'}{' '}
{scoringConfig.dropPolicySummary}
</p>
</div>
</div>
{primaryChampionship && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-200">
Weekend structure & timings
</h3>
<div className="space-y-3 pt-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-200">
Weekend structure & timings
</h3>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{primaryChampionship.sessionTypes.map((session) => (
<span
key={session}
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
>
{session}
</span>
))}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs text-gray-300">
<p>
<span className="text-gray-400">Practice:</span>{' '}
{resolvedPractice ? `${resolvedPractice} min` : '—'}
</p>
<p>
<span className="text-gray-400">Qualifying:</span>{' '}
{resolvedQualifying} min
</p>
<p>
<span className="text-gray-400">Sprint:</span>{' '}
{resolvedSprint ? `${resolvedSprint} min` : '—'}
</p>
<p>
<span className="text-gray-400">Main race:</span>{' '}
{resolvedMain} min
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Practice</p>
<p className="text-sm font-medium text-white">
{resolvedPractice ? `${resolvedPractice} min` : '—'}
</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Qualifying</p>
<p className="text-sm font-medium text-white">{resolvedQualifying} min</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Sprint</p>
<p className="text-sm font-medium text-white">
{resolvedSprint ? `${resolvedSprint} min` : ''}
</p>
</div>
<div className="p-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Main race</p>
<p className="text-sm font-medium text-white">{resolvedMain} min</p>
</div>
</div>
</div>
)}
@@ -163,10 +184,13 @@ export default function LeagueScoringTab({
)}
{championship.bonusSummary.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Bonus points
</h4>
<div className="p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-yellow-400" />
<h4 className="text-xs font-semibold text-yellow-400">
Bonus points
</h4>
</div>
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
{championship.bonusSummary.map((item, index) => (
<li key={index}>{item}</li>
@@ -175,10 +199,13 @@ export default function LeagueScoringTab({
</div>
)}
<div>
<h4 className="text-xs font-semibold text-gray-400 mb-1">
Drop score policy
</h4>
<div className="p-3 bg-primary-blue/5 border border-primary-blue/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Info className="w-4 h-4 text-primary-blue" />
<h4 className="text-xs font-semibold text-primary-blue">
Drop score policy
</h4>
</div>
<p className="text-xs text-gray-300">
{championship.dropPolicyDescription}
</p>

View File

@@ -0,0 +1,112 @@
"use client";
import { useState, useEffect } from "react";
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
import { Race } from "@gridpilot/racing/domain/entities/Race";
import { DriverDTO } from "@gridpilot/racing/application/dto/DriverDTO";
import Card from "../ui/Card";
import Button from "../ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points";
interface PenaltyHistoryListProps {
protests: Protest[];
races: Record<string, Race>;
drivers: Record<string, DriverDTO>;
}
export function PenaltyHistoryList({
protests,
races,
drivers,
}: PenaltyHistoryListProps) {
const [filteredProtests, setFilteredProtests] = useState<Protest[]>([]);
const [filterType, setFilterType] = useState<"all">("all");
useEffect(() => {
setFilteredProtests(protests);
}, [protests]);
const getStatusColor = (status: string) => {
switch (status) {
case "upheld":
return "text-red-400 bg-red-500/20";
case "dismissed":
return "text-gray-400 bg-gray-500/20";
case "withdrawn":
return "text-blue-400 bg-blue-500/20";
default:
return "text-orange-400 bg-orange-500/20";
}
};
return (
<div className="space-y-4">
{filteredProtests.length === 0 ? (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4 text-gray-400">
<AlertCircle className="h-12 w-12 opacity-50" />
<div>
<p className="font-medium text-lg">No Resolved Protests</p>
<p className="text-sm mt-1">
No protests have been resolved in this league
</p>
</div>
</div>
</Card>
) : (
<div className="space-y-3">
{filteredProtests.map((protest) => {
const race = races[protest.raceId];
const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId];
return (
<Card key={protest.id} className="p-4">
<div className="flex items-start gap-4">
<div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${getStatusColor(protest.status)}`}>
<Flag className="h-5 w-5" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
Resolved {new Date(protest.reviewedAt || protest.filedAt).toLocaleDateString()}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColor(protest.status)}`}>
{protest.status.toUpperCase()}
</span>
</div>
<div className="space-y-1 text-sm">
<p className="text-gray-400">
<span className="font-medium">{protester?.name || 'Unknown'}</span> vs <span className="font-medium">{accused?.name || 'Unknown'}</span>
</p>
{race && (
<p className="text-gray-500">
{race.track} ({race.car}) - Lap {protest.incident.lap}
</p>
)}
</div>
<p className="text-gray-300 text-sm">{protest.incident.description}</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward Notes:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
import { Race } from "@gridpilot/racing/domain/entities/Race";
import { DriverDTO } from "@gridpilot/racing/application/dto/DriverDTO";
import Card from "../ui/Card";
import Button from "../ui/Button";
import Link from "next/link";
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";
interface PendingProtestsListProps {
protests: Protest[];
races: Record<string, Race>;
drivers: Record<string, DriverDTO>;
leagueId: string;
onReviewProtest: (protest: Protest) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
races,
drivers,
leagueId,
onReviewProtest,
onProtestReviewed,
}: PendingProtestsListProps) {
if (protests.length === 0) {
return (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="h-8 w-8 text-performance-green" />
</div>
<div>
<p className="font-semibold text-lg text-white mb-2">All Clear! 🏁</p>
<p className="text-sm text-gray-400">No pending protests to review</p>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{protests.map((protest) => {
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2;
return (
<Card
key={protest.id}
className={`p-6 hover:border-warning-amber/40 transition-all ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="h-10 w-10 rounded-full bg-warning-amber/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-5 w-5 text-warning-amber" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
Filed {new Date(protest.filedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full flex items-center gap-1">
<Clock className="h-3 w-3" />
Pending
</span>
{isUrgent && (
<span className="px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Flag className="h-4 w-4 text-gray-400" />
<span className="text-gray-400">Lap {protest.incident.lap}</span>
</div>
<p className="text-gray-300 line-clamp-2 leading-relaxed">
{protest.incident.description}
</p>
{protest.proofVideoUrl && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-blue/10 text-primary-blue rounded-lg border border-primary-blue/20">
<Video className="h-4 w-4" />
<span>Video evidence attached</span>
</div>
)}
</div>
</div>
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button
variant="secondary"
className="flex-shrink-0"
>
<ChevronRight className="h-5 w-5" />
</Button>
</Link>
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import Card from '@/components/ui/Card';
interface PointsBreakdownTableProps {
positionPoints: Array<{ position: number; points: number }>;
}
export function PointsBreakdownTable({ positionPoints }: PointsBreakdownTableProps) {
const getPositionStyle = (position: number): string => {
if (position === 1) return 'bg-yellow-500 text-black';
if (position === 2) return 'bg-gray-400 text-black';
if (position === 3) return 'bg-amber-600 text-white';
return 'bg-charcoal-outline text-white';
};
const getRowHighlight = (position: number): string => {
if (position === 1) return 'bg-yellow-500/5 border-l-2 border-l-yellow-500';
if (position === 2) return 'bg-gray-400/5 border-l-2 border-l-gray-400';
if (position === 3) return 'bg-amber-600/5 border-l-2 border-l-amber-600';
return 'border-l-2 border-l-transparent';
};
const formatPosition = (position: number): string => {
if (position === 1) return '1st';
if (position === 2) return '2nd';
if (position === 3) return '3rd';
return `${position}th`;
};
return (
<Card className="overflow-hidden">
<div className="mb-4">
<h3 className="text-lg font-semibold text-white">Position Points</h3>
<p className="text-sm text-gray-400 mt-1">Points awarded by finishing position</p>
</div>
<div className="overflow-x-auto -mx-6 -mb-6">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline bg-deep-graphite">
<th className="text-left py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
Position
</th>
<th className="text-right py-3 px-6 font-medium text-gray-400 uppercase text-xs tracking-wider">
Points
</th>
</tr>
</thead>
<tbody>
{positionPoints.map(({ position, points }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${getRowHighlight(position)}`}
>
<td className="py-3 px-6">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${getPositionStyle(position)}`}>
{position}
</div>
<span className="text-white font-medium">{formatPosition(position)}</span>
</div>
</td>
<td className="py-3 px-6 text-right">
<span className="text-white font-semibold tabular-nums">{points}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react';
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
import type { League } from '@gridpilot/racing/domain/entities/League';
interface ReadonlyLeagueInfoProps {
league: League;
configForm: LeagueConfigFormModel;
}
export function ReadonlyLeagueInfo({ league, configForm }: ReadonlyLeagueInfoProps) {
const basics = configForm.basics;
const structure = configForm.structure;
const timings = configForm.timings;
const scoring = configForm.scoring;
const infoItems = [
{
icon: Hash,
label: 'League Name',
value: basics.name,
},
{
icon: Eye,
label: 'Visibility',
value: basics.visibility === 'ranked' || basics.visibility === 'public' ? 'Ranked' : 'Unranked',
},
{
icon: Users,
label: 'Structure',
value: structure.mode === 'solo'
? `Solo • ${structure.maxDrivers} drivers`
: `Teams • ${structure.maxTeams} × ${structure.driversPerTeam} drivers`,
},
{
icon: Gamepad2,
label: 'Platform',
value: 'iRacing',
},
{
icon: Award,
label: 'Scoring',
value: scoring.patternId ?? 'Standard',
},
{
icon: Calendar,
label: 'Created',
value: new Date(league.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}),
},
{
icon: Trophy,
label: 'Season',
value: `${timings.roundsPlanned ?? '—'} rounds`,
},
];
return (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-4">League Information</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{infoItems.map((item, index) => {
const Icon = item.icon;
return (
<div key={index} className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-iron-gray/60 shrink-0">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[10px] text-gray-500 mb-0.5">{item.label}</div>
<div className="text-xs font-medium text-gray-300 truncate">
{item.value}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,387 @@
"use client";
import { useState } from "react";
import { Protest } from "@gridpilot/racing/domain/entities/Protest";
import { PenaltyType } from "@gridpilot/racing/domain/entities/Penalty";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Card from "../ui/Card";
import {
AlertCircle,
Video,
Clock,
Grid3x3,
TrendingDown,
CheckCircle,
XCircle,
FileText,
AlertTriangle,
ShieldAlert,
Ban,
DollarSign,
FileWarning,
} from "lucide-react";
interface ReviewProtestModalProps {
protest: Protest | null;
onClose: () => void;
onAccept: (
protestId: string,
penaltyType: PenaltyType,
penaltyValue: number,
stewardNotes: string
) => Promise<void>;
onReject: (protestId: string, stewardNotes: string) => Promise<void>;
}
export function ReviewProtestModal({
protest,
onClose,
onAccept,
onReject,
}: ReviewProtestModalProps) {
const [decision, setDecision] = useState<"accept" | "reject" | null>(null);
const [penaltyType, setPenaltyType] = useState<PenaltyType>("time_penalty");
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
if (!protest) return null;
const handleSubmit = async () => {
if (!decision || !stewardNotes.trim()) return;
setSubmitting(true);
try {
if (decision === "accept") {
await onAccept(protest.id, penaltyType, penaltyValue, stewardNotes);
} else {
await onReject(protest.id, stewardNotes);
}
onClose();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to submit decision");
} finally {
setSubmitting(false);
setShowConfirmation(false);
}
};
const getPenaltyIcon = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return Clock;
case "grid_penalty":
return Grid3x3;
case "points_deduction":
return TrendingDown;
case "disqualification":
return XCircle;
case "warning":
return AlertTriangle;
case "license_points":
return ShieldAlert;
case "probation":
return FileWarning;
case "fine":
return DollarSign;
case "race_ban":
return Ban;
default:
return AlertCircle;
}
};
const getPenaltyLabel = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return "seconds";
case "grid_penalty":
return "grid positions";
case "points_deduction":
return "points";
case "license_points":
return "points";
case "fine":
return "points";
case "race_ban":
return "races";
default:
return "";
}
};
const getPenaltyColor = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
case "grid_penalty":
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
case "points_deduction":
return "text-red-400 bg-red-500/10 border-red-500/30";
case "disqualification":
return "text-red-500 bg-red-500/10 border-red-500/30";
case "warning":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/30";
case "license_points":
return "text-orange-400 bg-orange-500/10 border-orange-500/30";
case "probation":
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
case "fine":
return "text-green-400 bg-green-500/10 border-green-500/30";
case "race_ban":
return "text-red-600 bg-red-600/10 border-red-600/30";
default:
return "text-warning-amber bg-warning-amber/10 border-warning-amber/30";
}
};
if (showConfirmation) {
return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
<div className="p-6 space-y-6">
<div className="text-center space-y-4">
{decision === "accept" ? (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<AlertCircle className="h-8 w-8 text-orange-400" />
</div>
</div>
) : (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center">
<XCircle className="h-8 w-8 text-gray-400" />
</div>
</div>
)}
<div>
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2">
{decision === "accept"
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?`
: "Reject this protest?"}
</p>
</div>
</div>
<Card className="p-4 bg-gray-800/50">
<p className="text-sm text-gray-300">{stewardNotes}</p>
</Card>
<div className="flex gap-3">
<Button
variant="secondary"
className="flex-1"
onClick={() => setShowConfirmation(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="primary"
className="flex-1"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Submitting..." : "Confirm Decision"}
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
<div className="p-6 space-y-6">
<div className="flex items-start gap-4">
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-6 w-6 text-orange-400" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">Review Protest</h2>
<p className="text-gray-400 mt-1">
Protest #{protest.id.substring(0, 8)}
</p>
</div>
</div>
<div className="space-y-4">
<Card className="p-4 bg-gray-800/50">
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Filed Date</span>
<span className="text-white font-medium">
{new Date(protest.filedAt).toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Incident Lap</span>
<span className="text-white font-medium">
Lap {protest.incident.lap}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Status</span>
<span className="px-2 py-1 rounded text-xs font-medium bg-orange-500/20 text-orange-400">
{protest.status}
</span>
</div>
</div>
</Card>
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Description
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.incident.description}</p>
</Card>
</div>
{protest.comment && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Additional Comment
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.comment}</p>
</Card>
</div>
)}
{protest.proofVideoUrl && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Evidence
</label>
<Card className="p-4 bg-gray-800/50">
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-orange-400 hover:text-orange-300 transition-colors"
>
<Video className="h-4 w-4" />
<span className="text-sm">View video evidence</span>
</a>
</Card>
</div>
)}
</div>
<div className="border-t border-gray-800 pt-6 space-y-4">
<h3 className="text-lg font-semibold text-white">Stewarding Decision</h3>
<div className="grid grid-cols-2 gap-3">
<Button
variant={decision === "accept" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("accept")}
>
<CheckCircle className="h-4 w-4" />
Accept Protest
</Button>
<Button
variant={decision === "reject" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("reject")}
>
<XCircle className="h-4 w-4" />
Reject Protest
</Button>
</div>
{decision === "accept" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" },
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" },
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" },
{ type: "disqualification" as PenaltyType, label: "Disqualification" },
{ type: "warning" as PenaltyType, label: "Warning" },
{ type: "license_points" as PenaltyType, label: "License Points" },
{ type: "probation" as PenaltyType, label: "Probation" },
{ type: "fine" as PenaltyType, label: "Fine" },
{ type: "race_ban" as PenaltyType, label: "Race Ban" },
].map(({ type, label }) => {
const Icon = getPenaltyIcon(type);
const colorClass = getPenaltyColor(type);
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => setPenaltyType(type)}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} />
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p>
</button>
);
})}
</div>
</div>
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({getPenaltyLabel(penaltyType)})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-orange-500"
/>
</div>
)}
</div>
)}
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Steward Notes *
</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision and reasoning..."
rows={4}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 resize-none"
/>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-800">
<Button
variant="secondary"
className="flex-1"
onClick={onClose}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="primary"
className="flex-1"
onClick={() => setShowConfirmation(true)}
disabled={!decision || !stewardNotes.trim() || submitting}
>
Submit Decision
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,54 @@
import Card from '@/components/ui/Card';
interface ScoringOverviewCardProps {
gameName: string;
scoringPresetName?: string;
dropPolicySummary: string;
totalChampionships: number;
}
export function ScoringOverviewCard({
gameName,
scoringPresetName,
dropPolicySummary,
totalChampionships
}: ScoringOverviewCardProps) {
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Scoring System</h2>
<p className="text-sm text-gray-400 mt-1">Points allocation and championship rules</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
<span className="text-sm font-medium text-primary-blue">{scoringPresetName || 'Custom'}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Platform</p>
<p className="text-lg font-semibold text-white">{gameName}</p>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Championships</p>
<p className="text-lg font-semibold text-white">{totalChampionships}</p>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1 font-medium">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={dropPolicySummary}>
{dropPolicySummary.includes('Best') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
dropPolicySummary.includes('Worst') ? dropPolicySummary.split(' ').slice(0, 3).join(' ') :
'All count'}
</p>
</div>
</div>
<div className="p-4 bg-deep-graphite rounded-lg border border-charcoal-outline">
<p className="text-sm text-gray-400">{dropPolicySummary}</p>
</div>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
interface SeasonStatistics {
racesCompleted: number;
totalRaces: number;
averagePoints: number;
highestScore: number;
totalPoints: number;
}
interface SeasonStatsCardProps {
stats: SeasonStatistics;
}
export function SeasonStatsCard({ stats }: SeasonStatsCardProps) {
const completionPercentage = stats.totalRaces > 0
? Math.round((stats.racesCompleted / stats.totalRaces) * 100)
: 0;
if (stats.racesCompleted === 0) {
return null;
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
📈 Season Statistics
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Your performance this season
</p>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-1 font-medium">
Races Completed
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.racesCompleted}
<span className="text-lg text-gray-500 dark:text-gray-400">/{stats.totalRaces}</span>
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<p className="text-xs text-green-600 dark:text-green-400 uppercase tracking-wider mb-1 font-medium">
Average Points
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.averagePoints.toFixed(1)}
</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<p className="text-xs text-purple-600 dark:text-purple-400 uppercase tracking-wider mb-1 font-medium">
Highest Score
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.highestScore}
</p>
</div>
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 rounded-lg p-4 border border-orange-200 dark:border-orange-800">
<p className="text-xs text-orange-600 dark:text-orange-400 uppercase tracking-wider mb-1 font-medium">
Total Points
</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{stats.totalPoints}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600 dark:text-gray-400">Season Progress</span>
<span className="font-semibold text-gray-900 dark:text-white">{completionPercentage}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-3 rounded-full transition-all duration-500 ease-out"
style={{ width: `${completionPercentage}%` }}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,20 +1,246 @@
'use client';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Star } from 'lucide-react';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import type { LeagueMembership, MembershipRole } from '@/lib/leagueMembership';
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
import { getDriverStats } from '@/lib/di-container';
import { getImageService } from '@/lib/di-container';
import CountryFlag from '@/components/ui/CountryFlag';
// Position background colors
const getPositionBgColor = (position: number): string => {
switch (position) {
case 1: return 'bg-yellow-500/10 border-l-4 border-l-yellow-500';
case 2: return 'bg-gray-300/10 border-l-4 border-l-gray-400';
case 3: return 'bg-amber-600/10 border-l-4 border-l-amber-600';
default: return 'border-l-4 border-l-transparent';
}
};
interface StandingsTableProps {
standings: LeagueDriverSeasonStatsDTO[];
drivers: DriverDTO[];
leagueId: string;
memberships?: LeagueMembership[];
currentDriverId?: string;
isAdmin?: boolean;
onRemoveMember?: (driverId: string) => void;
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
}
export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) {
export default function StandingsTable({
standings,
drivers,
leagueId,
memberships = [],
currentDriverId,
isAdmin = false,
onRemoveMember,
onUpdateRole
}: StandingsTableProps) {
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getDriver = (driverId: string): DriverDTO | undefined => {
return drivers.find((d) => d.id === driverId);
};
const getMembership = (driverId: string): LeagueMembership | undefined => {
return memberships.find((m) => m.driverId === driverId);
};
const canModifyMember = (driverId: string): boolean => {
if (!isAdmin) return false;
if (driverId === currentDriverId) return false;
const membership = getMembership(driverId);
// Allow managing drivers even without formal membership (they have standings = they're participating)
// But don't allow modifying the owner
if (membership && membership.role === 'owner') return false;
return true;
};
const isCurrentUser = (driverId: string): boolean => {
return driverId === currentDriverId;
};
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
if (!onUpdateRole) return;
const membership = getMembership(driverId);
if (!membership) return;
const confirmationMessages: Record<MembershipRole, string> = {
owner: 'Cannot promote to owner',
admin: 'Promote this member to Admin? They will have full management permissions.',
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
member: 'Demote to regular Member? They will lose elevated permissions.'
};
if (newRole === 'owner') {
alert(confirmationMessages.owner);
return;
}
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
onUpdateRole(driverId, newRole);
setActiveMenu(null);
}
};
const handleRemove = (driverId: string) => {
if (!onRemoveMember) return;
const driver = getDriver(driverId);
const driverName = driver?.name || 'this member';
if (confirm(`Remove ${driverName} from the league? This action cannot be undone.`)) {
onRemoveMember(driverId);
setActiveMenu(null);
}
};
const MemberActionMenu = ({ driverId }: { driverId: string }) => {
const membership = getMembership(driverId);
// For drivers without membership, show limited options (add as member, remove from standings)
const hasMembership = !!membership;
return (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[200px]"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
Member Management
</div>
<div className="space-y-1">
{hasMembership ? (
<>
{/* Role Management for existing members */}
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-purple-500/10 rounded flex items-center gap-2 transition-colors"
>
<span>🛡</span>
<span>Promote to Admin</span>
</button>
)}
{membership!.role === 'admin' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
>
<span></span>
<span>Demote to Member</span>
</button>
)}
{membership!.role === 'member' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-blue-500/10 rounded flex items-center gap-2 transition-colors"
>
<span>🏁</span>
<span>Make Steward</span>
</button>
)}
{membership!.role === 'steward' && (
<button
onClick={(e) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
>
<span>🏁</span>
<span>Remove Steward</span>
</button>
)}
<div className="border-t border-charcoal-outline my-1"></div>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
>
<span>🚫</span>
<span>Remove from League</span>
</button>
</>
) : (
<>
{/* Options for drivers without membership (participating but not formal members) */}
<div className="text-xs text-yellow-400/80 px-2 py-1 mb-1 bg-yellow-500/10 rounded">
Driver not a formal member
</div>
<button
onClick={(e) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-green-500/10 rounded flex items-center gap-2 transition-colors"
>
<span></span>
<span>Add as Member</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleRemove(driverId); }}
className="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded flex items-center gap-2 transition-colors"
>
<span>🚫</span>
<span>Remove from Standings</span>
</button>
</>
)}
</div>
</div>
);
};
const PointsActionMenu = ({ driverId }: { driverId: string }) => {
return (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 z-50 bg-deep-graphite border border-charcoal-outline rounded-lg shadow-xl p-2 min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
Score Actions
</div>
<div className="space-y-1">
<button
onClick={(e) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
>
<span>📊</span>
<span>View Details</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
>
<span></span>
<span>Adjust Points</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-iron-gray/20 rounded flex items-center gap-2 transition-colors"
>
<span>📝</span>
<span>Race History</span>
</button>
</div>
</div>
);
};
if (standings.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
@@ -24,128 +250,191 @@ export default function StandingsTable({ standings, drivers, leagueId }: Standin
}
return (
<div className="overflow-x-auto">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pos</th>
<th className="text-center py-3 px-3 font-semibold text-gray-400 w-14">Pos</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Total Pts</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Pts / Race</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Started</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Finished</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">DNF</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">NoShows</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Penalty</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Bonus</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating Δ</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Points</th>
<th className="text-center py-3 px-4 font-semibold text-gray-400">Races</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Avg Finish</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Penalty</th>
<th className="text-right py-3 px-4 font-semibold text-gray-400">Bonus</th>
</tr>
</thead>
<tbody>
{standings.map((row) => {
const isLeader = row.position === 1;
const driver = getDriver(row.driverId);
const membership = getMembership(row.driverId);
const roleDisplay = membership ? getLeagueRoleDisplay(membership.role) : null;
const canModify = canModifyMember(row.driverId);
const driverStatsData = getDriverStats(row.driverId);
const isRowHovered = hoveredRow === row.driverId;
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
const totalPointsLine =
row.penaltyPoints > 0
? `Total Points: ${row.totalPoints} (-${row.penaltyPoints} penalty)`
: `Total Points: ${row.totalPoints}`;
const ratingDelta =
row.ratingChange === null || row.ratingChange === 0
? '—'
: row.ratingChange > 0
? `+${row.ratingChange}`
: `${row.ratingChange}`;
const isMe = isCurrentUser(row.driverId);
return (
<tr
key={`${row.leagueId}-${row.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
className={`border-b border-charcoal-outline/50 transition-all duration-200 ${getPositionBgColor(row.position)} ${isRowHovered ? 'bg-iron-gray/10' : ''} ${isMe ? 'ring-2 ring-primary-blue/50 ring-inset bg-primary-blue/5' : ''}`}
onMouseEnter={() => setHoveredRow(row.driverId)}
onMouseLeave={() => {
setHoveredRow(null);
if (!isMemberMenuOpen && !isPointsMenuOpen) {
setActiveMenu(null);
}
}}
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{/* Position */}
<td className="py-3 px-3 text-center">
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-bold ${
row.position === 1 ? 'bg-yellow-500 text-black' :
row.position === 2 ? 'bg-gray-400 text-black' :
row.position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{row.position}
</span>
</td>
<td className="py-3 px-4">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${row.driverId}?from=league-standings&leagueId=${leagueId}`}
contextLabel={`P${row.position}`}
size="sm"
meta={totalPointsLine}
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
</td>
<td className="py-3 px-4">
<span className="text-gray-300">
{row.teamName ?? '—'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="text-white font-medium">{row.totalPoints}</span>
{row.penaltyPoints > 0 || row.bonusPoints !== 0 ? (
<span className="text-xs text-gray-400">
base {row.basePoints}
{row.penaltyPoints > 0 && (
<span className="text-red-400"> {row.penaltyPoints}</span>
)}
{row.bonusPoints !== 0 && (
<span className="text-green-400"> +{row.bonusPoints}</span>
)}
</span>
) : null}
</div>
</td>
{/* Driver with Rating and Nationality */}
<td className="py-3 px-4 relative">
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="relative">
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
{driver && (
<Image
src={getImageService().getDriverAvatar(driver.id)}
alt={driver.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
)}
</div>
{/* Nationality flag */}
{driver && driver.country && (
<div className="absolute -bottom-1 -right-1">
<CountryFlag countryCode={driver.country} size="sm" />
</div>
)}
</div>
{/* Name and Rating */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
href={`/drivers/${row.driverId}`}
className="font-medium text-white truncate hover:text-primary-blue transition-colors"
>
{driver?.name || 'Unknown Driver'}
</Link>
{isMe && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
You
</span>
)}
{roleDisplay && roleDisplay.text !== 'Member' && (
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
)}
</div>
<div className="text-xs flex items-center gap-1">
{driverStatsData && (
<span className="inline-flex items-center gap-1 text-amber-300">
<Star className="h-3 w-3" />
<span className="tabular-nums font-medium">{driverStatsData.rating}</span>
</span>
)}
</div>
</div>
{/* Hover Actions for Member Management */}
{isAdmin && canModify && (
<div className="flex items-center gap-1" style={{ opacity: isRowHovered || isMemberMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden' }}>
<button
onClick={(e) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
className={`p-1.5 rounded transition-colors ${isMemberMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
title="Manage member"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
</div>
)}
</div>
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
</td>
{/* Team */}
<td className="py-3 px-4">
<span className="text-white">
{row.pointsPerRace.toFixed(2)}
<span className="text-gray-300">{row.teamName ?? '—'}</span>
</td>
{/* Total Points with Hover Action */}
<td className="py-3 px-4 text-right relative">
<div className="flex items-center justify-end gap-2">
<span className="text-white font-bold text-lg">{row.totalPoints}</span>
{isAdmin && canModify && (
<button
onClick={(e) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
className={`p-1 rounded transition-colors ${isPointsMenuOpen ? 'bg-primary-blue/20 text-primary-blue' : 'text-gray-400 hover:text-white hover:bg-iron-gray/30'}`}
style={{ opacity: isRowHovered || isPointsMenuOpen ? 1 : 0, transition: 'opacity 0.2s', visibility: isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden' }}
title="Score actions"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
</div>
{isPointsMenuOpen && <PointsActionMenu driverId={row.driverId} />}
</td>
{/* Races (Finished/Started) */}
<td className="py-3 px-4 text-center">
<span className="text-white">{row.racesFinished}</span>
<span className="text-gray-500">/{row.racesStarted}</span>
</td>
{/* Avg Finish */}
<td className="py-3 px-4 text-right">
<span className="text-gray-300">
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesStarted}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.racesFinished}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.dnfs}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{row.noShows}</span>
</td>
<td className="py-3 px-4">
<span className={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-300'}>
{/* Penalty */}
<td className="py-3 px-4 text-right">
<span className={row.penaltyPoints > 0 ? 'text-red-400 font-medium' : 'text-gray-500'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-300'}>
{/* Bonus */}
<td className="py-3 px-4 text-right">
<span className={row.bonusPoints !== 0 ? 'text-green-400 font-medium' : 'text-gray-500'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white">
{row.avgFinish !== null ? row.avgFinish.toFixed(2) : '—'}
</span>
</td>
<td className="py-3 px-4">
<span className={row.ratingChange && row.ratingChange > 0 ? 'text-green-400' : row.ratingChange && row.ratingChange < 0 ? 'text-red-400' : 'text-gray-300'}>
{ratingDelta}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
<style jsx>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}</style>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import Button from '../ui/Button';
import { getImageService } from '@/lib/di-container';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag';
interface ProfileHeaderProps {
driver: DriverDTO;
@@ -41,9 +42,7 @@ export default function ProfileHeader({
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{driver.name}</h1>
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
<CountryFlag countryCode={driver.country} size="lg" />
{teamTag && (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamTag}
@@ -78,17 +77,4 @@ export default function ProfileHeader({
)}
</div>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
}

View File

@@ -22,7 +22,7 @@ export default function Button({
as = 'button',
...props
}: ButtonProps) {
const baseStyles = 'min-h-[44px] rounded-full px-6 py-3 text-sm font-semibold transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.03] active:scale-95';
const baseStyles = 'inline-flex items-center min-h-[44px] rounded-full px-6 py-3 text-sm font-semibold transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.03] active:scale-95';
const variantStyles = {
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',

View File

@@ -0,0 +1,130 @@
'use client';
import { useState } from 'react';
// ISO 3166-1 alpha-2 country code to full country name mapping
const countryNames: Record<string, string> = {
'US': 'United States',
'GB': 'United Kingdom',
'CA': 'Canada',
'AU': 'Australia',
'NZ': 'New Zealand',
'DE': 'Germany',
'FR': 'France',
'IT': 'Italy',
'ES': 'Spain',
'NL': 'Netherlands',
'BE': 'Belgium',
'SE': 'Sweden',
'NO': 'Norway',
'DK': 'Denmark',
'FI': 'Finland',
'PL': 'Poland',
'CZ': 'Czech Republic',
'AT': 'Austria',
'CH': 'Switzerland',
'PT': 'Portugal',
'IE': 'Ireland',
'BR': 'Brazil',
'MX': 'Mexico',
'AR': 'Argentina',
'JP': 'Japan',
'CN': 'China',
'KR': 'South Korea',
'IN': 'India',
'SG': 'Singapore',
'TH': 'Thailand',
'MY': 'Malaysia',
'ID': 'Indonesia',
'PH': 'Philippines',
'ZA': 'South Africa',
'RU': 'Russia',
'MC': 'Monaco',
'TR': 'Turkey',
'GR': 'Greece',
'HU': 'Hungary',
'RO': 'Romania',
'BG': 'Bulgaria',
'HR': 'Croatia',
'SI': 'Slovenia',
'SK': 'Slovakia',
'LT': 'Lithuania',
'LV': 'Latvia',
'EE': 'Estonia',
};
// ISO 3166-1 alpha-2 country code to flag emoji conversion
const countryCodeToFlag = (countryCode: string): string => {
if (!countryCode || countryCode.length !== 2) return '🏁';
// Convert ISO 3166-1 alpha-2 to regional indicator symbols
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
interface CountryFlagProps {
/**
* ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB', 'DE')
*/
countryCode: string;
/**
* Size of the flag emoji
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg';
/**
* Additional CSS classes
*/
className?: string;
/**
* Whether to show tooltip with country name
* @default true
*/
showTooltip?: boolean;
}
/**
* Reusable component for displaying country flags with tooltips
*
* @example
* <CountryFlag countryCode="US" />
* <CountryFlag countryCode="GB" size="lg" />
* <CountryFlag countryCode="DE" showTooltip={false} />
*/
export default function CountryFlag({
countryCode,
size = 'md',
className = '',
showTooltip = true
}: CountryFlagProps) {
const [showTooltipState, setShowTooltipState] = useState(false);
const sizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
};
const flag = countryCodeToFlag(countryCode);
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
return (
<span
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
onMouseEnter={() => setShowTooltipState(true)}
onMouseLeave={() => setShowTooltipState(false)}
title={showTooltip ? countryName : undefined}
>
<span className="select-none">{flag}</span>
{showTooltip && showTooltipState && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs font-medium text-white bg-deep-graphite border border-charcoal-outline rounded shadow-lg whitespace-nowrap z-50">
{countryName}
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-charcoal-outline"></span>
</span>
)}
</span>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useRef, useEffect } from 'react';
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
import CountryFlag from './CountryFlag';
export interface Country {
code: string;
@@ -51,14 +52,6 @@ export const COUNTRIES: Country[] = [
{ code: 'UA', name: 'Ukraine' },
];
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
return '🏁';
}
interface CountrySelectProps {
value: string;
@@ -130,7 +123,7 @@ export default function CountrySelect({
<Globe className="w-4 h-4 text-gray-500" />
{selectedCountry ? (
<span className="flex items-center gap-2">
<span className="text-lg">{getCountryFlag(selectedCountry.code)}</span>
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
<span>{selectedCountry.name}</span>
</span>
) : (
@@ -173,7 +166,7 @@ export default function CountrySelect({
}`}
>
<span className="flex items-center gap-3">
<span className="text-lg">{getCountryFlag(country.code)}</span>
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
<span>{country.name}</span>
</span>
{value === country.code && (

View File

@@ -40,6 +40,20 @@ import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFee
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import type { ImageServicePort } from '@gridpilot/media';
// Notifications package imports
import type { INotificationRepository, INotificationPreferenceRepository } from '@gridpilot/notifications/application';
import {
SendNotificationUseCase,
MarkNotificationReadUseCase,
GetUnreadNotificationsQuery
} from '@gridpilot/notifications/application';
import {
InMemoryNotificationRepository,
InMemoryNotificationPreferenceRepository,
NotificationGatewayRegistry,
InAppNotificationAdapter,
} from '@gridpilot/notifications/infrastructure';
import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
@@ -92,7 +106,10 @@ import {
ApplyPenaltyUseCase,
GetRaceProtestsQuery,
GetRacePenaltiesQuery,
RequestProtestDefenseUseCase,
SubmitProtestDefenseUseCase,
} from '@gridpilot/racing/application';
import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import type { DriverRatingProvider } from '@gridpilot/racing/application';
import {
createStaticRacingSeed,
@@ -194,6 +211,14 @@ class DIContainer {
private _trackRepository: ITrackRepository;
private _carRepository: ICarRepository;
// Notifications
private _notificationRepository: INotificationRepository;
private _notificationPreferenceRepository: INotificationPreferenceRepository;
private _notificationGatewayRegistry: NotificationGatewayRegistry;
private _sendNotificationUseCase: SendNotificationUseCase;
private _markNotificationReadUseCase: MarkNotificationReadUseCase;
private _getUnreadNotificationsQuery: GetUnreadNotificationsQuery;
// Racing application use-cases / queries
private _joinLeagueUseCase: JoinLeagueUseCase;
private _registerForRaceUseCase: RegisterForRaceUseCase;
@@ -219,6 +244,8 @@ class DIContainer {
private _applyPenaltyUseCase: ApplyPenaltyUseCase;
private _getRaceProtestsQuery: GetRaceProtestsQuery;
private _getRacePenaltiesQuery: GetRacePenaltiesQuery;
private _requestProtestDefenseUseCase: RequestProtestDefenseUseCase;
private _submitProtestDefenseUseCase: SubmitProtestDefenseUseCase;
private _createTeamUseCase: CreateTeamUseCase;
private _joinTeamUseCase: JoinTeamUseCase;
@@ -231,6 +258,7 @@ class DIContainer {
private _getTeamMembersQuery: GetTeamMembersQuery;
private _getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery;
private _getDriverTeamQuery: GetDriverTeamQuery;
private _transferLeagueOwnershipUseCase: TransferLeagueOwnershipUseCase;
private constructor() {
// Create seed data
@@ -435,8 +463,7 @@ class DIContainer {
for (const league of seedData.leagues) {
const archetype = getDemoLeagueArchetypeByName(league.name);
if (!archetype) continue;
const season = Season.create({
id: `season-${league.id}-demo`,
leagueId: league.id,
@@ -450,15 +477,14 @@ class DIContainer {
});
seededSeasons.push(season);
const infraPreset = getLeagueScoringPresetById(
archetype.scoringPresetId,
);
if (!infraPreset) {
// If a preset is missing, skip scoring config for this league in alpha seed.
continue;
// Use archetype preset if available, otherwise fall back to default club preset
const presetId = archetype?.scoringPresetId ?? 'club-default';
const infraPreset = getLeagueScoringPresetById(presetId);
if (infraPreset) {
const config = infraPreset.createConfig({ seasonId: season.id });
seededScoringConfigs.push(config);
}
const config = infraPreset.createConfig({ seasonId: season.id });
seededScoringConfigs.push(config);
}
this._gameRepository = new InMemoryGameRepository([game]);
@@ -550,6 +576,33 @@ class DIContainer {
});
}
// Seed sample league stewards for the primary driver's league (alpha demo)
if (primaryLeagueForAdmins) {
const stewardCandidates = seedData.drivers
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
.slice(2, 5);
stewardCandidates.forEach((driver) => {
const existing = seededMemberships.find(
(m) =>
m.leagueId === primaryLeagueForAdmins.id && m.driverId === driver.id,
);
if (existing) {
if (existing.role !== 'owner' && existing.role !== 'admin') {
existing.role = 'steward';
}
} else {
seededMemberships.push({
leagueId: primaryLeagueForAdmins.id,
driverId: driver.id,
role: 'steward',
status: 'active',
joinedAt: new Date(),
});
}
});
}
// Seed a few pending join requests for demo leagues (expanded to more leagues)
const seededJoinRequests: JoinRequest[] = [];
const demoLeagues = seedData.leagues.slice(0, 6); // Expanded from 2 to 6 leagues
@@ -762,6 +815,11 @@ class DIContainer {
this._teamMembershipRepository,
);
this._transferLeagueOwnershipUseCase = new TransferLeagueOwnershipUseCase(
this._leagueRepository,
this._leagueMembershipRepository,
);
// Stewarding use cases and queries
this._fileProtestUseCase = new FileProtestUseCase(
this._protestRepository,
@@ -787,6 +845,14 @@ class DIContainer {
this._penaltyRepository,
this._driverRepository,
);
this._requestProtestDefenseUseCase = new RequestProtestDefenseUseCase(
this._protestRepository,
this._raceRepository,
this._leagueMembershipRepository,
);
this._submitProtestDefenseUseCase = new SubmitProtestDefenseUseCase(
this._protestRepository,
);
// Social and feed adapters backed by static seed
this._feedRepository = new InMemoryFeedRepository(seedData);
@@ -972,6 +1038,29 @@ class DIContainer {
this._trackRepository = new InMemoryTrackRepository(seedTracks);
this._carRepository = new InMemoryCarRepository(seedCars);
// Initialize notifications
this._notificationRepository = new InMemoryNotificationRepository();
this._notificationPreferenceRepository = new InMemoryNotificationPreferenceRepository();
// Set up gateway registry with adapters
this._notificationGatewayRegistry = new NotificationGatewayRegistry([
new InAppNotificationAdapter(),
// Future: DiscordNotificationAdapter, EmailNotificationAdapter
]);
// Notification use cases
this._sendNotificationUseCase = new SendNotificationUseCase(
this._notificationRepository,
this._notificationPreferenceRepository,
this._notificationGatewayRegistry,
);
this._markNotificationReadUseCase = new MarkNotificationReadUseCase(
this._notificationRepository,
);
this._getUnreadNotificationsQuery = new GetUnreadNotificationsQuery(
this._notificationRepository,
);
}
/**
@@ -1187,6 +1276,26 @@ class DIContainer {
return this._carRepository;
}
get notificationRepository(): INotificationRepository {
return this._notificationRepository;
}
get notificationPreferenceRepository(): INotificationPreferenceRepository {
return this._notificationPreferenceRepository;
}
get sendNotificationUseCase(): SendNotificationUseCase {
return this._sendNotificationUseCase;
}
get markNotificationReadUseCase(): MarkNotificationReadUseCase {
return this._markNotificationReadUseCase;
}
get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
return this._getUnreadNotificationsQuery;
}
get fileProtestUseCase(): FileProtestUseCase {
return this._fileProtestUseCase;
}
@@ -1206,6 +1315,18 @@ class DIContainer {
get getRacePenaltiesQuery(): GetRacePenaltiesQuery {
return this._getRacePenaltiesQuery;
}
get requestProtestDefenseUseCase(): RequestProtestDefenseUseCase {
return this._requestProtestDefenseUseCase;
}
get submitProtestDefenseUseCase(): SubmitProtestDefenseUseCase {
return this._submitProtestDefenseUseCase;
}
get transferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseCase {
return this._transferLeagueOwnershipUseCase;
}
}
/**
@@ -1388,6 +1509,26 @@ export function getCarRepository(): ICarRepository {
return DIContainer.getInstance().carRepository;
}
export function getNotificationRepository(): INotificationRepository {
return DIContainer.getInstance().notificationRepository;
}
export function getNotificationPreferenceRepository(): INotificationPreferenceRepository {
return DIContainer.getInstance().notificationPreferenceRepository;
}
export function getSendNotificationUseCase(): SendNotificationUseCase {
return DIContainer.getInstance().sendNotificationUseCase;
}
export function getMarkNotificationReadUseCase(): MarkNotificationReadUseCase {
return DIContainer.getInstance().markNotificationReadUseCase;
}
export function getGetUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
return DIContainer.getInstance().getUnreadNotificationsQuery;
}
export function getFileProtestUseCase(): FileProtestUseCase {
return DIContainer.getInstance().fileProtestUseCase;
}
@@ -1408,6 +1549,18 @@ export function getGetRacePenaltiesQuery(): GetRacePenaltiesQuery {
return DIContainer.getInstance().getRacePenaltiesQuery;
}
export function getRequestProtestDefenseUseCase(): RequestProtestDefenseUseCase {
return DIContainer.getInstance().requestProtestDefenseUseCase;
}
export function getSubmitProtestDefenseUseCase(): SubmitProtestDefenseUseCase {
return DIContainer.getInstance().submitProtestDefenseUseCase;
}
export function getTransferLeagueOwnershipUseCase(): TransferLeagueOwnershipUseCase {
return DIContainer.getInstance().transferLeagueOwnershipUseCase;
}
/**
* Reset function for testing
*/

View File

@@ -5,7 +5,7 @@ import type {
MembershipRole,
MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import { leagues, memberships as seedMemberships } from '@gridpilot/testing-support';
import { leagues, memberships as seedMemberships, drivers } from '@gridpilot/testing-support';
/**
* Lightweight league membership model mirroring the domain type but with
@@ -65,6 +65,63 @@ const leagueMemberships = new Map<string, LeagueMembership[]>();
byLeague.set(league.id, list);
}
// Seed sample league admins for the primary driver's league (alpha demo)
const primaryDriverId = drivers[0]?.id ?? 'driver-1';
const primaryLeagueForAdmins = leagues.find((l) => l.ownerId === primaryDriverId) ?? leagues[0];
if (primaryLeagueForAdmins) {
const adminCandidates = drivers
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
.slice(0, 2);
adminCandidates.forEach((driver) => {
const list = byLeague.get(primaryLeagueForAdmins.id) ?? [];
const existing = list.find((m) => m.driverId === driver.id);
if (existing) {
if (existing.role !== 'owner') {
existing.role = 'admin';
}
} else {
const joinedAt = new Date().toISOString();
list.push({
leagueId: primaryLeagueForAdmins.id,
driverId: driver.id,
role: 'admin',
status: 'active',
joinedAt,
});
}
byLeague.set(primaryLeagueForAdmins.id, list);
});
}
// Seed sample league stewards for the primary driver's league (alpha demo)
if (primaryLeagueForAdmins) {
const stewardCandidates = drivers
.filter((d) => d.id !== primaryLeagueForAdmins.ownerId)
.slice(2, 5);
stewardCandidates.forEach((driver) => {
const list = byLeague.get(primaryLeagueForAdmins.id) ?? [];
const existing = list.find((m) => m.driverId === driver.id);
if (existing) {
if (existing.role !== 'owner' && existing.role !== 'admin') {
existing.role = 'steward';
}
} else {
const joinedAt = new Date().toISOString();
list.push({
leagueId: primaryLeagueForAdmins.id,
driverId: driver.id,
role: 'steward',
status: 'active',
joinedAt,
});
}
byLeague.set(primaryLeagueForAdmins.id, list);
});
}
for (const [leagueId, list] of byLeague.entries()) {
leagueMemberships.set(leagueId, list);
}

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@faker-js/faker": "^9.2.0",
"@gridpilot/identity": "file:../../packages/identity",
"@gridpilot/notifications": "file:../../packages/notifications",
"@gridpilot/racing": "file:../../packages/racing",
"@gridpilot/social": "file:../../packages/social",
"@gridpilot/testing-support": "file:../../packages/testing-support",

15
package-lock.json generated
View File

@@ -141,6 +141,7 @@
"dependencies": {
"@faker-js/faker": "^9.2.0",
"@gridpilot/identity": "file:../../packages/identity",
"@gridpilot/notifications": "file:../../packages/notifications",
"@gridpilot/racing": "file:../../packages/racing",
"@gridpilot/social": "file:../../packages/social",
"@gridpilot/testing-support": "file:../../packages/testing-support",
@@ -1547,6 +1548,10 @@
"resolved": "packages/media",
"link": true
},
"node_modules/@gridpilot/notifications": {
"resolved": "packages/notifications",
"link": true
},
"node_modules/@gridpilot/racing": {
"resolved": "packages/racing",
"link": true
@@ -13474,6 +13479,16 @@
"name": "@gridpilot/media",
"version": "0.1.0"
},
"packages/notifications": {
"name": "@gridpilot/notifications",
"version": "0.1.0",
"dependencies": {
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/uuid": "^10.0.0"
}
},
"packages/racing": {
"name": "@gridpilot/racing",
"version": "0.1.0",

View File

@@ -0,0 +1,26 @@
/**
* Notifications Application Layer
*
* Exports all use cases, queries, and ports.
*/
// Use Cases
export * from './use-cases/SendNotificationUseCase';
export * from './use-cases/MarkNotificationReadUseCase';
export * from './use-cases/GetUnreadNotificationsQuery';
export * from './use-cases/NotificationPreferencesUseCases';
// Ports
export * from './ports/INotificationGateway';
// Re-export domain types for convenience
export type { Notification, NotificationProps, NotificationStatus, NotificationData } 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';
export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType';
export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel';
// Re-export repository interfaces
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';

View File

@@ -0,0 +1,68 @@
/**
* Application Port: INotificationGateway
*
* Defines the contract for sending notifications through external channels.
* Implementations (adapters) handle the actual delivery mechanism.
*/
import type { Notification } from '../../domain/entities/Notification';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export interface NotificationDeliveryResult {
success: boolean;
channel: NotificationChannel;
/** External message ID (e.g., Discord message ID, email ID) */
externalId?: string;
/** Error message if delivery failed */
error?: string;
/** Timestamp of delivery attempt */
attemptedAt: Date;
}
export interface INotificationGateway {
/**
* Send a notification through this gateway's channel
*/
send(notification: Notification): Promise<NotificationDeliveryResult>;
/**
* Check if this gateway supports the given channel
*/
supportsChannel(channel: NotificationChannel): boolean;
/**
* Check if the gateway is configured and ready to send
*/
isConfigured(): boolean;
/**
* Get the channel this gateway handles
*/
getChannel(): NotificationChannel;
}
/**
* Registry for notification gateways
* Allows routing notifications to the appropriate gateway based on channel
*/
export interface INotificationGatewayRegistry {
/**
* Register a gateway for a channel
*/
register(gateway: INotificationGateway): void;
/**
* Get gateway for a specific channel
*/
getGateway(channel: NotificationChannel): INotificationGateway | null;
/**
* Get all registered gateways
*/
getAllGateways(): INotificationGateway[];
/**
* Send notification through appropriate gateway
*/
send(notification: Notification): Promise<NotificationDeliveryResult>;
}

View File

@@ -0,0 +1,89 @@
/**
* Application Query: GetUnreadNotificationsQuery
*
* Retrieves unread notifications for a recipient.
*/
import type { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface UnreadNotificationsResult {
notifications: Notification[];
totalCount: number;
}
export class GetUnreadNotificationsQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
return {
notifications,
totalCount: notifications.length,
};
}
}
/**
* Application Query: GetNotificationsQuery
*
* Retrieves all notifications for a recipient with optional filtering.
*/
export interface GetNotificationsOptions {
includeRead?: boolean;
includeDismissed?: boolean;
limit?: number;
offset?: number;
}
export class GetNotificationsQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string, options: GetNotificationsOptions = {}): Promise<Notification[]> {
const allNotifications = await this.notificationRepository.findByRecipientId(recipientId);
let filtered = allNotifications;
// Filter by status
if (!options.includeRead && !options.includeDismissed) {
filtered = filtered.filter(n => n.isUnread());
} else if (!options.includeDismissed) {
filtered = filtered.filter(n => !n.isDismissed());
} else if (!options.includeRead) {
filtered = filtered.filter(n => n.isUnread() || n.isDismissed());
}
// Sort by creation date (newest first)
filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Apply pagination
if (options.offset !== undefined) {
filtered = filtered.slice(options.offset);
}
if (options.limit !== undefined) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
}
/**
* Application Query: GetUnreadCountQuery
*
* Gets the count of unread notifications for a recipient.
*/
export class GetUnreadCountQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<number> {
return this.notificationRepository.countUnreadByRecipientId(recipientId);
}
}

View File

@@ -0,0 +1,87 @@
/**
* Application Use Case: MarkNotificationReadUseCase
*
* Marks a notification as read.
*/
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface MarkNotificationReadCommand {
notificationId: string;
recipientId: string; // For validation
}
export class MarkNotificationReadUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(command: MarkNotificationReadCommand): Promise<void> {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot mark another user\'s notification as read');
}
if (!notification.isUnread()) {
return; // Already read, nothing to do
}
const updatedNotification = notification.markAsRead();
await this.notificationRepository.update(updatedNotification);
}
}
/**
* Application Use Case: MarkAllNotificationsReadUseCase
*
* Marks all notifications as read for a recipient.
*/
export class MarkAllNotificationsReadUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<void> {
await this.notificationRepository.markAllAsReadByRecipientId(recipientId);
}
}
/**
* Application Use Case: DismissNotificationUseCase
*
* Dismisses a notification.
*/
export interface DismissNotificationCommand {
notificationId: string;
recipientId: string;
}
export class DismissNotificationUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(command: DismissNotificationCommand): Promise<void> {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot dismiss another user\'s notification');
}
if (notification.isDismissed()) {
return; // Already dismissed
}
const updatedNotification = notification.dismiss();
await this.notificationRepository.update(updatedNotification);
}
}

View File

@@ -0,0 +1,120 @@
/**
* Application Use Cases: Notification Preferences
*
* Manages user notification preferences.
*/
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { NotificationType } from '../../domain/value-objects/NotificationType';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
/**
* Query: GetNotificationPreferencesQuery
*/
export class GetNotificationPreferencesQuery {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(driverId: string): Promise<NotificationPreference> {
return this.preferenceRepository.getOrCreateDefault(driverId);
}
}
/**
* Use Case: UpdateChannelPreferenceUseCase
*/
export interface UpdateChannelPreferenceCommand {
driverId: string;
channel: NotificationChannel;
preference: ChannelPreference;
}
export class UpdateChannelPreferenceUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateChannel(command.channel, command.preference);
await this.preferenceRepository.save(updated);
}
}
/**
* Use Case: UpdateTypePreferenceUseCase
*/
export interface UpdateTypePreferenceCommand {
driverId: string;
type: NotificationType;
preference: TypePreference;
}
export class UpdateTypePreferenceUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateTypePreferenceCommand): Promise<void> {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateTypePreference(command.type, command.preference);
await this.preferenceRepository.save(updated);
}
}
/**
* Use Case: UpdateQuietHoursUseCase
*/
export interface UpdateQuietHoursCommand {
driverId: string;
startHour: number | undefined;
endHour: number | undefined;
}
export class UpdateQuietHoursUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateQuietHoursCommand): Promise<void> {
// Validate hours if provided
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
throw new Error('Start hour must be between 0 and 23');
}
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
throw new Error('End hour must be between 0 and 23');
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateQuietHours(command.startHour, command.endHour);
await this.preferenceRepository.save(updated);
}
}
/**
* Use Case: SetDigestModeUseCase
*/
export interface SetDigestModeCommand {
driverId: string;
enabled: boolean;
frequencyHours?: number;
}
export class SetDigestModeUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: SetDigestModeCommand): Promise<void> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
throw new Error('Digest frequency must be at least 1 hour');
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.setDigestMode(command.enabled, command.frequencyHours);
await this.preferenceRepository.save(updated);
}
}

View File

@@ -0,0 +1,119 @@
/**
* Application Use Case: SendNotificationUseCase
*
* Sends a notification to a recipient through the appropriate channels
* based on their preferences.
*/
import { v4 as uuid } from 'uuid';
import { Notification } from '../../domain/entities/Notification';
import type { NotificationData } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
import type { NotificationType } from '../../domain/value-objects/NotificationType';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export interface SendNotificationCommand {
recipientId: string;
type: NotificationType;
title: string;
body: string;
data?: NotificationData;
actionUrl?: string;
/** Override channels (skip preference check) */
forceChannels?: NotificationChannel[];
}
export interface SendNotificationResult {
/** The created notification */
notification: Notification;
/** Delivery results for each channel */
deliveryResults: NotificationDeliveryResult[];
}
export class SendNotificationUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly gatewayRegistry: INotificationGatewayRegistry,
) {}
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> {
// Get recipient's preferences
const preferences = await this.preferenceRepository.getOrCreateDefault(command.recipientId);
// Check if this notification type is enabled
if (!preferences.isTypeEnabled(command.type)) {
// User has disabled this type - create but don't deliver
const notification = Notification.create({
id: uuid(),
recipientId: command.recipientId,
type: command.type,
title: command.title,
body: command.body,
channel: 'in_app',
data: command.data,
actionUrl: command.actionUrl,
status: 'dismissed', // Auto-dismiss since user doesn't want these
});
await this.notificationRepository.create(notification);
return {
notification,
deliveryResults: [],
};
}
// Determine which channels to use
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
// Check quiet hours (skip external channels during quiet hours)
const effectiveChannels = preferences.isInQuietHours()
? channels.filter(ch => ch === 'in_app')
: channels;
// Ensure at least in_app is used
if (!effectiveChannels.includes('in_app')) {
effectiveChannels.unshift('in_app');
}
const deliveryResults: NotificationDeliveryResult[] = [];
let primaryNotification: Notification | null = null;
// Send through each channel
for (const channel of effectiveChannels) {
const notification = Notification.create({
id: uuid(),
recipientId: command.recipientId,
type: command.type,
title: command.title,
body: command.body,
channel,
data: command.data,
actionUrl: command.actionUrl,
});
// Save to repository (in_app channel) or attempt delivery (external channels)
if (channel === 'in_app') {
await this.notificationRepository.create(notification);
primaryNotification = notification;
deliveryResults.push({
success: true,
channel,
attemptedAt: new Date(),
});
} else {
// Attempt external delivery
const result = await this.gatewayRegistry.send(notification);
deliveryResults.push(result);
}
}
return {
notification: primaryNotification!,
deliveryResults,
};
}
}

View File

@@ -0,0 +1,135 @@
/**
* Domain Entity: Notification
*
* Represents a notification sent to a user.
* Immutable entity with factory methods and domain validation.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
export type NotificationStatus = 'unread' | 'read' | 'dismissed';
export interface NotificationData {
/** Reference to related protest */
protestId?: string;
/** Reference to related race */
raceId?: string;
/** Reference to related league */
leagueId?: string;
/** Reference to related driver (e.g., who filed protest) */
actorDriverId?: string;
/** Reference to related penalty */
penaltyId?: string;
/** Reference to related team */
teamId?: string;
/** Deadline for action (e.g., defense deadline) */
deadline?: Date;
/** Any additional context data */
[key: string]: unknown;
}
export interface NotificationProps {
id: string;
/** Driver who receives this notification */
recipientId: string;
/** Type of notification */
type: NotificationType;
/** Human-readable title */
title: string;
/** Notification body/message */
body: string;
/** Channel this notification was/will be sent through */
channel: NotificationChannel;
/** Current status */
status: NotificationStatus;
/** Structured data for linking/context */
data?: NotificationData;
/** Optional action URL */
actionUrl?: string;
/** When the notification was created */
createdAt: Date;
/** When the notification was read (if applicable) */
readAt?: Date;
}
export class Notification {
private constructor(private readonly props: NotificationProps) {}
static create(props: Omit<NotificationProps, 'status' | 'createdAt'> & {
status?: NotificationStatus;
createdAt?: Date;
}): Notification {
if (!props.id) throw new Error('Notification ID is required');
if (!props.recipientId) throw new Error('Recipient ID is required');
if (!props.type) throw new Error('Notification type is required');
if (!props.title?.trim()) throw new Error('Notification title is required');
if (!props.body?.trim()) throw new Error('Notification body is required');
if (!props.channel) throw new Error('Notification channel is required');
return new Notification({
...props,
status: props.status ?? 'unread',
createdAt: props.createdAt ?? new Date(),
});
}
get id(): string { return this.props.id; }
get recipientId(): string { return this.props.recipientId; }
get type(): NotificationType { return this.props.type; }
get title(): string { return this.props.title; }
get body(): string { return this.props.body; }
get channel(): NotificationChannel { return this.props.channel; }
get status(): NotificationStatus { return this.props.status; }
get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; }
get actionUrl(): string | undefined { return this.props.actionUrl; }
get createdAt(): Date { return this.props.createdAt; }
get readAt(): Date | undefined { return this.props.readAt; }
isUnread(): boolean {
return this.props.status === 'unread';
}
isRead(): boolean {
return this.props.status === 'read';
}
isDismissed(): boolean {
return this.props.status === 'dismissed';
}
/**
* Mark the notification as read
*/
markAsRead(): Notification {
if (this.props.status !== 'unread') {
return this; // Already read or dismissed, no change
}
return new Notification({
...this.props,
status: 'read',
readAt: new Date(),
});
}
/**
* Dismiss the notification
*/
dismiss(): Notification {
if (this.props.status === 'dismissed') {
return this; // Already dismissed
}
return new Notification({
...this.props,
status: 'dismissed',
readAt: this.props.readAt ?? new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationProps {
return { ...this.props };
}
}

View File

@@ -0,0 +1,193 @@
/**
* Domain Entity: NotificationPreference
*
* Represents a user's notification preferences for different channels and types.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
export interface ChannelPreference {
/** Whether this channel is enabled */
enabled: boolean;
/** Channel-specific settings (e.g., discord webhook URL, email address) */
settings?: Record<string, string>;
}
export interface TypePreference {
/** Whether notifications of this type are enabled */
enabled: boolean;
/** Which channels to use for this type (overrides global) */
channels?: NotificationChannel[];
}
export interface NotificationPreferenceProps {
/** Driver ID this preference belongs to */
driverId: string;
/** Global channel preferences */
channels: Record<NotificationChannel, ChannelPreference>;
/** Per-type preferences (optional overrides) */
typePreferences?: Partial<Record<NotificationType, TypePreference>>;
/** Whether to receive digest emails instead of individual notifications */
digestMode?: boolean;
/** Digest frequency in hours (e.g., 24 for daily) */
digestFrequencyHours?: number;
/** Quiet hours start (0-23) */
quietHoursStart?: number;
/** Quiet hours end (0-23) */
quietHoursEnd?: number;
/** Last updated timestamp */
updatedAt: Date;
}
export class NotificationPreference {
private constructor(private readonly props: NotificationPreferenceProps) {}
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.channels) throw new Error('Channel preferences are required');
return new NotificationPreference({
...props,
updatedAt: props.updatedAt ?? new Date(),
});
}
/**
* Create default preferences for a new user
*/
static createDefault(driverId: string): NotificationPreference {
return new NotificationPreference({
driverId,
channels: {
in_app: { enabled: true },
email: { enabled: false },
discord: { enabled: false },
push: { enabled: false },
},
digestMode: false,
updatedAt: new Date(),
});
}
get driverId(): string { return this.props.driverId; }
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
return this.props.typePreferences ? { ...this.props.typePreferences } : undefined;
}
get digestMode(): boolean { return this.props.digestMode ?? false; }
get digestFrequencyHours(): number { return this.props.digestFrequencyHours ?? 24; }
get quietHoursStart(): number | undefined { return this.props.quietHoursStart; }
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
get updatedAt(): Date { return this.props.updatedAt; }
/**
* Check if a specific channel is enabled
*/
isChannelEnabled(channel: NotificationChannel): boolean {
return this.props.channels[channel]?.enabled ?? false;
}
/**
* Check if a specific notification type is enabled
*/
isTypeEnabled(type: NotificationType): boolean {
const typePref = this.props.typePreferences?.[type];
return typePref?.enabled ?? true; // Default to enabled if not specified
}
/**
* Get enabled channels for a specific notification type
*/
getEnabledChannelsForType(type: NotificationType): NotificationChannel[] {
// Check type-specific channel overrides
const typePref = this.props.typePreferences?.[type];
if (typePref?.channels && typePref.channels.length > 0) {
return typePref.channels.filter(ch => this.isChannelEnabled(ch));
}
// Fall back to globally enabled channels
return (Object.keys(this.props.channels) as NotificationChannel[])
.filter(ch => this.isChannelEnabled(ch));
}
/**
* Check if current time is in quiet hours
*/
isInQuietHours(): boolean {
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
return false;
}
const now = new Date();
const currentHour = now.getHours();
if (this.props.quietHoursStart < this.props.quietHoursEnd) {
// Normal range (e.g., 22:00 to 07:00 next day is NOT this case)
return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd;
} else {
// Overnight range (e.g., 22:00 to 07:00)
return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd;
}
}
/**
* Update channel preference
*/
updateChannel(channel: NotificationChannel, preference: ChannelPreference): NotificationPreference {
return new NotificationPreference({
...this.props,
channels: {
...this.props.channels,
[channel]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update type preference
*/
updateTypePreference(type: NotificationType, preference: TypePreference): NotificationPreference {
return new NotificationPreference({
...this.props,
typePreferences: {
...this.props.typePreferences,
[type]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update quiet hours
*/
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
return new NotificationPreference({
...this.props,
quietHoursStart: start,
quietHoursEnd: end,
updatedAt: new Date(),
});
}
/**
* Toggle digest mode
*/
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
return new NotificationPreference({
...this.props,
digestMode: enabled,
digestFrequencyHours: frequencyHours ?? this.props.digestFrequencyHours,
updatedAt: new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationPreferenceProps {
return { ...this.props };
}
}

View File

@@ -0,0 +1,29 @@
/**
* Repository Interface: INotificationPreferenceRepository
*
* Defines the contract for persisting and retrieving NotificationPreference entities.
*/
import type { NotificationPreference } from '../entities/NotificationPreference';
export interface INotificationPreferenceRepository {
/**
* Find preferences for a driver
*/
findByDriverId(driverId: string): Promise<NotificationPreference | null>;
/**
* Save preferences (create or update)
*/
save(preference: NotificationPreference): Promise<void>;
/**
* Delete preferences for a driver
*/
delete(driverId: string): Promise<void>;
/**
* Get or create default preferences for a driver
*/
getOrCreateDefault(driverId: string): Promise<NotificationPreference>;
}

View File

@@ -0,0 +1,60 @@
/**
* Repository Interface: INotificationRepository
*
* Defines the contract for persisting and retrieving Notification entities.
*/
import type { Notification } from '../entities/Notification';
import type { NotificationType } from '../value-objects/NotificationType';
export interface INotificationRepository {
/**
* Find a notification by ID
*/
findById(id: string): Promise<Notification | null>;
/**
* Find all notifications for a recipient
*/
findByRecipientId(recipientId: string): Promise<Notification[]>;
/**
* Find unread notifications for a recipient
*/
findUnreadByRecipientId(recipientId: string): Promise<Notification[]>;
/**
* Find notifications by type for a recipient
*/
findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]>;
/**
* Count unread notifications for a recipient
*/
countUnreadByRecipientId(recipientId: string): Promise<number>;
/**
* Save a new notification
*/
create(notification: Notification): Promise<void>;
/**
* Update an existing notification
*/
update(notification: Notification): Promise<void>;
/**
* Delete a notification
*/
delete(id: string): Promise<void>;
/**
* Delete all notifications for a recipient
*/
deleteAllByRecipientId(recipientId: string): Promise<void>;
/**
* Mark all notifications as read for a recipient
*/
markAllAsReadByRecipientId(recipientId: string): Promise<void>;
}

View File

@@ -0,0 +1,41 @@
/**
* Value Object: NotificationChannel
*
* Defines the delivery channels for notifications.
*/
export type NotificationChannel =
| 'in_app' // In-app notification (stored in database, shown in UI)
| 'email' // Email notification
| 'discord' // Discord webhook notification
| 'push'; // Push notification (future: mobile/browser)
/**
* Get human-readable name for channel
*/
export function getChannelDisplayName(channel: NotificationChannel): string {
const names: Record<NotificationChannel, string> = {
in_app: 'In-App',
email: 'Email',
discord: 'Discord',
push: 'Push Notification',
};
return names[channel];
}
/**
* Check if channel requires external integration
*/
export function isExternalChannel(channel: NotificationChannel): boolean {
return channel !== 'in_app';
}
/**
* Default channels that are always enabled
*/
export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app'];
/**
* All available channels
*/
export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];

View File

@@ -0,0 +1,97 @@
/**
* Value Object: NotificationType
*
* Defines the types of notifications that can be sent in the system.
*/
export type NotificationType =
// Protest-related
| 'protest_filed' // A protest was filed against you
| 'protest_defense_requested' // Steward requests your defense
| 'protest_defense_submitted' // Accused submitted their defense
| 'protest_comment_added' // New comment on a protest you're involved in
| 'protest_vote_required' // You need to vote on a protest
| 'protest_vote_cast' // Someone voted on a protest
| 'protest_resolved' // Protest has been resolved
// Penalty-related
| 'penalty_issued' // A penalty was issued to you
| 'penalty_appealed' // Penalty appeal submitted
| 'penalty_appeal_resolved' // Appeal was resolved
// Race-related
| 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder
| 'race_results_posted' // Race results are available
// League-related
| 'league_invite' // You were invited to a league
| 'league_join_request' // Someone requested to join your league
| 'league_join_approved' // Your join request was approved
| 'league_join_rejected' // Your join request was rejected
| 'league_role_changed' // Your role in a league changed
// Team-related
| 'team_invite' // You were invited to a team
| 'team_join_request' // Someone requested to join your team
| 'team_join_approved' // Your team join request was approved
// System
| 'system_announcement'; // System-wide announcement
/**
* Get human-readable title for notification type
*/
export function getNotificationTypeTitle(type: NotificationType): string {
const titles: Record<NotificationType, string> = {
protest_filed: 'Protest Filed',
protest_defense_requested: 'Defense Requested',
protest_defense_submitted: 'Defense Submitted',
protest_comment_added: 'New Comment',
protest_vote_required: 'Vote Required',
protest_vote_cast: 'Vote Cast',
protest_resolved: 'Protest Resolved',
penalty_issued: 'Penalty Issued',
penalty_appealed: 'Penalty Appealed',
penalty_appeal_resolved: 'Appeal Resolved',
race_registration_open: 'Registration Open',
race_reminder: 'Race Reminder',
race_results_posted: 'Results Posted',
league_invite: 'League Invitation',
league_join_request: 'Join Request',
league_join_approved: 'Request Approved',
league_join_rejected: 'Request Rejected',
league_role_changed: 'Role Changed',
team_invite: 'Team Invitation',
team_join_request: 'Team Join Request',
team_join_approved: 'Team Request Approved',
system_announcement: 'Announcement',
};
return titles[type];
}
/**
* Get priority level for notification type (higher = more urgent)
*/
export function getNotificationTypePriority(type: NotificationType): number {
const priorities: Record<NotificationType, number> = {
protest_filed: 8,
protest_defense_requested: 9,
protest_defense_submitted: 6,
protest_comment_added: 4,
protest_vote_required: 8,
protest_vote_cast: 3,
protest_resolved: 7,
penalty_issued: 9,
penalty_appealed: 7,
penalty_appeal_resolved: 7,
race_registration_open: 5,
race_reminder: 8,
race_results_posted: 5,
league_invite: 6,
league_join_request: 5,
league_join_approved: 7,
league_join_rejected: 7,
league_role_changed: 6,
team_invite: 5,
team_join_request: 4,
team_join_approved: 6,
system_announcement: 10,
};
return priorities[type];
}

View File

@@ -0,0 +1,71 @@
/**
* Infrastructure Adapter: DiscordNotificationAdapter (Stub)
*
* Handles Discord webhook notifications.
* Currently a stub - to be implemented when Discord integration is needed.
*/
import type { Notification } from '../../domain/entities/Notification';
import type {
INotificationGateway,
NotificationDeliveryResult
} from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export interface DiscordAdapterConfig {
webhookUrl?: string;
}
export class DiscordNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'discord';
private webhookUrl?: string;
constructor(config: DiscordAdapterConfig = {}) {
this.webhookUrl = config.webhookUrl;
}
async send(notification: Notification): Promise<NotificationDeliveryResult> {
if (!this.isConfigured()) {
return {
success: false,
channel: this.channel,
error: 'Discord webhook URL not configured',
attemptedAt: new Date(),
};
}
// TODO: Implement actual Discord webhook call
// For now, this is a stub that logs and returns success
console.log(`[Discord Stub] Would send notification to ${this.webhookUrl}:`, {
title: notification.title,
body: notification.body,
type: notification.type,
});
return {
success: true,
channel: this.channel,
externalId: `discord-stub-${notification.id}`,
attemptedAt: new Date(),
};
}
supportsChannel(channel: NotificationChannel): boolean {
return channel === this.channel;
}
isConfigured(): boolean {
return !!this.webhookUrl;
}
getChannel(): NotificationChannel {
return this.channel;
}
/**
* Configure the webhook URL
*/
setWebhookUrl(url: string): void {
this.webhookUrl = url;
}
}

View File

@@ -0,0 +1,76 @@
/**
* Infrastructure Adapter: EmailNotificationAdapter (Stub)
*
* Handles email notifications.
* Currently a stub - to be implemented when email integration is needed.
*/
import type { Notification } from '../../domain/entities/Notification';
import type {
INotificationGateway,
NotificationDeliveryResult
} from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export interface EmailAdapterConfig {
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPassword?: string;
fromAddress?: string;
}
export class EmailNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'email';
private config: EmailAdapterConfig;
constructor(config: EmailAdapterConfig = {}) {
this.config = config;
}
async send(notification: Notification): Promise<NotificationDeliveryResult> {
if (!this.isConfigured()) {
return {
success: false,
channel: this.channel,
error: 'Email SMTP not configured',
attemptedAt: new Date(),
};
}
// TODO: Implement actual email sending
// For now, this is a stub that logs and returns success
console.log(`[Email Stub] Would send email:`, {
to: notification.recipientId, // Would need to resolve to actual email
subject: notification.title,
body: notification.body,
type: notification.type,
});
return {
success: true,
channel: this.channel,
externalId: `email-stub-${notification.id}`,
attemptedAt: new Date(),
};
}
supportsChannel(channel: NotificationChannel): boolean {
return channel === this.channel;
}
isConfigured(): boolean {
return !!(this.config.smtpHost && this.config.fromAddress);
}
getChannel(): NotificationChannel {
return this.channel;
}
/**
* Update SMTP configuration
*/
configure(config: EmailAdapterConfig): void {
this.config = { ...this.config, ...config };
}
}

View File

@@ -0,0 +1,44 @@
/**
* Infrastructure Adapter: InAppNotificationAdapter
*
* Handles in-app notifications (stored in database, shown in UI).
* This is the primary/default notification channel.
*/
import type { Notification } from '../../domain/entities/Notification';
import type {
INotificationGateway,
NotificationDeliveryResult
} from '../../application/ports/INotificationGateway';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
export class InAppNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'in_app';
/**
* For in_app, sending is essentially a no-op since the notification
* is already persisted by the use case. This just confirms delivery.
*/
async send(notification: Notification): Promise<NotificationDeliveryResult> {
// In-app notifications are stored directly in the repository
// This adapter just confirms the "delivery" was successful
return {
success: true,
channel: this.channel,
externalId: notification.id,
attemptedAt: new Date(),
};
}
supportsChannel(channel: NotificationChannel): boolean {
return channel === this.channel;
}
isConfigured(): boolean {
return true; // Always configured
}
getChannel(): NotificationChannel {
return this.channel;
}
}

View File

@@ -0,0 +1,67 @@
/**
* Infrastructure: NotificationGatewayRegistry
*
* Manages notification gateways and routes notifications to appropriate channels.
*/
import type { Notification } from '../../domain/entities/Notification';
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
import type {
INotificationGateway,
INotificationGatewayRegistry,
NotificationDeliveryResult
} from '../../application/ports/INotificationGateway';
export class NotificationGatewayRegistry implements INotificationGatewayRegistry {
private gateways: Map<NotificationChannel, INotificationGateway> = new Map();
constructor(initialGateways: INotificationGateway[] = []) {
initialGateways.forEach(gateway => this.register(gateway));
}
register(gateway: INotificationGateway): void {
const channel = gateway.getChannel();
this.gateways.set(channel, gateway);
}
getGateway(channel: NotificationChannel): INotificationGateway | null {
return this.gateways.get(channel) || null;
}
getAllGateways(): INotificationGateway[] {
return Array.from(this.gateways.values());
}
async send(notification: Notification): Promise<NotificationDeliveryResult> {
const gateway = this.gateways.get(notification.channel);
if (!gateway) {
return {
success: false,
channel: notification.channel,
error: `No gateway registered for channel: ${notification.channel}`,
attemptedAt: new Date(),
};
}
if (!gateway.isConfigured()) {
return {
success: false,
channel: notification.channel,
error: `Gateway for channel ${notification.channel} is not configured`,
attemptedAt: new Date(),
};
}
try {
return await gateway.send(notification);
} catch (error) {
return {
success: false,
channel: notification.channel,
error: error instanceof Error ? error.message : 'Unknown error during delivery',
attemptedAt: new Date(),
};
}
}
}

View File

@@ -0,0 +1,11 @@
/**
* Infrastructure layer exports for notifications package
*/
// Repositories
export { InMemoryNotificationRepository } from './repositories/InMemoryNotificationRepository';
export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository';
// Adapters
export { InAppNotificationAdapter } from './adapters/InAppNotificationAdapter';
export { NotificationGatewayRegistry } from './adapters/NotificationGatewayRegistry';

View File

@@ -0,0 +1,41 @@
/**
* In-Memory Implementation: InMemoryNotificationPreferenceRepository
*
* Provides an in-memory storage implementation for notification preferences.
*/
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
export class InMemoryNotificationPreferenceRepository implements INotificationPreferenceRepository {
private preferences: Map<string, NotificationPreference> = new Map();
constructor(initialPreferences: NotificationPreference[] = []) {
initialPreferences.forEach(pref => {
this.preferences.set(pref.driverId, pref);
});
}
async findByDriverId(driverId: string): Promise<NotificationPreference | null> {
return this.preferences.get(driverId) || null;
}
async save(preference: NotificationPreference): Promise<void> {
this.preferences.set(preference.driverId, preference);
}
async delete(driverId: string): Promise<void> {
this.preferences.delete(driverId);
}
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
const existing = this.preferences.get(driverId);
if (existing) {
return existing;
}
const defaultPreference = NotificationPreference.createDefault(driverId);
this.preferences.set(driverId, defaultPreference);
return defaultPreference;
}
}

View File

@@ -0,0 +1,83 @@
/**
* In-Memory Implementation: InMemoryNotificationRepository
*
* Provides an in-memory storage implementation for notifications.
*/
import { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { NotificationType } from '../../domain/value-objects/NotificationType';
export class InMemoryNotificationRepository implements INotificationRepository {
private notifications: Map<string, Notification> = new Map();
constructor(initialNotifications: Notification[] = []) {
initialNotifications.forEach(notification => {
this.notifications.set(notification.id, notification);
});
}
async findById(id: string): Promise<Notification | null> {
return this.notifications.get(id) || null;
}
async findByRecipientId(recipientId: string): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async findUnreadByRecipientId(recipientId: string): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.type === type)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async countUnreadByRecipientId(recipientId: string): Promise<number> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread())
.length;
}
async create(notification: Notification): Promise<void> {
if (this.notifications.has(notification.id)) {
throw new Error(`Notification with ID ${notification.id} already exists`);
}
this.notifications.set(notification.id, notification);
}
async update(notification: Notification): Promise<void> {
if (!this.notifications.has(notification.id)) {
throw new Error(`Notification with ID ${notification.id} not found`);
}
this.notifications.set(notification.id, notification);
}
async delete(id: string): Promise<void> {
this.notifications.delete(id);
}
async deleteAllByRecipientId(recipientId: string): Promise<void> {
const toDelete = Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId)
.map(n => n.id);
toDelete.forEach(id => this.notifications.delete(id));
}
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
const toUpdate = Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread());
toUpdate.forEach(n => {
const updated = n.markAsRead();
this.notifications.set(updated.id, updated);
});
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@gridpilot/notifications",
"version": "0.1.0",
"type": "module",
"exports": {
"./domain/*": "./domain/*",
"./application": "./application/index.ts",
"./application/*": "./application/*",
"./infrastructure": "./infrastructure/index.ts",
"./infrastructure/*": "./infrastructure/*"
},
"dependencies": {
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/uuid": "^10.0.0"
}
}

View File

@@ -31,6 +31,8 @@ export * from './use-cases/ReviewProtestUseCase';
export * from './use-cases/ApplyPenaltyUseCase';
export * from './use-cases/GetRaceProtestsQuery';
export * from './use-cases/GetRacePenaltiesQuery';
export * from './use-cases/RequestProtestDefenseUseCase';
export * from './use-cases/SubmitProtestDefenseUseCase';
// Export ports
export * from './ports/DriverRatingProvider';

View File

@@ -0,0 +1,65 @@
/**
* Application Use Case: RequestProtestDefenseUseCase
*
* Allows a steward to request defense from the accused driver before making a decision.
* This will trigger a notification to the accused driver.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { isLeagueStewardOrHigherRole } from '../../domain/value-objects/LeagueRoles';
export interface RequestProtestDefenseCommand {
protestId: string;
stewardId: string;
}
export interface RequestProtestDefenseResult {
success: boolean;
accusedDriverId: string;
protestId: string;
}
export class RequestProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: RequestProtestDefenseCommand): Promise<RequestProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Get the race to find the league
const race = await this.raceRepository.findById(protest.raceId);
if (!race) {
throw new Error('Race not found');
}
// Verify the steward has permission
const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId);
if (!membership || !isLeagueStewardOrHigherRole(membership.role)) {
throw new Error('Only stewards and admins can request defense');
}
// Check if defense can be requested
if (!protest.canRequestDefense()) {
throw new Error('Defense cannot be requested for this protest');
}
// Request defense
const updatedProtest = protest.requestDefense(command.stewardId);
await this.protestRepository.update(updatedProtest);
return {
success: true,
accusedDriverId: protest.accusedDriverId,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,52 @@
/**
* Application Use Case: SubmitProtestDefenseUseCase
*
* Allows the accused driver to submit their defense statement for a protest.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
export interface SubmitProtestDefenseCommand {
protestId: string;
driverId: string;
statement: string;
videoUrl?: string;
}
export interface SubmitProtestDefenseResult {
success: boolean;
protestId: string;
}
export class SubmitProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
) {}
async execute(command: SubmitProtestDefenseCommand): Promise<SubmitProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Verify the submitter is the accused driver
if (protest.accusedDriverId !== command.driverId) {
throw new Error('Only the accused driver can submit a defense');
}
// Check if defense can be submitted
if (!protest.canSubmitDefense()) {
throw new Error('Defense cannot be submitted for this protest');
}
// Submit defense
const updatedProtest = protest.submitDefense(command.statement, command.videoUrl);
await this.protestRepository.update(updatedProtest);
return {
success: true,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,56 @@
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type {
LeagueMembership,
MembershipRole,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
export interface TransferLeagueOwnershipCommandDTO {
leagueId: string;
currentOwnerId: string;
newOwnerId: string;
}
export class TransferLeagueOwnershipUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly membershipRepository: ILeagueMembershipRepository
) {}
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<void> {
const { leagueId, currentOwnerId, newOwnerId } = command;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error('League not found');
}
if (league.ownerId !== currentOwnerId) {
throw new Error('Only the current owner can transfer ownership');
}
const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId);
if (!newOwnerMembership || newOwnerMembership.status !== 'active') {
throw new Error('New owner must be an active member of the league');
}
const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId);
await this.membershipRepository.saveMembership({
...newOwnerMembership,
role: 'owner' as MembershipRole,
});
if (currentOwnerMembership) {
await this.membershipRepository.saveMembership({
...currentOwnerMembership,
role: 'admin' as MembershipRole,
});
}
const updatedLeague = league.update({ ownerId: newOwnerId });
await this.leagueRepository.update(updatedLeague);
}
}

View File

@@ -118,6 +118,7 @@ export class League {
update(props: Partial<{
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
}>): League {
@@ -125,7 +126,7 @@ export class League {
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: this.ownerId,
ownerId: props.ownerId ?? this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,

View File

@@ -5,13 +5,16 @@
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
export type PenaltyType =
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
| 'grid_penalty' // Grid position penalty for next race
| 'points_deduction' // Deduct championship points
| 'disqualification' // DSQ from the race
| 'warning' // Official warning (no immediate consequence)
| 'license_points'; // Add penalty points to license (future feature)
| 'license_points' // Add penalty points to license (safety rating)
| 'probation' // Conditional penalty
| 'fine' // Monetary/points fine
| 'race_ban'; // Multi-race suspension
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
@@ -52,7 +55,7 @@ export class Penalty {
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction'].includes(props.type)) {
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
if (props.value === undefined || props.value <= 0) {
throw new Error(`${props.type} requires a positive value`);
}
@@ -135,6 +138,12 @@ export class Penalty {
return 'Official warning';
case 'license_points':
return `${this.props.value} license penalty points`;
case 'probation':
return 'Probationary period';
case 'fine':
return `${this.props.value} points fine`;
case 'race_ban':
return `${this.props.value} race suspension`;
default:
return 'Penalty';
}

View File

@@ -1,10 +1,18 @@
/**
* Domain Entity: Protest
*
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*
* Workflow states:
* - pending: Initial state when protest is filed
* - awaiting_defense: Defense has been requested from the accused driver
* - under_review: Steward is actively reviewing the protest
* - upheld: Protest was upheld (penalty will be applied)
* - dismissed: Protest was dismissed (no action taken)
* - withdrawn: Protesting driver withdrew the protest
*/
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export interface ProtestIncident {
/** Lap number where the incident occurred */
@@ -15,6 +23,15 @@ export interface ProtestIncident {
description: string;
}
export interface ProtestDefense {
/** The accused driver's statement/defense */
statement: string;
/** URL to defense video clip (optional) */
videoUrl?: string;
/** Timestamp when defense was submitted */
submittedAt: Date;
}
export interface ProtestProps {
id: string;
raceId: string;
@@ -38,6 +55,12 @@ export interface ProtestProps {
filedAt: Date;
/** Timestamp when the protest was reviewed */
reviewedAt?: Date;
/** Defense from the accused driver (if requested and submitted) */
defense?: ProtestDefense;
/** Timestamp when defense was requested */
defenseRequestedAt?: Date;
/** ID of the steward who requested defense */
defenseRequestedBy?: string;
}
export class Protest {
@@ -71,11 +94,18 @@ export class Protest {
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
get filedAt(): Date { return this.props.filedAt; }
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
isPending(): boolean {
return this.props.status === 'pending';
}
isAwaitingDefense(): boolean {
return this.props.status === 'awaiting_defense';
}
isUnderReview(): boolean {
return this.props.status === 'under_review';
}
@@ -84,12 +114,60 @@ export class Protest {
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
}
hasDefense(): boolean {
return this.props.defense !== undefined;
}
canRequestDefense(): boolean {
return this.isPending() && !this.hasDefense() && !this.props.defenseRequestedAt;
}
canSubmitDefense(): boolean {
return this.isAwaitingDefense() && !this.hasDefense();
}
/**
* Start reviewing the protest
* Request defense from the accused driver
*/
requestDefense(stewardId: string): Protest {
if (!this.canRequestDefense()) {
throw new Error('Defense can only be requested for pending protests without existing defense');
}
return new Protest({
...this.props,
status: 'awaiting_defense',
defenseRequestedAt: new Date(),
defenseRequestedBy: stewardId,
});
}
/**
* Submit defense from the accused driver
*/
submitDefense(statement: string, videoUrl?: string): Protest {
if (!this.canSubmitDefense()) {
throw new Error('Defense can only be submitted when protest is awaiting defense');
}
if (!statement?.trim()) {
throw new Error('Defense statement is required');
}
return new Protest({
...this.props,
status: 'under_review',
defense: {
statement: statement.trim(),
videoUrl,
submittedAt: new Date(),
},
});
}
/**
* Start reviewing the protest (without requiring defense)
*/
startReview(stewardId: string): Protest {
if (!this.isPending()) {
throw new Error('Only pending protests can be put under review');
if (!this.isPending() && !this.isAwaitingDefense()) {
throw new Error('Only pending or awaiting-defense protests can be put under review');
}
return new Protest({
...this.props,
@@ -102,8 +180,8 @@ export class Protest {
* Uphold the protest (finding the accused guilty)
*/
uphold(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be upheld');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
}
return new Protest({
...this.props,
@@ -118,8 +196,8 @@ export class Protest {
* Dismiss the protest (finding no fault)
*/
dismiss(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be dismissed');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
}
return new Protest({
...this.props,

View File

@@ -0,0 +1,73 @@
/**
* Domain Value Object: LeagueRoles
*
* Utility functions for working with league membership roles.
*/
import type { MembershipRole } from '../entities/LeagueMembership';
/**
* Role hierarchy (higher number = more authority)
*/
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
member: 0,
steward: 1,
admin: 2,
owner: 3,
};
/**
* Check if a role is at least steward level
*/
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
}
/**
* Check if a role is at least admin level
*/
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
}
/**
* Check if a role is owner
*/
export function isLeagueOwnerRole(role: MembershipRole): boolean {
return role === 'owner';
}
/**
* Compare two roles
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
*/
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
}
/**
* Get role display name
*/
export function getRoleDisplayName(role: MembershipRole): string {
const names: Record<MembershipRole, string> = {
member: 'Member',
steward: 'Steward',
admin: 'Admin',
owner: 'Owner',
};
return names[role];
}
/**
* Get all roles in order of hierarchy
*/
export function getAllRolesOrdered(): MembershipRole[] {
return ['member', 'steward', 'admin', 'owner'];
}
/**
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
*/
export function getAssignableRoles(): MembershipRole[] {
return ['member', 'steward', 'admin'];
}