439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
'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 } from '@/lib/di-container';
|
|
import {
|
|
getMembership,
|
|
getCurrentDriverId,
|
|
isRegistered,
|
|
registerForRace,
|
|
withdrawFromRace,
|
|
getRegisteredDrivers,
|
|
} from '@/lib/racingLegacyFacade';
|
|
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
|
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
|
|
|
export default function RaceDetailPage() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const raceId = params.id 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 = getCurrentDriverId();
|
|
|
|
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);
|
|
|
|
// Load league data
|
|
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
|
setLeague(leagueData);
|
|
|
|
// Load entry list
|
|
await loadEntryList(raceData.id, raceData.leagueId);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load race');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadEntryList = async (raceId: string, leagueId: string) => {
|
|
try {
|
|
const driverRepo = getDriverRepository();
|
|
const registeredDriverIds = getRegisteredDrivers(raceId);
|
|
const drivers = await Promise.all(
|
|
registeredDriverIds.map((id: string) => driverRepo.findById(id))
|
|
);
|
|
setEntryList(
|
|
drivers.filter((d: Driver | null): d is Driver => d !== null)
|
|
);
|
|
|
|
// Check user registration status
|
|
const userIsRegistered = isRegistered(raceId, currentDriverId);
|
|
setIsUserRegistered(userIsRegistered);
|
|
|
|
// Check if user can register (is league member and race is upcoming)
|
|
const membership = getMembership(leagueId, 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 {
|
|
registerForRace(race.id, currentDriverId, league.id);
|
|
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 {
|
|
withdrawFromRace(race.id, 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="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="text-center text-gray-400">Loading race details...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !race) {
|
|
return (
|
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
<Card className="text-center py-12">
|
|
<div className="text-warning-amber mb-4">
|
|
{error || 'Race not found'}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => router.push('/races')}
|
|
>
|
|
Back to Races
|
|
</Button>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
|
<div className="max-w-4xl mx-auto">
|
|
{/* Breadcrumb */}
|
|
<div className="mb-6">
|
|
<button
|
|
onClick={() => router.push('/races')}
|
|
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 Races
|
|
</button>
|
|
</div>
|
|
|
|
{/* Companion Status */}
|
|
<FeatureLimitationTooltip message="Companion automation available in production">
|
|
<div className="mb-6">
|
|
<CompanionStatus />
|
|
</div>
|
|
</FeatureLimitationTooltip>
|
|
|
|
{/* Race Header */}
|
|
<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>
|
|
|
|
{/* Companion Instructions for Scheduled Races */}
|
|
{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">
|
|
{/* Race Details */}
|
|
<Card className="lg:col-span-2">
|
|
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
|
|
|
|
<div className="space-y-6">
|
|
{/* Date & Time */}
|
|
<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>
|
|
|
|
{/* Track */}
|
|
<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>
|
|
|
|
{/* Car */}
|
|
<div>
|
|
<label className="text-sm text-gray-500 block mb-1">Car</label>
|
|
<p className="text-white">{race.car}</p>
|
|
</div>
|
|
|
|
{/* Session Type */}
|
|
<div>
|
|
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
|
|
<p className="text-white capitalize">{race.sessionType}</p>
|
|
</div>
|
|
|
|
{/* League */}
|
|
<div className="pt-4 border-t border-charcoal-outline">
|
|
<label className="text-sm text-gray-500 block mb-1">League</label>
|
|
{league ? (
|
|
<button
|
|
onClick={() => router.push(`/leagues/${league.id}`)}
|
|
className="text-primary-blue hover:underline"
|
|
>
|
|
{league.name}
|
|
</button>
|
|
) : (
|
|
<p className="text-white">ID: {race.leagueId.slice(0, 8)}...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Actions */}
|
|
<Card>
|
|
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
|
|
|
<div className="space-y-3">
|
|
{/* Registration Actions */}
|
|
{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('/races')}
|
|
>
|
|
Back to Races
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Entry List */}
|
|
{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}`)}
|
|
>
|
|
<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>
|
|
</div>
|
|
);
|
|
} |