wip
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
getFeedRepository,
|
||||
getRaceRepository,
|
||||
getResultRepository,
|
||||
getDriverRepository,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -21,7 +22,8 @@ export default async function DashboardPage() {
|
||||
const feedRepository = getFeedRepository();
|
||||
const raceRepository = getRaceRepository();
|
||||
const resultRepository = getResultRepository();
|
||||
|
||||
const driverRepository = getDriverRepository();
|
||||
|
||||
const [feedItems, upcomingRaces, allResults] = await Promise.all([
|
||||
feedRepository.getFeedForDriver(session.user.primaryDriverId ?? ''),
|
||||
raceRepository.findAll(),
|
||||
@@ -35,21 +37,27 @@ export default async function DashboardPage() {
|
||||
|
||||
const completedRaces = upcomingRaces.filter((race) => race.status === 'completed');
|
||||
|
||||
const latestResults = completedRaces.slice(0, 4).map((race) => {
|
||||
const raceResults = allResults.filter((result) => result.raceId === race.id);
|
||||
const winner = raceResults.sort((a, b) => a.position - b.position)[0];
|
||||
|
||||
return {
|
||||
raceId: race.id,
|
||||
leagueId: race.leagueId,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
winnerDriverId: winner?.driverId ?? '',
|
||||
winnerName: 'Race Winner',
|
||||
positionChange: winner ? winner.getPositionChange() : 0,
|
||||
};
|
||||
});
|
||||
const latestResults = await Promise.all(
|
||||
completedRaces.slice(0, 4).map(async (race) => {
|
||||
const raceResults = allResults.filter((result) => result.raceId === race.id);
|
||||
const winner = raceResults.slice().sort((a, b) => a.position - b.position)[0];
|
||||
const winnerDriverId = winner?.driverId ?? '';
|
||||
const winnerDriver = winnerDriverId
|
||||
? await driverRepository.findById(winnerDriverId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
raceId: race.id,
|
||||
leagueId: race.leagueId,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
winnerDriverId,
|
||||
winnerName: winnerDriver?.name ?? 'Race Winner',
|
||||
positionChange: winner ? winner.getPositionChange() : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-deep-graphite">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { getDriverRepository } from '@/lib/di-container';
|
||||
import DriverProfile from '@/components/drivers/DriverProfile';
|
||||
@@ -10,7 +11,11 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
|
||||
export default function DriverDetailPage() {
|
||||
export default function DriverDetailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const driverId = params.id as string;
|
||||
@@ -19,6 +24,15 @@ export default function DriverDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const from =
|
||||
typeof searchParams?.from === 'string' ? searchParams.from : undefined;
|
||||
const leagueId =
|
||||
typeof searchParams?.leagueId === 'string'
|
||||
? searchParams.leagueId
|
||||
: undefined;
|
||||
const backLink =
|
||||
from === 'league' && leagueId ? `/leagues/${leagueId}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
loadDriver();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -84,6 +98,17 @@ export default function DriverDetailPage() {
|
||||
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">
|
||||
{backLink && (
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href={backLink}
|
||||
className="inline-flex items-center text-xs font-medium text-gray-300 hover:text-white"
|
||||
>
|
||||
← Back to league
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { 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';
|
||||
|
||||
// Mock data (fictional demo drivers only)
|
||||
const MOCK_DRIVERS = [
|
||||
{
|
||||
id: '1',
|
||||
id: 'driver-1',
|
||||
name: 'Alex Vermeer',
|
||||
rating: 3245,
|
||||
skillLevel: 'pro' as const,
|
||||
@@ -22,7 +24,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
id: 'driver-2',
|
||||
name: 'Liam Hartmann',
|
||||
rating: 3198,
|
||||
skillLevel: 'pro' as const,
|
||||
@@ -34,7 +36,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
id: 'driver-3',
|
||||
name: 'Michael Schmidt',
|
||||
rating: 2912,
|
||||
skillLevel: 'advanced' as const,
|
||||
@@ -46,7 +48,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 3,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
id: 'driver-4',
|
||||
name: 'Emma Thompson',
|
||||
rating: 2789,
|
||||
skillLevel: 'advanced' as const,
|
||||
@@ -58,7 +60,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 5,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
id: 'driver-5',
|
||||
name: 'Sarah Chen',
|
||||
rating: 2456,
|
||||
skillLevel: 'advanced' as const,
|
||||
@@ -70,7 +72,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 8,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
id: 'driver-6',
|
||||
name: 'Isabella Rossi',
|
||||
rating: 2145,
|
||||
skillLevel: 'intermediate' as const,
|
||||
@@ -82,7 +84,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 12,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
id: 'driver-7',
|
||||
name: 'Carlos Rodriguez',
|
||||
rating: 1876,
|
||||
skillLevel: 'intermediate' as const,
|
||||
@@ -94,7 +96,7 @@ const MOCK_DRIVERS = [
|
||||
rank: 18,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
id: 'driver-8',
|
||||
name: 'Yuki Tanaka',
|
||||
rating: 1234,
|
||||
skillLevel: 'beginner' as const,
|
||||
@@ -258,9 +260,15 @@ export default function DriversPage() {
|
||||
<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 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">
|
||||
|
||||
52
apps/website/app/leagues/[id]/layout.tsx
Normal file
52
apps/website/app/leagues/[id]/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||
import { getLeagueRepository, getDriverRepository } from '@/lib/di-container';
|
||||
|
||||
export default async function LeagueLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { children, params } = props;
|
||||
const resolvedParams = await params;
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
const league = await leagueRepo.findById(resolvedParams.id);
|
||||
|
||||
if (!league) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">League not found</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await driverRepo.findById(league.ownerId);
|
||||
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
|
||||
|
||||
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">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Leagues', href: '/leagues' },
|
||||
{ label: league.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<LeagueHeader
|
||||
leagueId={league.id}
|
||||
leagueName={league.name}
|
||||
description={league.description}
|
||||
ownerId={league.ownerId}
|
||||
ownerName={ownerName}
|
||||
/>
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,17 +4,13 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
|
||||
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
||||
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 Breadcrumbs from '@/components/layout/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, getStandingRepository } from '@/lib/di-container';
|
||||
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/racingLegacyFacade';
|
||||
@@ -80,31 +76,23 @@ export default function LeagueDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-gray-400">Loading league...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !league) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</Button>
|
||||
</Card>
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,221 +102,194 @@ export default function LeagueDetailPage() {
|
||||
};
|
||||
|
||||
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: 'Leagues', href: '/leagues' },
|
||||
{ label: league.name }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* League Header */}
|
||||
<div className="mb-8">
|
||||
<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} />
|
||||
<>
|
||||
{/* Action Card */}
|
||||
{!membership && (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="w-48">
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</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
|
||||
</span>
|
||||
</FeatureLimitationTooltip>
|
||||
</div>
|
||||
<p className="text-gray-400">{league.description}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Card */}
|
||||
{!membership && (
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Overview section switcher (in-page, not primary tabs) */}
|
||||
<div className="mb-6">
|
||||
<div className="inline-flex flex-wrap gap-2 rounded-full bg-iron-gray/60 px-2 py-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'schedule'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('standings')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'standings'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
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')}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
activeTab === 'admin'
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'
|
||||
}`}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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 className="w-48">
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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'
|
||||
}`}
|
||||
>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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">Session Duration</label>
|
||||
<p className="text-white">{league.settings.sessionDuration} minutes</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>
|
||||
<label className="text-sm text-gray-500">Qualifying Format</label>
|
||||
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} />
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && (
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">League Members</h2>
|
||||
<LeagueMembers 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} leagueId={leagueId} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
<LeagueAdmin
|
||||
league={league}
|
||||
onLeagueUpdate={handleMembershipChange}
|
||||
key={refreshKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
402
apps/website/app/leagues/[id]/races/[raceId]/page.tsx
Normal file
402
apps/website/app/leagues/[id]/races/[raceId]/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import { getRaceRepository, getLeagueRepository, getDriverRepository } from '@/lib/di-container';
|
||||
import {
|
||||
getMembership,
|
||||
getCurrentDriverId,
|
||||
isRegistered,
|
||||
registerForRace,
|
||||
withdrawFromRace,
|
||||
getRegisteredDrivers,
|
||||
} from '@/lib/racingLegacyFacade';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
||||
|
||||
export default function LeagueRaceDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const raceId = params.raceId as string;
|
||||
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [entryList, setEntryList] = useState<Driver[]>([]);
|
||||
const [isUserRegistered, setIsUserRegistered] = useState(false);
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
const currentDriverId = getCurrentDriverId();
|
||||
|
||||
const loadRaceData = async () => {
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
|
||||
const raceData = await raceRepo.findById(raceId);
|
||||
|
||||
if (!raceData) {
|
||||
setError('Race not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRace(raceData);
|
||||
|
||||
const leagueData = await leagueRepo.findById(raceData.leagueId);
|
||||
setLeague(leagueData);
|
||||
|
||||
await loadEntryList(raceData.id, raceData.leagueId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load race');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 userIsRegistered = isRegistered(raceIdValue, currentDriverId);
|
||||
setIsUserRegistered(userIsRegistered);
|
||||
|
||||
const membership = getMembership(leagueIdValue, currentDriverId);
|
||||
const isUpcoming = race?.status === 'scheduled';
|
||||
setCanRegister(!!membership && membership.status === 'active' && !!isUpcoming);
|
||||
} catch (err) {
|
||||
console.error('Failed to load entry list:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRaceData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [raceId]);
|
||||
|
||||
const handleCancelRace = async () => {
|
||||
if (!race || race.status !== 'scheduled') return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to cancel this race? This action cannot be undone.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setCancelling(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const cancelledRace = race.cancel();
|
||||
await raceRepo.update(cancelledRace);
|
||||
setRace(cancelledRace);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to cancel race');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
registerForRace(race.id, currentDriverId, league.id);
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register for race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
if (!race || !league) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?\n\nYou can register again later if you change your mind.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegistering(true);
|
||||
try {
|
||||
withdrawFromRace(race.id, currentDriverId);
|
||||
await loadEntryList(race.id, league.id);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
} as const;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center text-gray-400">Loading race details...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !race) {
|
||||
return (
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'Race not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to League
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FeatureLimitationTooltip message="Companion automation available in production">
|
||||
<div className="mb-6">
|
||||
<CompanionStatus />
|
||||
</div>
|
||||
</FeatureLimitationTooltip>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{race.track}</h1>
|
||||
{league && (
|
||||
<p className="text-gray-400">{league.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 text-sm font-medium rounded border ${
|
||||
statusColors[race.status as keyof typeof statusColors]
|
||||
}`}
|
||||
>
|
||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<div className="mb-6">
|
||||
<CompanionInstructions race={race} leagueName={league?.name} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Race Details</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Scheduled Date & Time</label>
|
||||
<p className="text-white text-lg font-medium">
|
||||
{formatDateTime(race.scheduledAt)}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2 text-sm">
|
||||
<span className="text-gray-400">{formatDate(race.scheduledAt)}</span>
|
||||
<span className="text-gray-400">{formatTime(race.scheduledAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-charcoal-outline">
|
||||
<label className="text-sm text-gray-500 block mb-1">Track</label>
|
||||
<p className="text-white">{race.track}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Car</label>
|
||||
<p className="text-white">{race.car}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 block mb-1">Session Type</label>
|
||||
<p className="text-white capitalize">{race.sessionType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{race.status === 'scheduled' && canRegister && !isUserRegistered && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleRegister}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Registering...' : 'Register for Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && isUserRegistered && (
|
||||
<div className="space-y-2">
|
||||
<div className="px-3 py-2 bg-green-500/10 border border-green-500/30 rounded text-green-400 text-sm text-center">
|
||||
✓ Registered
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleWithdraw}
|
||||
disabled={registering}
|
||||
>
|
||||
{registering ? 'Withdrawing...' : 'Withdraw'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{race.status === 'completed' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/races/${race.id}/results`)}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleCancelRace}
|
||||
disabled={cancelling}
|
||||
>
|
||||
{cancelling ? 'Cancelling...' : 'Cancel Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
>
|
||||
Back to League
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{race.status === 'scheduled' && (
|
||||
<Card className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Entry List</h2>
|
||||
<span className="text-sm text-gray-400">
|
||||
{entryList.length} {entryList.length === 1 ? 'driver' : 'drivers'} registered
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entryList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="mb-2">No drivers registered yet</p>
|
||||
<p className="text-sm text-gray-500">Be the first to register!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entryList.map((driver, index) => (
|
||||
<div
|
||||
key={driver.id}
|
||||
className="flex items-center gap-4 p-3 bg-iron-gray/50 rounded-lg border border-charcoal-outline hover:border-primary-blue/50 transition-colors cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/drivers/${driver.id}?from=league&leagueId=${leagueId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import StandingsTable from '@/components/leagues/StandingsTable';
|
||||
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,
|
||||
getStandingRepository,
|
||||
getDriverRepository
|
||||
} from '@/lib/di-container';
|
||||
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';
|
||||
|
||||
export default function LeagueStandingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
export default function LeagueStandingsPage({ params }: { params: { id: string } }) {
|
||||
const leagueId = params.id;
|
||||
|
||||
const [league, setLeague] = useState<League | null>(null);
|
||||
const [standings, setStandings] = useState<Standing[]>([]);
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -27,27 +17,15 @@ export default function LeagueStandingsPage() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const standingRepo = getStandingRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueData = await leagueRepo.findById(leagueId);
|
||||
|
||||
if (!leagueData) {
|
||||
setError('League not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const allStandings = await standingRepo.findAll();
|
||||
const leagueStandings = allStandings.filter((s) => s.leagueId === leagueId);
|
||||
setStandings(leagueStandings);
|
||||
|
||||
setLeague(leagueData);
|
||||
|
||||
// Load standings
|
||||
const standingsData = await standingRepo.findByLeagueId(leagueId);
|
||||
setStandings(standingsData);
|
||||
|
||||
// Load drivers
|
||||
const driversData = await driverRepo.findAll();
|
||||
setDrivers(driversData);
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
setDrivers(allDrivers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load standings');
|
||||
} finally {
|
||||
@@ -62,73 +40,24 @@ export default function LeagueStandingsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center text-gray-400">Loading standings...</div>
|
||||
</div>
|
||||
<div className="text-center text-gray-400">
|
||||
Loading standings...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !league) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Card className="text-center py-12">
|
||||
<div className="text-warning-amber mb-4">
|
||||
{error || 'League not found'}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/leagues')}
|
||||
>
|
||||
Back to Leagues
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="text-center text-warning-amber">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push(`/leagues/${leagueId}`)}
|
||||
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to League Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Championship Standings</h1>
|
||||
<p className="text-gray-400">{league.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Standings Content */}
|
||||
<Card>
|
||||
{standings.length > 0 ? (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-white mb-6">Current Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-2">No standings available yet</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Standings will appear after race results are imported
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
|
||||
<StandingsTable standings={standings} drivers={drivers} leagueId={leagueId} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import Image from 'next/image';
|
||||
import Card from '@/components/ui/Card';
|
||||
import RankBadge from '@/components/drivers/RankBadge';
|
||||
import { getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
|
||||
|
||||
export interface DriverCardProps {
|
||||
id: string;
|
||||
@@ -16,6 +18,7 @@ export interface DriverCardProps {
|
||||
|
||||
export default function DriverCard(props: DriverCardProps) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
rating,
|
||||
nationality,
|
||||
@@ -35,8 +38,14 @@ export default function DriverCard(props: DriverCardProps) {
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<RankBadge rank={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">
|
||||
{name.charAt(0)}
|
||||
<div className="w-16 h-16 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={getDriverAvatarUrl(id)}
|
||||
alt={name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import Card from '../ui/Card';
|
||||
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
||||
|
||||
interface LeagueCardProps {
|
||||
league: League;
|
||||
@@ -15,27 +17,35 @@ export default function LeagueCard({ league, onClick }: LeagueCardProps) {
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(league.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-sm line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
|
||||
<div className="text-xs text-gray-500">
|
||||
Owner ID: {league.ownerId.slice(0, 8)}...
|
||||
<div className="space-y-3">
|
||||
<div className={getLeagueCoverClasses(league.id)} aria-hidden="true" />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-xl font-semibold text-white">{league.name}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(league.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-primary-blue font-medium">
|
||||
{league.settings.pointsSystem.toUpperCase()}
|
||||
|
||||
<p className="text-gray-400 text-sm line-clamp-2">
|
||||
{league.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
|
||||
<div className="text-xs text-gray-500">
|
||||
Owner:{' '}
|
||||
<Link
|
||||
href={`/drivers/${league.ownerId}?from=league&leagueId=${league.id}`}
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
{league.ownerId.slice(0, 8)}...
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-primary-blue font-medium">
|
||||
{league.settings.pointsSystem.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
70
apps/website/components/leagues/LeagueHeader.tsx
Normal file
70
apps/website/components/leagues/LeagueHeader.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
|
||||
|
||||
interface LeagueHeaderProps {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
description?: string | null;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
export default function LeagueHeader({
|
||||
leagueId,
|
||||
leagueName,
|
||||
description,
|
||||
ownerId,
|
||||
ownerName,
|
||||
}: LeagueHeaderProps) {
|
||||
const coverUrl = `https://picsum.photos/seed/${leagueId}/1200/280?blur=2`;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mb-4">
|
||||
<div className={getLeagueCoverClasses(leagueId)} aria-hidden="true">
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt="League cover placeholder"
|
||||
fill
|
||||
className="object-cover opacity-80"
|
||||
sizes="100vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{leagueName}</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
|
||||
</span>
|
||||
</FeatureLimitationTooltip>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className="text-gray-400 mb-2">{description}</p>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-400 mb-6">
|
||||
<span className="mr-2">Owner:</span>
|
||||
<Link
|
||||
href={`/drivers/${ownerId}?from=league&leagueId=${leagueId}`}
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
{ownerName}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
import {
|
||||
@@ -167,9 +168,12 @@ export default function LeagueMembers({
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">
|
||||
<Link
|
||||
href={`/drivers/${member.driverId}?from=league&leagueId=${leagueId}`}
|
||||
className="text-white font-medium hover:text-primary-blue transition-colors"
|
||||
>
|
||||
{getDriverName(member.driverId)}
|
||||
</span>
|
||||
</Link>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-gray-500">(You)</span>
|
||||
)}
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
? '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}`)}
|
||||
onClick={() => router.push(`/leagues/${leagueId}/races/${race.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
|
||||
interface StandingsTableProps {
|
||||
standings: Standing[];
|
||||
drivers: Driver[];
|
||||
leagueId: string;
|
||||
}
|
||||
|
||||
export default function StandingsTable({ standings, drivers }: StandingsTableProps) {
|
||||
export default function StandingsTable({ standings, drivers, leagueId }: StandingsTableProps) {
|
||||
const getDriverName = (driverId: string): string => {
|
||||
const driver = drivers.find(d => d.id === driverId);
|
||||
const driver = drivers.find((d) => d.id === driverId);
|
||||
return driver?.name || 'Unknown Driver';
|
||||
};
|
||||
|
||||
@@ -37,9 +39,9 @@ export default function StandingsTable({ standings, drivers }: StandingsTablePro
|
||||
<tbody>
|
||||
{standings.map((standing) => {
|
||||
const isLeader = standing.position === 1;
|
||||
|
||||
|
||||
return (
|
||||
<tr
|
||||
<tr
|
||||
key={`${standing.leagueId}-${standing.driverId}`}
|
||||
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
|
||||
>
|
||||
@@ -49,9 +51,16 @@ export default function StandingsTable({ standings, drivers }: StandingsTablePro
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={isLeader ? 'text-white font-semibold' : 'text-white'}>
|
||||
<Link
|
||||
href={`/drivers/${standing.driverId}?from=league&leagueId=${leagueId}`}
|
||||
className={
|
||||
isLeader
|
||||
? 'text-white font-semibold hover:text-primary-blue transition-colors'
|
||||
: 'text-white hover:text-primary-blue transition-colors'
|
||||
}
|
||||
>
|
||||
{getDriverName(standing.driverId)}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white font-medium">{standing.points}</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import Button from '../ui/Button';
|
||||
import { getDriverTeam } from '@/lib/racingLegacyFacade';
|
||||
import { getDriverTeam, getDriverAvatarUrl } from '@/lib/racingLegacyFacade';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: DriverDTO;
|
||||
@@ -14,8 +15,14 @@ export default function ProfileHeader({ driver, isOwnProfile = false, onEditClic
|
||||
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 className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={getDriverAvatarUrl(driver.id)}
|
||||
alt={driver.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Card from '../ui/Card';
|
||||
import { getTeamLogoUrl } from '@/lib/racingLegacyFacade';
|
||||
|
||||
interface TeamCardProps {
|
||||
id: string;
|
||||
@@ -36,14 +38,14 @@ export default function TeamCard({
|
||||
<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 className="w-16 h-16 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={logo || getTeamLogoUrl(id)}
|
||||
alt={name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-white truncate">
|
||||
|
||||
23
apps/website/lib/leagueCovers.ts
Normal file
23
apps/website/lib/leagueCovers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
function hashString(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
const GRADIENTS: string[] = [
|
||||
'bg-gradient-to-r from-blue-500/80 via-indigo-500/80 to-purple-500/80',
|
||||
'bg-gradient-to-r from-emerald-500/80 via-teal-500/80 to-cyan-500/80',
|
||||
'bg-gradient-to-r from-amber-500/80 via-orange-500/80 to-rose-500/80',
|
||||
'bg-gradient-to-r from-fuchsia-500/80 via-purple-500/80 to-sky-500/80',
|
||||
'bg-gradient-to-r from-lime-500/80 via-emerald-500/80 to-green-500/80',
|
||||
'bg-gradient-to-r from-slate-500/80 via-slate-600/80 to-slate-700/80',
|
||||
];
|
||||
|
||||
export function getLeagueCoverClasses(leagueId: string): string {
|
||||
const index = hashString(leagueId) % GRADIENTS.length;
|
||||
const baseLayout =
|
||||
'w-full h-32 rounded-lg overflow-hidden border border-charcoal-outline/60';
|
||||
return baseLayout + ' ' + GRADIENTS[index];
|
||||
}
|
||||
71
apps/website/lib/leagueRoles.ts
Normal file
71
apps/website/lib/leagueRoles.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MembershipRole } from '@/lib/racingLegacyFacade';
|
||||
|
||||
export type LeagueRole = MembershipRole;
|
||||
|
||||
export function isLeagueOwnerRole(role: LeagueRole): boolean {
|
||||
return role === 'owner';
|
||||
}
|
||||
|
||||
export function isLeagueAdminRole(role: LeagueRole): boolean {
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
export function isLeagueStewardRole(role: LeagueRole): boolean {
|
||||
return role === 'steward';
|
||||
}
|
||||
|
||||
export function isLeagueMemberRole(role: LeagueRole): boolean {
|
||||
return role === 'member';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for roles that should be treated as having elevated permissions.
|
||||
* This keeps UI logic open for future roles like steward, streamer, sponsor.
|
||||
*/
|
||||
export function isLeagueAdminOrHigherRole(role: LeagueRole): boolean {
|
||||
return role === 'owner' || role === 'admin' || role === 'steward';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordering helper for sorting memberships in tables.
|
||||
*/
|
||||
export function getLeagueRoleOrder(role: LeagueRole): number {
|
||||
const order: Record<LeagueRole, number> = {
|
||||
owner: 0,
|
||||
admin: 1,
|
||||
steward: 2,
|
||||
member: 3,
|
||||
};
|
||||
return order[role] ?? 99;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized display configuration for league membership roles.
|
||||
*/
|
||||
export function getLeagueRoleDisplay(
|
||||
role: LeagueRole,
|
||||
): { text: string; badgeClasses: string } {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return {
|
||||
text: 'Owner',
|
||||
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
||||
};
|
||||
case 'admin':
|
||||
return {
|
||||
text: 'Admin',
|
||||
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
|
||||
};
|
||||
case 'steward':
|
||||
return {
|
||||
text: 'Steward',
|
||||
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
|
||||
};
|
||||
case 'member':
|
||||
default:
|
||||
return {
|
||||
text: 'Member',
|
||||
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
} from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||
import { getDriverAvatar, getTeamLogo, getLeagueBanner, memberships as seedMemberships, leagues as seedLeagues } from '@gridpilot/testing-support';
|
||||
|
||||
export type { MembershipRole, MembershipStatus };
|
||||
|
||||
@@ -97,6 +98,69 @@ function generateId(prefix: string): string {
|
||||
return `${prefix}-${idCounter++}`;
|
||||
}
|
||||
|
||||
// Initialize league memberships from static seed data
|
||||
(function initializeLeagueMembershipsFromSeed() {
|
||||
if (leagueMemberships.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const membershipsByLeague = new Map<string, LeagueMembership[]>();
|
||||
|
||||
// Create base active memberships from seed
|
||||
for (const membership of seedMemberships) {
|
||||
const list = membershipsByLeague.get(membership.leagueId) ?? [];
|
||||
const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString();
|
||||
|
||||
list.push({
|
||||
leagueId: membership.leagueId,
|
||||
driverId: membership.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt,
|
||||
});
|
||||
|
||||
membershipsByLeague.set(membership.leagueId, list);
|
||||
}
|
||||
|
||||
// Ensure league owners are represented as owners in memberships
|
||||
for (const league of seedLeagues) {
|
||||
const list = membershipsByLeague.get(league.id) ?? [];
|
||||
const existingOwnerMembership = list.find((m) => m.driverId === league.ownerId);
|
||||
|
||||
if (existingOwnerMembership) {
|
||||
existingOwnerMembership.role = 'owner';
|
||||
} else {
|
||||
const joinedAt = new Date(2024, 0, 1 + (idCounter % 28)).toISOString();
|
||||
list.unshift({
|
||||
leagueId: league.id,
|
||||
driverId: league.ownerId,
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joinedAt,
|
||||
});
|
||||
}
|
||||
|
||||
membershipsByLeague.set(league.id, list);
|
||||
}
|
||||
|
||||
// Store into facade-local maps
|
||||
for (const [leagueId, list] of membershipsByLeague.entries()) {
|
||||
leagueMemberships.set(leagueId, list);
|
||||
}
|
||||
})();
|
||||
|
||||
export function getDriverAvatarUrl(driverId: string): string {
|
||||
return getDriverAvatar(driverId);
|
||||
}
|
||||
|
||||
export function getTeamLogoUrl(teamId: string): string {
|
||||
return getTeamLogo(teamId);
|
||||
}
|
||||
|
||||
export function getLeagueBannerUrl(leagueId: string): string {
|
||||
return getLeagueBanner(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* League membership API
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'picsum.photos',
|
||||
},
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
|
||||
13
apps/website/public/images/leagues/placeholder-cover.svg
Normal file
13
apps/website/public/images/leagues/placeholder-cover.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="640" height="160" viewBox="0 0 640 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="leaguePlaceholderGradient" x1="0" y1="0" x2="640" y2="160" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#38BDF8" stop-opacity="0.9" />
|
||||
<stop offset="0.5" stop-color="#4F46E5" stop-opacity="0.85" />
|
||||
<stop offset="1" stop-color="#EC4899" stop-opacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="640" height="160" rx="16" fill="url(#leaguePlaceholderGradient)" />
|
||||
<circle cx="96" cy="40" r="40" fill="#0F172A" fill-opacity="0.55" />
|
||||
<circle cx="280" cy="132" r="56" fill="#020617" fill-opacity="0.35" />
|
||||
<circle cx="520" cy="52" r="32" fill="#0B1120" fill-opacity="0.45" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 774 B |
@@ -6,8 +6,8 @@ import { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
||||
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO';
|
||||
import { faker } from '@gridpilot/testing-support';
|
||||
import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '@gridpilot/testing-support';
|
||||
import { faker } from '../faker/faker';
|
||||
import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '../images/images';
|
||||
|
||||
export type RacingMembership = {
|
||||
driverId: string;
|
||||
@@ -346,20 +346,25 @@ function createFeedEvents(
|
||||
const events: FeedItem[] = [];
|
||||
const now = new Date();
|
||||
const completedRaces = races.filter((race) => race.status === 'completed');
|
||||
const globalDrivers = faker.helpers.shuffle(drivers).slice(0, 10);
|
||||
|
||||
globalDrivers.forEach((driver, index) => {
|
||||
// Focus the global feed around a stable “core” of demo drivers
|
||||
const coreDrivers = faker.helpers.shuffle(drivers).slice(0, 16);
|
||||
|
||||
coreDrivers.forEach((driver, index) => {
|
||||
const league = pickOne(leagues);
|
||||
const race = completedRaces[index % Math.max(1, completedRaces.length)];
|
||||
const minutesAgo = 15 + index * 10;
|
||||
|
||||
const minutesAgo = 10 + index * 5;
|
||||
const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
|
||||
|
||||
const actorFriendId = driver.id;
|
||||
|
||||
// Joined league
|
||||
events.push({
|
||||
id: `friend-joined-league:${driver.id}:${minutesAgo}`,
|
||||
type: 'friend-joined-league',
|
||||
timestamp: baseTimestamp,
|
||||
actorDriverId: driver.id,
|
||||
actorFriendId,
|
||||
leagueId: league.id,
|
||||
headline: `${driver.name} joined ${league.name}`,
|
||||
body: 'They are now registered for the full season.',
|
||||
@@ -367,24 +372,66 @@ function createFeedEvents(
|
||||
ctaHref: `/leagues/${league.id}`,
|
||||
});
|
||||
|
||||
// Finished race / podium highlight
|
||||
const finishingPosition = (index % 5) + 1;
|
||||
events.push({
|
||||
id: `friend-finished-race:${driver.id}:${minutesAgo}`,
|
||||
type: 'friend-finished-race',
|
||||
timestamp: new Date(baseTimestamp.getTime() - 10 * 60 * 1000),
|
||||
timestamp: new Date(baseTimestamp.getTime() - 8 * 60 * 1000),
|
||||
actorDriverId: driver.id,
|
||||
actorFriendId,
|
||||
leagueId: race.leagueId,
|
||||
raceId: race.id,
|
||||
position: (index % 5) + 1,
|
||||
headline: `${driver.name} finished P${(index % 5) + 1} at ${race.track}`,
|
||||
body: `${driver.name} secured a strong result in ${race.car}.`,
|
||||
position: finishingPosition,
|
||||
headline: `${driver.name} finished P${finishingPosition} at ${race.track}`,
|
||||
body:
|
||||
finishingPosition <= 3
|
||||
? `${driver.name} scored a podium in ${race.car}.`
|
||||
: `${driver.name} secured a strong result in ${race.car}.`,
|
||||
ctaLabel: 'View results',
|
||||
ctaHref: `/races/${race.id}/results`,
|
||||
});
|
||||
|
||||
// New personal best
|
||||
events.push({
|
||||
id: `friend-new-personal-best:${driver.id}:${minutesAgo}`,
|
||||
type: 'friend-new-personal-best',
|
||||
timestamp: new Date(baseTimestamp.getTime() - 20 * 60 * 1000),
|
||||
actorDriverId: driver.id,
|
||||
actorFriendId,
|
||||
leagueId: race.leagueId,
|
||||
raceId: race.id,
|
||||
headline: `${driver.name} set a new personal best at ${race.track}`,
|
||||
body: 'Consistency and pace are trending up this season.',
|
||||
ctaLabel: 'View lap chart',
|
||||
ctaHref: `/races/${race.id}/analysis`,
|
||||
});
|
||||
|
||||
// Joined team (where applicable)
|
||||
const driverFriendships = friendships.filter((f) => f.driverId === driver.id);
|
||||
if (driverFriendships.length > 0) {
|
||||
const friend = pickOne(driverFriendships);
|
||||
const teammate = drivers.find((d) => d.id === friend.friendId);
|
||||
if (teammate) {
|
||||
events.push({
|
||||
id: `friend-joined-team:${driver.id}:${minutesAgo}`,
|
||||
type: 'friend-joined-team',
|
||||
timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000),
|
||||
actorDriverId: driver.id,
|
||||
actorFriendId,
|
||||
headline: `${driver.name} and ${teammate.name} are now teammates`,
|
||||
body: 'They will be sharing strategy and setups this season.',
|
||||
ctaLabel: 'View team',
|
||||
ctaHref: '/teams',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// League highlight
|
||||
events.push({
|
||||
id: `league-highlight:${league.id}:${minutesAgo}`,
|
||||
type: 'league-highlight',
|
||||
timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000),
|
||||
timestamp: new Date(baseTimestamp.getTime() - 45 * 60 * 1000),
|
||||
leagueId: league.id,
|
||||
headline: `${league.name} active with ${drivers.length}+ drivers`,
|
||||
body: 'Participation is growing. Perfect time to join the grid.',
|
||||
@@ -393,6 +440,40 @@ function createFeedEvents(
|
||||
});
|
||||
});
|
||||
|
||||
// Global “system” events: new race scheduled and results posted
|
||||
const upcomingRaces = races.filter((race) => race.status === 'scheduled').slice(0, 8);
|
||||
upcomingRaces.forEach((race, index) => {
|
||||
const minutesAgo = 60 + index * 15;
|
||||
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
|
||||
events.push({
|
||||
id: `new-race-scheduled:${race.id}`,
|
||||
type: 'new-race-scheduled',
|
||||
timestamp,
|
||||
leagueId: race.leagueId,
|
||||
raceId: race.id,
|
||||
headline: `New race scheduled at ${race.track}`,
|
||||
body: `${race.car} • ${race.scheduledAt.toLocaleString()}`,
|
||||
ctaLabel: 'View schedule',
|
||||
ctaHref: `/races/${race.id}`,
|
||||
});
|
||||
});
|
||||
|
||||
completedRaces.slice(0, 8).forEach((race, index) => {
|
||||
const minutesAgo = 180 + index * 20;
|
||||
const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
|
||||
events.push({
|
||||
id: `new-result-posted:${race.id}`,
|
||||
type: 'new-result-posted',
|
||||
timestamp,
|
||||
leagueId: race.leagueId,
|
||||
raceId: race.id,
|
||||
headline: `Results posted for ${race.track}`,
|
||||
body: 'Standings and stats updated across the grid.',
|
||||
ctaLabel: 'View classification',
|
||||
ctaHref: `/races/${race.id}/results`,
|
||||
});
|
||||
});
|
||||
|
||||
const sorted = events
|
||||
.slice()
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
@@ -428,7 +509,15 @@ export function createStaticRacingSeed(seed: number): RacingSeedData {
|
||||
|
||||
/**
|
||||
* Singleton seed used by website demo helpers.
|
||||
* This mirrors the previous apps/website/lib/demo-data/index.ts behavior.
|
||||
*
|
||||
* Alpha demo dataset (deterministic, in-memory only):
|
||||
* - 90+ drivers across multiple leagues
|
||||
* - Leagues with precomputed races, results and standings
|
||||
* - Team memberships and friendships forming social “circles”
|
||||
* - Feed events referencing real driver, league, race and team IDs
|
||||
*
|
||||
* This mirrors the previous apps/website/lib/demo-data/index.ts behavior while
|
||||
* keeping a stable shape for the website alpha experience.
|
||||
*/
|
||||
const staticSeed = createStaticRacingSeed(42);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user