This commit is contained in:
2025-12-04 23:31:55 +01:00
parent 9fa21a488a
commit fb509607c1
96 changed files with 5839 additions and 1609 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, use } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
@@ -14,7 +14,7 @@ import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
export default function DriverDetailPage({
searchParams,
}: {
searchParams?: { [key: string]: string | string[] | undefined };
searchParams: any;
}) {
const router = useRouter();
const params = useParams();
@@ -24,14 +24,36 @@ export default function DriverDetailPage({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined;
const from =
typeof searchParams?.from === 'string' ? searchParams.from : undefined;
const leagueId =
typeof searchParams?.leagueId === 'string'
? searchParams.leagueId
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('from') ?? undefined
: undefined;
const backLink =
from === 'league' && leagueId ? `/leagues/${leagueId}` : null;
const leagueId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('leagueId') ?? undefined
: undefined;
const raceId =
typeof unwrappedSearchParams?.get === 'function'
? unwrappedSearchParams.get('raceId') ?? undefined
: undefined;
let backLink: string | null = null;
if (from === 'league-standings' && leagueId) {
backLink = `/leagues/${leagueId}/standings`;
} else if (from === 'league' && leagueId) {
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-members' && leagueId) {
backLink = `/leagues/${leagueId}`;
} else if (from === 'league-race' && leagueId && raceId) {
backLink = `/leagues/${leagueId}/races/${raceId}`;
} else {
backLink = null;
}
useEffect(() => {
loadDriver();
@@ -119,7 +141,7 @@ export default function DriverDetailPage({
/>
{/* Driver Profile Component */}
<DriverProfile driver={driver} />
<DriverProfile driver={driver} isOwnProfile={false} />
</div>
</div>
);

View File

@@ -1,127 +1,101 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import DriverCard from '@/components/drivers/DriverCard';
import RankBadge from '@/components/drivers/RankBadge';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
import { getDriverRepository, getDriverStats, getAllDriverRankings } from '@/lib/di-container';
// Mock data (fictional demo drivers only)
const MOCK_DRIVERS = [
{
id: 'driver-1',
name: 'Alex Vermeer',
rating: 3245,
skillLevel: 'pro' as const,
nationality: 'Netherlands',
racesCompleted: 156,
wins: 45,
podiums: 89,
isActive: true,
rank: 1,
},
{
id: 'driver-2',
name: 'Liam Hartmann',
rating: 3198,
skillLevel: 'pro' as const,
nationality: 'United Kingdom',
racesCompleted: 234,
wins: 78,
podiums: 145,
isActive: true,
rank: 2,
},
{
id: 'driver-3',
name: 'Michael Schmidt',
rating: 2912,
skillLevel: 'advanced' as const,
nationality: 'Germany',
racesCompleted: 145,
wins: 34,
podiums: 67,
isActive: true,
rank: 3,
},
{
id: 'driver-4',
name: 'Emma Thompson',
rating: 2789,
skillLevel: 'advanced' as const,
nationality: 'Australia',
racesCompleted: 112,
wins: 23,
podiums: 56,
isActive: true,
rank: 5,
},
{
id: 'driver-5',
name: 'Sarah Chen',
rating: 2456,
skillLevel: 'advanced' as const,
nationality: 'Singapore',
racesCompleted: 89,
wins: 12,
podiums: 34,
isActive: true,
rank: 8,
},
{
id: 'driver-6',
name: 'Isabella Rossi',
rating: 2145,
skillLevel: 'intermediate' as const,
nationality: 'Italy',
racesCompleted: 67,
wins: 8,
podiums: 23,
isActive: true,
rank: 12,
},
{
id: 'driver-7',
name: 'Carlos Rodriguez',
rating: 1876,
skillLevel: 'intermediate' as const,
nationality: 'Spain',
racesCompleted: 45,
wins: 3,
podiums: 12,
isActive: false,
rank: 18,
},
{
id: 'driver-8',
name: 'Yuki Tanaka',
rating: 1234,
skillLevel: 'beginner' as const,
nationality: 'Japan',
racesCompleted: 12,
wins: 0,
podiums: 2,
isActive: true,
rank: 45,
},
];
type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
type DriverListItem = {
id: string;
name: string;
rating: number;
skillLevel: SkillLevel;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
};
export default function DriversPage() {
const router = useRouter();
const [drivers, setDrivers] = useState<DriverListItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState('all');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [selectedNationality, setSelectedNationality] = useState('all');
const [activeOnly, setActiveOnly] = useState(false);
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums'>('rank');
useEffect(() => {
const load = async () => {
const driverRepo = getDriverRepository();
const allDrivers = await driverRepo.findAll();
const rankings = getAllDriverRankings();
const items: DriverListItem[] = allDrivers.map((driver) => {
const stats = getDriverStats(driver.id);
const rating = stats?.rating ?? 0;
const wins = stats?.wins ?? 0;
const podiums = stats?.podiums ?? 0;
const totalRaces = stats?.totalRaces ?? 0;
let effectiveRank = Number.POSITIVE_INFINITY;
if (typeof stats?.overallRank === 'number' && stats.overallRank > 0) {
effectiveRank = stats.overallRank;
} else {
const indexInGlobal = rankings.findIndex(
(entry) => entry.driverId === driver.id,
);
if (indexInGlobal !== -1) {
effectiveRank = indexInGlobal + 1;
}
}
const skillLevel: SkillLevel =
rating >= 3000
? 'pro'
: rating >= 2500
? 'advanced'
: rating >= 1800
? 'intermediate'
: 'beginner';
const isActive = rankings.some((r) => r.driverId === driver.id);
return {
id: driver.id,
name: driver.name,
rating,
skillLevel,
nationality: driver.country,
racesCompleted: totalRaces,
wins,
podiums,
isActive,
rank: effectiveRank,
};
});
setDrivers(items);
setLoading(false);
};
void load();
}, []);
const nationalities = Array.from(
new Set(MOCK_DRIVERS.map((d) => d.nationality).filter(Boolean))
new Set(drivers.map((d) => d.nationality).filter(Boolean)),
).sort();
const filteredDrivers = MOCK_DRIVERS.filter((driver) => {
const filteredDrivers = drivers.filter((driver) => {
const matchesSearch = driver.name
.toLowerCase()
.includes(searchQuery.toLowerCase());
@@ -135,9 +109,12 @@ export default function DriversPage() {
});
const sortedDrivers = [...filteredDrivers].sort((a, b) => {
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
switch (sortBy) {
case 'rank':
return a.rank - b.rank;
return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name);
case 'rating':
return b.rating - a.rating;
case 'wins':
@@ -153,6 +130,14 @@ export default function DriversPage() {
router.push(`/drivers/${driverId}`);
};
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading drivers...</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
@@ -183,7 +168,7 @@ export default function DriversPage() {
<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)}
onChange={(e) => setSelectedSkill(e.target.value as SkillLevel | 'all')}
>
<option value="all">All Levels</option>
<option value="beginner">Beginner</option>
@@ -251,56 +236,20 @@ export default function DriversPage() {
</div>
<div className="space-y-4">
{sortedDrivers.map((driver, index) => (
<Card
{sortedDrivers.map((driver) => (
<DriverCard
key={driver.id}
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
id={driver.id}
name={driver.name}
rating={driver.rating}
skillLevel={driver.skillLevel}
nationality={driver.nationality}
racesCompleted={driver.racesCompleted}
wins={driver.wins}
podiums={driver.podiums}
rank={driver.rank}
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 overflow-hidden flex items-center justify-center">
<Image
src={getDriverAvatarUrl(driver.id)}
alt={driver.name}
width={64}
height={64}
className="w-full h-full object-cover"
/>
</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>

View File

@@ -6,6 +6,7 @@ import { getAuthService } from '@/lib/auth';
import { AlphaNav } from '@/components/alpha/AlphaNav';
import AlphaBanner from '@/components/alpha/AlphaBanner';
import AlphaFooter from '@/components/alpha/AlphaFooter';
import { AuthProvider } from '@/lib/auth/AuthContext';
export const dynamic = 'force-dynamic';
@@ -46,7 +47,6 @@ export default async function RootLayout({
if (mode === 'alpha') {
const authService = getAuthService();
const session = await authService.getCurrentSession();
const isAuthenticated = !!session;
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
@@ -54,12 +54,14 @@ export default async function RootLayout({
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
<AlphaNav isAuthenticated={isAuthenticated} />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
<AuthProvider initialSession={session}>
<AlphaNav />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}
</main>
<AlphaFooter />
</AuthProvider>
</body>
</html>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useState, useEffect, useMemo } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
@@ -9,11 +9,23 @@ import LeagueMembers from '@/components/leagues/LeagueMembers';
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import LeagueAdmin from '@/components/leagues/LeagueAdmin';
import StandingsTable from '@/components/leagues/StandingsTable';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container';
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/racingLegacyFacade';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
getLeagueRepository,
getRaceRepository,
getDriverRepository,
getGetLeagueDriverSeasonStatsQuery,
getDriverStats,
getAllDriverRankings,
} from '@/lib/di-container';
import { getMembership, getLeagueMembers, isOwnerOrAdmin } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -22,16 +34,17 @@ export default function LeagueDetailPage() {
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'members' | 'admin'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'admin'>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
const searchParams = useSearchParams();
const loadLeagueData = async () => {
try {
@@ -53,15 +66,18 @@ export default function LeagueDetailPage() {
const ownerData = await driverRepo.findById(leagueData.ownerId);
setOwner(ownerData);
// Load standings
const standingRepo = getStandingRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter(s => s.leagueId === leagueId);
// Load standings via rich season stats query
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
// Load all drivers for standings
// Load all drivers for standings and map to DTOs for UI components
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
const driverDtos: DriverDTO[] = allDrivers
.map((driver) => EntityMappers.toDriverDTO(driver))
.filter((dto): dto is DriverDTO => dto !== null);
setDrivers(driverDtos);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load league');
} finally {
@@ -74,34 +90,95 @@ export default function LeagueDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId]);
if (loading) {
return (
<div className="text-center text-gray-400">Loading league...</div>
);
}
useEffect(() => {
const initialTab = searchParams?.get('tab');
if (!initialTab) {
return;
}
if (error || !league) {
return (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
);
}
if (
initialTab === 'overview' ||
initialTab === 'schedule' ||
initialTab === 'standings' ||
initialTab === 'admin'
) {
setActiveTab(initialTab);
}
}, [searchParams]);
const handleMembershipChange = () => {
setRefreshKey(prev => prev + 1);
loadLeagueData();
};
return (
const driversById = useMemo(() => {
const map: Record<string, DriverDTO> = {};
for (const d of drivers) {
map[d.id] = d;
}
return map;
}, [drivers]);
const leagueMemberships = getLeagueMembers(leagueId);
const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null;
const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin');
const buildDriverSummary = (driverId: string) => {
const driverDto = driversById[driverId];
if (!driverDto) {
return null;
}
const stats = getDriverStats(driverDto.id);
const allRankings = getAllDriverRankings();
let rating: number | null = stats?.rating ?? null;
let rank: number | null = null;
if (stats) {
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
rank = stats.overallRank;
} else {
const indexInGlobal = allRankings.findIndex(
(stat) => stat.driverId === stats.driverId,
);
if (indexInGlobal !== -1) {
rank = indexInGlobal + 1;
}
}
if (rating === null) {
const globalEntry = allRankings.find(
(stat) => stat.driverId === stats.driverId,
);
if (globalEntry) {
rating = globalEntry.rating;
}
}
}
return {
driver: driverDto,
rating,
rank,
};
};
return loading ? (
<div className="text-center text-gray-400">Loading league...</div>
) : error || !league ? (
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error || 'League not found'}
</div>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
>
Back to Leagues
</Button>
</Card>
) : (
<>
{/* Action Card */}
{!membership && (
@@ -154,16 +231,6 @@ export default function LeagueDetailPage() {
>
Standings
</button>
<button
onClick={() => setActiveTab('members')}
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
activeTab === 'members'
? 'bg-primary-blue text-white'
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
}`}
>
Members
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
@@ -187,11 +254,6 @@ export default function LeagueDetailPage() {
<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">
@@ -223,6 +285,107 @@ export default function LeagueDetailPage() {
</div>
</div>
</div>
{league.socialLinks && (
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Community & Social</h3>
<div className="flex flex-wrap gap-3 text-sm">
{league.socialLinks.discordUrl && (
<a
href={league.socialLinks.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-3 py-1 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<span>Discord</span>
</a>
)}
{league.socialLinks.youtubeUrl && (
<a
href={league.socialLinks.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-3 py-1 text-red-400 hover:bg-red-500/20 transition-colors"
>
<span>YouTube</span>
</a>
)}
{league.socialLinks.websiteUrl && (
<a
href={league.socialLinks.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-3 py-1 text-gray-100 hover:bg-iron-gray transition-colors"
>
<span>Website</span>
</a>
)}
</div>
</div>
)}
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">Management</h3>
<div className="space-y-4">
{ownerMembership && (
<div>
<label className="text-sm text-gray-500 block mb-2">Owner</label>
{buildDriverSummary(ownerMembership.driverId) ? (
<DriverSummaryPill
driver={buildDriverSummary(ownerMembership.driverId)!.driver}
rating={buildDriverSummary(ownerMembership.driverId)!.rating}
rank={buildDriverSummary(ownerMembership.driverId)!.rank}
/>
) : (
<p className="text-sm text-gray-500">
{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}
</p>
)}
</div>
)}
{adminMemberships.length > 0 && (
<div>
<label className="text-sm text-gray-500 block mb-2">Admins</label>
<div className="space-y-2">
{adminMemberships.map((membership) => {
const driverDto = driversById[membership.driverId];
const summary = buildDriverSummary(membership.driverId);
const meta =
summary && summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div
key={membership.driverId}
className="flex items-center justify-between gap-3"
>
{driverDto ? (
<DriverIdentity
driver={driverDto}
href={`/drivers/${membership.driverId}?from=league-management&leagueId=${leagueId}`}
contextLabel="Admin"
meta={meta}
size="sm"
/>
) : (
<span className="text-sm text-gray-400">Unknown admin</span>
)}
</div>
);
})}
</div>
</div>
)}
{adminMemberships.length === 0 && !ownerMembership && (
<p className="text-sm text-gray-500">
Management roles have not been configured for this league yet.
</p>
)}
</div>
</div>
</div>
</Card>
@@ -276,13 +439,6 @@ export default function LeagueDetailPage() {
</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}

View File

@@ -8,15 +8,17 @@ import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationToolti
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import {
getMembership,
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { getMembership } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
@@ -36,7 +38,7 @@ export default function LeagueRaceDetailPage() {
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadRaceData = async () => {
try {
@@ -67,15 +69,19 @@ export default function LeagueRaceDetailPage() {
const loadEntryList = async (raceIdValue: string, leagueIdValue: string) => {
try {
const driverRepo = getDriverRepository();
const registeredDriverIds = getRegisteredDrivers(raceIdValue);
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id))
);
setEntryList(
drivers.filter((d: Driver | null): d is Driver => d !== null)
);
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId: raceIdValue });
const userIsRegistered = isRegistered(raceIdValue, currentDriverId);
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
);
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
raceId: raceIdValue,
driverId: currentDriverId,
});
setIsUserRegistered(userIsRegistered);
const membership = getMembership(leagueIdValue, currentDriverId);
@@ -124,7 +130,12 @@ export default function LeagueRaceDetailPage() {
setRegistering(true);
try {
registerForRace(race.id, currentDriverId, league.id);
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId: league.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
@@ -144,7 +155,11 @@ export default function LeagueRaceDetailPage() {
setRegistering(true);
try {
withdrawFromRace(race.id, currentDriverId);
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');

View File

@@ -3,29 +3,32 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import StandingsTable from '@/components/leagues/StandingsTable';
import type { Standing } from '@gridpilot/racing/domain/entities/Standing';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getStandingRepository, getDriverRepository } from '@/lib/di-container';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import type { LeagueDriverSeasonStatsDTO } from '@gridpilot/racing/application/dto/LeagueDriverSeasonStatsDTO';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getGetLeagueDriverSeasonStatsQuery, getDriverRepository } from '@/lib/di-container';
export default function LeagueStandingsPage({ params }: { params: { id: string } }) {
const leagueId = params.id;
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [standings, setStandings] = useState<LeagueDriverSeasonStatsDTO[]>([]);
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
try {
const standingRepo = getStandingRepository();
const getLeagueDriverSeasonStatsQuery = getGetLeagueDriverSeasonStatsQuery();
const driverRepo = getDriverRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter((s) => s.leagueId === leagueId);
const leagueStandings = await getLeagueDriverSeasonStatsQuery.execute({ leagueId });
setStandings(leagueStandings);
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
const driverDtos: DriverDTO[] = allDrivers
.map((driver) => EntityMappers.toDriverDTO(driver))
.filter((dto): dto is DriverDTO => dto !== null);
setDrivers(driverDtos);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {

View File

@@ -7,12 +7,12 @@ import CreateLeagueForm from '@/components/leagues/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';
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
import { getGetAllLeaguesWithCapacityQuery } from '@/lib/di-container';
export default function LeaguesPage() {
const router = useRouter();
const [leagues, setLeagues] = useState<League[]>([]);
const [leagues, setLeagues] = useState<LeagueDTO[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -24,8 +24,8 @@ export default function LeaguesPage() {
const loadLeagues = async () => {
try {
const leagueRepo = getLeagueRepository();
const allLeagues = await leagueRepo.findAll();
const query = getGetAllLeaguesWithCapacityQuery();
const allLeagues = await query.execute();
setLeagues(allLeagues);
} catch (error) {
console.error('Failed to load leagues:', error);

View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { getLeagueRepository, getLeagueMembershipRepository } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership';
interface LeagueWithRole {
league: League;
membership: LeagueMembership;
}
export default function ManageLeaguesPage() {
const [ownedLeagues, setOwnedLeagues] = useState<LeagueWithRole[]>([]);
const [memberLeagues, setMemberLeagues] = useState<LeagueWithRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectiveDriverId = useEffectiveDriverId();
useEffect(() => {
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const leagueRepo = getLeagueRepository();
const membershipRepo = getLeagueMembershipRepository();
const leagues = await leagueRepo.findAll();
const memberships = await Promise.all(
leagues.map(async (league) => {
const membership = await membershipRepo.getMembership(league.id, effectiveDriverId);
return { league, membership };
}),
);
if (cancelled) {
return;
}
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
for (const entry of memberships) {
if (!entry.membership || entry.membership.status !== 'active') {
continue;
}
if (entry.membership.role === 'owner') {
owned.push(entry as LeagueWithRole);
} else {
member.push(entry as LeagueWithRole);
}
}
setOwnedLeagues(owned);
setMemberLeagues(member);
setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load leagues');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [effectiveDriverId]);
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading your leagues...</div>
</div>
);
}
if (error) {
return (
<div className="max-w-6xl mx-auto">
<Card>
<div className="text-center py-8 text-red-400">{error}</div>
</Card>
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1>
<p className="text-gray-400 text-sm">
View leagues you own and participate in, and jump into league admin tools.
</p>
</div>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
{ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{ownedLeagues.length} {ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You don't own any leagues yet in this session.
</p>
) : (
<div className="space-y-3">
{ownedLeagues.map(({ league }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
</div>
<div className="flex items-center gap-2">
<Link
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</Link>
<Link href={`/leagues/${league.id}?tab=admin`}>
<Button variant="primary" className="text-xs px-3 py-1.5">
Manage
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
{memberLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{memberLeagues.length} {memberLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You're not a member of any other leagues yet.
</p>
) : (
<div className="space-y-3">
{memberLeagues.map(({ league, membership }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
<p className="text-xs text-gray-500 mt-1">
Your role:{' '}
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)}
</p>
</div>
<Link
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</Link>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -1,40 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
import CreateDriverForm from '@/components/drivers/CreateDriverForm';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import ProfileHeader from '@/components/profile/ProfileHeader';
import ProfileStats from '@/components/drivers/ProfileStats';
import DriverProfile from '@/components/drivers/DriverProfile';
import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory';
import ProfileSettings from '@/components/drivers/ProfileSettings';
import CareerHighlights from '@/components/drivers/CareerHighlights';
import RatingBreakdown from '@/components/drivers/RatingBreakdown';
import { getDriverTeam, getCurrentDriverId } from '@/lib/racingLegacyFacade';
type Tab = 'overview' | 'statistics' | 'history' | 'settings';
import { useEffectiveDriverId } from '@/lib/currentDriver';
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);
const effectiveDriverId = useEffectiveDriverId();
useEffect(() => {
const loadDriver = async () => {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const driverData = EntityMappers.toDriverDTO(drivers[0] || null);
const currentDriverId = effectiveDriverId;
const currentDriver = await driverRepo.findById(currentDriverId);
const driverData = EntityMappers.toDriverDTO(currentDriver);
setDriver(driverData);
setLoading(false);
};
loadDriver();
}, []);
void loadDriver();
}, [effectiveDriverId]);
const handleSaveSettings = async (updates: Partial<DriverDTO>) => {
if (!driver) return;
@@ -86,162 +80,15 @@ export default function ProfilePage() {
);
}
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">
<Card className="mb-6">
<ProfileHeader
driver={driver}
isOwnProfile
onEditClick={() => setActiveTab('settings')}
/>
<div className="max-w-6xl mx-auto space-y-6">
<DriverProfile driver={driver} isOwnProfile />
<Card>
<ProfileSettings driver={driver} onSave={handleSaveSettings} />
</Card>
<Card>
<ProfileRaceHistory />
</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>
</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

@@ -8,15 +8,17 @@ import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationToolti
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { League } from '@gridpilot/racing/domain/entities/League';
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
import {
getMembership,
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers,
} from '@/lib/racingLegacyFacade';
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
} from '@/lib/di-container';
import { getMembership } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
@@ -36,7 +38,7 @@ export default function RaceDetailPage() {
const [isUserRegistered, setIsUserRegistered] = useState(false);
const [canRegister, setCanRegister] = useState(false);
const currentDriverId = getCurrentDriverId();
const currentDriverId = useEffectiveDriverId();
const loadRaceData = async () => {
try {
@@ -69,19 +71,22 @@ 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: string) => driverRepo.findById(id))
);
setEntryList(
drivers.filter((d: Driver | null): d is Driver => d !== null)
);
// Check user registration status
const userIsRegistered = isRegistered(raceId, currentDriverId);
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId });
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
);
setEntryList(drivers.filter((d: Driver | null): d is Driver => d !== null));
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
raceId,
driverId: 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);
@@ -128,7 +133,12 @@ export default function RaceDetailPage() {
setRegistering(true);
try {
registerForRace(race.id, currentDriverId, league.id);
const useCase = getRegisterForRaceUseCase();
await useCase.execute({
raceId: race.id,
leagueId: league.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
@@ -148,7 +158,11 @@ export default function RaceDetailPage() {
setRegistering(true);
try {
withdrawFromRace(race.id, currentDriverId);
const useCase = getWithdrawFromRaceUseCase();
await useCase.execute({
raceId: race.id,
driverId: currentDriverId,
});
await loadEntryList(race.id, league.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');

View File

@@ -2,10 +2,8 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import RaceCard from '@/components/races/RaceCard';
import ScheduleRaceForm from '@/components/leagues/ScheduleRaceForm';
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
@@ -16,8 +14,6 @@ export default function RacesPage() {
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,14 +44,6 @@ 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 => {
@@ -90,37 +78,6 @@ export default function RacesPage() {
);
}
if (showScheduleForm) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<button
onClick={() => setShowScheduleForm(false)}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Races
</button>
</div>
<Card>
<h1 className="text-2xl font-bold text-white mb-6">Schedule New Race</h1>
<ScheduleRaceForm
preSelectedLeagueId={preselectedLeagueId}
onSuccess={(race) => {
router.push(`/races/${race.id}`);
}}
onCancel={() => setShowScheduleForm(false)}
/>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
@@ -128,12 +85,6 @@ export default function RacesPage() {
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold text-white">Races</h1>
<Button
variant="primary"
onClick={() => setShowScheduleForm(true)}
>
Schedule Race
</Button>
</div>
<p className="text-gray-400">
Manage and view all scheduled races across your leagues

View File

@@ -2,24 +2,22 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Image from 'next/image';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { getImageService } from '@/lib/di-container';
import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings';
import TeamAdmin from '@/components/teams/TeamAdmin';
import JoinTeamButton from '@/components/teams/JoinTeamButton';
import {
Team,
getTeam,
getTeamMembers,
getCurrentDriverId,
isTeamOwnerOrManager,
TeamMembership,
removeTeamMember,
updateTeamMemberRole,
TeamRole,
} from '@/lib/racingLegacyFacade';
getGetTeamDetailsQuery,
getGetTeamMembersQuery,
getTeamMembershipRepository,
} from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -32,50 +30,87 @@ export default function TeamDetailPage() {
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const currentDriverId = useEffectiveDriverId();
const loadTeamData = useCallback(() => {
const teamData = getTeam(teamId);
if (!teamData) {
const loadTeamData = useCallback(async () => {
setLoading(true);
try {
const detailsQuery = getGetTeamDetailsQuery();
const membersQuery = getGetTeamMembersQuery();
const details = await detailsQuery.execute({ teamId, driverId: currentDriverId });
const teamMemberships = await membersQuery.execute({ teamId });
const adminStatus =
teamMemberships.some(
(m) =>
m.driverId === currentDriverId &&
(m.role === 'owner' || m.role === 'manager'),
) ?? false;
setTeam(details.team);
setMemberships(teamMemberships);
setIsAdmin(adminStatus);
} finally {
setLoading(false);
return;
}
const teamMemberships = getTeamMembers(teamId);
const currentDriverId = getCurrentDriverId();
const adminStatus = isTeamOwnerOrManager(teamId, currentDriverId);
setTeam(teamData);
setMemberships(teamMemberships);
setIsAdmin(adminStatus);
setLoading(false);
}, [teamId]);
}, [teamId, currentDriverId]);
useEffect(() => {
loadTeamData();
void loadTeamData();
}, [loadTeamData]);
const handleUpdate = () => {
loadTeamData();
};
const handleRemoveMember = (driverId: string) => {
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const currentDriverId = getCurrentDriverId();
removeTeamMember(teamId, driverId, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const performer = await membershipRepo.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or managers can remove members');
}
const membership = await membershipRepo.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the team owner');
}
await membershipRepo.removeMembership(teamId, driverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to remove member');
}
};
const handleChangeRole = (driverId: string, newRole: TeamRole) => {
const handleChangeRole = async (driverId: string, newRole: TeamRole) => {
try {
const currentDriverId = getCurrentDriverId();
updateTeamMemberRole(teamId, driverId, newRole, currentDriverId);
const membershipRepo = getTeamMembershipRepository();
const performer = await membershipRepo.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or managers can update roles');
}
const membership = await membershipRepo.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
await membershipRepo.saveMembership({
...membership,
role: newRole,
});
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to change role');
@@ -131,10 +166,14 @@ export default function TeamDetailPage() {
<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 className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={getImageService().getTeamLogo(team.id)}
alt={team.name}
width={96}
height={96}
className="w-full h-full object-cover"
/>
</div>
<div>

View File

@@ -2,12 +2,21 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import TeamCard from '@/components/teams/TeamCard';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import { getAllTeams, getTeamMembers, type Team } from '@/lib/racingLegacyFacade';
import TeamLadderRow from '@/components/teams/TeamLadderRow';
import { getGetAllTeamsQuery, getGetTeamMembersQuery, getDriverStats } from '@/lib/di-container';
import type { Team } from '@gridpilot/racing';
type TeamLadderItem = {
team: Team;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
};
export default function TeamsPage() {
const router = useRouter();
@@ -15,25 +24,72 @@ export default function TeamsPage() {
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [memberFilter, setMemberFilter] = useState('all');
const [memberCounts, setMemberCounts] = useState<Record<string, number>>({});
const [teamStats, setTeamStats] = useState<Record<string, Omit<TeamLadderItem, 'team' | 'memberCount'>>>({});
const loadTeams = async () => {
const allTeamsQuery = getGetAllTeamsQuery();
const teamMembersQuery = getGetTeamMembersQuery();
const allTeams = await allTeamsQuery.execute();
setTeams(allTeams);
const counts: Record<string, number> = {};
const stats: Record<string, Omit<TeamLadderItem, 'team' | 'memberCount'>> = {};
await Promise.all(
allTeams.map(async (team) => {
const memberships = await teamMembersQuery.execute({ teamId: team.id });
counts[team.id] = memberships.length;
const memberDriverIds = memberships.map((m) => m.driverId);
let ratingSum = 0;
let ratingCount = 0;
let totalWins = 0;
let totalRaces = 0;
for (const driverId of memberDriverIds) {
const statsForDriver = getDriverStats(driverId);
if (!statsForDriver) continue;
if (typeof statsForDriver.rating === 'number') {
ratingSum += statsForDriver.rating;
ratingCount += 1;
}
totalWins += statsForDriver.wins ?? 0;
totalRaces += statsForDriver.totalRaces ?? 0;
}
const averageRating =
ratingCount > 0 ? ratingSum / ratingCount : null;
stats[team.id] = {
rating: averageRating,
totalWins,
totalRaces,
};
}),
);
setMemberCounts(counts);
setTeamStats(stats);
};
useEffect(() => {
loadTeams();
void loadTeams();
}, []);
const loadTeams = () => {
const allTeams = getAllTeams();
setTeams(allTeams);
};
const handleCreateSuccess = (teamId: string) => {
setShowCreateForm(false);
loadTeams();
void loadTeams();
router.push(`/teams/${teamId}`);
};
const filteredTeams = teams.filter((team) => {
const memberCount = getTeamMembers(team.id).length;
const memberCount = memberCounts[team.id] ?? 0;
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesMemberCount =
memberFilter === 'all' ||
@@ -44,6 +100,30 @@ export default function TeamsPage() {
return matchesSearch && matchesMemberCount;
});
const sortedTeams = [...filteredTeams].sort((a, b) => {
const statsA = teamStats[a.id];
const statsB = teamStats[b.id];
const ratingA = statsA?.rating ?? null;
const ratingB = statsB?.rating ?? null;
const ratingValA = ratingA === null ? Number.NEGATIVE_INFINITY : ratingA;
const ratingValB = ratingB === null ? Number.NEGATIVE_INFINITY : ratingB;
if (ratingValA !== ratingValB) {
return ratingValB - ratingValA;
}
const winsA = statsA?.totalWins ?? 0;
const winsB = statsB?.totalWins ?? 0;
if (winsA !== winsB) {
return winsB - winsA;
}
return a.name.localeCompare(b.name);
});
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
@@ -118,27 +198,54 @@ export default function TeamsPage() {
</div>
</Card>
<div className="mb-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{filteredTeams.length} {filteredTeams.length === 1 ? 'team' : 'teams'} found
{sortedTeams.length} {sortedTeams.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>
{sortedTeams.length > 0 && (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-semibold text-gray-400">#</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Team</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Rating</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Races</th>
<th className="text-left py-3 px-4 font-semibold text-gray-400">Members</th>
</tr>
</thead>
<tbody>
{sortedTeams.map((team, index) => {
const memberCount = memberCounts[team.id] ?? 0;
const statsForTeam = teamStats[team.id];
const rating = statsForTeam?.rating ?? null;
const totalWins = statsForTeam?.totalWins ?? 0;
const totalRaces = statsForTeam?.totalRaces ?? 0;
const rank = index + 1;
return (
<TeamLadderRow
key={team.id}
rank={rank}
teamId={team.id}
teamName={team.name}
memberCount={memberCount}
teamRating={rating}
totalWins={totalWins}
totalRaces={totalRaces}
/>
);
})}
</tbody>
</table>
</div>
</Card>
)}
{filteredTeams.length === 0 && (
<Card className="text-center py-12">