wip
This commit is contained in:
@@ -344,8 +344,8 @@ export default function DriverDetailPage({
|
||||
backLink = `/leagues/${leagueId}`;
|
||||
} else if (from === 'league-members' && leagueId) {
|
||||
backLink = `/leagues/${leagueId}`;
|
||||
} else if (from === 'league-race' && leagueId && raceId) {
|
||||
backLink = `/leagues/${leagueId}/races/${raceId}`;
|
||||
} else if (from === 'league-race' && raceId) {
|
||||
backLink = `/races/${raceId}`;
|
||||
} else {
|
||||
backLink = null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||
import { getLeagueRepository, getDriverRepository } from '@/lib/di-container';
|
||||
import { getLeagueRepository, getDriverRepository, getLeagueMembershipRepository } from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
export default async function LeagueLayout(props: {
|
||||
export default function LeagueLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { children, params } = props;
|
||||
const resolvedParams = await params;
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const league = await leagueRepo.findById(resolvedParams.id);
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [ownerName, setOwnerName] = useState<string>('');
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLeague() {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const owner = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwnerName(owner ? owner.name : `${leagueData.ownerId.slice(0, 8)}...`);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load league:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadLeague();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!league) {
|
||||
return (
|
||||
@@ -23,8 +77,25 @@ export default async function LeagueLayout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await driverRepo.findById(league.ownerId);
|
||||
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
|
||||
// Define tab configuration
|
||||
const baseTabs = [
|
||||
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
|
||||
{ label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false },
|
||||
{ label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false },
|
||||
{ label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false },
|
||||
];
|
||||
|
||||
const adminTabs = [
|
||||
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
|
||||
{ label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false },
|
||||
];
|
||||
|
||||
const tabs = isAdmin ? [...baseTabs, ...adminTabs] : baseTabs;
|
||||
|
||||
// Determine active tab
|
||||
const activeTab = tabs.find(tab =>
|
||||
tab.exact ? pathname === tab.href : pathname.startsWith(tab.href)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
@@ -45,6 +116,25 @@ export default async function LeagueLayout(props: {
|
||||
ownerName={ownerName}
|
||||
/>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 border-b border-charcoal-outline">
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.href}
|
||||
onClick={() => router.push(tab.href)}
|
||||
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
|
||||
(tab.exact ? pathname === tab.href : pathname.startsWith(tab.href))
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||
import LeagueMembers from '@/components/leagues/LeagueMembers';
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import LeagueScoringTab from '@/components/leagues/LeagueScoringTab';
|
||||
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
@@ -18,22 +13,21 @@ import {
|
||||
Driver,
|
||||
EntityMappers,
|
||||
type DriverDTO,
|
||||
type LeagueDriverSeasonStatsDTO,
|
||||
type LeagueScoringConfigDTO,
|
||||
} from '@gridpilot/racing';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getRaceRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueDriverSeasonStatsQuery,
|
||||
getGetLeagueScoringConfigQuery,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getGetLeagueStatsQuery,
|
||||
} from '@/lib/di-container';
|
||||
import { Zap, Users, Trophy, Calendar } from 'lucide-react';
|
||||
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
|
||||
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
const router = useRouter();
|
||||
@@ -42,22 +36,16 @@ export default function LeagueDetailPage() {
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [owner, setOwner] = useState<Driver | null>(null);
|
||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||
const [averageSOF, setAverageSOF] = useState<number | null>(null);
|
||||
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'overview' | 'schedule' | 'standings' | 'scoring' | 'admin'
|
||||
>('overview');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const membership = getMembership(leagueId, currentDriverId);
|
||||
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
@@ -80,11 +68,6 @@ export default function LeagueDetailPage() {
|
||||
const ownerData = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwner(ownerData);
|
||||
|
||||
// Load standings via rich season stats query
|
||||
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
|
||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||
setStandings(leagueStandings);
|
||||
|
||||
// Load scoring configuration for the active season
|
||||
const getLeagueScoringConfigQuery = getGetLeagueScoringConfigQuery();
|
||||
const scoring = await getLeagueScoringConfigQuery.execute({ leagueId });
|
||||
@@ -121,23 +104,6 @@ export default function LeagueDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialTab = searchParams?.get('tab');
|
||||
if (!initialTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
initialTab === 'overview' ||
|
||||
initialTab === 'schedule' ||
|
||||
initialTab === 'standings' ||
|
||||
initialTab === 'scoring' ||
|
||||
initialTab === 'admin'
|
||||
) {
|
||||
setActiveTab(initialTab as typeof activeTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleMembershipChange = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
loadLeagueData();
|
||||
@@ -154,6 +120,7 @@ export default function LeagueDetailPage() {
|
||||
const leagueMemberships = getLeagueMembers(leagueId);
|
||||
const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null;
|
||||
const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin');
|
||||
const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward');
|
||||
|
||||
const buildDriverSummary = (driverId: string) => {
|
||||
const driverDto = driversById[driverId];
|
||||
@@ -230,312 +197,181 @@ export default function LeagueDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Overview section switcher (in-page, not primary tabs) */}
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex flex-wrap gap-2 rounded-full bg-iron-gray/60 px-2 py-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'schedule'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('standings')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'standings'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Standings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scoring')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'scoring'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Scoring
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'admin'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
{/* League Overview - Activity Center with Info Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Center - Activity Feed */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={20} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - League Info */}
|
||||
<div className="space-y-6">
|
||||
{/* League Info - Combined */}
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{leagueMemberships.length}</div>
|
||||
<div className="text-xs text-gray-500">Members</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-white">{completedRacesCount}</div>
|
||||
<div className="text-xs text-gray-500">Races</div>
|
||||
</div>
|
||||
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
|
||||
<div className="text-xl font-bold text-warning-amber">{averageSOF ?? '—'}</div>
|
||||
<div className="text-xs text-gray-500">Avg SOF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Structure</span>
|
||||
<span className="text-white">Solo • {league.settings.maxDrivers ?? 32} max</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Scoring</span>
|
||||
<span className="text-white">{scoringConfig?.scoringPresetName ?? 'Standard'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.socialLinks && (
|
||||
<div className="mt-4 pt-4 border-t border-charcoal-outline">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{league.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={league.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={league.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
YouTube
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={league.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Management */}
|
||||
{(ownerMembership || adminMemberships.length > 0 || stewardMemberships.length > 0) && (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
|
||||
<div className="space-y-2">
|
||||
{ownerMembership && (() => {
|
||||
const driverDto = driversById[ownerMembership.driverId];
|
||||
const summary = buildDriverSummary(ownerMembership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('owner');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${ownerMembership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{adminMemberships.slice(0, 3).map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('admin');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div key={membership.driverId} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
|
||||
{stewardMemberships.slice(0, 3).map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const roleDisplay = getLeagueRoleDisplay('steward');
|
||||
const meta = summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return driverDto ? (
|
||||
<div key={membership.driverId} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
|
||||
{roleDisplay.text}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* League Info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Created</label>
|
||||
<p className="text-white">
|
||||
{new Date(league.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">At a glance</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Structure
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
Solo • {league.settings.maxDrivers ?? 32} drivers
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Schedule
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
{completedRacesCount > 0 ? `${completedRacesCount} races completed` : 'Season upcoming'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Scoring & drops
|
||||
</h4>
|
||||
<p className="text-gray-200 flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4 text-gray-500" />
|
||||
{scoringConfig?.scoringPresetName ?? scoringConfig?.scoringPresetId ?? 'Standard'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1">
|
||||
Avg. Strength of Field
|
||||
</h4>
|
||||
<p className="text-warning-amber font-medium flex items-center gap-1.5">
|
||||
<Zap className="w-4 h-4" />
|
||||
{averageSOF ? averageSOF : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{league.socialLinks && (
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">Community & Social</h3>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{league.socialLinks.discordUrl && (
|
||||
<a
|
||||
href={league.socialLinks.discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.youtubeUrl && (
|
||||
<a
|
||||
href={league.socialLinks.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<span>YouTube</span>
|
||||
</a>
|
||||
)}
|
||||
{league.socialLinks.websiteUrl && (
|
||||
<a
|
||||
href={league.socialLinks.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
|
||||
>
|
||||
<span>Website</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<h3 className="text-white font-medium mb-3">Management</h3>
|
||||
<div className="space-y-4">
|
||||
{ownerMembership && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-2">Owner</label>
|
||||
{buildDriverSummary(ownerMembership.driverId) ? (
|
||||
<DriverSummaryPill
|
||||
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
|
||||
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
|
||||
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminMemberships.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-2">Admins</label>
|
||||
<div className="space-y-2">
|
||||
{adminMemberships.map((membership) => {
|
||||
const driverDto = driversById[membership.driverId];
|
||||
const summary = buildDriverSummary(membership.driverId);
|
||||
const meta =
|
||||
summary && summary.rating !== null
|
||||
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={membership.driverId}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
{driverDto ? (
|
||||
<DriverIdentity
|
||||
driver={driverDto}
|
||||
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
|
||||
contextLabel="Admin"
|
||||
meta={meta}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">Unknown admin</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminMemberships.length === 0 && !ownerMembership && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Management roles have not been configured for this league yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{membership ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('standings')}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={8} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'scoring' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Scoring</h2>
|
||||
<LeagueScoringTab
|
||||
scoringConfig={scoringConfig}
|
||||
practiceMinutes={20}
|
||||
qualifyingMinutes={30}
|
||||
sprintRaceMinutes={20}
|
||||
mainRaceMinutes={
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<LeagueAdmin
|
||||
league={league}
|
||||
onLeagueUpdate={handleMembershipChange}
|
||||
key={refreshKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getLeagueRepository,
|
||||
getDriverRepository,
|
||||
getGetRaceRegistrationsQuery,
|
||||
getIsDriverRegisteredForRaceQuery,
|
||||
getRegisterForRaceUseCase,
|
||||
getWithdrawFromRaceUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { getMembership } from '@/lib/leagueMembership';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
||||
|
||||
export default function LeagueRaceDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const raceId = params.raceId as string;
|
||||
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [entryList, setEntryList] = useState<Driver[]>([]);
|
||||
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const loadRaceData = async () => {
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
|
||||
const raceData = await raceRepo.findById(raceId);
|
||||
|
||||
if (!raceData) {
|
||||
setError('Race not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRace(raceData);
|
||||
|
||||
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
||||
setLeague(leagueData);
|
||||
|
||||
await loadEntryList(raceData.id, raceData.leagueId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load race');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => {
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
|
||||
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: raceIdValue });
|
||||
|
||||
const drivers = await Promise.all(
|
||||
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
|
||||
);
|
||||
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
|
||||
|
||||
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||
const userIsRegistered = await isRegisteredQuery.execute({
|
||||
raceId: raceIdValue,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
setIsUserRegistered(userIsRegistered);
|
||||
|
||||
const membership = getMembership(leagueIdValue, currentDriverId);
|
||||
const isUpcoming = race?.status === 'scheduled';
|
||||
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
|
||||
} catch (err) {
|
||||
console.error('Failed to load entry list:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRaceData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [raceId]);
|
||||
|
||||
const handleCancelRace = async () => {
|
||||
if (!race || race.status !== 'scheduled') return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to cancel this race? This action cannot be undone.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setCancelling(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const cancelledRace = race.cancel();
|
||||
await raceRepo.update(cancelledRace);
|
||||
setRace(cancelledRace);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to cancel race');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
const useCase = getRegisterForRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
leagueId: league.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register for race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?\n\nYou can register again later if you change your mind.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
const useCase = getWithdrawFromRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
} as const;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">Loading race details...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !race) {
|
||||
return (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'Race not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to League
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FeatureLimitationTooltip message="Companion automation available in production">
|
||||
<div className="mb-6">
|
||||
<CompanionStatus />
|
||||
</div>
|
||||
</FeatureLimitationTooltip>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
|
||||
{league && (
|
||||
<p className="text-gray-400">{league.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-medium rounded border ${
|
||||
statusColors[race.status as keyof typeof statusColors]
|
||||
}`}
|
||||
>
|
||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<div className="mb-6">
|
||||
<CompanionInstructions race={race} leagueName={league?.name} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
|
||||
<p className="text-white text-lg font-medium">
|
||||
{formatDateTime(race.scheduledAt)}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2 text-sm">
|
||||
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
|
||||
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<label className="text-sm text-gray-500 block mb-1">Track</label>
|
||||
<p className="text-white">{race.track}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Car</label>
|
||||
<p className="text-white">{race.car}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
|
||||
<p className="text-white capitalize">{race.sessionType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && isUserRegistered && (
|
||||
<div className="space-y-2">
|
||||
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
|
||||
✓ Registered
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleWithdraw}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{race.status === 'completed' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/races/${race.id}/results`)}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleCancelRace}
|
||||
disabled={cancelling}
|
||||
>
|
||||
{cancelling ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Card className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Entry List</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to register!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entryList.map((driver, index) => (
|
||||
<div
|
||||
key={driver.id}
|
||||
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/drivers/${driver.id}?from=league&leagueId=${leagueId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="w-8 text-center text-gray-400 font-mono text-sm">
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-bold text-gray-500">
|
||||
{driver.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{driver.name}</p>
|
||||
<p className="text-sm text-gray-400">{driver.country}</p>
|
||||
</div>
|
||||
{driver.id === currentDriverId && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
apps/website/app/leagues/[id]/rulebook/page.tsx
Normal file
314
apps/website/app/leagues/[id]/rulebook/page.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getGetLeagueScoringConfigQuery
|
||||
} from '@/lib/di-container';
|
||||
import type { LeagueScoringConfigDTO } from '@gridpilot/racing/application/dto/LeagueScoringConfigDTO';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
|
||||
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
|
||||
|
||||
export default function LeagueRulebookPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [scoringConfig, setScoringConfig] = useState<LeagueScoringConfigDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const scoringQuery = getGetLeagueScoringConfigQuery();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const scoring = await scoringQuery.execute({ leagueId });
|
||||
setScoringConfig(scoring);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scoring config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [leagueId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!league || !scoringConfig) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0];
|
||||
const positionPoints = primaryChampionship?.pointsPreview
|
||||
.filter(p => p.sessionType === primaryChampionship.sessionTypes[0])
|
||||
.map(p => ({ position: p.position, points: p.points }))
|
||||
.sort((a, b) => a.position - b.position) || [];
|
||||
|
||||
const sections: { id: RulebookSection; label: string }[] = [
|
||||
{ id: 'scoring', label: 'Scoring' },
|
||||
{ id: 'conduct', label: 'Conduct' },
|
||||
{ id: 'protests', label: 'Protests' },
|
||||
{ id: 'penalties', label: 'Penalties' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
|
||||
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
|
||||
<span className="text-sm font-medium text-primary-blue">{scoringConfig.scoringPresetName || 'Custom Rules'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeSection === section.id
|
||||
? 'bg-iron-gray text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Sections */}
|
||||
{activeSection === 'scoring' && (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
|
||||
<p className="text-lg font-semibold text-white">{scoringConfig.gameName}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
|
||||
<p className="text-lg font-semibold text-white">{scoringConfig.championships.length}</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
|
||||
<p className="text-lg font-semibold text-white truncate" title={scoringConfig.dropPolicySummary}>
|
||||
{scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points Table */}
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Points Distribution</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positionPoints.map(({ position, points }) => (
|
||||
<tr
|
||||
key={position}
|
||||
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
|
||||
position <= 3 ? 'bg-iron-gray/20' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
position === 1 ? 'bg-yellow-500 text-black' :
|
||||
position === 2 ? 'bg-gray-400 text-black' :
|
||||
position === 3 ? 'bg-amber-600 text-white' :
|
||||
'bg-charcoal-outline text-white'
|
||||
}`}>
|
||||
{position}
|
||||
</div>
|
||||
<span className="text-white font-medium">
|
||||
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-white font-semibold tabular-nums">{points}</span>
|
||||
<span className="text-gray-500 ml-1">pts</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bonus Points */}
|
||||
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
|
||||
<div className="space-y-2">
|
||||
{primaryChampionship.bonusSummary.map((bonus, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-performance-green text-sm font-bold">+</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{bonus}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drop Policy */}
|
||||
{!scoringConfig.dropPolicySummary.includes('All results count') && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
|
||||
<p className="text-sm text-gray-300">{scoringConfig.dropPolicySummary}</p>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Drop rules are applied automatically when calculating championship standings.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'conduct' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">1. Respect</h3>
|
||||
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
|
||||
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
|
||||
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
|
||||
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">5. Communication</h3>
|
||||
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'protests' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
|
||||
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Evidence</h3>
|
||||
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Review Process</h3>
|
||||
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white mb-2">Outcomes</h3>
|
||||
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === 'penalties' && (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
|
||||
<div className="space-y-4 text-sm text-gray-300">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
|
||||
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/50">
|
||||
<tr>
|
||||
<td className="py-3">Causing avoidable contact</td>
|
||||
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsafe rejoin</td>
|
||||
<td className="py-3 text-warning-amber">5 second time penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Blocking</td>
|
||||
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Repeated track limit violations</td>
|
||||
<td className="py-3 text-warning-amber">5 second penalty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Intentional wrecking</td>
|
||||
<td className="py-3 text-red-400">Disqualification</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3">Unsportsmanlike conduct</td>
|
||||
<td className="py-3 text-red-400">Points deduction or ban</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Penalties are applied at steward discretion based on incident severity and driver history.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/website/app/leagues/[id]/schedule/page.tsx
Normal file
38
apps/website/app/leagues/[id]/schedule/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
|
||||
import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getLeagueMembershipRepository } from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
|
||||
export default function LeagueSchedulePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Schedule</h2>
|
||||
<LeagueSchedule leagueId={leagueId} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
apps/website/app/leagues/[id]/scoring/page.tsx
Normal file
6
apps/website/app/leagues/[id]/scoring/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ScoringPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/leagues/${id}/rulebook`);
|
||||
}
|
||||
299
apps/website/app/leagues/[id]/settings/page.tsx
Normal file
299
apps/website/app/leagues/[id]/settings/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigQuery,
|
||||
getLeagueMembershipRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getListLeagueScoringPresetsQuery,
|
||||
getTransferLeagueOwnershipUseCase
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
|
||||
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
|
||||
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import { AlertTriangle, Settings, Trophy, Calendar, TrendingDown, Edit, Users, UserCog } from 'lucide-react';
|
||||
|
||||
export default function LeagueSettingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [configForm, setConfigForm] = useState<LeagueConfigFormModel | null>(null);
|
||||
const [presets, setPresets] = useState<LeagueScoringPresetDTO[]>([]);
|
||||
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showTransferDialog, setShowTransferDialog] = useState(false);
|
||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('');
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [allMembers, setAllMembers] = useState<DriverDTO[]>([]);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const query = getGetLeagueFullConfigQuery();
|
||||
const presetsQuery = getListLeagueScoringPresetsQuery();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
if (!leagueData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
const form = await query.execute({ leagueId });
|
||||
setConfigForm(form);
|
||||
|
||||
const presetsData = await presetsQuery.execute();
|
||||
setPresets(presetsData);
|
||||
|
||||
const entity = await driverRepo.findById(leagueData.ownerId);
|
||||
if (entity) {
|
||||
setOwnerDriver(EntityMappers.toDriverDTO(entity));
|
||||
}
|
||||
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const memberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||
const memberDrivers: DriverDTO[] = [];
|
||||
for (const m of memberships) {
|
||||
if (m.driverId !== leagueData.ownerId && m.status === 'active') {
|
||||
const d = await driverRepo.findById(m.driverId);
|
||||
if (d) {
|
||||
const dto = EntityMappers.toDriverDTO(d);
|
||||
if (dto) {
|
||||
memberDrivers.push(dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setAllMembers(memberDrivers);
|
||||
} catch (err) {
|
||||
console.error('Failed to load league settings:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [leagueId, isAdmin]);
|
||||
|
||||
const ownerSummary = useMemo(() => {
|
||||
if (!ownerDriver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find(
|
||||
(stat) => stat.driverId === stats.driverId,
|
||||
);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
};
|
||||
}, [ownerDriver]);
|
||||
|
||||
const handleTransferOwnership = async () => {
|
||||
if (!selectedNewOwner || !league) return;
|
||||
|
||||
setTransferring(true);
|
||||
try {
|
||||
const useCase = getTransferLeagueOwnershipUseCase();
|
||||
await useCase.execute({
|
||||
leagueId,
|
||||
currentOwnerId: currentDriverId,
|
||||
newOwnerId: selectedNewOwner,
|
||||
});
|
||||
|
||||
setShowTransferDialog(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Failed to transfer ownership:', err);
|
||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership');
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can access settings.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-400">Loading configuration…</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!configForm || !league) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="py-6 text-sm text-gray-500">
|
||||
Unable to load league configuration for this demo league.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
||||
<Settings className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">League Settings</h1>
|
||||
<p className="text-sm text-gray-400">Manage your league configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* READONLY INFORMATION SECTION - Compact */}
|
||||
<div className="space-y-4">
|
||||
<ReadonlyLeagueInfo league={league} configForm={configForm} />
|
||||
|
||||
{/* League Owner - Compact */}
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-3">League Owner</h3>
|
||||
{ownerSummary ? (
|
||||
<DriverSummaryPill
|
||||
driver={ownerSummary.driver}
|
||||
rating={ownerSummary.rating}
|
||||
rank={ownerSummary.rank}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading owner details...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer Ownership - Owner Only */}
|
||||
{league.ownerId === currentDriverId && allMembers.length > 0 && (
|
||||
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserCog className="w-4 h-4 text-gray-400" />
|
||||
<h3 className="text-sm font-semibold text-gray-400">Transfer Ownership</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Transfer league ownership to another active member. You will become an admin.
|
||||
</p>
|
||||
|
||||
{!showTransferDialog ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowTransferDialog(true)}
|
||||
>
|
||||
Transfer Ownership
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedNewOwner}
|
||||
onChange={(e) => setSelectedNewOwner(e.target.value)}
|
||||
className="w-full rounded-lg border border-charcoal-outline bg-charcoal-card px-3 py-2 text-sm text-white focus:border-primary-blue focus:outline-none"
|
||||
>
|
||||
<option value="">Select new owner...</option>
|
||||
{allMembers.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleTransferOwnership}
|
||||
disabled={!selectedNewOwner || transferring}
|
||||
>
|
||||
{transferring ? 'Transferring...' : 'Confirm Transfer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowTransferDialog(false);
|
||||
setSelectedNewOwner('');
|
||||
}}
|
||||
disabled={transferring}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
import {
|
||||
@@ -8,20 +9,32 @@ import {
|
||||
type DriverDTO,
|
||||
type LeagueDriverSeasonStatsDTO,
|
||||
} from '@gridpilot/racing';
|
||||
import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container';
|
||||
import {
|
||||
getGetLeagueDriverSeasonStatsQuery,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { MembershipRole, LeagueMembership } from '@/lib/leagueMembership';
|
||||
|
||||
export default function LeagueStandingsPage({ params }: any) {
|
||||
const leagueId = params.id;
|
||||
export default function LeagueStandingsPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
|
||||
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
|
||||
const driverRepo = getDriverRepository();
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
|
||||
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
|
||||
setStandings(leagueStandings);
|
||||
@@ -31,17 +44,86 @@ export default function LeagueStandingsPage({ params }: any) {
|
||||
.map((driver) => EntityMappers.toDriverDTO(driver))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
setDrivers(driverDtos);
|
||||
|
||||
// Load league memberships from repository (consistent with other data)
|
||||
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||
// Convert to the format expected by StandingsTable
|
||||
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
|
||||
leagueId: m.leagueId,
|
||||
driverId: m.driverId,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
joinedAt: m.joinedAt instanceof Date ? m.joinedAt.toISOString() : String(m.joinedAt),
|
||||
}));
|
||||
setMemberships(membershipData);
|
||||
|
||||
// Check if current user is admin
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
}, [loadData]);
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(leagueId, driverId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
|
||||
try {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -59,10 +141,68 @@ export default function LeagueStandingsPage({ params }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
const leader = standings[0];
|
||||
const totalRaces = Math.max(...standings.map(s => s.racesStarted), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
{/* Championship Stats */}
|
||||
{standings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏆</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
|
||||
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
|
||||
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
|
||||
<span className="text-2xl">🏁</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
|
||||
<p className="text-2xl font-bold text-white">{totalRaces}</p>
|
||||
<p className="text-sm text-gray-400">Season in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
|
||||
<p className="text-2xl font-bold text-white">{standings.length}</p>
|
||||
<p className="text-sm text-gray-400">Competing for points</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||
<StandingsTable
|
||||
standings={standings}
|
||||
drivers={drivers}
|
||||
leagueId={leagueId}
|
||||
memberships={memberships}
|
||||
currentDriverId={currentDriverId}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
508
apps/website/app/leagues/[id]/stewarding/page.tsx
Normal file
508
apps/website/app/leagues/[id]/stewarding/page.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository,
|
||||
getReviewProtestUseCase,
|
||||
getApplyPenaltyUseCase,
|
||||
getPenaltyRepository
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { Penalty, PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
AlertTriangle, Clock, CheckCircle, Flag, ChevronRight,
|
||||
Calendar, MapPin, AlertCircle, Video, Gavel
|
||||
} from 'lucide-react';
|
||||
|
||||
interface RaceWithProtests {
|
||||
race: Race;
|
||||
pendingProtests: Protest[];
|
||||
resolvedProtests: Protest[];
|
||||
penalties: Penalty[];
|
||||
}
|
||||
|
||||
export default function LeagueStewardingPage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [protestsByRace, setProtestsByRace] = useState<Record<string, Protest[]>>({});
|
||||
const [penaltiesByRace, setPenaltiesByRace] = useState<Record<string, Penalty[]>>({});
|
||||
const [driversById, setDriversById] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<Protest | null>(null);
|
||||
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const penaltyRepo = getPenaltyRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
// Get all races for this league
|
||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||
setRaces(leagueRaces);
|
||||
|
||||
// Get protests and penalties for each race
|
||||
const protestsMap: Record<string, Protest[]> = {};
|
||||
const penaltiesMap: Record<string, Penalty[]> = {};
|
||||
const driverIds = new Set<string>();
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
const racePenalties = await penaltyRepo.findByRaceId(race.id);
|
||||
|
||||
protestsMap[race.id] = raceProtests;
|
||||
penaltiesMap[race.id] = racePenalties;
|
||||
|
||||
// Collect driver IDs
|
||||
raceProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
racePenalties.forEach((p) => {
|
||||
driverIds.add(p.driverId);
|
||||
});
|
||||
}
|
||||
|
||||
setProtestsByRace(protestsMap);
|
||||
setPenaltiesByRace(penaltiesMap);
|
||||
|
||||
// Load driver info
|
||||
const driverEntities = await Promise.all(
|
||||
Array.from(driverIds).map((id) => driverRepo.findById(id)),
|
||||
);
|
||||
const byId: Record<string, DriverDTO> = {};
|
||||
driverEntities.forEach((driver) => {
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
byId[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
});
|
||||
setDriversById(byId);
|
||||
|
||||
// Auto-expand races with pending protests
|
||||
const racesWithPending = new Set<string>();
|
||||
Object.entries(protestsMap).forEach(([raceId, protests]) => {
|
||||
if (protests.some(p => p.status === 'pending' || p.status === 'under_review')) {
|
||||
racesWithPending.add(raceId);
|
||||
}
|
||||
});
|
||||
setExpandedRaces(racesWithPending);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadData();
|
||||
}
|
||||
}, [leagueId, isAdmin]);
|
||||
|
||||
// Compute race data with protest/penalty info
|
||||
const racesWithData = useMemo((): RaceWithProtests[] => {
|
||||
return races.map(race => {
|
||||
const protests = protestsByRace[race.id] || [];
|
||||
const penalties = penaltiesByRace[race.id] || [];
|
||||
return {
|
||||
race,
|
||||
pendingProtests: protests.filter(p => p.status === 'pending' || p.status === 'under_review'),
|
||||
resolvedProtests: protests.filter(p => p.status === 'upheld' || p.status === 'dismissed' || p.status === 'withdrawn'),
|
||||
penalties
|
||||
};
|
||||
}).sort((a, b) => b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime());
|
||||
}, [races, protestsByRace, penaltiesByRace]);
|
||||
|
||||
// Filter races based on active tab
|
||||
const filteredRaces = useMemo(() => {
|
||||
if (activeTab === 'pending') {
|
||||
return racesWithData.filter(r => r.pendingProtests.length > 0);
|
||||
}
|
||||
return racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
|
||||
}, [racesWithData, activeTab]);
|
||||
|
||||
// Stats
|
||||
const totalPending = racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);
|
||||
const totalResolved = racesWithData.reduce((sum, r) => sum + r.resolvedProtests.length, 0);
|
||||
const totalPenalties = racesWithData.reduce((sum, r) => sum + r.penalties.length, 0);
|
||||
|
||||
const handleAcceptProtest = async (
|
||||
protestId: string,
|
||||
penaltyType: PenaltyType,
|
||||
penaltyValue: number,
|
||||
stewardNotes: string
|
||||
) => {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
const penaltyUseCase = getApplyPenaltyUseCase();
|
||||
|
||||
await reviewUseCase.execute({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
// Find the protest
|
||||
let foundProtest: Protest | undefined;
|
||||
Object.values(protestsByRace).forEach(protests => {
|
||||
const p = protests.find(pr => pr.id === protestId);
|
||||
if (p) foundProtest = p;
|
||||
});
|
||||
|
||||
if (foundProtest) {
|
||||
await penaltyUseCase.execute({
|
||||
raceId: foundProtest.raceId,
|
||||
driverId: foundProtest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: penaltyValue,
|
||||
reason: foundProtest.incident.description,
|
||||
protestId,
|
||||
notes: stewardNotes,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
|
||||
await reviewUseCase.execute({
|
||||
protestId,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
};
|
||||
|
||||
const handleProtestReviewed = () => {
|
||||
setSelectedProtest(null);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const toggleRaceExpanded = (raceId: string) => {
|
||||
setExpandedRaces(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(raceId)) {
|
||||
next.delete(raceId);
|
||||
} else {
|
||||
next.add(raceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'under_review':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
|
||||
case 'upheld':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
|
||||
case 'dismissed':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can access stewarding functions.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Quick overview of protests and penalties across all races
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-warning-amber mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Pending Review</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalPending}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-performance-green mb-1">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Resolved</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalResolved}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
|
||||
<div className="flex items-center gap-2 text-red-400 mb-1">
|
||||
<Gavel className="w-4 h-4" />
|
||||
<span className="text-xs font-medium uppercase">Penalties</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{totalPenalties}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="border-b border-charcoal-outline mb-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'pending'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pending Protests
|
||||
{totalPending > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{totalPending}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={`pb-3 px-1 font-medium transition-colors ${
|
||||
activeTab === 'history'
|
||||
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="animate-pulse">Loading stewarding data...</div>
|
||||
</div>
|
||||
) : filteredRaces.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
|
||||
<Flag className="w-8 h-8 text-performance-green" />
|
||||
</div>
|
||||
<p className="font-semibold text-lg text-white mb-2">
|
||||
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{activeTab === 'pending'
|
||||
? 'No pending protests to review'
|
||||
: 'No resolved protests or penalties'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
|
||||
const isExpanded = expandedRaces.has(race.id);
|
||||
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
|
||||
|
||||
return (
|
||||
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
|
||||
{/* Race Header */}
|
||||
<button
|
||||
onClick={() => toggleRaceExpanded(race.id)}
|
||||
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium text-white">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
{activeTab === 'pending' && pendingProtests.length > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
|
||||
{pendingProtests.length} pending
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'history' && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
|
||||
{resolvedProtests.length} protests, {penalties.length} penalties
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
||||
{displayProtests.length === 0 && penalties.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
||||
) : (
|
||||
<>
|
||||
{displayProtests.map((protest) => {
|
||||
const protester = driversById[protest.protestingDriverId];
|
||||
const accused = driversById[protest.accusedDriverId];
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={protest.id}
|
||||
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
|
||||
<span className="font-medium text-white">
|
||||
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
|
||||
</span>
|
||||
{getStatusBadge(protest.status)}
|
||||
{isUrgent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
|
||||
<span>Lap {protest.incident.lap}</span>
|
||||
<span>•</span>
|
||||
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
{protest.proofVideoUrl && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 text-primary-blue">
|
||||
<Video className="w-3 h-3" />
|
||||
Video
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 line-clamp-2">
|
||||
{protest.incident.description}
|
||||
</p>
|
||||
{protest.decisionNotes && (
|
||||
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
<span className="font-medium">Steward:</span> {protest.decisionNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(protest.status === 'pending' || protest.status === 'under_review') && (
|
||||
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
|
||||
<Button variant="primary">
|
||||
Review
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTab === 'history' && penalties.map((penalty) => {
|
||||
const driver = driversById[penalty.driverId];
|
||||
return (
|
||||
<div
|
||||
key={penalty.id}
|
||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gavel className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
{penalty.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{penalty.reason}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
|
||||
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
|
||||
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
|
||||
{penalty.type === 'disqualification' && 'DSQ'}
|
||||
{penalty.type === 'warning' && 'Warning'}
|
||||
{penalty.type === 'license_points' && `${penalty.value} LP`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
getProtestRepository,
|
||||
getRaceRepository,
|
||||
getDriverRepository,
|
||||
getLeagueMembershipRepository,
|
||||
getReviewProtestUseCase,
|
||||
getApplyPenaltyUseCase,
|
||||
getRequestProtestDefenseUseCase,
|
||||
getSendNotificationUseCase
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
AlertCircle,
|
||||
Video,
|
||||
Clock,
|
||||
Grid3x3,
|
||||
TrendingDown,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
ArrowLeft,
|
||||
Flag,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
Ban,
|
||||
DollarSign,
|
||||
FileWarning,
|
||||
User,
|
||||
Calendar,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Shield,
|
||||
Gavel,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
|
||||
timestamp: Date;
|
||||
actor: DriverDTO | null;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PENALTY_TYPES = [
|
||||
{
|
||||
type: 'time_penalty' as PenaltyType,
|
||||
label: 'Time Penalty',
|
||||
description: 'Add seconds to race result',
|
||||
icon: Clock,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'seconds',
|
||||
defaultValue: 5
|
||||
},
|
||||
{
|
||||
type: 'grid_penalty' as PenaltyType,
|
||||
label: 'Grid Penalty',
|
||||
description: 'Grid positions for next race',
|
||||
icon: Grid3x3,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'positions',
|
||||
defaultValue: 3
|
||||
},
|
||||
{
|
||||
type: 'points_deduction' as PenaltyType,
|
||||
label: 'Points Deduction',
|
||||
description: 'Deduct championship points',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'points',
|
||||
defaultValue: 5
|
||||
},
|
||||
{
|
||||
type: 'disqualification' as PenaltyType,
|
||||
label: 'Disqualification',
|
||||
description: 'Disqualify from race',
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
||||
requiresValue: false,
|
||||
valueLabel: '',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
type: 'warning' as PenaltyType,
|
||||
label: 'Warning',
|
||||
description: 'Official warning only',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
requiresValue: false,
|
||||
valueLabel: '',
|
||||
defaultValue: 0
|
||||
},
|
||||
{
|
||||
type: 'license_points' as PenaltyType,
|
||||
label: 'License Points',
|
||||
description: 'Safety rating penalty',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
requiresValue: true,
|
||||
valueLabel: 'points',
|
||||
defaultValue: 2
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProtestReviewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [protest, setProtest] = useState<Protest | null>(null);
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [protestingDriver, setProtestingDriver] = useState<DriverDTO | null>(null);
|
||||
const [accusedDriver, setAccusedDriver] = useState<DriverDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Comment state
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAdmin() {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const membership = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
}
|
||||
checkAdmin();
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadProtest() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const protestRepo = getProtestRepository();
|
||||
const raceRepo = getRaceRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const protestEntity = await protestRepo.findById(protestId);
|
||||
if (!protestEntity) {
|
||||
throw new Error('Protest not found');
|
||||
}
|
||||
setProtest(protestEntity);
|
||||
|
||||
const raceEntity = await raceRepo.findById(protestEntity.raceId);
|
||||
if (!raceEntity) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
setRace(raceEntity);
|
||||
|
||||
const protestingDriverEntity = await driverRepo.findById(protestEntity.protestingDriverId);
|
||||
const accusedDriverEntity = await driverRepo.findById(protestEntity.accusedDriverId);
|
||||
|
||||
setProtestingDriver(protestingDriverEntity ? EntityMappers.toDriverDTO(protestingDriverEntity) : null);
|
||||
setAccusedDriver(accusedDriverEntity ? EntityMappers.toDriverDTO(accusedDriverEntity) : null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load protest:', err);
|
||||
alert('Failed to load protest details');
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
loadProtest();
|
||||
}
|
||||
}, [protestId, leagueId, isAdmin, currentDriverId, router]);
|
||||
|
||||
// Build timeline from protest data
|
||||
const timeline = useMemo((): TimelineEvent[] => {
|
||||
if (!protest) return [];
|
||||
|
||||
const events: TimelineEvent[] = [
|
||||
{
|
||||
id: 'filed',
|
||||
type: 'protest_filed',
|
||||
timestamp: new Date(protest.filedAt),
|
||||
actor: protestingDriver,
|
||||
content: protest.incident.description,
|
||||
metadata: {
|
||||
lap: protest.incident.lap,
|
||||
comment: protest.comment
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Add decision event if resolved
|
||||
if (protest.status === 'upheld' || protest.status === 'dismissed') {
|
||||
events.push({
|
||||
id: 'decision',
|
||||
type: 'decision',
|
||||
timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(),
|
||||
actor: null, // Would need to load steward driver
|
||||
content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'),
|
||||
metadata: {
|
||||
decision: protest.status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}, [protest, protestingDriver]);
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !protest) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const reviewUseCase = getReviewProtestUseCase();
|
||||
const penaltyUseCase = getApplyPenaltyUseCase();
|
||||
|
||||
if (decision === 'uphold') {
|
||||
await reviewUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
|
||||
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
||||
await penaltyUseCase.execute({
|
||||
raceId: protest.raceId,
|
||||
driverId: protest.accusedDriverId,
|
||||
stewardId: currentDriverId,
|
||||
type: penaltyType,
|
||||
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
|
||||
reason: protest.incident.description,
|
||||
protestId: protest.id,
|
||||
notes: stewardNotes,
|
||||
});
|
||||
} else {
|
||||
await reviewUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: stewardNotes,
|
||||
});
|
||||
}
|
||||
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!protest) return;
|
||||
|
||||
try {
|
||||
const requestDefenseUseCase = getRequestProtestDefenseUseCase();
|
||||
const sendNotificationUseCase = getSendNotificationUseCase();
|
||||
|
||||
// Request defense
|
||||
const result = await requestDefenseUseCase.execute({
|
||||
protestId: protest.id,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
|
||||
// Send notification to accused driver
|
||||
await sendNotificationUseCase.execute({
|
||||
recipientId: result.accusedDriverId,
|
||||
type: 'protest_filed',
|
||||
title: 'Defense Requested',
|
||||
body: `A steward has requested your defense for a protest filed against you.`,
|
||||
actionUrl: `/leagues/${leagueId}/stewarding/protests/${protest.id}`,
|
||||
data: {
|
||||
protestId: protest.id,
|
||||
raceId: protest.raceId,
|
||||
leagueId,
|
||||
},
|
||||
});
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
|
||||
case 'under_review':
|
||||
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
|
||||
case 'awaiting_defense':
|
||||
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
|
||||
case 'upheld':
|
||||
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
|
||||
case 'dismissed':
|
||||
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
|
||||
default:
|
||||
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !protest || !race) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-pulse text-gray-400">Loading protest details...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending' || protest.status === 'under_review' || protest.status === 'awaiting_defense';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.track}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.track}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.scheduledAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Evidence */}
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.filedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.filedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.incident.description}</p>
|
||||
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PENALTY_TYPES.map((penalty) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<button
|
||||
key={penalty.type}
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${
|
||||
isSelected
|
||||
? `${penalty.color} border`
|
||||
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
|
||||
}`}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
|
||||
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
|
||||
{penalty.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user