wip
This commit is contained in:
34
apps/website/components/leagues/BonusPointsCard.tsx
Normal file
34
apps/website/components/leagues/BonusPointsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/website/components/leagues/ChampionshipCard.tsx
Normal file
95
apps/website/components/leagues/ChampionshipCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/website/components/leagues/DropRulesExplanation.tsx
Normal file
31
apps/website/components/leagues/DropRulesExplanation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
112
apps/website/components/leagues/PenaltyHistoryList.tsx
Normal file
112
apps/website/components/leagues/PenaltyHistoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
apps/website/components/leagues/PendingProtestsList.tsx
Normal file
115
apps/website/components/leagues/PendingProtestsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/website/components/leagues/PointsBreakdownTable.tsx
Normal file
73
apps/website/components/leagues/PointsBreakdownTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/website/components/leagues/ReadonlyLeagueInfo.tsx
Normal file
86
apps/website/components/leagues/ReadonlyLeagueInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal file
387
apps/website/components/leagues/ReviewProtestModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/website/components/leagues/ScoringOverviewCard.tsx
Normal file
54
apps/website/components/leagues/ScoringOverviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/leagues/SeasonStatsCard.tsx
Normal file
87
apps/website/components/leagues/SeasonStatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">No‑Shows</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>
|
||||
);
|
||||
}
|
||||
@@ -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 '🏁';
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
130
apps/website/components/ui/CountryFlag.tsx
Normal file
130
apps/website/components/ui/CountryFlag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user