alpha wip

This commit is contained in:
2025-12-03 00:46:08 +01:00
parent 3b55fd1a63
commit 97e29d3d80
51 changed files with 6321 additions and 237 deletions

View File

@@ -1,13 +1,10 @@
# GridPilot Website Environment Variables
# Application Mode
# Controls whether the site is in pre-launch or post-launch mode
# Valid values: "pre-launch" | "post-launch"
# Controls whether the site is in pre-launch or alpha mode
# Valid values: "pre-launch" | "alpha"
# Default: "pre-launch" (if not set)
GRIDPILOT_MODE=pre-launch
# For client-side mode detection (must match GRIDPILOT_MODE)
# Note: NEXT_PUBLIC_ prefix exposes this to the browser
# Note: NEXT_PUBLIC_ prefix exposes this to both server and browser
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
# Vercel KV (for email signups and rate limiting)
@@ -26,6 +23,5 @@ NEXT_PUBLIC_SITE_URL=https://gridpilot.com
# Example: https://discord.gg/your-invite-code
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
# Example for post-launch mode:
# GRIDPILOT_MODE=post-launch
# NEXT_PUBLIC_GRIDPILOT_MODE=post-launch
# Example for alpha mode:
# NEXT_PUBLIC_GRIDPILOT_MODE=alpha

View File

@@ -4,7 +4,7 @@ Pre-launch landing page for GridPilot with email signup functionality.
## Features
- **Mode Switching**: Toggle between pre-launch (landing page only) and post-launch (full platform) modes
- **Mode Switching**: Toggle between pre-launch (landing page only) and alpha (full platform) modes
- **Email Capture**: Collect email signups with validation and rate limiting
- **Production Ready**: Configured for Vercel deployment with KV storage
@@ -34,8 +34,7 @@ cp .env.example .env.local
4. Configure environment variables in `.env.local`:
```bash
# Application Mode (pre-launch or post-launch)
GRIDPILOT_MODE=pre-launch
# Application Mode (pre-launch or alpha)
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
# Vercel KV (required for email signups)
@@ -59,8 +58,7 @@ Visit `http://localhost:3000` to see the landing page.
| Variable | Description | Example |
|----------|-------------|---------|
| `GRIDPILOT_MODE` | Application mode | `pre-launch` or `post-launch` |
| `NEXT_PUBLIC_GRIDPILOT_MODE` | Client-side mode detection | Must match `GRIDPILOT_MODE` |
| `NEXT_PUBLIC_GRIDPILOT_MODE` | Application mode (server & client) | `pre-launch` or `alpha` |
| `KV_REST_API_URL` | Vercel KV REST API endpoint | From Vercel Dashboard |
| `KV_REST_API_TOKEN` | Vercel KV authentication token | From Vercel Dashboard |
| `NEXT_PUBLIC_SITE_URL` | Public site URL | `https://gridpilot.com` |
@@ -85,7 +83,6 @@ The application supports two modes:
To activate:
```bash
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
```
@@ -97,8 +94,7 @@ NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
To activate:
```bash
GRIDPILOT_MODE=post-launch
NEXT_PUBLIC_GRIDPILOT_MODE=post-launch
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
```
## Email Signup API
@@ -179,7 +175,6 @@ console.log(signups);
**Production:**
```
GRIDPILOT_MODE=pre-launch
NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch
KV_REST_API_URL=<your_vercel_kv_url>
KV_REST_API_TOKEN=<your_vercel_kv_token>
@@ -198,7 +193,7 @@ console.log(signups);
When ready to launch the full platform:
1. Go to Vercel Dashboard → Project Settings → Environment Variables
2. Update `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` to `post-launch`
2. Update `NEXT_PUBLIC_GRIDPILOT_MODE` to `alpha`
3. Redeploy the application (automatic if you save changes)
### Custom Domain Setup
@@ -263,10 +258,10 @@ Submit the same email 5 times within an hour. The 6th submission should return a
### Mode Switching Not Working
**Issue**: Routes still show 404 in post-launch mode
**Issue**: Routes still show 404 in alpha mode
**Solution**:
1. Ensure `GRIDPILOT_MODE` and `NEXT_PUBLIC_GRIDPILOT_MODE` are both set to `post-launch`
**Solution**:
1. Ensure `NEXT_PUBLIC_GRIDPILOT_MODE` is set to `alpha`
2. Restart the development server or redeploy to Vercel
3. Clear browser cache

View File

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

View 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 &ldquo;Schedule Race&rdquo; 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,174 @@
/**
* Application Layer: Entity to DTO Mappers
*
* Transforms domain entities to plain objects for crossing architectural boundaries.
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
*/
import { Driver } from '@/domain/entities/Driver';
import { League } from '@/domain/entities/League';
import { Race } from '@/domain/entities/Race';
import { Result } from '@/domain/entities/Result';
import { Standing } from '@/domain/entities/Standing';
export type DriverDTO = {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: string;
};
export type LeagueDTO = {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
};
createdAt: string;
};
export type RaceDTO = {
id: string;
leagueId: string;
scheduledAt: string;
track: string;
car: string;
sessionType: 'practice' | 'qualifying' | 'race';
status: 'scheduled' | 'completed' | 'cancelled';
};
export type ResultDTO = {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
};
export type StandingDTO = {
leagueId: string;
driverId: string;
points: number;
wins: number;
position: number;
racesCompleted: number;
};
export class EntityMappers {
static toDriverDTO(driver: Driver | null): DriverDTO | null {
if (!driver) return null;
return {
id: driver.id,
iracingId: driver.iracingId,
name: driver.name,
country: driver.country,
bio: driver.bio,
joinedAt: driver.joinedAt.toISOString(),
};
}
static toLeagueDTO(league: League | null): LeagueDTO | null {
if (!league) return null;
return {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
};
}
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
return leagues.map(league => ({
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: league.settings,
createdAt: league.createdAt.toISOString(),
}));
}
static toRaceDTO(race: Race | null): RaceDTO | null {
if (!race) return null;
return {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
car: race.car,
sessionType: race.sessionType,
status: race.status,
};
}
static toRaceDTOs(races: Race[]): RaceDTO[] {
return races.map(race => ({
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
car: race.car,
sessionType: race.sessionType,
status: race.status,
}));
}
static toResultDTO(result: Result | null): ResultDTO | null {
if (!result) return null;
return {
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
};
}
static toResultDTOs(results: Result[]): ResultDTO[] {
return results.map(result => ({
id: result.id,
raceId: result.raceId,
driverId: result.driverId,
position: result.position,
fastestLap: result.fastestLap,
incidents: result.incidents,
startPosition: result.startPosition,
}));
}
static toStandingDTO(standing: Standing | null): StandingDTO | null {
if (!standing) return null;
return {
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
};
}
static toStandingDTOs(standings: Standing[]): StandingDTO[] {
return standings.map(standing => ({
leagueId: standing.leagueId,
driverId: standing.driverId,
points: standing.points,
wins: standing.wins,
position: standing.position,
racesCompleted: standing.racesCompleted,
}));
}
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: IDriverRepository
*
* Repository interface for Driver entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Driver } from '../../domain/entities/Driver';
export interface IDriverRepository {
/**
* Find a driver by ID
*/
findById(id: string): Promise<Driver | null>;
/**
* Find a driver by iRacing ID
*/
findByIRacingId(iracingId: string): Promise<Driver | null>;
/**
* Find all drivers
*/
findAll(): Promise<Driver[]>;
/**
* Create a new driver
*/
create(driver: Driver): Promise<Driver>;
/**
* Update an existing driver
*/
update(driver: Driver): Promise<Driver>;
/**
* Delete a driver by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a driver exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if an iRacing ID is already registered
*/
existsByIRacingId(iracingId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,50 @@
/**
* Application Port: ILeagueRepository
*
* Repository interface for League entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { League } from '../../domain/entities/League';
export interface ILeagueRepository {
/**
* Find a league by ID
*/
findById(id: string): Promise<League | null>;
/**
* Find all leagues
*/
findAll(): Promise<League[]>;
/**
* Find leagues by owner ID
*/
findByOwnerId(ownerId: string): Promise<League[]>;
/**
* Create a new league
*/
create(league: League): Promise<League>;
/**
* Update an existing league
*/
update(league: League): Promise<League>;
/**
* Delete a league by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a league exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Search leagues by name
*/
searchByName(query: string): Promise<League[]>;
}

View File

@@ -0,0 +1,65 @@
/**
* Application Port: IRaceRepository
*
* Repository interface for Race entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Race, RaceStatus } from '../../domain/entities/Race';
export interface IRaceRepository {
/**
* Find a race by ID
*/
findById(id: string): Promise<Race | null>;
/**
* Find all races
*/
findAll(): Promise<Race[]>;
/**
* Find races by league ID
*/
findByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find upcoming races for a league
*/
findUpcomingByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find completed races for a league
*/
findCompletedByLeagueId(leagueId: string): Promise<Race[]>;
/**
* Find races by status
*/
findByStatus(status: RaceStatus): Promise<Race[]>;
/**
* Find races scheduled within a date range
*/
findByDateRange(startDate: Date, endDate: Date): Promise<Race[]>;
/**
* Create a new race
*/
create(race: Race): Promise<Race>;
/**
* Update an existing race
*/
update(race: Race): Promise<Race>;
/**
* Delete a race by ID
*/
delete(id: string): Promise<void>;
/**
* Check if a race exists by ID
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,70 @@
/**
* Application Port: IResultRepository
*
* Repository interface for Result entity CRUD operations.
* Defines async methods using domain entities as types.
*/
import { Result } from '../../domain/entities/Result';
export interface IResultRepository {
/**
* Find a result by ID
*/
findById(id: string): Promise<Result | null>;
/**
* Find all results
*/
findAll(): Promise<Result[]>;
/**
* Find results by race ID
*/
findByRaceId(raceId: string): Promise<Result[]>;
/**
* Find results by driver ID
*/
findByDriverId(driverId: string): Promise<Result[]>;
/**
* Find results by driver ID for a specific league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]>;
/**
* Create a new result
*/
create(result: Result): Promise<Result>;
/**
* Create multiple results
*/
createMany(results: Result[]): Promise<Result[]>;
/**
* Update an existing result
*/
update(result: Result): Promise<Result>;
/**
* Delete a result by ID
*/
delete(id: string): Promise<void>;
/**
* Delete all results for a race
*/
deleteByRaceId(raceId: string): Promise<void>;
/**
* Check if a result exists by ID
*/
exists(id: string): Promise<boolean>;
/**
* Check if results exist for a race
*/
existsByRaceId(raceId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,55 @@
/**
* Application Port: IStandingRepository
*
* Repository interface for Standing entity operations.
* Includes methods for calculating and retrieving standings.
*/
import { Standing } from '../../domain/entities/Standing';
export interface IStandingRepository {
/**
* Find standings by league ID (sorted by position)
*/
findByLeagueId(leagueId: string): Promise<Standing[]>;
/**
* Find standing for a specific driver in a league
*/
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null>;
/**
* Find all standings
*/
findAll(): Promise<Standing[]>;
/**
* Create or update a standing
*/
save(standing: Standing): Promise<Standing>;
/**
* Create or update multiple standings
*/
saveMany(standings: Standing[]): Promise<Standing[]>;
/**
* Delete a standing
*/
delete(leagueId: string, driverId: string): Promise<void>;
/**
* Delete all standings for a league
*/
deleteByLeagueId(leagueId: string): Promise<void>;
/**
* Check if a standing exists
*/
exists(leagueId: string, driverId: string): Promise<boolean>;
/**
* Recalculate standings for a league based on race results
*/
recalculate(leagueId: string): Promise<Standing[]>;
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useState, useEffect } from 'react';
export default function AlphaBanner() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('alpha-banner-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('alpha-banner-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="sticky top-0 z-50 bg-warning-amber/10 border-b border-warning-amber/20 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-warning-amber flex-shrink-0" 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>
<p className="text-sm text-white">
Alpha Version Data resets on page reload. No persistent storage.
</p>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1"
aria-label="Dismiss banner"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
export default function AlphaFooter() {
return (
<footer className="mt-auto border-t border-charcoal-outline bg-deep-graphite">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-2 py-1 bg-warning-amber/10 text-warning-amber rounded border border-warning-amber/20 font-medium">
Alpha v0.1
</span>
<span>In-memory prototype</span>
</div>
<div className="flex items-center gap-6 text-sm">
<a
href="https://discord.gg/gridpilot"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Give Feedback
</a>
<a
href="/docs/roadmap"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Roadmap
</a>
<a
href="/"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Back to Landing
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navLinks = [
{ href: '/', label: 'Dashboard' },
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/races', label: 'Races' },
] as const;
export function AlphaNav() {
const pathname = usePathname();
return (
<nav className="sticky top-0 z-40 bg-deep-graphite/95 backdrop-blur-md border-b border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-14">
<div className="flex items-baseline space-x-3">
<Link href="/" className="text-xl font-semibold text-white hover:text-primary-blue transition-colors">
GridPilot
</Link>
<span className="text-xs text-gray-500 font-light">ALPHA</span>
</div>
<div className="hidden md:flex items-center space-x-1">
{navLinks.map((link) => {
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`
relative px-4 py-2 text-sm font-medium transition-all duration-200
${isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{link.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue rounded-full" />
)}
</Link>
);
})}
</div>
<div className="md:hidden w-8" />
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Race } from '../../domain/entities/Race';
interface CompanionInstructionsProps {
race: Race;
leagueName?: string;
}
export default function CompanionInstructions({ race, leagueName }: CompanionInstructionsProps) {
const [copied, setCopied] = useState(false);
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 raceDetails = `GridPilot Race: ${leagueName || 'League'}
Track: ${race.track}
Car: ${race.car}
Date/Time: ${formatDateTime(race.scheduledAt)}
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
const handleCopyDetails = async () => {
try {
await navigator.clipboard.writeText(raceDetails);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<Card className="border border-primary-blue/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">Alpha Manual Workflow</h3>
<p className="text-sm text-gray-400">
Companion automation coming in production. For alpha, races are created manually.
</p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary-blue/20 text-primary-blue text-xs font-semibold flex-shrink-0">
1
</span>
<p className="text-sm text-gray-300 pt-0.5">
Schedule race in GridPilot (completed)
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
2
</span>
<p className="text-sm text-gray-300 pt-0.5">
Copy race details using button below
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
3
</span>
<p className="text-sm text-gray-300 pt-0.5">
Create hosted session manually in iRacing website
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
4
</span>
<p className="text-sm text-gray-300 pt-0.5">
Return to GridPilot after race completes
</p>
</div>
<div className="flex items-start gap-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-charcoal-outline text-gray-400 text-xs font-semibold flex-shrink-0">
5
</span>
<p className="text-sm text-gray-300 pt-0.5">
Import results via CSV upload
</p>
</div>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<div className="bg-deep-graphite rounded-lg p-3 mb-3">
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono">
{raceDetails}
</pre>
</div>
<Button
variant="primary"
onClick={handleCopyDetails}
className="w-full"
>
<div className="flex items-center justify-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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{copied ? 'Copied!' : 'Copy Race Details'}
</div>
</Button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
interface CompanionStatusProps {
className?: string;
}
export default function CompanionStatus({ className = '' }: CompanionStatusProps) {
// Alpha: always disconnected
const isConnected = false;
const statusMessage = "Companion app available in production";
return (
<div className={`flex items-center gap-3 ${className}`}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-performance-green' : 'bg-gray-500'}`} />
<span className="text-sm text-gray-400">
Companion App: <span className={isConnected ? 'text-performance-green' : 'text-gray-400'}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</span>
</div>
<span className="text-xs text-gray-500">
{statusMessage}
</span>
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Driver } from '../../domain/entities/Driver';
import { getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
iracingId?: string;
country?: string;
bio?: string;
submit?: string;
}
export default function CreateDriverForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
iracingId: '',
country: '',
bio: ''
});
const validateForm = async (): Promise<boolean> => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.iracingId.trim()) {
newErrors.iracingId = 'iRacing ID is required';
} else {
const driverRepo = getDriverRepository();
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
if (exists) {
newErrors.iracingId = 'This iRacing ID is already registered';
}
}
if (!formData.country.trim()) {
newErrors.country = 'Country is required';
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
newErrors.country = 'Invalid country code (use 2-3 letter ISO code)';
}
if (formData.bio && formData.bio.length > 500) {
newErrors.bio = 'Bio must be 500 characters or less';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
const isValid = await validateForm();
if (!isValid) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const driver = Driver.create({
id: crypto.randomUUID(),
iracingId: formData.iracingId.trim(),
name: formData.name.trim(),
country: formData.country.trim().toUpperCase(),
bio: formData.bio.trim() || undefined,
});
await driverRepo.create(driver);
router.push('/profile');
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Driver Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="Max Verstappen"
disabled={loading}
/>
</div>
<div>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
iRacing ID *
</label>
<Input
id="iracingId"
type="text"
value={formData.iracingId}
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
error={!!errors.iracingId}
errorMessage={errors.iracingId}
placeholder="123456"
disabled={loading}
/>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country Code *
</label>
<Input
id="country"
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
error={!!errors.country}
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
</div>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
Bio (Optional)
</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.bio.length}/500
</p>
{errors.bio && (
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
)}
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,195 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { League } from '../../domain/entities/League';
import { getLeagueRepository, getDriverRepository } from '../../lib/di-container';
interface FormErrors {
name?: string;
description?: string;
pointsSystem?: string;
sessionDuration?: string;
submit?: string;
}
export default function CreateLeagueForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
name: '',
description: '',
pointsSystem: 'f1-2024' as 'f1-2024' | 'indycar',
sessionDuration: 60
});
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
} else if (formData.name.length > 100) {
newErrors.name = 'Name must be 100 characters or less';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length > 500) {
newErrors.description = 'Description must be 500 characters or less';
}
if (formData.sessionDuration < 1 || formData.sessionDuration > 240) {
newErrors.sessionDuration = 'Session duration must be between 1 and 240 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
if (!validateForm()) return;
setLoading(true);
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
setLoading(false);
return;
}
const leagueRepo = getLeagueRepository();
const league = League.create({
id: crypto.randomUUID(),
name: formData.name.trim(),
description: formData.description.trim(),
ownerId: currentDriver.id,
settings: {
pointsSystem: formData.pointsSystem,
sessionDuration: formData.sessionDuration,
qualifyingFormat: 'open',
},
});
await leagueRepo.create(league);
router.push(`/leagues/${league.id}`);
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create league'
});
setLoading(false);
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
League Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="European GT Championship"
maxLength={100}
disabled={loading}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.name.length}/100
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Weekly GT3 racing with professional drivers"
maxLength={500}
rows={4}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.description.length}/500
</p>
{errors.description && (
<p className="mt-2 text-sm text-warning-amber">{errors.description}</p>
)}
</div>
<div>
<label htmlFor="pointsSystem" className="block text-sm font-medium text-gray-300 mb-2">
Points System *
</label>
<select
id="pointsSystem"
value={formData.pointsSystem}
onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })}
disabled={loading}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
>
<option value="f1-2024">F1 2024</option>
<option value="indycar">IndyCar</option>
</select>
</div>
<div>
<label htmlFor="sessionDuration" className="block text-sm font-medium text-gray-300 mb-2">
Session Duration (minutes) *
</label>
<Input
id="sessionDuration"
type="number"
value={formData.sessionDuration}
onChange={(e) => setFormData({ ...formData, sessionDuration: parseInt(e.target.value) || 60 })}
error={!!errors.sessionDuration}
errorMessage={errors.sessionDuration}
min={1}
max={240}
disabled={loading}
/>
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={loading}
className="w-full"
>
{loading ? 'Creating League...' : 'Create League'}
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
export default function DataWarning() {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const dismissed = sessionStorage.getItem('data-warning-dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}, []);
const handleDismiss = () => {
sessionStorage.setItem('data-warning-dismissed', 'true');
setIsDismissed(true);
};
if (!isMounted) return null;
if (isDismissed) return null;
return (
<div className="mb-6 bg-iron-gray border border-charcoal-outline rounded-lg p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-blue/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-gray-300">
Your data will be lost when you refresh the page. Alpha uses in-memory storage only.
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-white transition-colors p-1 flex-shrink-0"
aria-label="Dismiss warning"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { DriverDTO } from '@/application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
interface DriverProfileProps {
driver: DriverDTO;
}
export default function DriverProfile({ driver }: DriverProfileProps) {
const formattedDate = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(new Date(driver.joinedAt));
return (
<Card className="max-w-2xl mx-auto">
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{driver.name}</h2>
<p className="text-gray-400 text-sm">iRacing ID: {driver.iracingId}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
</div>
{driver.bio && (
<div className="border-t border-charcoal-outline pt-4">
<h3 className="text-sm font-semibold text-gray-400 mb-2">Bio</h3>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
</div>
)}
<div className="border-t border-charcoal-outline pt-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
<span>Member since {formattedDate}</span>
</div>
</div>
<div className="pt-4">
<Button
variant="secondary"
className="w-full"
disabled
>
Edit Profile
</Button>
<p className="text-xs text-gray-500 text-center mt-2">
Profile editing coming soon
</p>
</div>
</div>
</Card>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
}

View File

@@ -0,0 +1,23 @@
'use client';
interface FeatureLimitationTooltipProps {
message: string;
children: React.ReactNode;
}
export default function FeatureLimitationTooltip({ message, children }: FeatureLimitationTooltipProps) {
return (
<div className="group relative inline-block">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-sm text-gray-300 whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 pointer-events-none z-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary-blue flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</div>
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-iron-gray" />
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Result } from '../../domain/entities/Result';
import { v4 as uuidv4 } from 'uuid';
interface ImportResultsFormProps {
raceId: string;
onSuccess: (results: Result[]) => void;
onError: (error: string) => void;
}
interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const parseCSV = (content: string): CSVRow[] => {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
// Parse header
const header = lines[0].toLowerCase().split(',').map(h => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Parse rows
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
if (values.length !== header.length) {
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
}
const row: any = {};
header.forEach((field, index) => {
row[field] = values[index];
});
// Validate and convert types
const driverId = row.driverid;
const position = parseInt(row.position, 10);
const fastestLap = parseFloat(row.fastestlap);
const incidents = parseInt(row.incidents, 10);
const startPosition = parseInt(row.startposition, 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
// Validate no duplicate positions
const positions = rows.map(r => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
// Validate no duplicate drivers
const driverIds = rows.map(r => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
// Read file
const content = await file.text();
// Parse CSV
const rows = parseCSV(content);
// Create Result entities
const results = rows.map(row =>
Result.create({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
})
);
onSuccess(results);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to parse CSV file';
setError(errorMessage);
onError(errorMessage);
} finally {
setUploading(false);
// Reset file input
event.target.value = '';
}
};
return (
<>
<DataWarning />
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Upload Results CSV
</label>
<p className="text-xs text-gray-500 mb-3">
CSV format: driverId, position, fastestLap, incidents, startPosition
</p>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
disabled={uploading}
className="block w-full text-sm text-gray-400
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-semibold
file:bg-primary-blue file:text-white
file:cursor-pointer file:transition-colors
hover:file:bg-primary-blue/80
disabled:file:opacity-50 disabled:file:cursor-not-allowed"
/>
</div>
{error && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber text-sm">
<strong>Error:</strong> {error}
</div>
)}
{uploading && (
<div className="text-center text-gray-400 text-sm">
Parsing CSV and importing results...
</div>
)}
<div className="p-4 bg-iron-gray/20 rounded text-xs text-gray-500">
<p className="font-semibold mb-2">CSV Example:</p>
<pre className="text-gray-400">
{`driverId,position,fastestLap,incidents,startPosition
550e8400-e29b-41d4-a716-446655440001,1,92.456,0,3
550e8400-e29b-41d4-a716-446655440002,2,92.789,1,1
550e8400-e29b-41d4-a716-446655440003,3,93.012,2,2`}
</pre>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { League } from '../../domain/entities/League';
import Card from '../ui/Card';
interface LeagueCardProps {
league: League;
onClick?: () => void;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
onClick={onClick}
>
<Card>
<div className="space-y-3">
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
<span className="text-xs text-gray-500">
{new Date(league.createdAt).toLocaleDateString()}
</span>
</div>
<p className="text-gray-400 text-sm line-clamp-2">
{league.description}
</p>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<div className="text-xs text-gray-500">
Owner ID: {league.ownerId.slice(0, 8)}...
</div>
<div className="text-xs text-primary-blue font-medium">
{league.settings.pointsSystem.toUpperCase()}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Race } from '../../domain/entities/Race';
interface RaceCardProps {
race: Race;
leagueName?: string;
onClick?: () => void;
}
export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
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',
};
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 getRelativeTime = (date: Date) => {
const now = new Date();
const targetDate = new Date(date);
const diffMs = targetDate.getTime() - now.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) return null;
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `in ${diffDays} days`;
return null;
};
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
return (
<div
onClick={onClick}
className={`
p-6 rounded-lg bg-iron-gray border border-charcoal-outline
transition-all duration-200
${onClick ? 'cursor-pointer hover:scale-[1.03] hover:border-primary-blue' : ''}
`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white">{race.track}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded border ${statusColors[race.status]}`}>
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
</span>
</div>
<p className="text-gray-400 text-sm">{race.car}</p>
{leagueName && (
<p className="text-gray-500 text-xs mt-1">{leagueName}</p>
)}
</div>
<div className="text-right">
<p className="text-white font-medium text-sm">{formatDate(race.scheduledAt)}</p>
<p className="text-gray-400 text-xs">{formatTime(race.scheduledAt)}</p>
{relativeTime && (
<p className="text-primary-blue text-xs mt-1">{relativeTime}</p>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 uppercase tracking-wide">
{race.sessionType}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { Result } from '../../domain/entities/Result';
import { Driver } from '../../domain/entities/Driver';
interface ResultsTableProps {
results: Result[];
drivers: Driver[];
pointsSystem: Record<number, number>;
fastestLapTime?: number;
}
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime }: ResultsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
const formatLapTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(3);
return `${minutes}:${secs.padStart(6, '0')}`;
};
const getPoints = (position: number): number => {
return pointsSystem[position] || 0;
};
const getPositionChangeColor = (change: number): string => {
if (change > 0) return 'text-performance-green';
if (change < 0) return 'text-warning-amber';
return 'text-gray-500';
};
const getPositionChangeText = (change: number): string => {
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
};
if (results.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No results available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Fastest Lap</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Incidents</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
const positionChange = result.getPositionChange();
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
return (
<tr
key={result.id}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className="text-white font-semibold">{result.position}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{getDriverName(result.driverId)}</span>
</td>
<td className="py-3 px-4">
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
{formatLapTime(result.fastestLap)}
</span>
</td>
<td className="py-3 px-4">
<span className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
{result.incidents}×
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{getPoints(result.position)}</span>
</td>
<td className="py-3 px-4">
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
{getPositionChangeText(positionChange)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,313 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import DataWarning from './DataWarning';
import { Race } from '../../domain/entities/Race';
import { League } from '../../domain/entities/League';
import { SessionType } from '../../domain/entities/Race';
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
import { InMemoryRaceRepository } from '../../infrastructure/repositories/InMemoryRaceRepository';
interface ScheduleRaceFormProps {
preSelectedLeagueId?: string;
onSuccess?: (race: Race) => void;
onCancel?: () => void;
}
export default function ScheduleRaceForm({
preSelectedLeagueId,
onSuccess,
onCancel
}: ScheduleRaceFormProps) {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
leagueId: preSelectedLeagueId || '',
track: '',
car: '',
sessionType: 'race' as SessionType,
scheduledDate: '',
scheduledTime: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadLeagues = async () => {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
setLeagues(allLeagues);
};
loadLeagues();
}, []);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.leagueId) {
errors.leagueId = 'League is required';
}
if (!formData.track.trim()) {
errors.track = 'Track is required';
}
if (!formData.car.trim()) {
errors.car = 'Car is required';
}
if (!formData.scheduledDate) {
errors.scheduledDate = 'Date is required';
}
if (!formData.scheduledTime) {
errors.scheduledTime = 'Time is required';
}
// Validate future date
if (formData.scheduledDate && formData.scheduledTime) {
const scheduledDateTime = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const now = new Date();
if (scheduledDateTime <= now) {
errors.scheduledDate = 'Date must be in the future';
}
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
setError(null);
try {
const raceRepo = getRaceRepository();
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
const race = Race.create({
id: InMemoryRaceRepository.generateId(),
leagueId: formData.leagueId,
track: formData.track.trim(),
car: formData.car.trim(),
sessionType: formData.sessionType,
scheduledAt,
status: 'scheduled',
});
const createdRace = await raceRepo.create(race);
if (onSuccess) {
onSuccess(createdRace);
} else {
router.push(`/races/${createdRace.id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create race');
} finally {
setLoading(false);
}
};
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear validation error for this field
if (validationErrors[field]) {
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
</div>
)}
{/* Companion App Notice */}
<div className="p-4 rounded-lg bg-iron-gray border border-charcoal-outline">
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 flex-1">
<input
type="checkbox"
disabled
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue opacity-50 cursor-not-allowed"
/>
<label className="text-sm text-gray-400">
Use Companion App
</label>
<button
type="button"
className="text-gray-500 hover:text-gray-400 transition-colors"
title="Companion automation available in production. For alpha, races are created manually."
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-2 ml-6">
Companion automation available in production. For alpha, races are created manually.
</p>
</div>
{/* League Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League *
</label>
<select
value={formData.leagueId}
onChange={(e) => handleChange('leagueId', e.target.value)}
disabled={!!preSelectedLeagueId}
className={`
w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white
focus:outline-none focus:ring-2 focus:ring-primary-blue
disabled:opacity-50 disabled:cursor-not-allowed
${validationErrors.leagueId ? 'border-red-500' : 'border-charcoal-outline'}
`}
>
<option value="">Select a league</option>
{leagues.map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
{validationErrors.leagueId && (
<p className="mt-1 text-sm text-red-400">{validationErrors.leagueId}</p>
)}
</div>
{/* Track */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Track *
</label>
<Input
type="text"
value={formData.track}
onChange={(e) => handleChange('track', e.target.value)}
placeholder="e.g., Spa-Francorchamps"
className={validationErrors.track ? 'border-red-500' : ''}
/>
{validationErrors.track && (
<p className="mt-1 text-sm text-red-400">{validationErrors.track}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing track name</p>
</div>
{/* Car */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Car *
</label>
<Input
type="text"
value={formData.car}
onChange={(e) => handleChange('car', e.target.value)}
placeholder="e.g., Porsche 911 GT3 R"
className={validationErrors.car ? 'border-red-500' : ''}
/>
{validationErrors.car && (
<p className="mt-1 text-sm text-red-400">{validationErrors.car}</p>
)}
<p className="mt-1 text-xs text-gray-500">Enter the iRacing car name</p>
</div>
{/* Session Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Session Type *
</label>
<select
value={formData.sessionType}
onChange={(e) => handleChange('sessionType', 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="practice">Practice</option>
<option value="qualifying">Qualifying</option>
<option value="race">Race</option>
</select>
</div>
{/* Date and Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Date *
</label>
<Input
type="date"
value={formData.scheduledDate}
onChange={(e) => handleChange('scheduledDate', e.target.value)}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Time *
</label>
<Input
type="time"
value={formData.scheduledTime}
onChange={(e) => handleChange('scheduledTime', e.target.value)}
className={validationErrors.scheduledTime ? 'border-red-500' : ''}
/>
{validationErrors.scheduledTime && (
<p className="mt-1 text-sm text-red-400">{validationErrors.scheduledTime}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex-1"
>
{loading ? 'Creating...' : 'Schedule Race'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</form>
</>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { Standing } from '../../domain/entities/Standing';
import { Driver } from '../../domain/entities/Driver';
interface StandingsTableProps {
standings: Standing[];
drivers: Driver[];
}
export default function StandingsTable({ standings, drivers }: StandingsTableProps) {
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
if (standings.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No standings available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Races</th>
</tr>
</thead>
<tbody>
{standings.map((standing) => {
const isLeader = standing.position === 1;
return (
<tr
key={`${standing.leagueId}-${standing.driverId}`}
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
>
<td className="py-3 px-4">
<span className={`font-semibold ${isLeader ? 'text-yellow-500' : 'text-white'}`}>
{standing.position}
</span>
</td>
<td className="py-3 px-4">
<span className={isLeader ? 'text-white font-semibold' : 'text-white'}>
{getDriverName(standing.driverId)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{standing.points}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.wins}</span>
</td>
<td className="py-3 px-4">
<span className="text-white">{standing.racesCompleted}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,21 +1,21 @@
'use client';
import { ReactNode } from 'react';
import { useState, useEffect, ReactNode } from 'react';
/**
* ModeGuard - Conditional rendering component based on application mode
*
*
* Usage:
* <ModeGuard mode="pre-launch">
* <PreLaunchContent />
* </ModeGuard>
*
* <ModeGuard mode="post-launch">
*
* <ModeGuard mode="alpha">
* <FullPlatformContent />
* </ModeGuard>
*/
export type GuardMode = 'pre-launch' | 'post-launch';
export type GuardMode = 'pre-launch' | 'alpha';
interface ModeGuardProps {
mode: GuardMode;
@@ -29,13 +29,23 @@ interface ModeGuardProps {
* This component is for conditional UI rendering within accessible pages
*/
export function ModeGuard({ mode, children, fallback = null }: ModeGuardProps) {
const currentMode = getClientMode();
if (currentMode === mode) {
return <>{children}</>;
const [isMounted, setIsMounted] = useState(false);
const [currentMode, setCurrentMode] = useState<GuardMode>('pre-launch');
useEffect(() => {
setIsMounted(true);
setCurrentMode(getClientMode());
}, []);
if (!isMounted) {
return <>{fallback}</>;
}
return <>{fallback}</>;
if (currentMode !== mode) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
@@ -49,8 +59,8 @@ function getClientMode(): GuardMode {
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (mode === 'post-launch') {
return 'post-launch';
if (mode === 'alpha') {
return 'alpha';
}
return 'pre-launch';
@@ -71,8 +81,8 @@ export function useIsPreLaunch(): boolean {
}
/**
* Hook to check if in post-launch mode
* Hook to check if in alpha mode
*/
export function useIsPostLaunch(): boolean {
return getClientMode() === 'post-launch';
export function useIsAlpha(): boolean {
return getClientMode() === 'alpha';
}

View File

@@ -0,0 +1,99 @@
/**
* Domain Entity: Driver
*
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Driver {
readonly id: string;
readonly iracingId: string;
readonly name: string;
readonly country: string;
readonly bio?: string;
readonly joinedAt: Date;
private constructor(props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: Date;
}) {
this.id = props.id;
this.iracingId = props.iracingId;
this.name = props.name;
this.country = props.country;
this.bio = props.bio;
this.joinedAt = props.joinedAt;
}
/**
* Factory method to create a new Driver entity
*/
static create(props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt?: Date;
}): Driver {
this.validate(props);
return new Driver({
...props,
joinedAt: props.joinedAt ?? new Date(),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
iracingId: string;
name: string;
country: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Driver ID is required');
}
if (!props.iracingId || props.iracingId.trim().length === 0) {
throw new Error('iRacing ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Driver name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new Error('Country code is required');
}
// Validate ISO country code format (2-3 letters)
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
throw new Error('Country must be a valid ISO code (2-3 letters)');
}
}
/**
* Create a copy with updated properties
*/
update(props: Partial<{
name: string;
country: string;
bio: string;
}>): Driver {
return new Driver({
id: this.id,
iracingId: this.iracingId,
name: props.name ?? this.name,
country: props.country ?? this.country,
bio: props.bio ?? this.bio,
joinedAt: this.joinedAt,
});
}
}

View File

@@ -0,0 +1,115 @@
/**
* Domain Entity: League
*
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export interface LeagueSettings {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
}
export class League {
readonly id: string;
readonly name: string;
readonly description: string;
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
private constructor(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
createdAt: Date;
}) {
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.ownerId = props.ownerId;
this.settings = props.settings;
this.createdAt = props.createdAt;
}
/**
* Factory method to create a new League entity
*/
static create(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings?: Partial<LeagueSettings>;
createdAt?: Date;
}): League {
this.validate(props);
const defaultSettings: LeagueSettings = {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
};
return new League({
id: props.id,
name: props.name,
description: props.description,
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
description: string;
ownerId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('League name is required');
}
if (props.name.length > 100) {
throw new Error('League name must be 100 characters or less');
}
if (!props.description || props.description.trim().length === 0) {
throw new Error('League description is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new Error('League owner ID is required');
}
}
/**
* Create a copy with updated properties
*/
update(props: Partial<{
name: string;
description: string;
settings: LeagueSettings;
}>): League {
return new League({
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
});
}
}

View File

@@ -0,0 +1,143 @@
/**
* Domain Entity: Race
*
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
export class Race {
readonly id: string;
readonly leagueId: string;
readonly scheduledAt: Date;
readonly track: string;
readonly car: string;
readonly sessionType: SessionType;
readonly status: RaceStatus;
private constructor(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType: SessionType;
status: RaceStatus;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.scheduledAt = props.scheduledAt;
this.track = props.track;
this.car = props.car;
this.sessionType = props.sessionType;
this.status = props.status;
}
/**
* Factory method to create a new Race entity
*/
static create(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
sessionType?: SessionType;
status?: RaceStatus;
}): Race {
this.validate(props);
return new Race({
id: props.id,
leagueId: props.leagueId,
scheduledAt: props.scheduledAt,
track: props.track,
car: props.car,
sessionType: props.sessionType ?? 'race',
status: props.status ?? 'scheduled',
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
leagueId: string;
scheduledAt: Date;
track: string;
car: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Race ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
throw new Error('Valid scheduled date is required');
}
if (!props.track || props.track.trim().length === 0) {
throw new Error('Track is required');
}
if (!props.car || props.car.trim().length === 0) {
throw new Error('Car is required');
}
}
/**
* Mark race as completed
*/
complete(): Race {
if (this.status === 'completed') {
throw new Error('Race is already completed');
}
if (this.status === 'cancelled') {
throw new Error('Cannot complete a cancelled race');
}
return new Race({
...this,
status: 'completed',
});
}
/**
* Cancel the race
*/
cancel(): Race {
if (this.status === 'completed') {
throw new Error('Cannot cancel a completed race');
}
if (this.status === 'cancelled') {
throw new Error('Race is already cancelled');
}
return new Race({
...this,
status: 'cancelled',
});
}
/**
* Check if race is in the past
*/
isPast(): boolean {
return this.scheduledAt < new Date();
}
/**
* Check if race is upcoming
*/
isUpcoming(): boolean {
return this.status === 'scheduled' && !this.isPast();
}
}

View File

@@ -0,0 +1,113 @@
/**
* Domain Entity: Result
*
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Result {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly position: number;
readonly fastestLap: number;
readonly incidents: number;
readonly startPosition: number;
private constructor(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.position = props.position;
this.fastestLap = props.fastestLap;
this.incidents = props.incidents;
this.startPosition = props.startPosition;
}
/**
* Factory method to create a new Result entity
*/
static create(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): Result {
this.validate(props);
return new Result(props);
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Result ID is required');
}
if (!props.raceId || props.raceId.trim().length === 0) {
throw new Error('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
}
if (!Number.isInteger(props.position) || props.position < 1) {
throw new Error('Position must be a positive integer');
}
if (props.fastestLap < 0) {
throw new Error('Fastest lap cannot be negative');
}
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
throw new Error('Incidents must be a non-negative integer');
}
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
throw new Error('Start position must be a positive integer');
}
}
/**
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition - this.position;
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position <= 3;
}
/**
* Check if driver had a clean race (0 incidents)
*/
isClean(): boolean {
return this.incidents === 0;
}
}

View File

@@ -0,0 +1,117 @@
/**
* Domain Entity: Standing
*
* Represents a championship standing in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
export class Standing {
readonly leagueId: string;
readonly driverId: string;
readonly points: number;
readonly wins: number;
readonly position: number;
readonly racesCompleted: number;
private constructor(props: {
leagueId: string;
driverId: string;
points: number;
wins: number;
position: number;
racesCompleted: number;
}) {
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.points = props.points;
this.wins = props.wins;
this.position = props.position;
this.racesCompleted = props.racesCompleted;
}
/**
* Factory method to create a new Standing entity
*/
static create(props: {
leagueId: string;
driverId: string;
points?: number;
wins?: number;
position?: number;
racesCompleted?: number;
}): Standing {
this.validate(props);
return new Standing({
leagueId: props.leagueId,
driverId: props.driverId,
points: props.points ?? 0,
wins: props.wins ?? 0,
position: props.position ?? 0,
racesCompleted: props.racesCompleted ?? 0,
});
}
/**
* Domain validation logic
*/
private static validate(props: {
leagueId: string;
driverId: string;
}): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new Error('Driver ID is required');
}
}
/**
* Add points from a race result
*/
addRaceResult(position: number, pointsSystem: Record<number, number>): Standing {
const racePoints = pointsSystem[position] ?? 0;
const isWin = position === 1;
return new Standing({
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points + racePoints,
wins: this.wins + (isWin ? 1 : 0),
position: this.position,
racesCompleted: this.racesCompleted + 1,
});
}
/**
* Update championship position
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new Error('Position must be a positive integer');
}
return new Standing({
...this,
position,
});
}
/**
* Calculate average points per race
*/
getAveragePoints(): number {
if (this.racesCompleted === 0) return 0;
return this.points / this.racesCompleted;
}
/**
* Calculate win percentage
*/
getWinPercentage(): number {
if (this.racesCompleted === 0) return 0;
return (this.wins / this.racesCompleted) * 100;
}
}

8
apps/website/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_GRIDPILOT_MODE?: 'prelaunch' | 'alpha';
}
}

View File

@@ -0,0 +1,86 @@
/**
* Infrastructure Adapter: InMemoryDriverRepository
*
* In-memory implementation of IDriverRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Driver } from '../../domain/entities/Driver';
import { IDriverRepository } from '../../application/ports/IDriverRepository';
export class InMemoryDriverRepository implements IDriverRepository {
private drivers: Map<string, Driver>;
constructor(seedData?: Driver[]) {
this.drivers = new Map();
if (seedData) {
seedData.forEach(driver => {
this.drivers.set(driver.id, driver);
});
}
}
async findById(id: string): Promise<Driver | null> {
return this.drivers.get(id) ?? null;
}
async findByIRacingId(iracingId: string): Promise<Driver | null> {
const driver = Array.from(this.drivers.values()).find(
d => d.iracingId === iracingId
);
return driver ?? null;
}
async findAll(): Promise<Driver[]> {
return Array.from(this.drivers.values());
}
async create(driver: Driver): Promise<Driver> {
if (await this.exists(driver.id)) {
throw new Error(`Driver with ID ${driver.id} already exists`);
}
if (await this.existsByIRacingId(driver.iracingId)) {
throw new Error(`Driver with iRacing ID ${driver.iracingId} already exists`);
}
this.drivers.set(driver.id, driver);
return driver;
}
async update(driver: Driver): Promise<Driver> {
if (!await this.exists(driver.id)) {
throw new Error(`Driver with ID ${driver.id} not found`);
}
this.drivers.set(driver.id, driver);
return driver;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Driver with ID ${id} not found`);
}
this.drivers.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.drivers.has(id);
}
async existsByIRacingId(iracingId: string): Promise<boolean> {
return Array.from(this.drivers.values()).some(
d => d.iracingId === iracingId
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,82 @@
/**
* Infrastructure Adapter: InMemoryLeagueRepository
*
* In-memory implementation of ILeagueRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
export class InMemoryLeagueRepository implements ILeagueRepository {
private leagues: Map<string, League>;
constructor(seedData?: League[]) {
this.leagues = new Map();
if (seedData) {
seedData.forEach(league => {
this.leagues.set(league.id, league);
});
}
}
async findById(id: string): Promise<League | null> {
return this.leagues.get(id) ?? null;
}
async findAll(): Promise<League[]> {
return Array.from(this.leagues.values());
}
async findByOwnerId(ownerId: string): Promise<League[]> {
return Array.from(this.leagues.values()).filter(
league => league.ownerId === ownerId
);
}
async create(league: League): Promise<League> {
if (await this.exists(league.id)) {
throw new Error(`League with ID ${league.id} already exists`);
}
this.leagues.set(league.id, league);
return league;
}
async update(league: League): Promise<League> {
if (!await this.exists(league.id)) {
throw new Error(`League with ID ${league.id} not found`);
}
this.leagues.set(league.id, league);
return league;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`League with ID ${id} not found`);
}
this.leagues.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.leagues.has(id);
}
async searchByName(query: string): Promise<League[]> {
const normalizedQuery = query.toLowerCase();
return Array.from(this.leagues.values()).filter(league =>
league.name.toLowerCase().includes(normalizedQuery)
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,110 @@
/**
* Infrastructure Adapter: InMemoryRaceRepository
*
* In-memory implementation of IRaceRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Race, RaceStatus } from '../../domain/entities/Race';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
export class InMemoryRaceRepository implements IRaceRepository {
private races: Map<string, Race>;
constructor(seedData?: Race[]) {
this.races = new Map();
if (seedData) {
seedData.forEach(race => {
this.races.set(race.id, race);
});
}
}
async findById(id: string): Promise<Race | null> {
return this.races.get(id) ?? null;
}
async findAll(): Promise<Race[]> {
return Array.from(this.races.values());
}
async findByLeagueId(leagueId: string): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race => race.leagueId === leagueId)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findUpcomingByLeagueId(leagueId: string): Promise<Race[]> {
const now = new Date();
return Array.from(this.races.values())
.filter(race =>
race.leagueId === leagueId &&
race.status === 'scheduled' &&
race.scheduledAt > now
)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findCompletedByLeagueId(leagueId: string): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race =>
race.leagueId === leagueId &&
race.status === 'completed'
)
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
}
async findByStatus(status: RaceStatus): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race => race.status === status)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async findByDateRange(startDate: Date, endDate: Date): Promise<Race[]> {
return Array.from(this.races.values())
.filter(race =>
race.scheduledAt >= startDate &&
race.scheduledAt <= endDate
)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
async create(race: Race): Promise<Race> {
if (await this.exists(race.id)) {
throw new Error(`Race with ID ${race.id} already exists`);
}
this.races.set(race.id, race);
return race;
}
async update(race: Race): Promise<Race> {
if (!await this.exists(race.id)) {
throw new Error(`Race with ID ${race.id} not found`);
}
this.races.set(race.id, race);
return race;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Race with ID ${id} not found`);
}
this.races.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.races.has(id);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,125 @@
/**
* Infrastructure Adapter: InMemoryResultRepository
*
* In-memory implementation of IResultRepository.
* Stores data in Map structure with UUID generation.
*/
import { v4 as uuidv4 } from 'uuid';
import { Result } from '../../domain/entities/Result';
import { IResultRepository } from '../../application/ports/IResultRepository';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
export class InMemoryResultRepository implements IResultRepository {
private results: Map<string, Result>;
private raceRepository?: IRaceRepository;
constructor(seedData?: Result[], raceRepository?: IRaceRepository) {
this.results = new Map();
this.raceRepository = raceRepository;
if (seedData) {
seedData.forEach(result => {
this.results.set(result.id, result);
});
}
}
async findById(id: string): Promise<Result | null> {
return this.results.get(id) ?? null;
}
async findAll(): Promise<Result[]> {
return Array.from(this.results.values());
}
async findByRaceId(raceId: string): Promise<Result[]> {
return Array.from(this.results.values())
.filter(result => result.raceId === raceId)
.sort((a, b) => a.position - b.position);
}
async findByDriverId(driverId: string): Promise<Result[]> {
return Array.from(this.results.values())
.filter(result => result.driverId === driverId);
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]> {
if (!this.raceRepository) {
return [];
}
const leagueRaces = await this.raceRepository.findByLeagueId(leagueId);
const leagueRaceIds = new Set(leagueRaces.map(race => race.id));
return Array.from(this.results.values())
.filter(result =>
result.driverId === driverId &&
leagueRaceIds.has(result.raceId)
);
}
async create(result: Result): Promise<Result> {
if (await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} already exists`);
}
this.results.set(result.id, result);
return result;
}
async createMany(results: Result[]): Promise<Result[]> {
const created: Result[] = [];
for (const result of results) {
if (await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} already exists`);
}
this.results.set(result.id, result);
created.push(result);
}
return created;
}
async update(result: Result): Promise<Result> {
if (!await this.exists(result.id)) {
throw new Error(`Result with ID ${result.id} not found`);
}
this.results.set(result.id, result);
return result;
}
async delete(id: string): Promise<void> {
if (!await this.exists(id)) {
throw new Error(`Result with ID ${id} not found`);
}
this.results.delete(id);
}
async deleteByRaceId(raceId: string): Promise<void> {
const raceResults = await this.findByRaceId(raceId);
raceResults.forEach(result => {
this.results.delete(result.id);
});
}
async exists(id: string): Promise<boolean> {
return this.results.has(id);
}
async existsByRaceId(raceId: string): Promise<boolean> {
return Array.from(this.results.values()).some(
result => result.raceId === raceId
);
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}

View File

@@ -0,0 +1,188 @@
/**
* Infrastructure Adapter: InMemoryStandingRepository
*
* In-memory implementation of IStandingRepository.
* Stores data in Map structure and calculates standings from race results.
*/
import { Standing } from '../../domain/entities/Standing';
import { IStandingRepository } from '../../application/ports/IStandingRepository';
import { IResultRepository } from '../../application/ports/IResultRepository';
import { IRaceRepository } from '../../application/ports/IRaceRepository';
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
/**
* Points systems presets
*/
const POINTS_SYSTEMS: 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
}
};
export class InMemoryStandingRepository implements IStandingRepository {
private standings: Map<string, Standing>;
private resultRepository?: IResultRepository;
private raceRepository?: IRaceRepository;
private leagueRepository?: ILeagueRepository;
constructor(
seedData?: Standing[],
resultRepository?: IResultRepository,
raceRepository?: IRaceRepository,
leagueRepository?: ILeagueRepository
) {
this.standings = new Map();
this.resultRepository = resultRepository;
this.raceRepository = raceRepository;
this.leagueRepository = leagueRepository;
if (seedData) {
seedData.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
});
}
}
private getKey(leagueId: string, driverId: string): string {
return `${leagueId}:${driverId}`;
}
async findByLeagueId(leagueId: string): Promise<Standing[]> {
return Array.from(this.standings.values())
.filter(standing => standing.leagueId === leagueId)
.sort((a, b) => {
// Sort by position (lower is better)
if (a.position !== b.position) {
return a.position - b.position;
}
// If positions are equal, sort by points (higher is better)
return b.points - a.points;
});
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null> {
const key = this.getKey(leagueId, driverId);
return this.standings.get(key) ?? null;
}
async findAll(): Promise<Standing[]> {
return Array.from(this.standings.values());
}
async save(standing: Standing): Promise<Standing> {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
return standing;
}
async saveMany(standings: Standing[]): Promise<Standing[]> {
standings.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.set(key, standing);
});
return standings;
}
async delete(leagueId: string, driverId: string): Promise<void> {
const key = this.getKey(leagueId, driverId);
this.standings.delete(key);
}
async deleteByLeagueId(leagueId: string): Promise<void> {
const toDelete = Array.from(this.standings.values())
.filter(standing => standing.leagueId === leagueId);
toDelete.forEach(standing => {
const key = this.getKey(standing.leagueId, standing.driverId);
this.standings.delete(key);
});
}
async exists(leagueId: string, driverId: string): Promise<boolean> {
const key = this.getKey(leagueId, driverId);
return this.standings.has(key);
}
async recalculate(leagueId: string): Promise<Standing[]> {
if (!this.resultRepository || !this.raceRepository || !this.leagueRepository) {
throw new Error('Cannot recalculate standings: missing required repositories');
}
// Get league to determine points system
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League with ID ${leagueId} not found`);
}
// Get points system
const pointsSystem = league.settings.customPoints ??
POINTS_SYSTEMS[league.settings.pointsSystem] ??
POINTS_SYSTEMS['f1-2024'];
// Get all completed races for the league
const races = await this.raceRepository.findCompletedByLeagueId(leagueId);
// Get all results for these races
const allResults = await Promise.all(
races.map(race => this.resultRepository!.findByRaceId(race.id))
);
const results = allResults.flat();
// Calculate standings per driver
const standingsMap = new Map<string, Standing>();
results.forEach(result => {
let standing = standingsMap.get(result.driverId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId: result.driverId,
});
}
// Add points from this result
standing = standing.addRaceResult(result.position, pointsSystem);
standingsMap.set(result.driverId, standing);
});
// Sort by points and assign positions
const sortedStandings = Array.from(standingsMap.values())
.sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
// Tie-breaker: most wins
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
// Tie-breaker: most races completed
return b.racesCompleted - a.racesCompleted;
});
// Assign positions
const updatedStandings = sortedStandings.map((standing, index) =>
standing.updatePosition(index + 1)
);
// Save all standings
await this.saveMany(updatedStandings);
return updatedStandings;
}
/**
* Get available points systems
*/
static getPointsSystems(): Record<string, Record<number, number>> {
return POINTS_SYSTEMS;
}
}

View File

@@ -0,0 +1,187 @@
/**
* Dependency Injection Container
*
* Initializes all in-memory repositories and provides accessor functions.
* Allows easy swapping to persistent repositories later.
*/
import { Driver } from '../domain/entities/Driver';
import { League } from '../domain/entities/League';
import { Race } from '../domain/entities/Race';
import { Result } from '../domain/entities/Result';
import { Standing } from '../domain/entities/Standing';
import { IDriverRepository } from '../application/ports/IDriverRepository';
import { ILeagueRepository } from '../application/ports/ILeagueRepository';
import { IRaceRepository } from '../application/ports/IRaceRepository';
import { IResultRepository } from '../application/ports/IResultRepository';
import { IStandingRepository } from '../application/ports/IStandingRepository';
import { InMemoryDriverRepository } from '../infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '../infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '../infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryResultRepository } from '../infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '../infrastructure/repositories/InMemoryStandingRepository';
/**
* Seed data for development
*/
function createSeedData() {
// Create a sample driver
const driver1 = Driver.create({
id: '550e8400-e29b-41d4-a716-446655440001',
iracingId: '123456',
name: 'Max Verstappen',
country: 'NL',
bio: 'Three-time world champion',
joinedAt: new Date('2024-01-15'),
});
// Create a sample league
const league1 = League.create({
id: '550e8400-e29b-41d4-a716-446655440002',
name: 'European GT Championship',
description: 'Weekly GT3 racing with professional drivers',
ownerId: driver1.id,
settings: {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: new Date('2024-01-20'),
});
// Create sample races
const race1 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440003',
leagueId: league1.id,
scheduledAt: new Date('2024-03-15T19:00:00Z'),
track: 'Monza GP',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'completed',
});
const race2 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440004',
leagueId: league1.id,
scheduledAt: new Date('2024-03-22T19:00:00Z'),
track: 'Spa-Francorchamps',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'scheduled',
});
return {
drivers: [driver1],
leagues: [league1],
races: [race1, race2],
};
}
/**
* DI Container class
*/
class DIContainer {
private static instance: DIContainer;
private _driverRepository: IDriverRepository;
private _leagueRepository: ILeagueRepository;
private _raceRepository: IRaceRepository;
private _resultRepository: IResultRepository;
private _standingRepository: IStandingRepository;
private constructor() {
// Create seed data
const seedData = createSeedData();
// Initialize repositories with seed data
this._driverRepository = new InMemoryDriverRepository(seedData.drivers);
this._leagueRepository = new InMemoryLeagueRepository(seedData.leagues);
this._raceRepository = new InMemoryRaceRepository(seedData.races);
// Result repository needs race repository for league-based queries
this._resultRepository = new InMemoryResultRepository(
undefined,
this._raceRepository
);
// Standing repository needs all three for recalculation
this._standingRepository = new InMemoryStandingRepository(
undefined,
this._resultRepository,
this._raceRepository,
this._leagueRepository
);
}
/**
* Get singleton instance
*/
static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
/**
* Reset the container (useful for testing)
*/
static reset(): void {
DIContainer.instance = new DIContainer();
}
/**
* Repository getters
*/
get driverRepository(): IDriverRepository {
return this._driverRepository;
}
get leagueRepository(): ILeagueRepository {
return this._leagueRepository;
}
get raceRepository(): IRaceRepository {
return this._raceRepository;
}
get resultRepository(): IResultRepository {
return this._resultRepository;
}
get standingRepository(): IStandingRepository {
return this._standingRepository;
}
}
/**
* Exported accessor functions
*/
export function getDriverRepository(): IDriverRepository {
return DIContainer.getInstance().driverRepository;
}
export function getLeagueRepository(): ILeagueRepository {
return DIContainer.getInstance().leagueRepository;
}
export function getRaceRepository(): IRaceRepository {
return DIContainer.getInstance().raceRepository;
}
export function getResultRepository(): IResultRepository {
return DIContainer.getInstance().resultRepository;
}
export function getStandingRepository(): IStandingRepository {
return DIContainer.getInstance().standingRepository;
}
/**
* Reset function for testing
*/
export function resetContainer(): void {
DIContainer.reset();
}

View File

@@ -1,13 +1,13 @@
/**
* Mode detection system for GridPilot website
*
* Controls whether the site shows pre-launch content or full platform
* Based on GRIDPILOT_MODE environment variable
*
* Controls whether the site shows pre-launch content or alpha platform
* Based on NEXT_PUBLIC_GRIDPILOT_MODE environment variable
*/
export type AppMode = 'pre-launch' | 'post-launch';
export type AppMode = 'pre-launch' | 'alpha';
const VALID_MODES: readonly AppMode[] = ['pre-launch', 'post-launch'] as const;
const VALID_MODES: readonly AppMode[] = ['pre-launch', 'alpha'] as const;
/**
* Get the current application mode from environment variable
@@ -17,7 +17,7 @@ const VALID_MODES: readonly AppMode[] = ['pre-launch', 'post-launch'] as const;
* @returns {AppMode} The current application mode
*/
export function getAppMode(): AppMode {
const mode = process.env.GRIDPILOT_MODE;
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
if (!mode) {
return 'pre-launch';
@@ -25,7 +25,7 @@ export function getAppMode(): AppMode {
if (!isValidMode(mode)) {
const validModes = VALID_MODES.join(', ');
const error = `Invalid GRIDPILOT_MODE: "${mode}". Must be one of: ${validModes}`;
const error = `Invalid NEXT_PUBLIC_GRIDPILOT_MODE: "${mode}". Must be one of: ${validModes}`;
if (process.env.NODE_ENV === 'development') {
throw new Error(error);
@@ -53,10 +53,10 @@ export function isPreLaunch(): boolean {
}
/**
* Check if currently in post-launch mode
* Check if currently in alpha mode
*/
export function isPostLaunch(): boolean {
return getAppMode() === 'post-launch';
export function isAlpha(): boolean {
return getAppMode() === 'alpha';
}
/**

View File

@@ -4,20 +4,20 @@ import { getAppMode, isPublicRoute } from './lib/mode';
/**
* Next.js middleware for route protection based on application mode
*
*
* In pre-launch mode:
* - Only allows access to public routes (/, /api/signup)
* - Returns 404 for all other routes
*
* In post-launch mode:
*
* In alpha mode:
* - All routes are accessible
*/
export function middleware(request: NextRequest) {
const mode = getAppMode();
const { pathname } = request.nextUrl;
// In post-launch mode, allow all routes
if (mode === 'post-launch') {
// In alpha mode, allow all routes
if (mode === 'alpha') {
return NextResponse.next();
}