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

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