alpha wip
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { getAppMode } from '@/lib/mode';
|
||||
import { AlphaNav } from '@/components/alpha/AlphaNav';
|
||||
import AlphaBanner from '@/components/alpha/AlphaBanner';
|
||||
import AlphaFooter from '@/components/alpha/AlphaFooter';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GridPilot - iRacing League Racing Platform',
|
||||
@@ -33,6 +37,26 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const mode = getAppMode();
|
||||
|
||||
if (mode === 'alpha') {
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth overflow-x-hidden">
|
||||
<head>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
|
||||
<AlphaNav />
|
||||
<AlphaBanner />
|
||||
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AlphaFooter />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth overflow-x-hidden">
|
||||
<head>
|
||||
|
||||
236
apps/website/app/leagues/[id]/page.tsx
Normal file
236
apps/website/app/leagues/[id]/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'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 { League } from '@/domain/entities/League';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [owner, setOwner] = useState<Driver | null>(null);
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadLeagueData = async () => {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const raceRepo = getRaceRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
|
||||
if (!leagueData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
// Load owner data
|
||||
const ownerData = await driverRepo.findById(leagueData.ownerId);
|
||||
setOwner(ownerData);
|
||||
|
||||
// Load races for this league
|
||||
const allRaces = await raceRepo.findAll();
|
||||
const leagueRaces = allRaces
|
||||
.filter(race => race.leagueId === leagueId)
|
||||
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
|
||||
|
||||
setRaces(leagueRaces);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load league');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagueData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
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 (error || !league) {
|
||||
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">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingRaces = races.filter(race => race.status === 'scheduled');
|
||||
|
||||
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">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push('/leagues')}
|
||||
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 Leagues
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* League Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
|
||||
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||
Alpha: Single League
|
||||
</span>
|
||||
</FeatureLimitationTooltip>
|
||||
</div>
|
||||
<p className="text-gray-400">{league.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* 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">Owner</label>
|
||||
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
|
||||
</div>
|
||||
|
||||
<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">League Settings</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Points System</label>
|
||||
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Session Duration</label>
|
||||
<p className="text-white">{league.settings.sessionDuration} minutes</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/races?leagueId=${leagueId}`)}
|
||||
>
|
||||
Schedule Race
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/leagues/${leagueId}/standings`)}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Races */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Upcoming Races</h2>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No upcoming races scheduled</p>
|
||||
<p className="text-sm text-gray-500">Click “Schedule Race” to create your first race</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRaces.map((race) => (
|
||||
<div
|
||||
key={race.id}
|
||||
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue transition-all duration-200 cursor-pointer hover:scale-[1.02]"
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{race.track}</h3>
|
||||
<p className="text-sm text-gray-400">{race.car}</p>
|
||||
<p className="text-xs text-gray-500 mt-1 uppercase">{race.sessionType}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-sm">
|
||||
{new Date(race.scheduledAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(race.scheduledAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
apps/website/app/leagues/[id]/standings/page.tsx
Normal file
134
apps/website/app/leagues/[id]/standings/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'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 StandingsTable from '@/components/alpha/StandingsTable';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Standing } from '@/domain/entities/Standing';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getStandingRepository,
|
||||
getDriverRepository
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export default function LeagueStandingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [standings, setStandings] = useState<Standing[]>([]);
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const standingRepo = getStandingRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
|
||||
if (!leagueData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
// Load standings
|
||||
const standingsData = await standingRepo.findByLeagueId(leagueId);
|
||||
setStandings(standingsData);
|
||||
|
||||
// Load drivers
|
||||
const driversData = await driverRepo.findAll();
|
||||
setDrivers(driversData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leagueId]);
|
||||
|
||||
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 standings...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !league) {
|
||||
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">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</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-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<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 Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Championship Standings</h1>
|
||||
<p className="text-gray-400">{league.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Standings Content */}
|
||||
<Card>
|
||||
{standings.length > 0 ? (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Current Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-2">No standings available yet</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Standings will appear after race results are imported
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/website/app/leagues/page.tsx
Normal file
120
apps/website/app/leagues/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LeagueCard from '@/components/alpha/LeagueCard';
|
||||
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export default function LeaguesPage() {
|
||||
const router = useRouter();
|
||||
const [leagues, setLeagues] = useState<League[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLeagues();
|
||||
}, []);
|
||||
|
||||
const loadLeagues = async () => {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
setLeagues(allLeagues);
|
||||
} catch (error) {
|
||||
console.error('Failed to load leagues:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading leagues...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Leagues</h1>
|
||||
<p className="text-gray-400">
|
||||
{leagues.length === 0
|
||||
? 'Create your first league to get started'
|
||||
: `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
>
|
||||
{showCreateForm ? 'Cancel' : 'Create League'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<Card className="mb-8 max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Create New League</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Experiment with different point systems
|
||||
</p>
|
||||
</div>
|
||||
<CreateLeagueForm />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{leagues.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-600 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-white mb-2">No leagues yet</h3>
|
||||
<p className="text-sm mb-4">
|
||||
Create one to get started. Alpha data resets on page reload.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
Create Your First League
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{leagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league}
|
||||
onClick={() => handleLeagueClick(league.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ModeGuard } from '@/components/shared/ModeGuard';
|
||||
'use client';
|
||||
|
||||
import { getAppMode } from '@/lib/mode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import Hero from '@/components/landing/Hero';
|
||||
import AlternatingSection from '@/components/landing/AlternatingSection';
|
||||
import FeatureGrid from '@/components/landing/FeatureGrid';
|
||||
@@ -11,10 +17,270 @@ import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationM
|
||||
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
|
||||
import MockupStack from '@/components/ui/MockupStack';
|
||||
|
||||
export default function HomePage() {
|
||||
function AlphaDashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ModeGuard mode="pre-launch">
|
||||
<main className="min-h-screen">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Welcome Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">GridPilot Alpha</h1>
|
||||
<p className="text-gray-400 text-lg">
|
||||
Complete workflow prototype. Test freely — all data is temporary.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Companion Status */}
|
||||
<div className="mb-8">
|
||||
<CompanionStatus />
|
||||
</div>
|
||||
|
||||
{/* What's in Alpha */}
|
||||
<Card className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">What's in Alpha</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Driver profile creation</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">League management</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Race scheduling</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">CSV result import</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Championship standings</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Full workflow end-to-end</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* What's Coming */}
|
||||
<Card className="mb-8 bg-iron-gray">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">What's Coming</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Persistent data storage</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Automated session creation</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Automated result import</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Multi-league memberships</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Team championships</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Advanced statistics</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">Social features</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span className="text-gray-300">League discovery</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Known Limitations */}
|
||||
<Card className="mb-8 border border-warning-amber/20 bg-iron-gray">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-warning-amber/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-warning-amber" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Known Limitations</h3>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>Data resets on page reload (in-memory only)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>Manual iRacing session creation required</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>Manual CSV result upload required</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>Single league membership per driver</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>No user authentication</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-warning-amber mt-0.5">•</span>
|
||||
<span>iRacing platform only</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Start Guide */}
|
||||
<Card className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">Quick Start Guide</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-primary-blue/20 text-primary-blue font-semibold flex-shrink-0">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-1">Create Your Profile</h3>
|
||||
<p className="text-sm text-gray-400">Set up your driver profile with racing number and iRacing ID.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/profile')}
|
||||
className="mt-2"
|
||||
>
|
||||
Go to Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-1">Join or Create a League</h3>
|
||||
<p className="text-sm text-gray-400">Browse available leagues or create your own.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
className="mt-2"
|
||||
>
|
||||
Browse Leagues
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-1">Schedule Races</h3>
|
||||
<p className="text-sm text-gray-400">Create race events and manage your schedule.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/races')}
|
||||
className="mt-2"
|
||||
>
|
||||
View Races
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Navigation Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div onClick={() => router.push('/profile')} className="cursor-pointer">
|
||||
<Card className="hover:border-primary-blue/30 transition-colors">
|
||||
<div className="flex flex-col items-center text-center py-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-1">Profile</h3>
|
||||
<p className="text-sm text-gray-400">Manage your driver profile</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div onClick={() => router.push('/leagues')} className="cursor-pointer">
|
||||
<Card className="hover:border-primary-blue/30 transition-colors">
|
||||
<div className="flex flex-col items-center text-center py-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-1">Leagues</h3>
|
||||
<p className="text-sm text-gray-400">Browse and join leagues</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div onClick={() => router.push('/races')} className="cursor-pointer">
|
||||
<Card className="hover:border-primary-blue/30 transition-colors">
|
||||
<div className="flex flex-col items-center text-center py-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold mb-1">Races</h3>
|
||||
<p className="text-sm text-gray-400">View race schedule</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingPage() {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<Hero />
|
||||
|
||||
{/* Section 1: A Persistent Identity */}
|
||||
@@ -206,10 +472,19 @@ export default function HomePage() {
|
||||
layout="text-right"
|
||||
/>
|
||||
|
||||
<DiscordCTA />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</main>
|
||||
</ModeGuard>
|
||||
<DiscordCTA />
|
||||
<FAQ />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const mode = getAppMode();
|
||||
|
||||
if (mode === 'alpha') {
|
||||
return <AlphaDashboard />;
|
||||
}
|
||||
|
||||
return <LandingPage />;
|
||||
}
|
||||
43
apps/website/app/profile/page.tsx
Normal file
43
apps/website/app/profile/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getDriverRepository } from '@/lib/di-container';
|
||||
import { EntityMappers } from '@/application/mappers/EntityMappers';
|
||||
import CreateDriverForm from '@/components/alpha/CreateDriverForm';
|
||||
import DriverProfile from '@/components/alpha/DriverProfile';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const driverRepo = getDriverRepository();
|
||||
const drivers = await driverRepo.findAll();
|
||||
const driver = EntityMappers.toDriverDTO(drivers[0] || null);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Driver Profile</h1>
|
||||
<p className="text-gray-400">
|
||||
{driver ? 'Your GridPilot profile' : 'Create your GridPilot profile to get started'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{driver ? (
|
||||
<>
|
||||
<FeatureLimitationTooltip message="Profile editing coming in production">
|
||||
<div className="opacity-75 pointer-events-none">
|
||||
<DriverProfile driver={driver} />
|
||||
</div>
|
||||
</FeatureLimitationTooltip>
|
||||
</>
|
||||
) : (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Create Your Profile</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Create your driver profile. Alpha data resets on reload, so test freely.
|
||||
</p>
|
||||
</div>
|
||||
<CreateDriverForm />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
apps/website/app/races/[id]/page.tsx
Normal file
277
apps/website/app/races/[id]/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'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 { Race } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
||||
|
||||
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 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);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load race');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 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',
|
||||
};
|
||||
|
||||
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]}`}>
|
||||
{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">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
apps/website/app/races/[id]/results/page.tsx
Normal file
247
apps/website/app/races/[id]/results/page.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
'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 ResultsTable from '@/components/alpha/ResultsTable';
|
||||
import ImportResultsForm from '@/components/alpha/ImportResultsForm';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Result } from '@/domain/entities/Result';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getLeagueRepository,
|
||||
getResultRepository,
|
||||
getStandingRepository,
|
||||
getDriverRepository
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export default function RaceResultsPage() {
|
||||
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 [results, setResults] = useState<Result[]>([]);
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const resultRepo = getResultRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
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 results
|
||||
const resultsData = await resultRepo.findByRaceId(raceId);
|
||||
setResults(resultsData);
|
||||
|
||||
// Load drivers
|
||||
const driversData = await driverRepo.findAll();
|
||||
setDrivers(driversData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load race data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [raceId]);
|
||||
|
||||
const handleImportSuccess = async (importedResults: Result[]) => {
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resultRepo = getResultRepository();
|
||||
const standingRepo = getStandingRepository();
|
||||
|
||||
// Check if results already exist
|
||||
const existingResults = await resultRepo.existsByRaceId(raceId);
|
||||
if (existingResults) {
|
||||
throw new Error('Results already exist for this race');
|
||||
}
|
||||
|
||||
// Create all results
|
||||
await resultRepo.createMany(importedResults);
|
||||
|
||||
// Recalculate standings for the league
|
||||
if (league) {
|
||||
await standingRepo.recalculate(league.id);
|
||||
}
|
||||
|
||||
// Reload results
|
||||
const resultsData = await resultRepo.findByRaceId(raceId);
|
||||
setResults(resultsData);
|
||||
setImportSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to import results');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportError = (errorMessage: string) => {
|
||||
setError(errorMessage);
|
||||
};
|
||||
|
||||
const getPointsSystem = (): Record<number, number> => {
|
||||
if (!league) return {};
|
||||
|
||||
const pointsSystems: Record<string, Record<number, number>> = {
|
||||
'f1-2024': {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10,
|
||||
6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||
},
|
||||
'indycar': {
|
||||
1: 50, 2: 40, 3: 35, 4: 32, 5: 30,
|
||||
6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
|
||||
11: 19, 12: 18, 13: 17, 14: 16, 15: 15
|
||||
}
|
||||
};
|
||||
|
||||
return league.settings.customPoints ||
|
||||
pointsSystems[league.settings.pointsSystem] ||
|
||||
pointsSystems['f1-2024'];
|
||||
};
|
||||
|
||||
const getFastestLapTime = (): number | undefined => {
|
||||
if (results.length === 0) return undefined;
|
||||
return Math.min(...results.map(r => r.fastestLap));
|
||||
};
|
||||
|
||||
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 results...</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-6xl 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>
|
||||
);
|
||||
}
|
||||
|
||||
const hasResults = results.length > 0;
|
||||
|
||||
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">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push(`/races/${raceId}`)}
|
||||
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 Race Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Race Results</h1>
|
||||
{race && (
|
||||
<div>
|
||||
<p className="text-gray-400">{race.track}</p>
|
||||
{league && (
|
||||
<p className="text-sm text-gray-500">{league.name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{importSuccess && (
|
||||
<div className="mb-6 p-4 bg-performance-green/10 border border-performance-green/30 rounded text-performance-green">
|
||||
<strong>Success!</strong> Results imported and standings updated.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Card>
|
||||
{hasResults ? (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Results</h2>
|
||||
<ResultsTable
|
||||
results={results}
|
||||
drivers={drivers}
|
||||
pointsSystem={getPointsSystem()}
|
||||
fastestLapTime={getFastestLapTime()}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
No results imported. Upload CSV to test the standings system.
|
||||
</p>
|
||||
{importing ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
Importing results and updating standings...
|
||||
</div>
|
||||
) : (
|
||||
<ImportResultsForm
|
||||
raceId={raceId}
|
||||
onSuccess={handleImportSuccess}
|
||||
onError={handleImportError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
apps/website/app/races/page.tsx
Normal file
224
apps/website/app/races/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import RaceCard from '@/components/alpha/RaceCard';
|
||||
import ScheduleRaceForm from '@/components/alpha/ScheduleRaceForm';
|
||||
import { Race, RaceStatus } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export default function RacesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showScheduleForm, setShowScheduleForm] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [timeFilter, setTimeFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
||||
|
||||
const loadRaces = async () => {
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
|
||||
const [allRaces, allLeagues] = await Promise.all([
|
||||
raceRepo.findAll(),
|
||||
leagueRepo.findAll()
|
||||
]);
|
||||
|
||||
setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()));
|
||||
|
||||
const leagueMap = new Map<string, League>();
|
||||
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
||||
setLeagues(leagueMap);
|
||||
} catch (err) {
|
||||
console.error('Failed to load races:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRaces();
|
||||
}, []);
|
||||
|
||||
const filteredRaces = races.filter(race => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// League filter
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Time filter
|
||||
if (timeFilter === 'upcoming' && !race.isUpcoming()) {
|
||||
return false;
|
||||
}
|
||||
if (timeFilter === 'past' && !race.isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
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 races...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showScheduleForm) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowScheduleForm(false)}
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
|
||||
<ScheduleRaceForm
|
||||
preSelectedLeagueId={searchParams.get('leagueId') || undefined}
|
||||
onSuccess={(race) => {
|
||||
router.push(`/races/${race.id}`);
|
||||
}}
|
||||
onCancel={() => setShowScheduleForm(false)}
|
||||
/>
|
||||
</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-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-3xl font-bold text-white">Races</h1>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowScheduleForm(true)}
|
||||
>
|
||||
Schedule Race
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
Manage and view all scheduled races across your leagues
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Time Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Time
|
||||
</label>
|
||||
<select
|
||||
value={timeFilter}
|
||||
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Races</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* League Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
League
|
||||
</label>
|
||||
<select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
>
|
||||
<option value="all">All Leagues</option>
|
||||
{Array.from(leagues.values()).map(league => (
|
||||
<option key={league.id} value={league.id}>
|
||||
{league.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race List */}
|
||||
{filteredRaces.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
{races.length === 0 ? (
|
||||
<>
|
||||
<p className="mb-2">No races scheduled</p>
|
||||
<p className="text-sm text-gray-500">Try the full workflow in alpha mode</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2">No races match your filters</p>
|
||||
<p className="text-sm text-gray-500">Try adjusting your filter criteria</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredRaces.map(race => (
|
||||
<RaceCard
|
||||
key={race.id}
|
||||
race={race}
|
||||
leagueName={leagues.get(race.leagueId)?.name}
|
||||
onClick={() => router.push(`/races/${race.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user