This commit is contained in:
2025-12-03 16:33:12 +01:00
parent a572e6edce
commit c0fdae3d3c
157 changed files with 7824 additions and 1042 deletions

View File

@@ -1,27 +1,27 @@
import { app } from 'electron';
import * as path from 'path';
import { InMemorySessionRepository } from '@/packages/infrastructure/repositories/InMemorySessionRepository';
import { MockBrowserAutomationAdapter, PlaywrightAutomationAdapter, AutomationAdapterMode, FixtureServer } from '@/packages/infrastructure/adapters/automation';
import { MockAutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter';
import { AutomationEngineAdapter } from '@/packages/infrastructure/adapters/automation/engine/AutomationEngineAdapter';
import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/StartAutomationSessionUseCase';
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
import { ConfirmCheckoutUseCase } from '@/packages/application/use-cases/ConfirmCheckoutUseCase';
import { loadAutomationConfig, getAutomationMode, AutomationMode, BrowserModeConfigLoader } from '@/packages/infrastructure/config';
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfig';
import type { ISessionRepository } from '@/packages/application/ports/ISessionRepository';
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
import type { ICheckoutConfirmationPort } from '@/packages/application/ports/ICheckoutConfirmationPort';
import type { ILogger } from '@/packages/application/ports/ILogger';
import type { IAutomationLifecycleEmitter } from '@/packages/infrastructure/adapters/IAutomationLifecycleEmitter';
import type { IOverlaySyncPort } from '@/packages/application/ports/IOverlaySyncPort';
import { OverlaySyncService } from '@/packages/application/services/OverlaySyncService';
import { InMemorySessionRepository } from '@/packages/automation-infrastructure/repositories/InMemorySessionRepository';
import { MockBrowserAutomationAdapter, PlaywrightAutomationAdapter, AutomationAdapterMode, FixtureServer } from '@/packages/automation-infrastructure/adapters/automation';
import { MockAutomationEngineAdapter } from '@/packages/automation-infrastructure/adapters/automation/engine/MockAutomationEngineAdapter';
import { AutomationEngineAdapter } from '@/packages/automation-infrastructure/adapters/automation/engine/AutomationEngineAdapter';
import { StartAutomationSessionUseCase } from '@/packages/automation-application/use-cases/StartAutomationSessionUseCase';
import { CheckAuthenticationUseCase } from '@/packages/automation-application/use-cases/CheckAuthenticationUseCase';
import { InitiateLoginUseCase } from '@/packages/automation-application/use-cases/InitiateLoginUseCase';
import { ClearSessionUseCase } from '@/packages/automation-application/use-cases/ClearSessionUseCase';
import { ConfirmCheckoutUseCase } from '@/packages/automation-application/use-cases/ConfirmCheckoutUseCase';
import { loadAutomationConfig, getAutomationMode, AutomationMode, BrowserModeConfigLoader } from '@/packages/automation-infrastructure/config';
import { PinoLogAdapter } from '@/packages/automation-infrastructure/adapters/logging/PinoLogAdapter';
import { NoOpLogAdapter } from '@/packages/automation-infrastructure/adapters/logging/NoOpLogAdapter';
import { loadLoggingConfig } from '@/packages/automation-infrastructure/config/LoggingConfig';
import type { ISessionRepository } from '@/packages/automation-application/ports/ISessionRepository';
import type { IScreenAutomation } from '@/packages/automation-application/ports/IScreenAutomation';
import type { IAutomationEngine } from '@/packages/automation-application/ports/IAutomationEngine';
import type { IAuthenticationService } from '@/packages/automation-application/ports/IAuthenticationService';
import type { ICheckoutConfirmationPort } from '@/packages/automation-application/ports/ICheckoutConfirmationPort';
import type { ILogger } from '@/packages/automation-application/ports/ILogger';
import type { IAutomationLifecycleEmitter } from '@/packages/automation-infrastructure/adapters/IAutomationLifecycleEmitter';
import type { IOverlaySyncPort } from '@/packages/automation-application/ports/IOverlaySyncPort';
import { OverlaySyncService } from '@/packages/automation-application/services/OverlaySyncService';
export interface BrowserConnectionResult {
success: boolean;

View File

@@ -1,3 +1,8 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"rules": {
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
"react-hooks/exhaustive-deps": "warn"
}
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
import DriverProfile from '@/components/alpha/DriverProfile';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
export default function DriverDetailPage() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDriver();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driverId]);
const loadDriver = async () => {
try {
const driverRepo = getDriverRepository();
const driverEntity = await driverRepo.findById(driverId);
if (!driverEntity) {
setError('Driver not found');
setLoading(false);
return;
}
const driverDto = EntityMappers.toDriverDTO(driverEntity);
if (!driverDto) {
setError('Driver not found');
setLoading(false);
return;
}
setDriver(driverDto);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load driver');
} finally {
setLoading(false);
}
};
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 driver profile...</div>
</div>
</div>
);
}
if (error || !driver) {
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 || 'Driver not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/drivers')}
>
Back to Drivers
</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 */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: driver.name }
]}
/>
{/* Driver Profile Component */}
<DriverProfile driver={driver} />
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverCard from '@/components/alpha/DriverCard';
import RankBadge from '@/components/alpha/RankBadge';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
// Mock data
const MOCK_DRIVERS = [
{
id: '1',
name: 'Max Verstappen',
rating: 3245,
skillLevel: 'pro' as const,
nationality: 'Netherlands',
racesCompleted: 156,
wins: 45,
podiums: 89,
isActive: true,
rank: 1,
},
{
id: '2',
name: 'Lewis Hamilton',
rating: 3198,
skillLevel: 'pro' as const,
nationality: 'United Kingdom',
racesCompleted: 234,
wins: 78,
podiums: 145,
isActive: true,
rank: 2,
},
{
id: '3',
name: 'Michael Schmidt',
rating: 2912,
skillLevel: 'advanced' as const,
nationality: 'Germany',
racesCompleted: 145,
wins: 34,
podiums: 67,
isActive: true,
rank: 3,
},
{
id: '4',
name: 'Emma Thompson',
rating: 2789,
skillLevel: 'advanced' as const,
nationality: 'Australia',
racesCompleted: 112,
wins: 23,
podiums: 56,
isActive: true,
rank: 5,
},
{
id: '5',
name: 'Sarah Chen',
rating: 2456,
skillLevel: 'advanced' as const,
nationality: 'Singapore',
racesCompleted: 89,
wins: 12,
podiums: 34,
isActive: true,
rank: 8,
},
{
id: '6',
name: 'Isabella Rossi',
rating: 2145,
skillLevel: 'intermediate' as const,
nationality: 'Italy',
racesCompleted: 67,
wins: 8,
podiums: 23,
isActive: true,
rank: 12,
},
{
id: '7',
name: 'Carlos Rodriguez',
rating: 1876,
skillLevel: 'intermediate' as const,
nationality: 'Spain',
racesCompleted: 45,
wins: 3,
podiums: 12,
isActive: false,
rank: 18,
},
{
id: '8',
name: 'Yuki Tanaka',
rating: 1234,
skillLevel: 'beginner' as const,
nationality: 'Japan',
racesCompleted: 12,
wins: 0,
podiums: 2,
isActive: true,
rank: 45,
},
];
export default function DriversPage() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState('all');
const [selectedNationality, setSelectedNationality] = useState('all');
const [activeOnly, setActiveOnly] = useState(false);
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums'>('rank');
const nationalities = Array.from(
new Set(MOCK_DRIVERS.map((d) => d.nationality).filter(Boolean))
).sort();
const filteredDrivers = MOCK_DRIVERS.filter((driver) => {
const matchesSearch = driver.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchesSkill =
selectedSkill === 'all' || driver.skillLevel === selectedSkill;
const matchesNationality =
selectedNationality === 'all' || driver.nationality === selectedNationality;
const matchesActive = !activeOnly || driver.isActive;
return matchesSearch && matchesSkill && matchesNationality && matchesActive;
});
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
switch (sortBy) {
case 'rank':
return a.rank - b.rank;
case 'rating':
return b.rating - a.rating;
case 'wins':
return b.wins - a.wins;
case 'podiums':
return b.podiums - a.podiums;
default:
return 0;
}
});
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Drivers</h1>
<p className="text-gray-400">
Browse driver profiles and stats
</p>
</div>
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Search Drivers
</label>
<Input
type="text"
placeholder="Search by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Skill Level
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={selectedSkill}
onChange={(e) => setSelectedSkill(e.target.value)}
>
<option value="all">All Levels</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="pro">Pro</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Nationality
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={selectedNationality}
onChange={(e) => setSelectedNationality(e.target.value)}
>
<option value="all">All Countries</option>
{nationalities.map((nat) => (
<option key={nat} value={nat}>
{nat}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Status
</label>
<label className="flex items-center pt-3">
<input
type="checkbox"
className="w-4 h-4 text-primary-blue bg-iron-gray border-charcoal-outline rounded focus:ring-primary-blue focus:ring-2"
checked={activeOnly}
onChange={(e) => setActiveOnly(e.target.checked)}
/>
<span className="ml-2 text-sm text-gray-400">Active only</span>
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Sort By
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
>
<option value="rank">Overall Rank</option>
<option value="rating">Rating</option>
<option value="wins">Wins</option>
<option value="podiums">Podiums</option>
</select>
</div>
</div>
</Card>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{sortedDrivers.length} {sortedDrivers.length === 1 ? 'driver' : 'drivers'} found
</p>
</div>
<div className="space-y-4">
{sortedDrivers.map((driver, index) => (
<Card
key={driver.id}
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
onClick={() => handleDriverClick(driver.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={driver.rank} size="lg" />
<div className="w-16 h-16 rounded-full bg-primary-blue/20 flex items-center justify-center text-2xl font-bold text-white">
{driver.name.charAt(0)}
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">{driver.name}</h3>
<p className="text-sm text-gray-400">
{driver.nationality} {driver.racesCompleted} races
</p>
</div>
</div>
<div className="flex items-center gap-8 text-center">
<div>
<div className="text-2xl font-bold text-primary-blue">{driver.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{driver.wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-warning-amber">{driver.podiums}</div>
<div className="text-xs text-gray-400">Podiums</div>
</div>
<div>
<div className="text-sm text-gray-400">
{((driver.wins / driver.racesCompleted) * 100).toFixed(0)}%
</div>
<div className="text-xs text-gray-500">Win Rate</div>
</div>
</div>
</div>
</Card>
))}
</div>
{sortedDrivers.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No drivers found matching your filters.</p>
</div>
)}
</div>
);
}

View File

@@ -5,10 +5,20 @@ 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 JoinLeagueButton from '@/components/alpha/JoinLeagueButton';
import MembershipStatus from '@/components/alpha/MembershipStatus';
import LeagueMembers from '@/components/alpha/LeagueMembers';
import LeagueSchedule from '@/components/alpha/LeagueSchedule';
import LeagueAdmin from '@/components/alpha/LeagueAdmin';
import StandingsTable from '@/components/alpha/StandingsTable';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container';
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/membership-data';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -17,9 +27,16 @@ export default function LeagueDetailPage() {
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [races, setRaces] = useState<Race[]>([]);
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'members' | 'admin'>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = getCurrentDriverId();
const membership = getMembership(leagueId, currentDriverId);
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
const loadLeagueData = async () => {
try {
@@ -41,13 +58,15 @@ export default function LeagueDetailPage() {
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);
// Load standings
const standingRepo = getStandingRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter(s => s.leagueId === leagueId);
setStandings(leagueStandings);
// Load all drivers for standings
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load league');
} finally {
@@ -90,28 +109,30 @@ export default function LeagueDetailPage() {
);
}
const upcomingRaces = races.filter(race => race.status === 'scheduled');
const handleMembershipChange = () => {
setRefreshKey(prev => prev + 1);
loadLeagueData();
};
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>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' },
{ label: league.name }
]}
/>
{/* 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>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
<MembershipStatus leagueId={leagueId} />
</div>
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Alpha: Single League
@@ -121,115 +142,195 @@ export default function LeagueDetailPage() {
<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">
<DataWarning />
{/* Action Card */}
{!membership && (
<Card className="mb-6">
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-gray-500">Owner</label>
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
<h3 className="text-lg font-semibold text-white mb-2">Join This League</h3>
<p className="text-gray-400 text-sm">Become a member to participate in races and track your progress</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 className="w-48">
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</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}`)}
{/* Tabs Navigation */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-4 overflow-x-auto">
<button
onClick={() => setActiveTab('overview')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'overview'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'schedule'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Schedule
</button>
<button
onClick={() => setActiveTab('standings')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'standings'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Standings
</button>
<button
onClick={() => setActiveTab('members')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'members'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Members
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'admin'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Schedule Race
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => router.push(`/leagues/${leagueId}/standings`)}
>
View Standings
</Button>
</div>
</Card>
Admin
</button>
)}
</div>
</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">
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">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>
<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>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</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>
<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>
</div>
</Card>
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
{membership ? (
<>
<Button
variant="primary"
className="w-full"
onClick={() => setActiveTab('schedule')}
>
View Schedule
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => setActiveTab('standings')}
>
View Standings
</Button>
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</>
) : (
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
)}
</div>
</Card>
</div>
)}
{activeTab === 'schedule' && (
<Card>
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'standings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} />
</Card>
)}
{activeTab === 'members' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Members</h2>
<LeagueMembers leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}
onLeagueUpdate={handleMembershipChange}
key={refreshKey}
/>
)}
</div>
</div>
);

View File

@@ -6,6 +6,7 @@ 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 Input from '@/components/ui/Input';
import { League } from '@gridpilot/racing-domain/entities/League';
import { getLeagueRepository } from '@/lib/di-container';
@@ -14,6 +15,8 @@ export default function LeaguesPage() {
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
useEffect(() => {
loadLeagues();
@@ -35,6 +38,24 @@ export default function LeaguesPage() {
router.push(`/leagues/${leagueId}`);
};
const filteredLeagues = leagues
.filter((league) => {
const matchesSearch =
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
league.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
})
.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'recent':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
default:
return 0;
}
});
if (loading) {
return (
<div className="max-w-6xl mx-auto">
@@ -49,8 +70,8 @@ export default function LeaguesPage() {
<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 === 0
? 'Create your first league to get started'
: `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`}
</p>
</div>
@@ -75,6 +96,38 @@ export default function LeaguesPage() {
</Card>
)}
{leagues.length > 0 && (
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Search Leagues
</label>
<Input
type="text"
placeholder="Search by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Sort By
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="name">Name</option>
<option value="recent">Most Recent</option>
</select>
</div>
</div>
</Card>
)}
{leagues.length === 0 ? (
<Card className="text-center py-12">
<div className="text-gray-400">
@@ -105,16 +158,28 @@ export default function LeaguesPage() {
</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 className="mb-4">
<p className="text-sm text-gray-400">
{filteredLeagues.length} {filteredLeagues.length === 1 ? 'league' : 'leagues'} found
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLeagues.map((league) => (
<LeagueCard
key={league.id}
league={league}
onClick={() => handleLeagueClick(league.id)}
/>
))}
</div>
{filteredLeagues.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No leagues found matching your search.</p>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -2,9 +2,14 @@
import { getAppMode } from '@/lib/mode';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import DataWarning from '@/components/alpha/DataWarning';
import RaceCard from '@/components/alpha/RaceCard';
import LeagueCard from '@/components/alpha/LeagueCard';
import TeamCard from '@/components/alpha/TeamCard';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
@@ -16,12 +21,164 @@ import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import { getAllTeams, getTeamMembers } from '@/lib/team-data';
import { getLeagueMembers } from '@/lib/membership-data';
import type { Race } from '@gridpilot/racing-domain/entities/Race';
import type { League } from '@gridpilot/racing-domain/entities/League';
function AlphaDashboard() {
const router = useRouter();
const [upcomingRaces, setUpcomingRaces] = useState<Race[]>([]);
const [topLeagues, setTopLeagues] = useState<League[]>([]);
const [featuredTeams, setFeaturedTeams] = useState<any[]>([]);
const [recentActivity, setRecentActivity] = useState<any[]>([]);
useEffect(() => {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
// Get upcoming races
raceRepo.findAll().then(races => {
const upcoming = races
.filter(r => r.status === 'scheduled')
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime())
.slice(0, 5);
setUpcomingRaces(upcoming);
});
// Get top leagues
leagueRepo.findAll().then(leagues => {
const sorted = leagues
.map(league => ({
league,
memberCount: getLeagueMembers(league.id).length,
}))
.sort((a, b) => b.memberCount - a.memberCount)
.slice(0, 4)
.map(item => item.league);
setTopLeagues(sorted);
});
// Get featured teams
const teams = getAllTeams();
const featured = teams
.map(team => ({
...team,
memberCount: getTeamMembers(team.id).length,
}))
.sort((a, b) => b.memberCount - a.memberCount)
.slice(0, 4);
setFeaturedTeams(featured);
// Generate recent activity
const activities = [
{ type: 'race', text: 'Max Verstappen won at Monza GP', time: '2 hours ago' },
{ type: 'join', text: 'Lando Norris joined European GT Championship', time: '5 hours ago' },
{ type: 'team', text: 'Charles Leclerc joined Weekend Warriors', time: '1 day ago' },
{ type: 'race', text: 'Upcoming: Spa-Francorchamps in 2 days', time: '2 days ago' },
{ type: 'league', text: 'European GT Championship: 4 active members', time: '3 days ago' },
];
setRecentActivity(activities);
}, []);
return (
<div className="max-w-4xl mx-auto">
<div className="max-w-7xl mx-auto">
<DataWarning />
{/* Upcoming Races Section */}
{upcomingRaces.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Upcoming Races</h2>
<Button variant="secondary" onClick={() => router.push('/races')}>
View All Races
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingRaces.slice(0, 3).map(race => (
<RaceCard
key={race.id}
race={race}
onClick={() => router.push(`/races/${race.id}`)}
/>
))}
</div>
</div>
)}
{/* Top Leagues Section */}
{topLeagues.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Top Leagues</h2>
<Button variant="secondary" onClick={() => router.push('/leagues')}>
Browse Leagues
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{topLeagues.map(league => (
<LeagueCard
key={league.id}
league={league}
onClick={() => router.push(`/leagues/${league.id}`)}
/>
))}
</div>
</div>
)}
{/* Featured Teams Section */}
{featuredTeams.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Featured Teams</h2>
<Button variant="secondary" onClick={() => router.push('/teams')}>
Browse Teams
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{featuredTeams.map(team => (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
logo={undefined}
memberCount={team.memberCount}
leagues={team.leagues}
onClick={() => router.push(`/teams/${team.id}`)}
/>
))}
</div>
</div>
)}
{/* Recent Activity Section */}
{recentActivity.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold text-white mb-6">Recent Activity</h2>
<Card>
<div className="space-y-4">
{recentActivity.map((activity, idx) => (
<div
key={idx}
className={`flex items-start gap-3 pb-4 ${
idx < recentActivity.length - 1 ? 'border-b border-charcoal-outline' : ''
}`}
>
<div className="w-2 h-2 rounded-full bg-primary-blue mt-2 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-300">{activity.text}</p>
<p className="text-xs text-gray-500 mt-1">{activity.time}</p>
</div>
</div>
))}
</div>
</Card>
</div>
)}
<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>
@@ -274,6 +431,7 @@ function AlphaDashboard() {
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,43 +1,249 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
import { EntityMappers } from '@/application/mappers/EntityMappers';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-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';
import Button from '@/components/ui/Button';
import DataWarning from '@/components/alpha/DataWarning';
import ProfileHeader from '@/components/alpha/ProfileHeader';
import ProfileStats from '@/components/alpha/ProfileStats';
import ProfileRaceHistory from '@/components/alpha/ProfileRaceHistory';
import ProfileSettings from '@/components/alpha/ProfileSettings';
import CareerHighlights from '@/components/alpha/CareerHighlights';
import RatingBreakdown from '@/components/alpha/RatingBreakdown';
import { getDriverTeam, getCurrentDriverId } from '@/lib/team-data';
export default async function ProfilePage() {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const driver = EntityMappers.toDriverDTO(drivers[0] || null);
type Tab = 'overview' | 'statistics' | 'history' | 'settings';
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'}
export default function ProfilePage() {
const router = useRouter();
const [driver, setDriver] = useState<DriverDTO | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadDriver = async () => {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const driverData = EntityMappers.toDriverDTO(drivers[0] || null);
setDriver(driverData);
setLoading(false);
};
loadDriver();
}, []);
const handleSaveSettings = async (updates: Partial<DriverDTO>) => {
if (!driver) return;
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
if (currentDriver) {
const updatedDriver: Driver = currentDriver.update({
bio: updates.bio ?? currentDriver.bio,
country: updates.country ?? currentDriver.country,
});
const persistedDriver = await driverRepo.update(updatedDriver);
const updatedDto = EntityMappers.toDriverDTO(persistedDriver);
setDriver(updatedDto);
}
};
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading profile...</div>
</div>
);
}
if (!driver) {
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">
Create your GridPilot profile to get started
</p>
</div>
<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>
);
}
{driver ? (
<>
<FeatureLimitationTooltip message="Profile editing coming in production">
<div className="opacity-75 pointer-events-none">
<DriverProfile driver={driver} />
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'statistics', label: 'Statistics' },
{ id: 'history', label: 'Race History' },
{ id: 'settings', label: 'Settings' }
];
return (
<div className="max-w-6xl mx-auto">
<DataWarning className="mb-6" />
<Card className="mb-6">
<ProfileHeader
driver={driver}
isOwnProfile
onEditClick={() => setActiveTab('settings')}
/>
</Card>
<div className="mb-6">
<div className="flex items-center gap-2 border-b border-charcoal-outline">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
px-4 py-3 font-medium transition-all relative
${activeTab === tab.id
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
)}
</button>
))}
</div>
</div>
<div>
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
{driver.bio ? (
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
) : (
<p className="text-gray-500 italic">No bio yet. Add one in settings!</p>
)}
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Rating" value="1450" color="text-primary-blue" />
<StatItem label="Safety" value="92%" color="text-green-400" />
<StatItem label="Sportsmanship" value="4.8/5" color="text-warning-amber" />
<StatItem label="Total Races" value="147" color="text-white" />
</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>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Preferences</h3>
<div className="space-y-3">
<PreferenceItem icon="🏎️" label="Favorite Car" value="Porsche 911 GT3 R" />
<PreferenceItem icon="🏁" label="Favorite Series" value="Endurance" />
<PreferenceItem icon="⚔️" label="Competitive Level" value="Competitive" />
<PreferenceItem icon="🌍" label="Regions" value="EU, NA" />
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
{(() => {
const currentDriverId = getCurrentDriverId();
const teamData = getDriverTeam(currentDriverId);
if (teamData) {
const { team, membership } = teamData;
return (
<div
className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors cursor-pointer"
onClick={() => router.push(`/teams/${team.id}`)}
>
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
{team.tag}
</div>
<div className="flex-1">
<div className="text-white font-medium">{team.name}</div>
<div className="text-sm text-gray-400">
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
</div>
);
}
return (
<div className="text-center py-8">
<p className="text-gray-400 mb-4">You're not on a team yet</p>
<Button
variant="primary"
onClick={() => router.push('/teams')}
>
Browse Teams
</Button>
</div>
);
})()}
</Card>
</div>
<CareerHighlights />
</div>
)}
{activeTab === 'statistics' && (
<div className="space-y-6">
<ProfileStats driverId={driver.id} />
<RatingBreakdown />
</div>
)}
{activeTab === 'history' && (
<ProfileRaceHistory />
)}
{activeTab === 'settings' && (
<ProfileSettings driver={driver} onSave={handleSaveSettings} />
)}
</div>
</div>
);
}
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`font-semibold ${color}`}>{value}</span>
</div>
);
}
function PreferenceItem({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<div className="flex-1">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-white text-sm">{value}</div>
</div>
</div>
);
}

View File

@@ -7,9 +7,19 @@ import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { League } from '@gridpilot/racing-domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import { getMembership, getCurrentDriverId } from '@/lib/membership-data';
import {
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers
} from '@/lib/registration-data';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
export default function RaceDetailPage() {
const router = useRouter();
@@ -21,6 +31,12 @@ export default function RaceDetailPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false);
const [entryList, setEntryList] = useState<Driver[]>([]);
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = getCurrentDriverId();
const loadRaceData = async () => {
try {
@@ -40,6 +56,9 @@ export default function RaceDetailPage() {
// Load league data
const leagueData = await leagueRepo.findById(raceData.leagueId);
setLeague(leagueData);
// Load entry list
await loadEntryList(raceData.id, raceData.leagueId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load race');
} finally {
@@ -47,6 +66,28 @@ export default function RaceDetailPage() {
}
};
const loadEntryList = async (raceId: string, leagueId: string) => {
try {
const driverRepo = getDriverRepository();
const registeredDriverIds = getRegisteredDrivers(raceId);
const drivers = await Promise.all(
registeredDriverIds.map(id => driverRepo.findById(id))
);
setEntryList(drivers.filter((d): d is Driver => d !== null));
// Check user registration status
const userIsRegistered = isRegistered(raceId, currentDriverId);
setIsUserRegistered(userIsRegistered);
// Check if user can register (is league member and race is upcoming)
const membership = getMembership(leagueId, currentDriverId);
const isUpcoming = race?.status === 'scheduled';
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
} catch (err) {
console.error('Failed to load entry list:', err);
}
};
useEffect(() => {
loadRaceData();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -74,6 +115,46 @@ export default function RaceDetailPage() {
}
};
const handleRegister = async () => {
if (!race || !league) return;
const confirmed = window.confirm(
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`
);
if (!confirmed) return;
setRegistering(true);
try {
registerForRace(race.id, currentDriverId, league.id);
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
} finally {
setRegistering(false);
}
};
const handleWithdraw = async () => {
if (!race || !league) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.'
);
if (!confirmed) return;
setRegistering(true);
try {
withdrawFromRace(race.id, currentDriverId);
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
} finally {
setRegistering(false);
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
@@ -240,6 +321,34 @@ export default function RaceDetailPage() {
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
<div className="space-y-3">
{/* Registration Actions */}
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
<Button
variant="primary"
className="w-full"
onClick={handleRegister}
disabled={registering}
>
{registering ? 'Registering...' : 'Register for Race'}
</Button>
)}
{race.status === 'scheduled' && isUserRegistered && (
<div className="space-y-2">
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
Registered
</div>
<Button
variant="secondary"
className="w-full"
onClick={handleWithdraw}
disabled={registering}
>
{registering ? 'Withdrawing...' : 'Withdraw'}
</Button>
</div>
)}
{race.status === 'completed' && (
<Button
variant="primary"
@@ -271,6 +380,55 @@ export default function RaceDetailPage() {
</div>
</Card>
</div>
{/* Entry List */}
{race.status === 'scheduled' && (
<Card className="mt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Entry List</h2>
<span className="text-sm text-gray-400">
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
</span>
</div>
<DataWarning className="mb-4" />
{entryList.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No drivers registered yet</p>
<p className="text-sm text-gray-500">Be the first to register!</p>
</div>
) : (
<div className="space-y-2">
{entryList.map((driver, index) => (
<div
key={driver.id}
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
onClick={() => router.push(`/drivers/${driver.id}`)}
>
<div className="w-8 text-center text-gray-400 font-mono text-sm">
#{index + 1}
</div>
<div className="w-10 h-10 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-gray-500">
{driver.name.charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="text-white font-medium">{driver.name}</p>
<p className="text-sm text-gray-400">{driver.country}</p>
</div>
{driver.id === currentDriverId && (
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/20 text-primary-blue rounded">
You
</span>
)}
</div>
))}
</div>
)}
</Card>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import RaceCard from '@/components/alpha/RaceCard';
@@ -12,12 +12,12 @@ 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);
const [preselectedLeagueId, setPreselectedLeagueId] = useState<string | undefined>(undefined);
// Filters
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
@@ -48,6 +48,14 @@ export default function RacesPage() {
useEffect(() => {
loadRaces();
try {
const params = new URLSearchParams(window.location.search);
const leagueId = params.get('leagueId') || undefined;
setPreselectedLeagueId(leagueId || undefined);
} catch {
setPreselectedLeagueId(undefined);
}
}, []);
const filteredRaces = races.filter(race => {
@@ -101,7 +109,7 @@ export default function RacesPage() {
<Card>
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
<ScheduleRaceForm
preSelectedLeagueId={searchParams.get('leagueId') || undefined}
preSelectedLeagueId={preselectedLeagueId}
onSuccess={(race) => {
router.push(`/races/${race.id}`);
}}

View File

@@ -0,0 +1,170 @@
'use client';
import Card from '@/components/ui/Card';
// Mock data for highlights
const MOCK_HIGHLIGHTS = [
{
id: '1',
type: 'race',
title: 'Epic finish in GT3 Championship',
description: 'Max Verstappen wins by 0.003 seconds',
time: '2 hours ago',
},
{
id: '2',
type: 'league',
title: 'New league created: Endurance Masters',
description: '12 teams already registered',
time: '5 hours ago',
},
{
id: '3',
type: 'achievement',
title: 'Sarah Chen unlocked "Century Club"',
description: '100 races completed',
time: '1 day ago',
},
];
const TRENDING_DRIVERS = [
{ id: '1', name: 'Max Verstappen', metric: '+156 rating this week' },
{ id: '2', name: 'Emma Thompson', metric: '5 wins in a row' },
{ id: '3', name: 'Lewis Hamilton', metric: 'Most laps led' },
];
const TRENDING_TEAMS = [
{ id: '1', name: 'Apex Racing', metric: '12 new members' },
{ id: '2', name: 'Speed Demons', metric: '3 championship wins' },
{ id: '3', name: 'Endurance Elite', metric: '24h race victory' },
];
export default function SocialPage() {
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Social Hub</h1>
<p className="text-gray-400">
Stay updated with the racing community
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Activity Feed */}
<div className="lg:col-span-2">
<Card>
<div className="space-y-6">
<h2 className="text-xl font-semibold text-white">
Activity Feed
</h2>
<div className="bg-primary-blue/10 border border-primary-blue/20 rounded-lg p-8 text-center">
<div className="text-4xl mb-4">🚧</div>
<h3 className="text-lg font-semibold text-white mb-2">
Coming Soon
</h3>
<p className="text-gray-400">
The activity feed will show real-time updates from your
friends, leagues, and teams. This feature is currently in
development for the alpha release.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-400 mb-4">
Recent Highlights
</h3>
<div className="space-y-4">
{MOCK_HIGHLIGHTS.map((highlight) => (
<div
key={highlight.id}
className="border-l-4 border-primary-blue pl-4 py-2"
>
<h4 className="font-semibold text-white">
{highlight.title}
</h4>
<p className="text-sm text-gray-400">
{highlight.description}
</p>
<p className="text-xs text-gray-500 mt-1">
{highlight.time}
</p>
</div>
))}
</div>
</div>
</div>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Trending Drivers */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white">
🔥 Trending Drivers
</h2>
<div className="space-y-3">
{TRENDING_DRIVERS.map((driver, index) => (
<div key={driver.id} className="flex items-center gap-3">
<div className="w-8 h-8 bg-charcoal-outline rounded-full flex items-center justify-center text-sm font-bold text-gray-400">
{index + 1}
</div>
<div className="flex-1">
<div className="font-medium text-white">
{driver.name}
</div>
<div className="text-xs text-gray-400">
{driver.metric}
</div>
</div>
</div>
))}
</div>
</div>
</Card>
{/* Trending Teams */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white">
Trending Teams
</h2>
<div className="space-y-3">
{TRENDING_TEAMS.map((team, index) => (
<div key={team.id} className="flex items-center gap-3">
<div className="w-8 h-8 bg-charcoal-outline rounded-lg flex items-center justify-center text-sm font-bold text-gray-400">
{index + 1}
</div>
<div className="flex-1">
<div className="font-medium text-white">
{team.name}
</div>
<div className="text-xs text-gray-400">
{team.metric}
</div>
</div>
</div>
))}
</div>
</div>
</Card>
{/* Friend Activity Placeholder */}
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-white">
Friends
</h2>
<div className="bg-charcoal-outline rounded-lg p-4 text-center">
<p className="text-sm text-gray-400">
Friend features coming soon in alpha
</p>
</div>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import TeamRoster from '@/components/alpha/TeamRoster';
import TeamStandings from '@/components/alpha/TeamStandings';
import TeamAdmin from '@/components/alpha/TeamAdmin';
import JoinTeamButton from '@/components/alpha/JoinTeamButton';
import {
Team,
getTeam,
getTeamMembers,
getCurrentDriverId,
isTeamOwnerOrManager,
TeamMembership,
removeTeamMember,
updateTeamMemberRole,
TeamRole,
} from '@/lib/team-data';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailPage() {
const params = useParams();
const teamId = params.id as string;
const [team, setTeam] = useState<Team | null>(null);
const [memberships, setMemberships] = useState<TeamMembership[]>([]);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const loadTeamData = () => {
const teamData = getTeam(teamId);
if (!teamData) {
setLoading(false);
return;
}
const teamMemberships = getTeamMembers(teamId);
const currentDriverId = getCurrentDriverId();
const adminStatus = isTeamOwnerOrManager(teamId, currentDriverId);
setTeam(teamData);
setMemberships(teamMemberships);
setIsAdmin(adminStatus);
setLoading(false);
};
useEffect(() => {
loadTeamData();
}, [teamId]);
const handleUpdate = () => {
loadTeamData();
};
const handleRemoveMember = (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const currentDriverId = getCurrentDriverId();
removeTeamMember(teamId, driverId, currentDriverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to remove member');
}
};
const handleChangeRole = (driverId: string, newRole: TeamRole) => {
try {
const currentDriverId = getCurrentDriverId();
updateTeamMemberRole(teamId, driverId, newRole, currentDriverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to change role');
}
};
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading team...</div>
</div>
);
}
if (!team) {
return (
<div className="max-w-6xl mx-auto">
<Card>
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2>
<p className="text-gray-400 mb-6">
The team you're looking for doesn't exist or has been disbanded.
</p>
<Button variant="primary" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
</Card>
</div>
);
}
const tabs: { id: Tab; label: string; visible: boolean }[] = [
{ id: 'overview', label: 'Overview', visible: true },
{ id: 'roster', label: 'Roster', visible: true },
{ id: 'standings', label: 'Standings', visible: true },
{ id: 'admin', label: 'Admin', visible: isAdmin },
];
const visibleTabs = tabs.filter(tab => tab.visible);
return (
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: team.name }
]}
/>
<DataWarning className="mb-6" />
<Card className="mb-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-4xl font-bold text-gray-500">
{team.tag}
</span>
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
<span className="px-3 py-1 bg-charcoal-outline text-gray-300 rounded-full text-sm font-medium">
{team.tag}
</span>
</div>
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
<span></span>
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
{team.leagues.length > 0 && (
<>
<span></span>
<span>{team.leagues.length} {team.leagues.length === 1 ? 'league' : 'leagues'}</span>
</>
)}
</div>
</div>
</div>
<JoinTeamButton teamId={teamId} onUpdate={handleUpdate} />
</div>
</Card>
<div className="mb-6">
<div className="flex items-center gap-2 border-b border-charcoal-outline">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
px-4 py-3 font-medium transition-all relative
${activeTab === tab.id
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
)}
</button>
))}
</div>
</div>
<div>
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{team.description}</p>
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
<StatItem label="Leagues" value={team.leagues.length.toString()} color="text-green-400" />
<StatItem label="Founded" value={new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} color="text-gray-300" />
</div>
</Card>
</div>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3>
<div className="text-center py-8 text-gray-400">
No recent activity to display
</div>
</Card>
</div>
)}
{activeTab === 'roster' && (
<TeamRoster
teamId={teamId}
memberships={memberships}
isAdmin={isAdmin}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={teamId} leagues={team.leagues} />
)}
{activeTab === 'admin' && isAdmin && (
<TeamAdmin team={team} onUpdate={handleUpdate} />
)}
</div>
</div>
);
}
function StatItem({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-400 text-sm">{label}</span>
<span className={`font-semibold ${color}`}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import TeamCard from '@/components/alpha/TeamCard';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import CreateTeamForm from '@/components/alpha/CreateTeamForm';
import DataWarning from '@/components/alpha/DataWarning';
import { getAllTeams, getTeamMembers, Team } from '@/lib/team-data';
export default function TeamsPage() {
const router = useRouter();
const [teams, setTeams] = useState<Team[]>([]);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [memberFilter, setMemberFilter] = useState('all');
useEffect(() => {
loadTeams();
}, []);
const loadTeams = () => {
const allTeams = getAllTeams();
setTeams(allTeams);
};
const handleCreateSuccess = (teamId: string) => {
setShowCreateForm(false);
loadTeams();
router.push(`/teams/${teamId}`);
};
const filteredTeams = teams.filter((team) => {
const memberCount = getTeamMembers(team.id).length;
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesMemberCount =
memberFilter === 'all' ||
(memberFilter === 'small' && memberCount < 5) ||
(memberFilter === 'medium' && memberCount >= 5 && memberCount < 10) ||
(memberFilter === 'large' && memberCount >= 10);
return matchesSearch && matchesMemberCount;
});
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
if (showCreateForm) {
return (
<div className="max-w-4xl mx-auto">
<DataWarning className="mb-6" />
<div className="mb-6">
<Button
variant="secondary"
onClick={() => setShowCreateForm(false)}
>
Back to Teams
</Button>
</div>
<Card>
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
<CreateTeamForm
onCancel={() => setShowCreateForm(false)}
onSuccess={handleCreateSuccess}
/>
</Card>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<DataWarning className="mb-6" />
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1>
<p className="text-gray-400">
Browse and join racing teams
</p>
</div>
<Button variant="primary" onClick={() => setShowCreateForm(true)}>
Create Team
</Button>
</div>
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Search Teams
</label>
<Input
type="text"
placeholder="Search by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Team Size
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={memberFilter}
onChange={(e) => setMemberFilter(e.target.value)}
>
<option value="all">All Sizes</option>
<option value="small">Small (&lt;5)</option>
<option value="medium">Medium (5-9)</option>
<option value="large">Large (10+)</option>
</select>
</div>
</div>
</Card>
<div className="mb-4">
<p className="text-sm text-gray-400">
{filteredTeams.length} {filteredTeams.length === 1 ? 'team' : 'teams'} found
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTeams.map((team) => {
const memberCount = getTeamMembers(team.id).length;
return (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
memberCount={memberCount}
leagues={team.leagues}
onClick={() => handleTeamClick(team.id)}
/>
);
})}
</div>
{filteredTeams.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="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>
<h3 className="text-lg font-medium text-white mb-2">
{teams.length === 0 ? 'No teams yet' : 'No teams found'}
</h3>
<p className="text-sm mb-4">
{teams.length === 0
? 'Create your first team to start racing together.'
: 'Try adjusting your search or filters.'}
</p>
{teams.length === 0 && (
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
>
Create Your First Team
</Button>
)}
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import Link from 'next/link';
export default function AlphaFooter() {
return (
<footer className="mt-auto border-t border-charcoal-outline bg-deep-graphite">
@@ -27,12 +29,12 @@ export default function AlphaFooter() {
>
Roadmap
</a>
<a
href="/"
<Link
href="/"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Back to Landing
</a>
</Link>
</div>
</div>
</div>

View File

@@ -7,7 +7,9 @@ const navLinks = [
{ href: '/', label: 'Dashboard' },
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/races', label: 'Races' },
{ href: '/teams', label: 'Teams' },
{ href: '/drivers', label: 'Drivers' },
{ href: '/social', label: 'Social' },
] as const;
export function AlphaNav() {

View File

@@ -0,0 +1,57 @@
'use client';
import { useRouter } from 'next/navigation';
export interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
const router = useRouter();
return (
<nav className="flex items-center gap-2 text-sm mb-6">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<div key={index} className="flex items-center gap-2">
{item.href && !isLast ? (
<button
onClick={() => router.push(item.href!)}
className="text-gray-400 hover:text-primary-blue transition-colors"
>
{item.label}
</button>
) : (
<span className={isLast ? 'text-white font-medium' : 'text-gray-400'}>
{item.label}
</span>
)}
{!isLast && (
<svg
className="w-4 h-4 text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</div>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import Card from '../ui/Card';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
const mockAchievements: Achievement[] = [
{ id: '1', title: 'First Victory', description: 'Won your first race', icon: '🏆', unlockedAt: '2024-03-15', rarity: 'common' },
{ id: '2', title: '10 Podiums', description: 'Achieved 10 podium finishes', icon: '🥈', unlockedAt: '2024-05-22', rarity: 'rare' },
{ id: '3', title: 'Clean Racer', description: 'Completed 25 races with 0 incidents', icon: '✨', unlockedAt: '2024-08-10', rarity: 'epic' },
{ id: '4', title: 'Comeback King', description: 'Won a race after starting P10 or lower', icon: '⚡', unlockedAt: '2024-09-03', rarity: 'rare' },
{ id: '5', title: 'Perfect Weekend', description: 'Pole, fastest lap, and win in same race', icon: '💎', unlockedAt: '2024-10-17', rarity: 'legendary' },
{ id: '6', title: 'Century Club', description: 'Completed 100 races', icon: '💯', unlockedAt: '2024-11-01', rarity: 'epic' },
];
const rarityColors = {
common: 'border-gray-500 bg-gray-500/10',
rare: 'border-blue-400 bg-blue-400/10',
epic: 'border-purple-400 bg-purple-400/10',
legendary: 'border-warning-amber bg-warning-amber/10'
};
export default function CareerHighlights() {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Key Milestones</h3>
<div className="space-y-3">
<MilestoneItem
label="First Race"
value="March 15, 2024"
icon="🏁"
/>
<MilestoneItem
label="First Win"
value="March 15, 2024 (Imola)"
icon="🏆"
/>
<MilestoneItem
label="Highest Rating"
value="1487 (Nov 2024)"
icon="📈"
/>
<MilestoneItem
label="Longest Win Streak"
value="4 races (Oct 2024)"
icon="🔥"
/>
<MilestoneItem
label="Most Wins (Track)"
value="Spa-Francorchamps (7)"
icon="🗺️"
/>
<MilestoneItem
label="Favorite Car"
value="Porsche 911 GT3 R (45 races)"
icon="🏎️"
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Achievements</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{mockAchievements.map((achievement) => (
<div
key={achievement.id}
className={`p-4 rounded-lg border ${rarityColors[achievement.rarity]}`}
>
<div className="flex items-start gap-3">
<div className="text-3xl">{achievement.icon}</div>
<div className="flex-1">
<div className="text-white font-medium mb-1">{achievement.title}</div>
<div className="text-xs text-gray-400 mb-2">{achievement.description}</div>
<div className="text-xs text-gray-500">
{new Date(achievement.unlockedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
</div>
</div>
))}
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">🎯</div>
<h3 className="text-lg font-semibold text-white">Next Goals</h3>
</div>
<div className="space-y-2 text-sm text-gray-400">
<div className="flex items-center justify-between">
<span>Win 25 races</span>
<span className="text-primary-blue">23/25</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2">
<div className="bg-primary-blue rounded-full h-2" style={{ width: '92%' }} />
</div>
</div>
</Card>
</div>
);
}
function MilestoneItem({ label, value, icon }: { label: string; value: string; icon: string }) {
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<span className="text-gray-400 text-sm">{label}</span>
</div>
<span className="text-white text-sm font-medium">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { createTeam, getCurrentDriverId } from '@/lib/team-data';
interface CreateTeamFormProps {
onCancel?: () => void;
onSuccess?: (teamId: string) => void;
}
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
tag: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'Team name is required';
} else if (formData.name.length < 3) {
newErrors.name = 'Team name must be at least 3 characters';
}
if (!formData.tag.trim()) {
newErrors.tag = 'Team tag is required';
} else if (formData.tag.length > 4) {
newErrors.tag = 'Team tag must be 4 characters or less';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length < 10) {
newErrors.description = 'Description must be at least 10 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setSubmitting(true);
try {
const currentDriverId = getCurrentDriverId();
const team = createTeam(
formData.name,
formData.tag.toUpperCase(),
formData.description,
currentDriverId,
[] // Empty leagues array for now
);
if (onSuccess) {
onSuccess(team.id);
} else {
router.push(`/teams/${team.id}`);
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to create team');
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Team Name *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter team name..."
disabled={submitting}
/>
{errors.name && (
<p className="text-danger-red text-xs mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Team Tag *
</label>
<Input
type="text"
value={formData.tag}
onChange={(e) => setFormData({ ...formData, tag: e.target.value.toUpperCase() })}
placeholder="e.g., APEX"
maxLength={4}
disabled={submitting}
/>
<p className="text-xs text-gray-500 mt-1">Max 4 characters</p>
{errors.tag && (
<p className="text-danger-red text-xs mt-1">{errors.tag}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Description *
</label>
<textarea
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm resize-none"
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe your team's goals and racing style..."
disabled={submitting}
/>
{errors.description && (
<p className="text-danger-red text-xs mt-1">{errors.description}</p>
)}
</div>
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="text-2xl"></div>
<div className="flex-1">
<h4 className="text-white font-medium mb-1">About Team Creation</h4>
<ul className="text-sm text-gray-400 space-y-1">
<li> You will be assigned as the team owner</li>
<li> You can invite other drivers to join your team</li>
<li> Team standings are calculated across leagues</li>
<li> This is alpha data - it resets on page reload</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
disabled={submitting}
className="flex-1"
>
{submitting ? 'Creating Team...' : 'Create Team'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={submitting}
>
Cancel
</Button>
)}
</div>
</form>
);
}

View File

@@ -2,7 +2,11 @@
import { useState, useEffect } from 'react';
export default function DataWarning() {
interface DataWarningProps {
className?: string;
}
export default function DataWarning({ className }: DataWarningProps) {
const [isDismissed, setIsDismissed] = useState(false);
const [isMounted, setIsMounted] = useState(false);
@@ -23,7 +27,7 @@ export default function DataWarning() {
if (isDismissed) return null;
return (
<div className="mb-6 bg-iron-gray border border-charcoal-outline rounded-lg p-4">
<div className={`${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">

View File

@@ -0,0 +1,99 @@
'use client';
import Card from '../ui/Card';
interface DriverCardProps {
id: string;
name: string;
avatar?: string;
rating: number;
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
nationality?: string;
racesCompleted: number;
wins: number;
isActive?: boolean;
onClick?: () => void;
}
export default function DriverCard({
id,
name,
avatar,
rating,
skillLevel,
nationality,
racesCompleted,
wins,
isActive = true,
onClick,
}: DriverCardProps) {
const skillBadgeColors = {
beginner: 'bg-green-500/20 text-green-400',
intermediate: 'bg-blue-500/20 text-blue-400',
advanced: 'bg-purple-500/20 text-purple-400',
pro: 'bg-red-500/20 text-red-400',
};
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
onClick={onClick}
>
<Card>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="relative">
<div className="w-16 h-16 bg-charcoal-outline rounded-full flex items-center justify-center flex-shrink-0">
{avatar ? (
<img
src={avatar}
alt={name}
className="w-full h-full object-cover rounded-full"
/>
) : (
<span className="text-2xl font-bold text-gray-500">
{name.charAt(0)}
</span>
)}
</div>
{isActive && (
<div className="absolute bottom-0 right-0 w-4 h-4 bg-green-500 border-2 border-iron-gray rounded-full" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate">
{name}
</h3>
{nationality && (
<p className="text-sm text-gray-400">{nationality}</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="text-2xl font-bold text-white">{rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-white">{wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div className="flex-1 text-right">
<div className="text-2xl font-bold text-white">{racesCompleted}</div>
<div className="text-xs text-gray-400">Races</div>
</div>
</div>
<span
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
skillBadgeColors[skillLevel]
}`}
>
{skillLevel.charAt(0).toUpperCase() + skillLevel.slice(1)}
</span>
</div>
</Card>
</div>
);
}

View File

@@ -1,87 +1,171 @@
'use client';
import { DriverDTO } from '@/application/mappers/EntityMappers';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
import ProfileHeader from './ProfileHeader';
import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { getDriverTeam } from '@/lib/team-data';
import { getDriverStats, getLeagueRankings } from '@/lib/di-container';
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));
const driverStats = getDriverStats(driver.id);
const leagueRank = getLeagueRankings(driver.id, 'league-1');
const performanceStats = driverStats ? {
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100,
dnfRate: (driverStats.dnfs / driverStats.totalRaces) * 100,
avgFinish: driverStats.avgFinish,
consistency: driverStats.consistency,
bestFinish: driverStats.bestFinish,
worstFinish: driverStats.worstFinish,
} : null;
const rankings = driverStats ? [
{
type: 'overall' as const,
name: 'Overall Ranking',
rank: driverStats.overallRank,
totalDrivers: 850,
percentile: driverStats.percentile,
rating: driverStats.rating,
},
{
type: 'league' as const,
name: 'European GT Championship',
rank: leagueRank.rank,
totalDrivers: leagueRank.totalDrivers,
percentile: leagueRank.percentile,
rating: driverStats.rating,
},
] : [];
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>
<div className="space-y-6">
<Card>
<ProfileHeader driver={driver} isOwnProfile={false} />
</Card>
{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>
)}
{driver.bio && (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
</Card>
)}
<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>
{driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Rating" value={driverStats.rating.toString()} color="text-primary-blue" />
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
</div>
</Card>
<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>
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
</div>
<DriverRankings rankings={rankings} />
</div>
)}
{!driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Rating" value="1450" color="text-primary-blue" />
<StatCard label="Total Races" value="147" color="text-white" />
<StatCard label="Wins" value="23" color="text-green-400" />
<StatCard label="Podiums" value="56" color="text-warning-amber" />
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Team</h3>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
const { team, membership } = teamData;
return (
<div className="flex items-center gap-4 p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="w-12 h-12 rounded-lg bg-primary-blue/20 flex items-center justify-center text-xl font-bold text-white">
{team.tag}
</div>
<div>
<div className="text-white font-medium">{team.name}</div>
<div className="text-sm text-gray-400">
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)} Joined {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
</div>
);
}
return (
<div className="text-center py-4 text-gray-400 text-sm">
Not on a team
</div>
);
})()}
</Card>
</div>
</Card>
)}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
<ProfileStats stats={driverStats ? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
} : undefined} />
</Card>
<CareerHighlights />
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">🔒</div>
<h3 className="text-lg font-semibold text-white">Private Information</h3>
</div>
<p className="text-gray-400 text-sm">
Detailed race history, settings, and preferences are only visible to the driver.
</p>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
Per-car statistics, per-track performance, and head-to-head comparisons will be available in production.
</p>
</Card>
</div>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="text-center p-4 rounded bg-deep-graphite border border-charcoal-outline">
<div className="text-sm text-gray-400 mb-1">{label}</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import Card from '../ui/Card';
import RankBadge from './RankBadge';
interface RankingData {
type: 'overall' | 'league' | 'class';
name: string;
rank: number;
totalDrivers: number;
percentile: number;
rating: number;
}
interface DriverRankingsProps {
rankings: RankingData[];
}
export default function DriverRankings({ rankings }: DriverRankingsProps) {
const getPercentileColor = (percentile: number) => {
if (percentile >= 90) return 'text-green-400';
if (percentile >= 75) return 'text-primary-blue';
if (percentile >= 50) return 'text-warning-amber';
return 'text-gray-400';
};
const getPercentileLabel = (percentile: number) => {
if (percentile >= 90) return 'Top 10%';
if (percentile >= 75) return 'Top 25%';
if (percentile >= 50) return 'Top 50%';
return `${(100 - percentile).toFixed(0)}th percentile`;
};
return (
<Card>
<h3 className="text-xl font-semibold text-white mb-6">Rankings</h3>
<div className="space-y-4">
{rankings.map((ranking, index) => (
<div
key={index}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<RankBadge rank={ranking.rank} size="md" />
<div>
<div className="text-white font-medium">{ranking.name}</div>
<div className="text-sm text-gray-400">
{ranking.rank} of {ranking.totalDrivers} drivers
</div>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-primary-blue">{ranking.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Percentile</span>
<span className={`font-medium ${getPercentileColor(ranking.percentile)}`}>
{getPercentileLabel(ranking.percentile)}
</span>
</div>
</div>
))}
</div>
{rankings.length === 0 && (
<div className="text-center py-8 text-gray-400">
No ranking data available yet.
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import {
getMembership,
joinLeague,
leaveLeague,
requestToJoin,
getCurrentDriverId,
type MembershipStatus,
} from '@/lib/membership-data';
interface JoinLeagueButtonProps {
leagueId: string;
isInviteOnly?: boolean;
onMembershipChange?: () => void;
}
export default function JoinLeagueButton({
leagueId,
isInviteOnly = false,
onMembershipChange,
}: JoinLeagueButtonProps) {
const currentDriverId = getCurrentDriverId();
const membership = getMembership(leagueId, currentDriverId);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogAction, setDialogAction] = useState<'join' | 'leave' | 'request'>('join');
const handleJoin = async () => {
setLoading(true);
setError(null);
try {
if (isInviteOnly) {
requestToJoin(leagueId, currentDriverId);
} else {
joinLeague(leagueId, currentDriverId);
}
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join league');
} finally {
setLoading(false);
}
};
const handleLeave = async () => {
setLoading(true);
setError(null);
try {
leaveLeague(leagueId, currentDriverId);
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to leave league');
} finally {
setLoading(false);
}
};
const openDialog = (action: 'join' | 'leave' | 'request') => {
setDialogAction(action);
setShowConfirmDialog(true);
setError(null);
};
const closeDialog = () => {
setShowConfirmDialog(false);
setError(null);
};
const getButtonText = (): string => {
if (!membership) {
return isInviteOnly ? 'Request to Join' : 'Join League';
}
if (membership.role === 'owner') {
return 'League Owner';
}
return 'Leave League';
};
const getButtonVariant = (): 'primary' | 'secondary' | 'danger' => {
if (!membership) return 'primary';
if (membership.role === 'owner') return 'secondary';
return 'danger';
};
const isDisabled = membership?.role === 'owner' || loading;
return (
<>
<Button
variant={getButtonVariant()}
onClick={() => {
if (membership) {
openDialog('leave');
} else {
openDialog(isInviteOnly ? 'request' : 'join');
}
}}
disabled={isDisabled}
className="w-full"
>
{loading ? 'Processing...' : getButtonText()}
</Button>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-iron-gray border border-charcoal-outline rounded-lg max-w-md w-full p-6">
<h3 className="text-xl font-semibold text-white mb-4">
{dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
</h3>
<p className="text-gray-400 mb-6">
{dialogAction === 'leave'
? 'Are you sure you want to leave this league? You can rejoin later.'
: dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</p>
{error && (
<div className="mb-4 p-3 rounded bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-3">
<Button
variant={dialogAction === 'leave' ? 'danger' : 'primary'}
onClick={dialogAction === 'leave' ? handleLeave : handleJoin}
disabled={loading}
className="flex-1"
>
{loading ? 'Processing...' : 'Confirm'}
</Button>
<Button
variant="secondary"
onClick={closeDialog}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import Button from '@/components/ui/Button';
import {
getCurrentDriverId,
getTeamMembership,
getDriverTeam,
joinTeam,
requestToJoinTeam,
leaveTeam,
} from '@/lib/team-data';
interface JoinTeamButtonProps {
teamId: string;
requiresApproval?: boolean;
onUpdate?: () => void;
}
export default function JoinTeamButton({
teamId,
requiresApproval = false,
onUpdate,
}: JoinTeamButtonProps) {
const [loading, setLoading] = useState(false);
const currentDriverId = getCurrentDriverId();
const membership = getTeamMembership(teamId, currentDriverId);
const currentTeam = getDriverTeam(currentDriverId);
const handleJoin = async () => {
setLoading(true);
try {
if (requiresApproval) {
requestToJoinTeam(teamId, currentDriverId);
alert('Join request sent! Wait for team approval.');
} else {
joinTeam(teamId, currentDriverId);
alert('Successfully joined team!');
}
onUpdate?.();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to join team');
} finally {
setLoading(false);
}
};
const handleLeave = async () => {
if (!confirm('Are you sure you want to leave this team?')) {
return;
}
setLoading(true);
try {
leaveTeam(teamId, currentDriverId);
alert('Successfully left team');
onUpdate?.();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to leave team');
} finally {
setLoading(false);
}
};
// Already a member
if (membership && membership.status === 'active') {
if (membership.role === 'owner') {
return (
<Button variant="secondary" disabled>
Team Owner
</Button>
);
}
return (
<Button
variant="danger"
onClick={handleLeave}
disabled={loading}
>
{loading ? 'Leaving...' : 'Leave Team'}
</Button>
);
}
// Already on another team
if (currentTeam && currentTeam.team.id !== teamId) {
return (
<Button variant="secondary" disabled>
Already on {currentTeam.team.name}
</Button>
);
}
// Can join
return (
<Button
variant="primary"
onClick={handleJoin}
disabled={loading}
>
{loading
? 'Processing...'
: requiresApproval
? 'Request to Join'
: 'Join Team'}
</Button>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LeagueMembers from './LeagueMembers';
import DataWarning from './DataWarning';
import { League } from '@gridpilot/racing-domain/entities/League';
import {
getJoinRequests,
approveJoinRequest,
rejectJoinRequest,
removeMember,
updateMemberRole,
getCurrentDriverId,
type JoinRequest,
type MembershipRole,
} from '@/lib/membership-data';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
interface LeagueAdminProps {
league: League;
onLeagueUpdate?: () => void;
}
export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps) {
const router = useRouter();
const currentDriverId = getCurrentDriverId();
const [joinRequests, setJoinRequests] = useState<JoinRequest[]>([]);
const [requestDrivers, setRequestDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'members' | 'requests' | 'races' | 'settings'>('members');
useEffect(() => {
loadJoinRequests();
}, [league.id]);
const loadJoinRequests = async () => {
setLoading(true);
try {
const requests = getJoinRequests(league.id);
setJoinRequests(requests);
const driverRepo = getDriverRepository();
const drivers = await Promise.all(
requests.map(r => driverRepo.findById(r.driverId))
);
setRequestDrivers(drivers.filter((d): d is Driver => d !== null));
} catch (err) {
console.error('Failed to load join requests:', err);
} finally {
setLoading(false);
}
};
const handleApproveRequest = (requestId: string) => {
try {
approveJoinRequest(requestId);
loadJoinRequests();
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve request');
}
};
const handleRejectRequest = (requestId: string) => {
try {
rejectJoinRequest(requestId);
loadJoinRequests();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject request');
}
};
const handleRemoveMember = (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
removeMember(league.id, driverId, currentDriverId);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = (driverId: string, newRole: MembershipRole) => {
try {
updateMemberRole(league.id, driverId, newRole, currentDriverId);
onLeagueUpdate?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const getDriverName = (driverId: string): string => {
const driver = requestDrivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
return (
<div>
<DataWarning />
{error && (
<div className="mb-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
<button
onClick={() => setError(null)}
className="ml-4 text-sm underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
{/* Admin Tabs */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('members')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'members'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Manage Members
</button>
<button
onClick={() => setActiveTab('requests')}
className={`pb-3 px-1 font-medium transition-colors flex items-center gap-2 ${
activeTab === 'requests'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Join Requests
{joinRequests.length > 0 && (
<span className="px-2 py-0.5 text-xs bg-primary-blue text-white rounded-full">
{joinRequests.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('races')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'races'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Create Race
</button>
<button
onClick={() => setActiveTab('settings')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'settings'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Settings
</button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'members' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Manage Members</h2>
<LeagueMembers
leagueId={league.id}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
showActions={true}
/>
</Card>
)}
{activeTab === 'requests' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Join Requests</h2>
{loading ? (
<div className="text-center py-8 text-gray-400">Loading requests...</div>
) : joinRequests.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No pending join requests
</div>
) : (
<div className="space-y-4">
{joinRequests.map((request) => (
<div
key={request.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-white font-medium">
{getDriverName(request.driverId)}
</h3>
<p className="text-sm text-gray-400 mt-1">
Requested {new Date(request.requestedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
{request.message && (
<p className="text-sm text-gray-400 mt-2 italic">
&ldquo;{request.message}&rdquo;
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => handleApproveRequest(request.id)}
className="px-4"
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
className="px-4"
>
Reject
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
)}
{activeTab === 'races' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Create New Race</h2>
<p className="text-gray-400 mb-4">
Schedule a new race for this league
</p>
<Button
variant="primary"
onClick={() => router.push(`/races?leagueId=${league.id}`)}
>
Go to Race Scheduler
</Button>
</Card>
)}
{activeTab === 'settings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
League Name
</label>
<p className="text-white">{league.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<p className="text-white">{league.description}</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Racing 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 className="pt-4 border-t border-charcoal-outline">
<p className="text-sm text-gray-400">
League settings editing will be available in a future update
</p>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,243 @@
'use client';
import { useState, useEffect } from 'react';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { getLeagueMembers, getCurrentDriverId, type LeagueMembership, type MembershipRole } from '@/lib/membership-data';
interface LeagueMembersProps {
leagueId: string;
onRemoveMember?: (driverId: string) => void;
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
showActions?: boolean;
}
export default function LeagueMembers({
leagueId,
onRemoveMember,
onUpdateRole,
showActions = false
}: LeagueMembersProps) {
const [members, setMembers] = useState<LeagueMembership[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
const currentDriverId = getCurrentDriverId();
useEffect(() => {
loadMembers();
}, [leagueId]);
const loadMembers = async () => {
setLoading(true);
try {
const membershipData = getLeagueMembers(leagueId);
setMembers(membershipData);
const driverRepo = getDriverRepository();
const driverData = await Promise.all(
membershipData.map(m => driverRepo.findById(m.driverId))
);
setDrivers(driverData.filter((d): d is Driver => d !== null));
} catch (error) {
console.error('Failed to load members:', error);
} finally {
setLoading(false);
}
};
const getDriverName = (driverId: string): string => {
const driver = drivers.find(d => d.id === driverId);
return driver?.name || 'Unknown Driver';
};
const getRoleOrder = (role: MembershipRole): number => {
const order = { owner: 0, admin: 1, steward: 2, member: 3 };
return order[role];
};
const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) {
case 'role':
return getRoleOrder(a.role) - getRoleOrder(b.role);
case 'name':
return getDriverName(a.driverId).localeCompare(getDriverName(b.driverId));
case 'date':
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
case 'rating': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
return (statsB?.rating || 0) - (statsA?.rating || 0);
}
case 'points':
return 0;
case 'wins': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
return (statsB?.wins || 0) - (statsA?.wins || 0);
}
default:
return 0;
}
});
const getRoleBadgeColor = (role: MembershipRole): string => {
switch (role) {
case 'owner':
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
case 'admin':
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
case 'steward':
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
case 'member':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
}
};
if (loading) {
return (
<div className="text-center py-8 text-gray-400">
Loading members...
</div>
);
}
if (members.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No members found
</div>
);
}
return (
<div>
{/* Sort Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{members.length} {members.length === 1 ? 'member' : 'members'}
</p>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>
<option value="points">Points</option>
<option value="wins">Wins</option>
<option value="role">Role</option>
<option value="name">Name</option>
<option value="date">Join Date</option>
</select>
</div>
</div>
{/* Members Table */}
<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">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</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">Role</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th>
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
</tr>
</thead>
<tbody>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats(member.driverId);
const isTopPerformer = index < 3 && sortBy === 'rating';
return (
<tr
key={member.driverId}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="text-white font-medium">
{getDriverName(member.driverId)}
</span>
{isCurrentUser && (
<span className="text-xs text-gray-500">(You)</span>
)}
{isTopPerformer && (
<span className="text-xs"></span>
)}
</div>
</td>
<td className="py-3 px-4">
<span className="text-primary-blue font-medium">
{driverStats?.rating || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-gray-300">
#{driverStats?.overallRank || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-green-400 font-medium">
{driverStats?.wins || 0}
</span>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}>
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white text-sm">
{new Date(member.joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</td>
{showActions && (
<td className="py-3 px-4 text-right">
{!cannotModify && !isCurrentUser && (
<div className="flex items-center justify-end gap-2">
{onUpdateRole && (
<select
value={member.role}
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="member">Member</option>
<option value="steward">Steward</option>
<option value="admin">Admin</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(member.driverId)}
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
>
Remove
</button>
)}
</div>
)}
{cannotModify && (
<span className="text-xs text-gray-500"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { getRaceRepository } from '@/lib/di-container';
import { getCurrentDriverId } from '@/lib/membership-data';
import {
isRegistered,
registerForRace,
withdrawFromRace
} from '@/lib/registration-data';
interface LeagueScheduleProps {
leagueId: string;
}
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const router = useRouter();
const [races, setRaces] = useState<Race[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
const [processingRace, setProcessingRace] = useState<string | null>(null);
const currentDriverId = getCurrentDriverId();
useEffect(() => {
loadRaces();
}, [leagueId]);
const loadRaces = async () => {
setLoading(true);
try {
const raceRepo = getRaceRepository();
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);
// Load registration states
const states: Record<string, boolean> = {};
leagueRaces.forEach(race => {
states[race.id] = isRegistered(race.id, currentDriverId);
});
setRegistrationStates(states);
} catch (error) {
console.error('Failed to load races:', error);
} finally {
setLoading(false);
}
};
const handleRegister = async (race: Race, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(
`Register for ${race.track}?`
);
if (!confirmed) return;
setProcessingRace(race.id);
try {
registerForRace(race.id, currentDriverId, leagueId);
setRegistrationStates(prev => ({ ...prev, [race.id]: true }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register');
} finally {
setProcessingRace(null);
}
};
const handleWithdraw = async (race: Race, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(
'Withdraw from this race?'
);
if (!confirmed) return;
setProcessingRace(race.id);
try {
withdrawFromRace(race.id, currentDriverId);
setRegistrationStates(prev => ({ ...prev, [race.id]: false }));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw');
} finally {
setProcessingRace(null);
}
};
const now = new Date();
const upcomingRaces = races.filter(race => race.status === 'scheduled' && new Date(race.scheduledAt) > now);
const pastRaces = races.filter(race => race.status === 'completed' || new Date(race.scheduledAt) <= now);
const getDisplayRaces = () => {
switch (filter) {
case 'upcoming':
return upcomingRaces;
case 'past':
return pastRaces.reverse();
case 'all':
return [...upcomingRaces, ...pastRaces.reverse()];
default:
return races;
}
};
const displayRaces = getDisplayRaces();
if (loading) {
return (
<div className="text-center py-8 text-gray-400">
Loading schedule...
</div>
);
}
return (
<div>
{/* Filter Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p>
<div className="flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'upcoming'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Upcoming ({upcomingRaces.length})
</button>
<button
onClick={() => setFilter('past')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'past'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Past ({pastRaces.length})
</button>
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'all'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
All ({races.length})
</button>
</div>
</div>
{/* Race List */}
{displayRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No {filter} races</p>
{filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
)}
</div>
) : (
<div className="space-y-3">
{displayRaces.map((race) => {
const isPast = race.status === 'completed' || new Date(race.scheduledAt) <= now;
const isUpcoming = race.status === 'scheduled' && new Date(race.scheduledAt) > now;
return (
<div
key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
isPast
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{race.track}</h3>
{isUpcoming && !registrationStates[race.id] && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
)}
{isUpcoming && registrationStates[race.id] && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
)}
{isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
Completed
</span>
)}
</div>
<p className="text-sm text-gray-400">{race.car}</p>
<div className="flex items-center gap-3 mt-2">
<p className="text-xs text-gray-500 uppercase">{race.sessionType}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-white font-medium">
{new Date(race.scheduledAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-sm text-gray-400">
{new Date(race.scheduledAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
{isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p>
)}
</div>
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
{!registrationStates[race.id] ? (
<button
onClick={(e) => handleRegister(race, e)}
disabled={processingRace === race.id}
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{processingRace === race.id ? 'Registering...' : 'Register'}
</button>
) : (
<button
onClick={(e) => handleWithdraw(race, e)}
disabled={processingRace === race.id}
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{processingRace === race.id ? 'Withdrawing...' : 'Withdraw'}
</button>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { getMembership, getCurrentDriverId, type MembershipRole } from '@/lib/membership-data';
interface MembershipStatusProps {
leagueId: string;
className?: string;
}
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
const currentDriverId = getCurrentDriverId();
const membership = getMembership(leagueId, currentDriverId);
if (!membership) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
Not a Member
</span>
);
}
const getRoleDisplay = (role: MembershipRole): { text: string; bgColor: string; textColor: string; borderColor: string } => {
switch (role) {
case 'owner':
return {
text: 'Owner',
bgColor: 'bg-yellow-500/10',
textColor: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
};
case 'admin':
return {
text: 'Admin',
bgColor: 'bg-purple-500/10',
textColor: 'text-purple-400',
borderColor: 'border-purple-500/30',
};
case 'steward':
return {
text: 'Steward',
bgColor: 'bg-blue-500/10',
textColor: 'text-blue-400',
borderColor: 'border-blue-500/30',
};
case 'member':
return {
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
}
};
const { text, bgColor, textColor, borderColor } = getRoleDisplay(membership.role);
return (
<span className={`px-3 py-1 text-xs font-medium ${bgColor} ${textColor} rounded border ${borderColor} ${className}`}>
{text}
</span>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import Card from '../ui/Card';
interface PerformanceMetricsProps {
stats: {
winRate: number;
podiumRate: number;
dnfRate: number;
avgFinish: number;
consistency: number;
bestFinish: number;
worstFinish: number;
};
}
export default function PerformanceMetrics({ stats }: PerformanceMetricsProps) {
const getPerformanceColor = (value: number, type: 'rate' | 'finish' | 'consistency') => {
if (type === 'rate') {
if (value >= 30) return 'text-green-400';
if (value >= 15) return 'text-warning-amber';
return 'text-gray-300';
}
if (type === 'consistency') {
if (value >= 80) return 'text-green-400';
if (value >= 60) return 'text-warning-amber';
return 'text-gray-300';
}
return 'text-white';
};
const metrics = [
{
label: 'Win Rate',
value: `${stats.winRate.toFixed(1)}%`,
color: getPerformanceColor(stats.winRate, 'rate'),
icon: '🏆'
},
{
label: 'Podium Rate',
value: `${stats.podiumRate.toFixed(1)}%`,
color: getPerformanceColor(stats.podiumRate, 'rate'),
icon: '🥇'
},
{
label: 'DNF Rate',
value: `${stats.dnfRate.toFixed(1)}%`,
color: stats.dnfRate < 10 ? 'text-green-400' : 'text-danger-red',
icon: '❌'
},
{
label: 'Avg Finish',
value: stats.avgFinish.toFixed(1),
color: 'text-white',
icon: '📊'
},
{
label: 'Consistency',
value: `${stats.consistency.toFixed(0)}%`,
color: getPerformanceColor(stats.consistency, 'consistency'),
icon: '🎯'
},
{
label: 'Best / Worst',
value: `${stats.bestFinish} / ${stats.worstFinish}`,
color: 'text-gray-300',
icon: '📈'
}
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{metrics.map((metric, index) => (
<Card key={index} className="text-center">
<div className="text-2xl mb-2">{metric.icon}</div>
<div className="text-sm text-gray-400 mb-1">{metric.label}</div>
<div className={`text-xl font-bold ${metric.color}`}>{metric.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import Button from '../ui/Button';
import { getDriverTeam } from '@/lib/team-data';
interface ProfileHeaderProps {
driver: DriverDTO;
isOwnProfile?: boolean;
onEditClick?: () => void;
}
export default function ProfileHeader({ driver, isOwnProfile = false, onEditClick }: ProfileHeaderProps) {
return (
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 flex items-center justify-center text-3xl font-bold text-white">
{driver.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{driver.name}</h1>
<span className="text-3xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-medium">
{teamData.team.tag}
</span>
);
}
return null;
})()}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>iRacing ID: {driver.iracingId}</span>
<span></span>
<span>Rating: 1450</span>
{(() => {
const teamData = getDriverTeam(driver.id);
if (teamData) {
return (
<>
<span></span>
<span className="text-primary-blue">{teamData.team.name}</span>
</>
);
}
return null;
})()}
</div>
</div>
</div>
{isOwnProfile && (
<Button variant="secondary" onClick={onEditClick}>
Edit Profile
</Button>
)}
</div>
);
}
function getCountryFlag(countryCode: string): string {
const code = countryCode.toUpperCase();
if (code.length === 2) {
const codePoints = [...code].map(char =>
127397 + char.charCodeAt(0)
);
return String.fromCodePoint(...codePoints);
}
return '🏁';
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
interface RaceResult {
id: string;
date: string;
track: string;
car: string;
position: number;
startPosition: number;
incidents: number;
league: string;
}
const mockRaceHistory: RaceResult[] = [
{ id: '1', date: '2024-11-28', track: 'Spa-Francorchamps', car: 'Porsche 911 GT3 R', position: 1, startPosition: 3, incidents: 0, league: 'GridPilot Championship' },
{ id: '2', date: '2024-11-21', track: 'Nürburgring GP', car: 'Porsche 911 GT3 R', position: 4, startPosition: 5, incidents: 2, league: 'GridPilot Championship' },
{ id: '3', date: '2024-11-14', track: 'Monza', car: 'Ferrari 488 GT3', position: 2, startPosition: 1, incidents: 1, league: 'GT3 Sprint Series' },
{ id: '4', date: '2024-11-07', track: 'Silverstone', car: 'Audi R8 LMS GT3', position: 7, startPosition: 12, incidents: 0, league: 'GridPilot Championship' },
{ id: '5', date: '2024-10-31', track: 'Interlagos', car: 'Mercedes-AMG GT3', position: 3, startPosition: 4, incidents: 1, league: 'GT3 Sprint Series' },
{ id: '6', date: '2024-10-24', track: 'Road Atlanta', car: 'Porsche 911 GT3 R', position: 5, startPosition: 8, incidents: 2, league: 'GridPilot Championship' },
{ id: '7', date: '2024-10-17', track: 'Watkins Glen', car: 'BMW M4 GT3', position: 1, startPosition: 2, incidents: 0, league: 'GT3 Sprint Series' },
{ id: '8', date: '2024-10-10', track: 'Brands Hatch', car: 'Porsche 911 GT3 R', position: 6, startPosition: 7, incidents: 3, league: 'GridPilot Championship' },
{ id: '9', date: '2024-10-03', track: 'Suzuka', car: 'McLaren 720S GT3', position: 2, startPosition: 6, incidents: 1, league: 'GT3 Sprint Series' },
{ id: '10', date: '2024-09-26', track: 'Bathurst', car: 'Porsche 911 GT3 R', position: 8, startPosition: 10, incidents: 0, league: 'GridPilot Championship' },
{ id: '11', date: '2024-09-19', track: 'Laguna Seca', car: 'Ferrari 488 GT3', position: 3, startPosition: 5, incidents: 2, league: 'GT3 Sprint Series' },
{ id: '12', date: '2024-09-12', track: 'Imola', car: 'Audi R8 LMS GT3', position: 1, startPosition: 1, incidents: 0, league: 'GridPilot Championship' },
];
export default function ProfileRaceHistory() {
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
const [page, setPage] = useState(1);
const resultsPerPage = 10;
const filteredResults = mockRaceHistory.filter(result => {
if (filter === 'wins') return result.position === 1;
if (filter === 'podiums') return result.position <= 3;
return true;
});
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
const paginatedResults = filteredResults.slice(
(page - 1) * resultsPerPage,
page * resultsPerPage
);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button
variant={filter === 'all' ? 'primary' : 'secondary'}
onClick={() => { setFilter('all'); setPage(1); }}
className="text-sm"
>
All Races
</Button>
<Button
variant={filter === 'wins' ? 'primary' : 'secondary'}
onClick={() => { setFilter('wins'); setPage(1); }}
className="text-sm"
>
Wins Only
</Button>
<Button
variant={filter === 'podiums' ? 'primary' : 'secondary'}
onClick={() => { setFilter('podiums'); setPage(1); }}
className="text-sm"
>
Podiums
</Button>
</div>
<Card>
<div className="space-y-2">
{paginatedResults.map((result) => (
<div
key={result.id}
className="p-4 rounded bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className={`
w-8 h-8 rounded flex items-center justify-center font-bold text-sm
${result.position === 1 ? 'bg-green-400/20 text-green-400' :
result.position === 2 ? 'bg-gray-400/20 text-gray-400' :
result.position === 3 ? 'bg-warning-amber/20 text-warning-amber' :
'bg-charcoal-outline text-gray-400'}
`}>
P{result.position}
</div>
<div>
<div className="text-white font-medium">{result.track}</div>
<div className="text-sm text-gray-400">{result.car}</div>
</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-400">
{new Date(result.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
<div className="text-xs text-gray-500">{result.league}</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>Started P{result.startPosition}</span>
<span></span>
<span className={result.incidents === 0 ? 'text-green-400' : result.incidents > 2 ? 'text-red-400' : ''}>
{result.incidents}x incidents
</span>
{result.position < result.startPosition && (
<>
<span></span>
<span className="text-green-400">+{result.startPosition - result.position} positions</span>
</>
)}
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4 pt-4 border-t border-charcoal-outline">
<Button
variant="secondary"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="text-sm"
>
Previous
</Button>
<span className="text-gray-400 text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="secondary"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="text-sm"
>
Next
</Button>
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
interface ProfileSettingsProps {
driver: DriverDTO;
onSave?: (updates: Partial<DriverDTO>) => void;
}
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
const [bio, setBio] = useState(driver.bio || '');
const [nationality, setNationality] = useState(driver.country);
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
const [favoriteSeries, setFavoriteSeries] = useState('Endurance');
const [competitiveLevel, setCompetitiveLevel] = useState('competitive');
const [preferredRegions, setPreferredRegions] = useState<string[]>(['EU']);
const handleSave = () => {
onSave?.({
bio,
country: nationality
});
};
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Profile Information</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Bio</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent resize-none"
rows={4}
placeholder="Tell us about yourself..."
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Nationality</label>
<Input
type="text"
value={nationality}
onChange={(e) => setNationality(e.target.value)}
placeholder="e.g., US, GB, DE"
maxLength={2}
/>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Racing Preferences</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Favorite Car Class</label>
<select
value={favoriteCarClass}
onChange={(e) => setFavoriteCarClass(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 focus:border-transparent"
>
<option value="GT3">GT3</option>
<option value="GT4">GT4</option>
<option value="Formula">Formula</option>
<option value="LMP2">LMP2</option>
<option value="Touring">Touring Cars</option>
<option value="NASCAR">NASCAR</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Favorite Series Type</label>
<select
value={favoriteSeries}
onChange={(e) => setFavoriteSeries(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 focus:border-transparent"
>
<option value="Sprint">Sprint</option>
<option value="Endurance">Endurance</option>
<option value="Mixed">Mixed</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Competitive Level</label>
<select
value={competitiveLevel}
onChange={(e) => setCompetitiveLevel(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 focus:border-transparent"
>
<option value="casual">Casual - Just for fun</option>
<option value="competitive">Competitive - Aiming to win</option>
<option value="professional">Professional - Esports focused</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Preferred Regions</label>
<div className="space-y-2">
{['NA', 'EU', 'ASIA', 'OCE'].map(region => (
<label key={region} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={preferredRegions.includes(region)}
onChange={(e) => {
if (e.target.checked) {
setPreferredRegions([...preferredRegions, region]);
} else {
setPreferredRegions(preferredRegions.filter(r => r !== region));
}
}}
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
<span className="text-white text-sm">{region}</span>
</label>
))}
</div>
</div>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3>
<div className="space-y-3">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Show profile to other drivers</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Show race history</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Allow friend requests</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
</div>
</Card>
<div className="flex gap-3">
<Button variant="primary" onClick={handleSave} className="flex-1">
Save Changes
</Button>
<Button variant="secondary" className="flex-1">
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import Card from '../ui/Card';
import RankBadge from './RankBadge';
import { getDriverStats, getAllDriverRankings, getLeagueRankings } from '@/lib/di-container';
interface ProfileStatsProps {
driverId?: string;
stats?: {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number;
completionRate: number;
};
}
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const driverStats = driverId ? getDriverStats(driverId) : null;
const allRankings = getAllDriverRankings();
const leagueRank = driverId ? getLeagueRankings(driverId, 'league-1') : null;
const defaultStats = stats || (driverStats ? {
totalRaces: driverStats.totalRaces,
wins: driverStats.wins,
podiums: driverStats.podiums,
dnfs: driverStats.dnfs,
avgFinish: driverStats.avgFinish,
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
} : {
totalRaces: 147,
wins: 23,
podiums: 56,
dnfs: 12,
avgFinish: 5.8,
completionRate: 91.8
});
const winRate = ((defaultStats.wins / defaultStats.totalRaces) * 100).toFixed(1);
const podiumRate = ((defaultStats.podiums / defaultStats.totalRaces) * 100).toFixed(1);
const getTrendIndicator = (value: number) => {
if (value > 0) return '↑';
if (value < 0) return '↓';
return '→';
};
const getPercentileLabel = (percentile: number) => {
if (percentile >= 90) return 'Top 10%';
if (percentile >= 75) return 'Top 25%';
if (percentile >= 50) return 'Top 50%';
return `${(100 - percentile).toFixed(0)}th percentile`;
};
const getPercentileColor = (percentile: number) => {
if (percentile >= 90) return 'text-green-400';
if (percentile >= 75) return 'text-primary-blue';
if (percentile >= 50) return 'text-warning-amber';
return 'text-gray-400';
};
return (
<div className="space-y-6">
{driverStats && (
<Card>
<h3 className="text-xl font-semibold text-white mb-6">Rankings Dashboard</h3>
<div className="space-y-4">
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<RankBadge rank={driverStats.overallRank} size="lg" />
<div>
<div className="text-white font-medium text-lg">Overall Ranking</div>
<div className="text-sm text-gray-400">
{driverStats.overallRank} of {allRankings.length} drivers
</div>
</div>
</div>
<div className="text-right">
<div className={`text-sm font-medium ${getPercentileColor(driverStats.percentile)}`}>
{getPercentileLabel(driverStats.percentile)}
</div>
<div className="text-xs text-gray-500">Global Percentile</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
<div className="text-center">
<div className="text-2xl font-bold text-primary-blue">{driverStats.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-green-400">
{getTrendIndicator(5)} {winRate}%
</div>
<div className="text-xs text-gray-400">Win Rate</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-warning-amber">
{getTrendIndicator(2)} {podiumRate}%
</div>
<div className="text-xs text-gray-400">Podium Rate</div>
</div>
</div>
</div>
{leagueRank && leagueRank.totalDrivers > 0 && (
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<RankBadge rank={leagueRank.rank} size="md" />
<div>
<div className="text-white font-medium">European GT Championship</div>
<div className="text-sm text-gray-400">
{leagueRank.rank} of {leagueRank.totalDrivers} drivers
</div>
</div>
</div>
<div className="text-right">
<div className={`text-sm font-medium ${getPercentileColor(leagueRank.percentile)}`}>
{getPercentileLabel(leagueRank.percentile)}
</div>
<div className="text-xs text-gray-500">League Percentile</div>
</div>
</div>
</div>
)}
</div>
</Card>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total Races', value: defaultStats.totalRaces, color: 'text-primary-blue' },
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{ label: 'Podiums', value: defaultStats.podiums, color: 'text-warning-amber' },
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{ label: 'Avg Finish', value: defaultStats.avgFinish.toFixed(1), color: 'text-white' },
{ label: 'Completion', value: `${defaultStats.completionRate.toFixed(1)}%`, color: 'text-green-400' },
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{ label: 'Podium Rate', value: `${podiumRate}%`, color: 'text-warning-amber' }
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Car Class</h3>
<div className="space-y-3 text-sm">
<PerformanceRow label="GT3" races={45} wins={12} podiums={23} avgFinish={4.2} />
<PerformanceRow label="Formula" races={38} wins={7} podiums={15} avgFinish={6.1} />
<PerformanceRow label="LMP2" races={32} wins={4} podiums={11} avgFinish={7.3} />
<PerformanceRow label="Other" races={32} wins={0} podiums={7} avgFinish={8.5} />
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
Performance trends, track-specific stats, head-to-head comparisons vs friends, and league member comparisons will be available in production.
</p>
</Card>
</div>
);
}
function PerformanceRow({ label, races, wins, podiums, avgFinish }: {
label: string;
races: number;
wins: number;
podiums: number;
avgFinish: number;
}) {
const winRate = ((wins / races) * 100).toFixed(0);
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<div className="flex-1">
<div className="text-white font-medium">{label}</div>
<div className="text-gray-500 text-xs">{races} races</div>
</div>
<div className="flex items-center gap-6 text-xs">
<div>
<div className="text-gray-500">Wins</div>
<div className="text-green-400 font-medium">{wins} ({winRate}%)</div>
</div>
<div>
<div className="text-gray-500">Podiums</div>
<div className="text-warning-amber font-medium">{podiums}</div>
</div>
<div>
<div className="text-gray-500">Avg</div>
<div className="text-white font-medium">{avgFinish.toFixed(1)}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
interface RankBadgeProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
}
export default function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) {
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return null;
}
};
const medal = getMedalEmoji(rank);
const sizeClasses = {
sm: 'text-sm px-2 py-1',
md: 'text-base px-3 py-1.5',
lg: 'text-lg px-4 py-2'
};
const getRankColor = (rank: number) => {
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
};
return (
<span className={`inline-flex items-center gap-1.5 rounded font-medium border ${getRankColor(rank)} ${sizeClasses[size]}`}>
{medal && <span>{medal}</span>}
{showLabel && <span>#{rank}</span>}
{!showLabel && !medal && <span>#{rank}</span>}
</span>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import Card from '../ui/Card';
interface RatingBreakdownProps {
skillRating?: number;
safetyRating?: number;
sportsmanshipRating?: number;
}
export default function RatingBreakdown({
skillRating = 1450,
safetyRating = 92,
sportsmanshipRating = 4.8
}: RatingBreakdownProps) {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-6">Rating Components</h3>
<div className="space-y-6">
<RatingComponent
label="Skill Rating"
value={skillRating}
maxValue={2000}
color="primary-blue"
description="Based on race results, competition strength, and consistency"
breakdown={[
{ label: 'Race Results', percentage: 60 },
{ label: 'Competition Quality', percentage: 25 },
{ label: 'Consistency', percentage: 15 }
]}
/>
<RatingComponent
label="Safety Rating"
value={safetyRating}
maxValue={100}
color="green-400"
suffix="%"
description="Reflects incident-free racing and clean overtakes"
breakdown={[
{ label: 'Incident Rate', percentage: 70 },
{ label: 'Clean Overtakes', percentage: 20 },
{ label: 'Position Awareness', percentage: 10 }
]}
/>
<RatingComponent
label="Sportsmanship"
value={sportsmanshipRating}
maxValue={5}
color="warning-amber"
suffix="/5"
description="Community feedback on racing behavior and fair play"
breakdown={[
{ label: 'Peer Reviews', percentage: 50 },
{ label: 'Fair Racing', percentage: 30 },
{ label: 'Team Play', percentage: 20 }
]}
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Rating History</h3>
<div className="space-y-3">
<HistoryItem
date="November 2024"
skillChange={+15}
safetyChange={+2}
sportsmanshipChange={0}
/>
<HistoryItem
date="October 2024"
skillChange={+28}
safetyChange={-1}
sportsmanshipChange={+0.1}
/>
<HistoryItem
date="September 2024"
skillChange={-12}
safetyChange={+3}
sportsmanshipChange={0}
/>
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Rating Insights</h3>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-green-400 mt-0.5"></span>
<span>Strong safety rating - keep up the clean racing!</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5"></span>
<span>Skill rating improving - competitive against higher-rated drivers</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5">i</span>
<span>Complete more races to stabilize your ratings</span>
</li>
</ul>
</Card>
</div>
);
}
function RatingComponent({
label,
value,
maxValue,
color,
suffix = '',
description,
breakdown
}: {
label: string;
value: number;
maxValue: number;
color: string;
suffix?: string;
description: string;
breakdown: { label: string; percentage: number }[];
}) {
const percentage = (value / maxValue) * 100;
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">{label}</span>
<span className={`text-2xl font-bold text-${color}`}>
{value}{suffix}
</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2 mb-3">
<div
className={`bg-${color} rounded-full h-2 transition-all duration-500`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-400 mb-3">{description}</p>
<div className="space-y-1">
{breakdown.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-gray-500">{item.label}</span>
<span className="text-gray-400">{item.percentage}%</span>
</div>
))}
</div>
</div>
);
}
function HistoryItem({
date,
skillChange,
safetyChange,
sportsmanshipChange
}: {
date: string;
skillChange: number;
safetyChange: number;
sportsmanshipChange: number;
}) {
const formatChange = (value: number) => {
if (value === 0) return '—';
return value > 0 ? `+${value}` : `${value}`;
};
const getChangeColor = (value: number) => {
if (value === 0) return 'text-gray-500';
return value > 0 ? 'text-green-400' : 'text-red-400';
};
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<span className="text-white text-sm">{date}</span>
<div className="flex items-center gap-4 text-xs">
<div className="text-center">
<div className="text-gray-500 mb-1">Skill</div>
<div className={getChangeColor(skillChange)}>{formatChange(skillChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Safety</div>
<div className={getChangeColor(safetyChange)}>{formatChange(safetyChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Sports</div>
<div className={getChangeColor(sportsmanshipChange)}>{formatChange(sportsmanshipChange)}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { getDriverRepository } from '@/lib/di-container';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import {
Team,
TeamJoinRequest,
getTeamJoinRequests,
approveTeamJoinRequest,
rejectTeamJoinRequest,
updateTeam,
} from '@/lib/team-data';
interface TeamAdminProps {
team: Team;
onUpdate: () => void;
}
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const [joinRequests, setJoinRequests] = useState<TeamJoinRequest[]>([]);
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [editedTeam, setEditedTeam] = useState({
name: team.name,
tag: team.tag,
description: team.description,
});
useEffect(() => {
loadJoinRequests();
}, [team.id]);
const loadJoinRequests = async () => {
const requests = getTeamJoinRequests(team.id);
setJoinRequests(requests);
const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll();
const driverMap: Record<string, DriverDTO> = {};
for (const request of requests) {
const driver = allDrivers.find(d => d.id === request.driverId);
if (driver) {
const dto = EntityMappers.toDriverDTO(driver);
if (dto) {
driverMap[request.driverId] = dto;
}
}
}
setRequestDrivers(driverMap);
setLoading(false);
};
const handleApprove = async (requestId: string) => {
try {
approveTeamJoinRequest(requestId);
await loadJoinRequests();
onUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to approve request');
}
};
const handleReject = async (requestId: string) => {
try {
rejectTeamJoinRequest(requestId);
await loadJoinRequests();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to reject request');
}
};
const handleSaveChanges = () => {
try {
updateTeam(team.id, editedTeam, team.ownerId);
setEditMode(false);
onUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to update team');
}
};
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold text-white">Team Settings</h3>
{!editMode && (
<Button variant="secondary" onClick={() => setEditMode(true)}>
Edit Details
</Button>
)}
</div>
{editMode ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Team Name
</label>
<Input
type="text"
value={editedTeam.name}
onChange={(e) => setEditedTeam({ ...editedTeam, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Team Tag
</label>
<Input
type="text"
value={editedTeam.tag}
onChange={(e) => setEditedTeam({ ...editedTeam, tag: e.target.value })}
maxLength={4}
/>
<p className="text-xs text-gray-500 mt-1">Max 4 characters</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Description
</label>
<textarea
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm resize-none"
rows={4}
value={editedTeam.description}
onChange={(e) => setEditedTeam({ ...editedTeam, description: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button variant="primary" onClick={handleSaveChanges}>
Save Changes
</Button>
<Button
variant="secondary"
onClick={() => {
setEditMode(false);
setEditedTeam({
name: team.name,
tag: team.tag,
description: team.description,
});
}}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<div className="text-sm text-gray-400">Team Name</div>
<div className="text-white font-medium">{team.name}</div>
</div>
<div>
<div className="text-sm text-gray-400">Team Tag</div>
<div className="text-white font-medium">{team.tag}</div>
</div>
<div>
<div className="text-sm text-gray-400">Description</div>
<div className="text-white">{team.description}</div>
</div>
</div>
)}
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-6">Join Requests</h3>
{loading ? (
<div className="text-center py-8 text-gray-400">Loading requests...</div>
) : joinRequests.length > 0 ? (
<div className="space-y-3">
{joinRequests.map((request) => {
const driver = requestDrivers[request.driverId];
if (!driver) return null;
return (
<div
key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
{driver.name.charAt(0)}
</div>
<div className="flex-1">
<h4 className="text-white font-medium">{driver.name}</h4>
<p className="text-sm text-gray-400">
{driver.country} Requested {new Date(request.requestedAt).toLocaleDateString()}
</p>
{request.message && (
<p className="text-sm text-gray-300 mt-1 italic">
"{request.message}"
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => handleApprove(request.id)}
>
Approve
</Button>
<Button
variant="danger"
onClick={() => handleReject(request.id)}
>
Reject
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-400">
No pending join requests
</div>
)}
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Danger Zone</h3>
<div className="space-y-4">
<div className="p-4 rounded-lg bg-danger-red/10 border border-danger-red/30">
<h4 className="text-white font-medium mb-2">Disband Team</h4>
<p className="text-sm text-gray-400 mb-4">
Permanently delete this team. This action cannot be undone.
</p>
<Button variant="danger" disabled>
Disband Team (Coming Soon)
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import Card from '../ui/Card';
interface TeamCardProps {
id: string;
name: string;
logo?: string;
memberCount: number;
leagues: string[];
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
onClick?: () => void;
}
export default function TeamCard({
id,
name,
logo,
memberCount,
leagues,
performanceLevel,
onClick,
}: TeamCardProps) {
const performanceBadgeColors = {
beginner: 'bg-green-500/20 text-green-400',
intermediate: 'bg-blue-500/20 text-blue-400',
advanced: 'bg-purple-500/20 text-purple-400',
pro: 'bg-red-500/20 text-red-400',
};
return (
<div
className="cursor-pointer hover:scale-[1.03] transition-transform duration-150"
onClick={onClick}
>
<Card>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0">
{logo ? (
<img src={logo} alt={name} className="w-full h-full object-cover rounded-lg" />
) : (
<span className="text-2xl font-bold text-gray-500">
{name.charAt(0)}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate">
{name}
</h3>
<p className="text-sm text-gray-400">
{memberCount} {memberCount === 1 ? 'member' : 'members'}
</p>
</div>
</div>
{performanceLevel && (
<div>
<span
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
performanceBadgeColors[performanceLevel]
}`}
>
{performanceLevel.charAt(0).toUpperCase() + performanceLevel.slice(1)}
</span>
</div>
)}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-400">Active in:</p>
<div className="flex flex-wrap gap-2">
{leagues.slice(0, 3).map((league, idx) => (
<span
key={idx}
className="inline-block px-2 py-1 bg-charcoal-outline text-gray-300 rounded text-xs"
>
{league}
</span>
))}
{leagues.length > 3 && (
<span className="inline-block px-2 py-1 bg-charcoal-outline text-gray-400 rounded text-xs">
+{leagues.length - 3} more
</span>
)}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,205 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { TeamMembership, TeamRole } from '@/lib/team-data';
interface TeamRosterProps {
teamId: string;
memberships: TeamMembership[];
isAdmin: boolean;
onRemoveMember?: (driverId: string) => void;
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
}
export default function TeamRoster({
teamId,
memberships,
isAdmin,
onRemoveMember,
onChangeRole,
}: TeamRosterProps) {
const [drivers, setDrivers] = useState<Record<string, DriverDTO>>({});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
useEffect(() => {
const loadDrivers = async () => {
const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll();
const driverMap: Record<string, DriverDTO> = {};
for (const membership of memberships) {
const driver = allDrivers.find(d => d.id === membership.driverId);
if (driver) {
const dto = EntityMappers.toDriverDTO(driver);
if (dto) {
driverMap[membership.driverId] = dto;
}
}
}
setDrivers(driverMap);
setLoading(false);
};
loadDrivers();
}, [memberships]);
const getRoleBadgeColor = (role: TeamRole) => {
switch (role) {
case 'owner':
return 'bg-warning-amber/20 text-warning-amber';
case 'manager':
return 'bg-primary-blue/20 text-primary-blue';
default:
return 'bg-charcoal-outline text-gray-300';
}
};
const getRoleLabel = (role: TeamRole) => {
return role.charAt(0).toUpperCase() + role.slice(1);
};
const sortedMemberships = [...memberships].sort((a, b) => {
switch (sortBy) {
case 'rating': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
return (statsB?.rating || 0) - (statsA?.rating || 0);
}
case 'role': {
const roleOrder = { owner: 0, manager: 1, driver: 2 };
return roleOrder[a.role] - roleOrder[b.role];
}
case 'name': {
const driverA = drivers[a.driverId];
const driverB = drivers[b.driverId];
return (driverA?.name || '').localeCompare(driverB?.name || '');
}
default:
return 0;
}
});
const teamAverageRating = memberships.length > 0
? Math.round(
memberships.reduce((sum, m) => {
const stats = getDriverStats(m.driverId);
return sum + (stats?.rating || 0);
}, 0) / memberships.length
)
: 0;
if (loading) {
return (
<Card>
<div className="text-center py-8 text-gray-400">Loading roster...</div>
</Card>
);
}
return (
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-semibold text-white">Team Roster</h3>
<p className="text-sm text-gray-400 mt-1">
{memberships.length} {memberships.length === 1 ? 'member' : 'members'} Avg Rating: <span className="text-primary-blue font-medium">{teamAverageRating}</span>
</p>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>
<option value="role">Role</option>
<option value="name">Name</option>
</select>
</div>
</div>
<div className="space-y-3">
{sortedMemberships.map((membership) => {
const driver = drivers[membership.driverId];
const driverStats = getDriverStats(membership.driverId);
if (!driver) return null;
return (
<div
key={membership.driverId}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
{driver.name.charAt(0)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-white font-medium">{driver.name}</h4>
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(membership.role)}`}>
{getRoleLabel(membership.role)}
</span>
</div>
<p className="text-sm text-gray-400">
{driver.country} Joined {new Date(membership.joinedAt).toLocaleDateString()}
</p>
</div>
{driverStats && (
<div className="flex items-center gap-6 text-center">
<div>
<div className="text-lg font-bold text-primary-blue">{driverStats.rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-sm text-gray-300">#{driverStats.overallRank}</div>
<div className="text-xs text-gray-500">Rank</div>
</div>
</div>
)}
</div>
{isAdmin && membership.role !== 'owner' && (
<div className="flex items-center gap-2">
{onChangeRole && (
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={membership.role}
onChange={(e) => onChangeRole(membership.driverId, e.target.value as TeamRole)}
>
<option value="driver">Driver</option>
<option value="manager">Manager</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(membership.driverId)}
className="px-3 py-2 bg-danger-red/20 hover:bg-danger-red/30 text-danger-red rounded text-sm font-medium transition-colors"
>
Remove
</button>
)}
</div>
)}
</div>
);
})}
</div>
{memberships.length === 0 && (
<div className="text-center py-8 text-gray-400">
No team members yet.
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getStandingRepository, getLeagueRepository } from '@/lib/di-container';
import { EntityMappers, LeagueDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { getTeamMembers } from '@/lib/team-data';
interface TeamStandingsProps {
teamId: string;
leagues: string[];
}
interface TeamLeagueStanding {
leagueId: string;
leagueName: string;
position: number;
points: number;
wins: number;
racesCompleted: number;
}
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const [standings, setStandings] = useState<TeamLeagueStanding[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadStandings = async () => {
const standingRepo = getStandingRepository();
const leagueRepo = getLeagueRepository();
const members = getTeamMembers(teamId);
const memberIds = members.map(m => m.driverId);
const teamStandings: TeamLeagueStanding[] = [];
for (const leagueId of leagues) {
const league = await leagueRepo.findById(leagueId);
if (!league) continue;
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
// Calculate team points (sum of all team members)
let totalPoints = 0;
let totalWins = 0;
let totalRaces = 0;
for (const standing of leagueStandings) {
if (memberIds.includes(standing.driverId)) {
totalPoints += standing.points;
totalWins += standing.wins;
totalRaces = Math.max(totalRaces, standing.racesCompleted);
}
}
// Calculate team position (simplified - based on total points)
const allTeamPoints = leagueStandings
.filter(s => memberIds.includes(s.driverId))
.reduce((sum, s) => sum + s.points, 0);
const position = leagueStandings
.filter((_, idx, arr) => {
const teamPoints = arr
.filter(s => memberIds.includes(s.driverId))
.reduce((sum, s) => sum + s.points, 0);
return teamPoints > allTeamPoints;
}).length + 1;
teamStandings.push({
leagueId,
leagueName: league.name,
position,
points: totalPoints,
wins: totalWins,
racesCompleted: totalRaces,
});
}
setStandings(teamStandings);
setLoading(false);
};
loadStandings();
}, [teamId, leagues]);
if (loading) {
return (
<Card>
<div className="text-center py-8 text-gray-400">Loading standings...</div>
</Card>
);
}
return (
<Card>
<h3 className="text-xl font-semibold text-white mb-6">League Standings</h3>
<div className="space-y-4">
{standings.map((standing) => (
<div
key={standing.leagueId}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-white font-medium">{standing.leagueName}</h4>
<span className="px-3 py-1 bg-primary-blue/20 text-primary-blue rounded-full text-sm font-semibold">
P{standing.position}
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-white">{standing.points}</div>
<div className="text-xs text-gray-400">Points</div>
</div>
<div>
<div className="text-2xl font-bold text-white">{standing.wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-white">{standing.racesCompleted}</div>
<div className="text-xs text-gray-400">Races</div>
</div>
</div>
</div>
))}
</div>
{standings.length === 0 && (
<div className="text-center py-8 text-gray-400">
No standings available yet.
</div>
)}
</Card>
);
}

View File

@@ -11,7 +11,7 @@ type ButtonAsLink = AnchorHTMLAttributes<HTMLAnchorElement> & {
};
type ButtonProps = (ButtonAsButton | ButtonAsLink) & {
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'danger';
children: ReactNode;
};
@@ -26,8 +26,9 @@ export default function Button({
const variantStyles = {
primary: 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.4)] hover:shadow-[0_0_25px_rgba(25,140,255,0.6)] active:ring-2 active:ring-primary-blue focus-visible:outline-primary-blue',
secondary: 'bg-iron-gray text-white border border-charcoal-outline shadow-[0_0_10px_rgba(25,140,255,0.2)] hover:shadow-[0_0_20px_rgba(25,140,255,0.4)] hover:border-primary-blue focus-visible:outline-primary-blue'
};
secondary: 'bg-iron-gray text-white border border-charcoal-outline shadow-[0_0_10px_rgba(25,140,255,0.2)] hover:shadow-[0_0_20px_rgba(25,140,255,0.4)] hover:border-primary-blue focus-visible:outline-primary-blue',
danger: 'bg-red-600 text-white shadow-[0_0_15px_rgba(248,113,113,0.4)] hover:shadow-[0_0_25px_rgba(248,113,113,0.6)] active:ring-2 active:ring-red-600 focus-visible:outline-red-600'
} as const;
const classes = `${baseStyles} ${variantStyles[variant]} ${className}`;

View File

@@ -1,13 +1,17 @@
import { ReactNode } from 'react';
import { ReactNode, MouseEventHandler } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
}
export default function Card({ children, className = '' }: CardProps) {
export default function Card({ children, className = '', onClick }: CardProps) {
return (
<div className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`}>
<div
className={`rounded-lg bg-iron-gray p-6 shadow-card border border-charcoal-outline hover:shadow-glow transition-shadow duration-200 ${className}`}
onClick={onClick}
>
{children}
</div>
);

View File

@@ -32,7 +32,7 @@ export default function MockupStack({ children, index = 0 }: MockupStackProps) {
<div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: rotation1,
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
@@ -46,7 +46,7 @@ export default function MockupStack({ children, index = 0 }: MockupStackProps) {
<div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: rotation2,
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',
@@ -75,7 +75,7 @@ export default function MockupStack({ children, index = 0 }: MockupStackProps) {
<motion.div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: rotation1,
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
@@ -91,7 +91,7 @@ export default function MockupStack({ children, index = 0 }: MockupStackProps) {
<motion.div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: rotation2,
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',

View File

@@ -26,20 +26,131 @@ import { InMemoryStandingRepository } from '@gridpilot/racing-infrastructure/rep
/**
* Seed data for development
*/
/**
* Driver statistics and ranking data
*/
export interface DriverStats {
driverId: string;
rating: number;
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
overallRank: number;
percentile: number;
}
/**
* Mock driver stats with calculated rankings
*/
const driverStats: Record<string, DriverStats> = {};
function createSeedData() {
// Create a sample driver
// Create sample drivers (matching membership-data.ts and team-data.ts)
const driver1 = Driver.create({
id: '550e8400-e29b-41d4-a716-446655440001',
id: 'driver-1',
iracingId: '123456',
name: 'Max Verstappen',
country: 'NL',
bio: 'Three-time world champion',
bio: 'Three-time world champion and team owner of Apex Racing',
joinedAt: new Date('2024-01-15'),
});
// Create a sample league
const driver2 = Driver.create({
id: 'driver-2',
iracingId: '234567',
name: 'Lewis Hamilton',
country: 'GB',
bio: 'Seven-time world champion leading Speed Demons',
joinedAt: new Date('2024-01-20'),
});
const driver3 = Driver.create({
id: 'driver-3',
iracingId: '345678',
name: 'Charles Leclerc',
country: 'MC',
bio: 'Ferrari race winner and Weekend Warriors team owner',
joinedAt: new Date('2024-02-01'),
});
const driver4 = Driver.create({
id: 'driver-4',
iracingId: '456789',
name: 'Lando Norris',
country: 'GB',
bio: 'Rising star in motorsport',
joinedAt: new Date('2024-02-15'),
});
// Initialize driver stats
driverStats['driver-1'] = {
driverId: 'driver-1',
rating: 3245,
totalRaces: 156,
wins: 45,
podiums: 89,
dnfs: 8,
avgFinish: 3.2,
bestFinish: 1,
worstFinish: 18,
consistency: 87,
overallRank: 1,
percentile: 99
};
driverStats['driver-2'] = {
driverId: 'driver-2',
rating: 3198,
totalRaces: 234,
wins: 78,
podiums: 145,
dnfs: 12,
avgFinish: 2.8,
bestFinish: 1,
worstFinish: 22,
consistency: 92,
overallRank: 2,
percentile: 98
};
driverStats['driver-3'] = {
driverId: 'driver-3',
rating: 2912,
totalRaces: 145,
wins: 34,
podiums: 67,
dnfs: 9,
avgFinish: 4.5,
bestFinish: 1,
worstFinish: 20,
consistency: 84,
overallRank: 3,
percentile: 96
};
driverStats['driver-4'] = {
driverId: 'driver-4',
rating: 2789,
totalRaces: 112,
wins: 23,
podiums: 56,
dnfs: 7,
avgFinish: 5.1,
bestFinish: 1,
worstFinish: 16,
consistency: 81,
overallRank: 5,
percentile: 93
};
// Create sample league (matching membership-data.ts)
const league1 = League.create({
id: '550e8400-e29b-41d4-a716-446655440002',
id: 'league-1',
name: 'European GT Championship',
description: 'Weekly GT3 racing with professional drivers',
ownerId: driver1.id,
@@ -53,7 +164,7 @@ function createSeedData() {
// Create sample races
const race1 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440003',
id: 'race-1',
leagueId: league1.id,
scheduledAt: new Date('2024-03-15T19:00:00Z'),
track: 'Monza GP',
@@ -63,7 +174,7 @@ function createSeedData() {
});
const race2 = Race.create({
id: '550e8400-e29b-41d4-a716-446655440004',
id: 'race-2',
leagueId: league1.id,
scheduledAt: new Date('2024-03-22T19:00:00Z'),
track: 'Spa-Francorchamps',
@@ -72,10 +183,58 @@ function createSeedData() {
status: 'scheduled',
});
const race3 = Race.create({
id: 'race-3',
leagueId: league1.id,
scheduledAt: new Date('2024-04-05T19:00:00Z'),
track: 'Nürburgring GP',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'scheduled',
});
// Create sample standings
const standing1 = Standing.create({
leagueId: league1.id,
driverId: driver1.id,
position: 1,
points: 25,
wins: 1,
racesCompleted: 1,
});
const standing2 = Standing.create({
leagueId: league1.id,
driverId: driver2.id,
position: 2,
points: 18,
wins: 0,
racesCompleted: 1,
});
const standing3 = Standing.create({
leagueId: league1.id,
driverId: driver3.id,
position: 3,
points: 15,
wins: 0,
racesCompleted: 1,
});
const standing4 = Standing.create({
leagueId: league1.id,
driverId: driver4.id,
position: 4,
points: 12,
wins: 0,
racesCompleted: 1,
});
return {
drivers: [driver1],
drivers: [driver1, driver2, driver3, driver4],
leagues: [league1],
races: [race1, race2],
races: [race1, race2, race3],
standings: [standing1, standing2, standing3, standing4],
};
}
@@ -108,7 +267,7 @@ class DIContainer {
// Standing repository needs all three for recalculation
this._standingRepository = new InMemoryStandingRepository(
undefined,
seedData.standings,
this._resultRepository,
this._raceRepository,
this._leagueRepository
@@ -184,4 +343,39 @@ export function getStandingRepository(): IStandingRepository {
*/
export function resetContainer(): void {
DIContainer.reset();
}
/**
* Get driver statistics and rankings
*/
export function getDriverStats(driverId: string): DriverStats | null {
return driverStats[driverId] || null;
}
/**
* Get all driver rankings sorted by rating
*/
export function getAllDriverRankings(): DriverStats[] {
return Object.values(driverStats).sort((a, b) => b.rating - a.rating);
}
/**
* Get league-specific rankings for a driver
*/
export function getLeagueRankings(driverId: string, leagueId: string): {
rank: number;
totalDrivers: number;
percentile: number;
} {
// Mock league rankings (in production, calculate from actual league membership)
const mockLeagueRanks: Record<string, Record<string, any>> = {
'league-1': {
'driver-1': { rank: 1, totalDrivers: 12, percentile: 92 },
'driver-2': { rank: 2, totalDrivers: 12, percentile: 84 },
'driver-3': { rank: 4, totalDrivers: 12, percentile: 67 },
'driver-4': { rank: 5, totalDrivers: 12, percentile: 58 }
}
};
return mockLeagueRanks[leagueId]?.[driverId] || { rank: 0, totalDrivers: 0, percentile: 0 };
}

View File

@@ -0,0 +1,208 @@
/**
* In-memory league membership data for alpha prototype
*/
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type MembershipStatus = 'active' | 'pending' | 'none';
export interface LeagueMembership {
leagueId: string;
driverId: string;
role: MembershipRole;
status: MembershipStatus;
joinedAt: Date;
}
export interface JoinRequest {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
}
// In-memory storage
let memberships: LeagueMembership[] = [];
let joinRequests: JoinRequest[] = [];
// Current driver ID (matches the one in di-container)
const CURRENT_DRIVER_ID = 'driver-1';
// Initialize with seed data
export function initializeMembershipData() {
memberships = [
{
leagueId: 'league-1',
driverId: CURRENT_DRIVER_ID,
role: 'owner',
status: 'active',
joinedAt: new Date('2024-01-15'),
},
{
leagueId: 'league-1',
driverId: 'driver-2',
role: 'member',
status: 'active',
joinedAt: new Date('2024-02-01'),
},
{
leagueId: 'league-1',
driverId: 'driver-3',
role: 'admin',
status: 'active',
joinedAt: new Date('2024-02-15'),
},
];
joinRequests = [];
}
// Get membership for a driver in a league
export function getMembership(leagueId: string, driverId: string): LeagueMembership | null {
return memberships.find(m => m.leagueId === leagueId && m.driverId === driverId) || null;
}
// Get all members for a league
export function getLeagueMembers(leagueId: string): LeagueMembership[] {
return memberships.filter(m => m.leagueId === leagueId && m.status === 'active');
}
// Get pending join requests for a league
export function getJoinRequests(leagueId: string): JoinRequest[] {
return joinRequests.filter(r => r.leagueId === leagueId);
}
// Join a league
export function joinLeague(leagueId: string, driverId: string): void {
const existing = getMembership(leagueId, driverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
memberships.push({
leagueId,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
});
}
// Request to join a league (for invite-only leagues)
export function requestToJoin(leagueId: string, driverId: string, message?: string): void {
const existing = getMembership(leagueId, driverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
const existingRequest = joinRequests.find(r => r.leagueId === leagueId && r.driverId === driverId);
if (existingRequest) {
throw new Error('Join request already pending');
}
joinRequests.push({
id: `request-${Date.now()}`,
leagueId,
driverId,
requestedAt: new Date(),
message,
});
}
// Leave a league
export function leaveLeague(leagueId: string, driverId: string): void {
const membership = getMembership(leagueId, driverId);
if (!membership) {
throw new Error('Not a member of this league');
}
if (membership.role === 'owner') {
throw new Error('League owner cannot leave. Transfer ownership first.');
}
memberships = memberships.filter(m => !(m.leagueId === leagueId && m.driverId === driverId));
}
// Approve join request
export function approveJoinRequest(requestId: string): void {
const request = joinRequests.find(r => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
memberships.push({
leagueId: request.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
joinedAt: new Date(),
});
joinRequests = joinRequests.filter(r => r.id !== requestId);
}
// Reject join request
export function rejectJoinRequest(requestId: string): void {
joinRequests = joinRequests.filter(r => r.id !== requestId);
}
// Remove member (admin action)
export function removeMember(leagueId: string, driverId: string, removedBy: string): void {
const removerMembership = getMembership(leagueId, removedBy);
if (!removerMembership || (removerMembership.role !== 'owner' && removerMembership.role !== 'admin')) {
throw new Error('Only owners and admins can remove members');
}
const targetMembership = getMembership(leagueId, driverId);
if (!targetMembership) {
throw new Error('Member not found');
}
if (targetMembership.role === 'owner') {
throw new Error('Cannot remove league owner');
}
memberships = memberships.filter(m => !(m.leagueId === leagueId && m.driverId === driverId));
}
// Update member role
export function updateMemberRole(
leagueId: string,
driverId: string,
newRole: MembershipRole,
updatedBy: string
): void {
const updaterMembership = getMembership(leagueId, updatedBy);
if (!updaterMembership || updaterMembership.role !== 'owner') {
throw new Error('Only league owner can change roles');
}
const targetMembership = getMembership(leagueId, driverId);
if (!targetMembership) {
throw new Error('Member not found');
}
if (newRole === 'owner') {
throw new Error('Use transfer ownership to change owner');
}
memberships = memberships.map(m =>
m.leagueId === leagueId && m.driverId === driverId
? { ...m, role: newRole }
: m
);
}
// Check if driver is owner or admin
export function isOwnerOrAdmin(leagueId: string, driverId: string): boolean {
const membership = getMembership(leagueId, driverId);
return membership?.role === 'owner' || membership?.role === 'admin';
}
// Get current driver ID
export function getCurrentDriverId(): string {
return CURRENT_DRIVER_ID;
}
// Initialize on module load
initializeMembershipData();

View File

@@ -0,0 +1,130 @@
/**
* In-memory race registration data for alpha prototype
*/
import { getMembership } from './membership-data';
export interface RaceRegistration {
raceId: string;
driverId: string;
registeredAt: Date;
}
// In-memory storage (Set for quick lookups)
const registrations = new Map<string, Set<string>>(); // raceId -> Set of driverIds
/**
* Generate registration key for storage
*/
function getRegistrationKey(raceId: string, driverId: string): string {
return `${raceId}:${driverId}`;
}
/**
* Check if driver is registered for a race
*/
export function isRegistered(raceId: string, driverId: string): boolean {
const raceRegistrations = registrations.get(raceId);
return raceRegistrations ? raceRegistrations.has(driverId) : false;
}
/**
* Get all registered drivers for a race
*/
export function getRegisteredDrivers(raceId: string): string[] {
const raceRegistrations = registrations.get(raceId);
return raceRegistrations ? Array.from(raceRegistrations) : [];
}
/**
* Get registration count for a race
*/
export function getRegistrationCount(raceId: string): number {
const raceRegistrations = registrations.get(raceId);
return raceRegistrations ? raceRegistrations.size : 0;
}
/**
* Register driver for a race
* Validates league membership before registering
*/
export function registerForRace(
raceId: string,
driverId: string,
leagueId: string
): void {
// Check if already registered
if (isRegistered(raceId, driverId)) {
throw new Error('Already registered for this race');
}
// Validate league membership
const membership = getMembership(leagueId, driverId);
if (!membership || membership.status !== 'active') {
throw new Error('Must be an active league member to register for races');
}
// Add registration
if (!registrations.has(raceId)) {
registrations.set(raceId, new Set());
}
registrations.get(raceId)!.add(driverId);
}
/**
* Withdraw from a race
*/
export function withdrawFromRace(raceId: string, driverId: string): void {
const raceRegistrations = registrations.get(raceId);
if (!raceRegistrations || !raceRegistrations.has(driverId)) {
throw new Error('Not registered for this race');
}
raceRegistrations.delete(driverId);
// Clean up empty sets
if (raceRegistrations.size === 0) {
registrations.delete(raceId);
}
}
/**
* Get all races a driver is registered for
*/
export function getDriverRegistrations(driverId: string): string[] {
const raceIds: string[] = [];
for (const [raceId, driverSet] of registrations.entries()) {
if (driverSet.has(driverId)) {
raceIds.push(raceId);
}
}
return raceIds;
}
/**
* Clear all registrations for a race (e.g., when race is cancelled)
*/
export function clearRaceRegistrations(raceId: string): void {
registrations.delete(raceId);
}
/**
* Initialize with seed data
*/
export function initializeRegistrationData(): void {
registrations.clear();
// Add some initial registrations for testing
// Race 2 (Spa-Francorchamps - upcoming)
registerForRace('race-2', 'driver-1', 'league-1');
registerForRace('race-2', 'driver-2', 'league-1');
registerForRace('race-2', 'driver-3', 'league-1');
// Race 3 (Nürburgring GP - upcoming)
registerForRace('race-3', 'driver-1', 'league-1');
}
// Initialize on module load
initializeRegistrationData();

View File

@@ -0,0 +1,335 @@
/**
* In-memory team data for alpha prototype
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
export interface Team {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
}
// In-memory storage
let teams: Team[] = [];
let teamMemberships: TeamMembership[] = [];
let teamJoinRequests: TeamJoinRequest[] = [];
// Current driver ID (matches di-container)
const CURRENT_DRIVER_ID = 'driver-1';
// Initialize with seed data
export function initializeTeamData() {
teams = [
{
id: 'team-1',
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing team competing at the highest level',
ownerId: CURRENT_DRIVER_ID,
leagues: ['league-1'],
createdAt: new Date('2024-01-20'),
},
{
id: 'team-2',
name: 'Speed Demons',
tag: 'SPDM',
description: 'Fast and furious racing with a competitive edge',
ownerId: 'driver-2',
leagues: ['league-1'],
createdAt: new Date('2024-02-01'),
},
{
id: 'team-3',
name: 'Weekend Warriors',
tag: 'WKND',
description: 'Casual but competitive weekend racing',
ownerId: 'driver-3',
leagues: ['league-1'],
createdAt: new Date('2024-02-10'),
},
];
teamMemberships = [
{
teamId: 'team-1',
driverId: CURRENT_DRIVER_ID,
role: 'owner',
status: 'active',
joinedAt: new Date('2024-01-20'),
},
{
teamId: 'team-2',
driverId: 'driver-2',
role: 'owner',
status: 'active',
joinedAt: new Date('2024-02-01'),
},
{
teamId: 'team-3',
driverId: 'driver-3',
role: 'owner',
status: 'active',
joinedAt: new Date('2024-02-10'),
},
];
teamJoinRequests = [];
}
// Get all teams
export function getAllTeams(): Team[] {
return teams;
}
// Get team by ID
export function getTeam(teamId: string): Team | null {
return teams.find(t => t.id === teamId) || null;
}
// Get team membership for a driver
export function getTeamMembership(teamId: string, driverId: string): TeamMembership | null {
return teamMemberships.find(m => m.teamId === teamId && m.driverId === driverId) || null;
}
// Get driver's team
export function getDriverTeam(driverId: string): { team: Team; membership: TeamMembership } | null {
const membership = teamMemberships.find(m => m.driverId === driverId && m.status === 'active');
if (!membership) return null;
const team = getTeam(membership.teamId);
if (!team) return null;
return { team, membership };
}
// Get all members for a team
export function getTeamMembers(teamId: string): TeamMembership[] {
return teamMemberships.filter(m => m.teamId === teamId && m.status === 'active');
}
// Get pending join requests for a team
export function getTeamJoinRequests(teamId: string): TeamJoinRequest[] {
return teamJoinRequests.filter(r => r.teamId === teamId);
}
// Create a new team
export function createTeam(
name: string,
tag: string,
description: string,
ownerId: string,
leagues: string[]
): Team {
// Check if driver already has a team
const existingTeam = getDriverTeam(ownerId);
if (existingTeam) {
throw new Error('Driver already belongs to a team');
}
const team: Team = {
id: `team-${Date.now()}`,
name,
tag,
description,
ownerId,
leagues,
createdAt: new Date(),
};
teams.push(team);
// Auto-assign creator as owner
teamMemberships.push({
teamId: team.id,
driverId: ownerId,
role: 'owner',
status: 'active',
joinedAt: new Date(),
});
return team;
}
// Join a team
export function joinTeam(teamId: string, driverId: string): void {
const existingTeam = getDriverTeam(driverId);
if (existingTeam) {
throw new Error('Driver already belongs to a team');
}
const existing = getTeamMembership(teamId, driverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
teamMemberships.push({
teamId,
driverId,
role: 'driver',
status: 'active',
joinedAt: new Date(),
});
}
// Request to join a team
export function requestToJoinTeam(teamId: string, driverId: string, message?: string): void {
const existingTeam = getDriverTeam(driverId);
if (existingTeam) {
throw new Error('Driver already belongs to a team');
}
const existing = getTeamMembership(teamId, driverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
const existingRequest = teamJoinRequests.find(r => r.teamId === teamId && r.driverId === driverId);
if (existingRequest) {
throw new Error('Join request already pending');
}
teamJoinRequests.push({
id: `team-request-${Date.now()}`,
teamId,
driverId,
requestedAt: new Date(),
message,
});
}
// Leave a team
export function leaveTeam(teamId: string, driverId: string): void {
const membership = getTeamMembership(teamId, driverId);
if (!membership) {
throw new Error('Not a member of this team');
}
if (membership.role === 'owner') {
throw new Error('Team owner cannot leave. Transfer ownership or disband team first.');
}
teamMemberships = teamMemberships.filter(m => !(m.teamId === teamId && m.driverId === driverId));
}
// Approve join request
export function approveTeamJoinRequest(requestId: string): void {
const request = teamJoinRequests.find(r => r.id === requestId);
if (!request) {
throw new Error('Join request not found');
}
teamMemberships.push({
teamId: request.teamId,
driverId: request.driverId,
role: 'driver',
status: 'active',
joinedAt: new Date(),
});
teamJoinRequests = teamJoinRequests.filter(r => r.id !== requestId);
}
// Reject join request
export function rejectTeamJoinRequest(requestId: string): void {
teamJoinRequests = teamJoinRequests.filter(r => r.id !== requestId);
}
// Remove member (admin action)
export function removeTeamMember(teamId: string, driverId: string, removedBy: string): void {
const removerMembership = getTeamMembership(teamId, removedBy);
if (!removerMembership || (removerMembership.role !== 'owner' && removerMembership.role !== 'manager')) {
throw new Error('Only owners and managers can remove members');
}
const targetMembership = getTeamMembership(teamId, driverId);
if (!targetMembership) {
throw new Error('Member not found');
}
if (targetMembership.role === 'owner') {
throw new Error('Cannot remove team owner');
}
teamMemberships = teamMemberships.filter(m => !(m.teamId === teamId && m.driverId === driverId));
}
// Update member role
export function updateTeamMemberRole(
teamId: string,
driverId: string,
newRole: TeamRole,
updatedBy: string
): void {
const updaterMembership = getTeamMembership(teamId, updatedBy);
if (!updaterMembership || updaterMembership.role !== 'owner') {
throw new Error('Only team owner can change roles');
}
const targetMembership = getTeamMembership(teamId, driverId);
if (!targetMembership) {
throw new Error('Member not found');
}
if (newRole === 'owner') {
throw new Error('Use transfer ownership to change owner');
}
teamMemberships = teamMemberships.map(m =>
m.teamId === teamId && m.driverId === driverId
? { ...m, role: newRole }
: m
);
}
// Check if driver is owner or manager
export function isTeamOwnerOrManager(teamId: string, driverId: string): boolean {
const membership = getTeamMembership(teamId, driverId);
return membership?.role === 'owner' || membership?.role === 'manager';
}
// Get current driver ID
export function getCurrentDriverId(): string {
return CURRENT_DRIVER_ID;
}
// Update team info
export function updateTeam(
teamId: string,
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>,
updatedBy: string
): void {
if (!isTeamOwnerOrManager(teamId, updatedBy)) {
throw new Error('Only owners and managers can update team info');
}
teams = teams.map(t =>
t.id === teamId
? { ...t, ...updates }
: t
);
}
// Initialize on module load
initializeTeamData();