This commit is contained in:
2025-12-04 11:54:23 +01:00
parent c0fdae3d3c
commit 9d5caa87f3
83 changed files with 1579 additions and 2151 deletions

View File

@@ -0,0 +1,41 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { getAuthService } from '../../../../lib/auth';
const SESSION_COOKIE = 'gp_demo_session';
const STATE_COOKIE = 'gp_demo_auth_state';
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code') ?? undefined;
const state = url.searchParams.get('state') ?? undefined;
const returnTo = url.searchParams.get('returnTo') ?? undefined;
if (!code || !state) {
return NextResponse.redirect('/auth/iracing');
}
const cookieStore = await cookies();
const storedState = cookieStore.get(STATE_COOKIE)?.value;
if (!storedState || storedState !== state) {
return NextResponse.redirect('/auth/iracing');
}
const authService = getAuthService();
const session = await authService.loginWithIracingCallback({ code, state, returnTo });
cookieStore.set(SESSION_COOKIE, JSON.stringify(session), {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
});
cookieStore.delete(STATE_COOKIE);
const redirectTarget = returnTo || '/dashboard';
const absoluteRedirect = new URL(redirectTarget, url.origin).toString();
return NextResponse.redirect(absoluteRedirect);
}

View File

@@ -0,0 +1,30 @@
import Link from 'next/link';
interface IracingAuthPageProps {
searchParams: Promise<{
returnTo?: string;
}>;
}
export default async function IracingAuthPage({ searchParams }: IracingAuthPageProps) {
const params = await searchParams;
const returnTo = params.returnTo ?? '/dashboard';
const startUrl = `/auth/iracing/start?returnTo=${encodeURIComponent(returnTo)}`;
return (
<main className="min-h-screen flex items-center justify-center bg-deep-graphite">
<div className="max-w-md w-full px-6 py-8 bg-iron-gray/80 rounded-lg border border-white/10 shadow-xl">
<h1 className="text-2xl font-semibold text-white mb-4">Authenticate with iRacing</h1>
<p className="text-sm text-gray-300 mb-6">
Connect a demo iRacing identity to explore the GridPilot dashboard with seeded data.
</p>
<Link
href={startUrl}
className="inline-flex items-center justify-center px-4 py-2 rounded-md bg-primary-blue text-sm font-medium text-white hover:bg-primary-blue/90 transition-colors w-full"
>
Start iRacing demo login
</Link>
</div>
</main>
);
}

View File

@@ -0,0 +1,23 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { getAuthService } from '../../../../lib/auth';
export async function GET(request: Request) {
const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') ?? undefined;
const authService = getAuthService();
const { redirectUrl, state } = await authService.startIracingAuthRedirect(returnTo);
const cookieStore = await cookies();
cookieStore.set('gp_demo_auth_state', state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
});
const absoluteRedirect = new URL(redirectUrl, url.origin).toString();
return NextResponse.redirect(absoluteRedirect);
}

View File

@@ -0,0 +1,11 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const cookieStore = await cookies();
cookieStore.delete('gp_demo_session');
const url = new URL(request.url);
const redirectUrl = new URL('/', url.origin);
return NextResponse.redirect(redirectUrl);
}

View File

@@ -0,0 +1,73 @@
import { redirect } from 'next/navigation';
import FeedLayout from '@/components/feed/FeedLayout';
import { getAuthService } from '@/lib/auth';
import {
getFeedRepository,
getRaceRepository,
getResultRepository,
} from '@/lib/di-container';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const authService = getAuthService();
const session = await authService.getCurrentSession();
if (!session) {
redirect('/auth/iracing?returnTo=/dashboard');
}
const feedRepository = getFeedRepository();
const raceRepository = getRaceRepository();
const resultRepository = getResultRepository();
const [feedItems, upcomingRaces, allResults] = await Promise.all([
feedRepository.getFeedForDriver(session.user.primaryDriverId ?? ''),
raceRepository.findAll(),
resultRepository.findAll(),
]);
const upcoming = upcomingRaces
.filter((race) => race.status === 'scheduled')
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())
.slice(0, 5);
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,
};
});
return (
<main className="min-h-screen bg-deep-graphite">
<section className="max-w-7xl mx-auto px-6 pt-10 pb-4">
<div className="flex items-baseline justify-between gap-4 mb-4">
<div>
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-sm text-gray-400">
Personalized activity from your friends, leagues, and teams.
</p>
</div>
</div>
</section>
<FeedLayout
feedItems={feedItems}
upcomingRaces={upcoming}
latestResults={latestResults}
/>
</main>
);
}

View File

@@ -3,11 +3,11 @@
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { getDriverRepository } from '@/lib/di-container';
import DriverProfile from '@/components/alpha/DriverProfile';
import DriverProfile from '@/components/drivers/DriverProfile';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
export default function DriverDetailPage() {
const router = useRouter();

View File

@@ -2,16 +2,16 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverCard from '@/components/alpha/DriverCard';
import RankBadge from '@/components/alpha/RankBadge';
import DriverCard from '@/components/drivers/DriverCard';
import RankBadge from '@/components/drivers/RankBadge';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
// Mock data
// Mock data (fictional demo drivers only)
const MOCK_DRIVERS = [
{
id: '1',
name: 'Max Verstappen',
name: 'Alex Vermeer',
rating: 3245,
skillLevel: 'pro' as const,
nationality: 'Netherlands',
@@ -23,7 +23,7 @@ const MOCK_DRIVERS = [
},
{
id: '2',
name: 'Lewis Hamilton',
name: 'Liam Hartmann',
rating: 3198,
skillLevel: 'pro' as const,
nationality: 'United Kingdom',

View File

@@ -1,10 +1,14 @@
import React from 'react';
import type { Metadata } from 'next';
import './globals.css';
import { getAppMode } from '@/lib/mode';
import { getAuthService } from '@/lib/auth';
import { AlphaNav } from '@/components/alpha/AlphaNav';
import AlphaBanner from '@/components/alpha/AlphaBanner';
import AlphaFooter from '@/components/alpha/AlphaFooter';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'GridPilot - iRacing League Racing Platform',
description: 'The dedicated home for serious iRacing leagues. Automatic results, standings, team racing, and professional race control.',
@@ -32,7 +36,7 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
@@ -40,13 +44,17 @@ export default function RootLayout({
const mode = getAppMode();
if (mode === 'alpha') {
const authService = getAuthService();
const session = await authService.getCurrentSession();
const isAuthenticated = !!session;
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased overflow-x-hidden min-h-screen bg-deep-graphite flex flex-col">
<AlphaNav />
<AlphaNav isAuthenticated={isAuthenticated} />
<AlphaBanner />
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
{children}

View File

@@ -5,20 +5,19 @@ import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import JoinLeagueButton from '@/components/alpha/JoinLeagueButton';
import MembershipStatus from '@/components/alpha/MembershipStatus';
import LeagueMembers from '@/components/alpha/LeagueMembers';
import LeagueSchedule from '@/components/alpha/LeagueSchedule';
import LeagueAdmin from '@/components/alpha/LeagueAdmin';
import StandingsTable from '@/components/alpha/StandingsTable';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import 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/membership-data';
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@gridpilot/racing/application';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -142,8 +141,6 @@ export default function LeagueDetailPage() {
<p className="text-gray-400">{league.description}</p>
</div>
<DataWarning />
{/* Action Card */}
{!membership && (
<Card className="mb-6">

View File

@@ -4,10 +4,10 @@ import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import StandingsTable from '@/components/alpha/StandingsTable';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
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,

View File

@@ -2,12 +2,12 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import LeagueCard from '@/components/alpha/LeagueCard';
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
import LeagueCard from '@/components/leagues/LeagueCard';
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 { League } from '@gridpilot/racing/domain/entities/League';
import { getLeagueRepository } from '@/lib/di-container';
export default function LeaguesPage() {

View File

@@ -1,15 +1,7 @@
'use client';
import { redirect } from 'next/navigation';
import { getAppMode } from '@/lib/mode';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import DataWarning from '@/components/alpha/DataWarning';
import RaceCard from '@/components/alpha/RaceCard';
import LeagueCard from '@/components/alpha/LeagueCard';
import TeamCard from '@/components/alpha/TeamCard';
import { getAuthService } from '@/lib/auth';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
@@ -21,628 +13,347 @@ import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import { getAllTeams, getTeamMembers } from '@/lib/team-data';
import { getLeagueMembers } from '@/lib/membership-data';
import type { Race } from '@gridpilot/racing-domain/entities/Race';
import type { League } from '@gridpilot/racing-domain/entities/League';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
topLeagues,
teams,
getUpcomingRaces,
} from '@gridpilot/testing-support';
function AlphaDashboard() {
const router = useRouter();
const [upcomingRaces, setUpcomingRaces] = useState<Race[]>([]);
const [topLeagues, setTopLeagues] = useState<League[]>([]);
const [featuredTeams, setFeaturedTeams] = useState<any[]>([]);
const [recentActivity, setRecentActivity] = useState<any[]>([]);
export default async function HomePage() {
const authService = getAuthService();
const session = await authService.getCurrentSession();
useEffect(() => {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
if (session) {
redirect('/dashboard');
}
// Get upcoming races
raceRepo.findAll().then(races => {
const upcoming = races
.filter(r => r.status === 'scheduled')
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime())
.slice(0, 5);
setUpcomingRaces(upcoming);
});
const mode = getAppMode();
const isAlpha = mode === 'alpha';
const upcomingRaces = getUpcomingRaces(3);
// Get top leagues
leagueRepo.findAll().then(leagues => {
const sorted = leagues
.map(league => ({
league,
memberCount: getLeagueMembers(league.id).length,
}))
.sort((a, b) => b.memberCount - a.memberCount)
.slice(0, 4)
.map(item => item.league);
setTopLeagues(sorted);
});
// Get featured teams
const teams = getAllTeams();
const featured = teams
.map(team => ({
...team,
memberCount: getTeamMembers(team.id).length,
}))
.sort((a, b) => b.memberCount - a.memberCount)
.slice(0, 4);
setFeaturedTeams(featured);
// Generate recent activity
const activities = [
{ type: 'race', text: 'Max Verstappen won at Monza GP', time: '2 hours ago' },
{ type: 'join', text: 'Lando Norris joined European GT Championship', time: '5 hours ago' },
{ type: 'team', text: 'Charles Leclerc joined Weekend Warriors', time: '1 day ago' },
{ type: 'race', text: 'Upcoming: Spa-Francorchamps in 2 days', time: '2 days ago' },
{ type: 'league', text: 'European GT Championship: 4 active members', time: '3 days ago' },
];
setRecentActivity(activities);
}, []);
return (
<div className="max-w-7xl mx-auto">
<DataWarning />
{/* Upcoming Races Section */}
{upcomingRaces.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Upcoming Races</h2>
<Button variant="secondary" onClick={() => router.push('/races')}>
View All Races
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcomingRaces.slice(0, 3).map(race => (
<RaceCard
key={race.id}
race={race}
onClick={() => router.push(`/races/${race.id}`)}
/>
))}
</div>
</div>
)}
{/* Top Leagues Section */}
{topLeagues.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Top Leagues</h2>
<Button variant="secondary" onClick={() => router.push('/leagues')}>
Browse Leagues
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{topLeagues.map(league => (
<LeagueCard
key={league.id}
league={league}
onClick={() => router.push(`/leagues/${league.id}`)}
/>
))}
</div>
</div>
)}
{/* Featured Teams Section */}
{featuredTeams.length > 0 && (
<div className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-white">Featured Teams</h2>
<Button variant="secondary" onClick={() => router.push('/teams')}>
Browse Teams
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{featuredTeams.map(team => (
<TeamCard
key={team.id}
id={team.id}
name={team.name}
logo={undefined}
memberCount={team.memberCount}
leagues={team.leagues}
onClick={() => router.push(`/teams/${team.id}`)}
/>
))}
</div>
</div>
)}
{/* Recent Activity Section */}
{recentActivity.length > 0 && (
<div className="mb-12">
<h2 className="text-3xl font-bold text-white mb-6">Recent Activity</h2>
<Card>
<div className="space-y-4">
{recentActivity.map((activity, idx) => (
<div
key={idx}
className={`flex items-start gap-3 pb-4 ${
idx < recentActivity.length - 1 ? 'border-b border-charcoal-outline' : ''
}`}
>
<div className="w-2 h-2 rounded-full bg-primary-blue mt-2 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-300">{activity.text}</p>
<p className="text-xs text-gray-500 mt-1">{activity.time}</p>
</div>
</div>
))}
</div>
</Card>
</div>
)}
<div className="max-w-4xl mx-auto">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-4">GridPilot Alpha</h1>
<p className="text-gray-400 text-lg">
Complete workflow prototype. Test freely all data is temporary.
</p>
</div>
{/* Companion Status */}
<div className="mb-8">
<CompanionStatus />
</div>
{/* What's in Alpha */}
<Card className="mb-8">
<h2 className="text-2xl font-semibold text-white mb-4">What's in Alpha</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Driver profile creation</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">League management</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Race scheduling</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">CSV result import</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Championship standings</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-performance-green flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-gray-300">Full workflow end-to-end</span>
</div>
</div>
</Card>
{/* What's Coming */}
<Card className="mb-8 bg-iron-gray">
<h2 className="text-2xl font-semibold text-white mb-4">What's Coming</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Persistent data storage</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Automated session creation</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Automated result import</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Multi-league memberships</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Team championships</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Advanced statistics</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">Social features</span>
</div>
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span className="text-gray-300">League discovery</span>
</div>
</div>
</Card>
{/* Known Limitations */}
<Card className="mb-8 border border-warning-amber/20 bg-iron-gray">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-warning-amber/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-warning-amber" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">Known Limitations</h3>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Data resets on page reload (in-memory only)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Manual iRacing session creation required</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Manual CSV result upload required</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>Single league membership per driver</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>No user authentication</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5">•</span>
<span>iRacing platform only</span>
</li>
</ul>
</div>
</div>
</Card>
{/* Quick Start Guide */}
<Card className="mb-8">
<h2 className="text-2xl font-semibold text-white mb-4">Quick Start Guide</h2>
<div className="space-y-4">
<div className="flex items-start gap-4">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-primary-blue/20 text-primary-blue font-semibold flex-shrink-0">
1
</span>
<div>
<h3 className="text-white font-medium mb-1">Create Your Profile</h3>
<p className="text-sm text-gray-400">Set up your driver profile with racing number and iRacing ID.</p>
<Button
variant="secondary"
onClick={() => router.push('/profile')}
className="mt-2"
>
Go to Profile
</Button>
</div>
</div>
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
2
</span>
<div>
<h3 className="text-white font-medium mb-1">Join or Create a League</h3>
<p className="text-sm text-gray-400">Browse available leagues or create your own.</p>
<Button
variant="secondary"
onClick={() => router.push('/leagues')}
className="mt-2"
>
Browse Leagues
</Button>
</div>
</div>
<div className="flex items-start gap-4 pt-4 border-t border-charcoal-outline">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-charcoal-outline text-gray-400 font-semibold flex-shrink-0">
3
</span>
<div>
<h3 className="text-white font-medium mb-1">Schedule Races</h3>
<p className="text-sm text-gray-400">Create race events and manage your schedule.</p>
<Button
variant="secondary"
onClick={() => router.push('/races')}
className="mt-2"
>
View Races
</Button>
</div>
</div>
</div>
</Card>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div onClick={() => router.push('/profile')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Profile</h3>
<p className="text-sm text-gray-400">Manage your driver profile</p>
</div>
</Card>
</div>
<div onClick={() => router.push('/leagues')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Leagues</h3>
<p className="text-sm text-gray-400">Browse and join leagues</p>
</div>
</Card>
</div>
<div onClick={() => router.push('/races')} className="cursor-pointer">
<Card className="hover:border-primary-blue/30 transition-colors">
<div className="flex flex-col items-center text-center py-4">
<div className="w-12 h-12 rounded-lg bg-primary-blue/10 flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-primary-blue" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-white font-semibold mb-1">Races</h3>
<p className="text-sm text-gray-400">View race schedule</p>
</div>
</Card>
</div>
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<main className="min-h-screen">
<Hero />
{/* Section 1: A Persistent Identity */}
<AlternatingSection
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<>
<p>
Your races, your seasons, your progress — finally in one place.
</p>
<div className="space-y-3 mt-4">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">Lifetime stats and season history across all your leagues</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">Track your performance, consistency, and team contributions</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">Your own rating that reflects real league competition</span>
<Hero />
{/* Section 1: A Persistent Identity */}
<AlternatingSection
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<>
<p>
Your races, your seasons, your progress finally in one place.
</p>
<div className="space-y-3 mt-4">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Lifetime stats and season history across all your leagues
</span>
</div>
</div>
<p className="mt-4">
iRacing gives you physics. GridPilot gives you a career.
</p>
</>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
/>
<FeatureGrid />
{/* Section 2: Results That Actually Stay */}
<AlternatingSection
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Every race you run stays with you.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">Your stats, your team, your story — all connected</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">One race result updates your profile, team points, rating, and season history</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">No more fragmented data across spreadsheets and forums</span>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Track your performance, consistency, and team contributions
</span>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Your racing career, finally in one place.
</p>
</>
}
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right"
/>
{/* Section 3: Automatic Session Creation */}
<AlternatingSection
heading="Automatic Session Creation"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">1</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">Our companion app syncs with your league schedule</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">2</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">When it's race time, it creates the iRacing session automatically</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">3</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">No clicking through wizards. No manual setup</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-green-600/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(34,197,94,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-green-600/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-green-600/25 to-green-900/25 border border-green-600/40 flex items-center justify-center shadow-lg group-hover:shadow-green-600/20 group-hover:scale-110 transition-all">
<svg className="w-4 h-4 md:w-5 md:h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">Runs on your machine, totally transparent, completely safe</span>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Your own rating that reflects real league competition
</span>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Automation instead of repetition.
</p>
</>
}
mockup={<CompanionAutomationMockup />}
layout="text-left"
/>
</div>
<p className="mt-4">
iRacing gives you physics. GridPilot gives you a career.
</p>
</>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
/>
{/* Section 4: Game-Agnostic Platform */}
<AlternatingSection
heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Right now, we're focused on making iRacing league racing better.
<FeatureGrid />
{/* Section 2: Results That Actually Stay */}
<AlternatingSection
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Every race you run stays with you.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Your stats, your team, your story all connected
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
One race result updates your profile, team points, rating, and season history
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No more fragmented data across spreadsheets and forums
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Your racing career, finally in one place.
</p>
</>
}
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right"
/>
{/* Section 3: Automatic Session Creation */}
<AlternatingSection
heading="Automatic Session Creation"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">1</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Our companion app syncs with your league schedule
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">2</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
When it's race time, it creates the iRacing session automatically
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">3</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No clicking through wizards. No manual setup
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Automation instead of repetition.
</p>
</>
}
mockup={<CompanionAutomationMockup />}
layout="text-left"
/>
{/* Section 4: Game-Agnostic Platform */}
<AlternatingSection
heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Right now, we're focused on making iRacing league racing better.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
But sims come and go. Your leagues, your teams, your rating — those stay.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
GridPilot is built to outlast any single platform.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
When the next sim arrives, your competitive identity moves with you.
</p>
</>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
{/* Alpha-only discovery section */}
{isAlpha && (
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
<div className="flex items-baseline justify-between mb-8">
<div>
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
<p className="text-sm text-gray-400">
Explore leagues, teams, and races that make up the GridPilot ecosystem.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
But sims come and go. Your leagues, your teams, your rating those stay.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
GridPilot is built to outlast any single platform.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
When the next sim arrives, your competitive identity moves with you.
</p>
</>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top leagues */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View all
</Button>
</div>
<ul className="space-y-3 text-sm">
{topLeagues.slice(0, 4).map(league => (
<li key={league.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
{league.name
.split(' ')
.map(word => word[0])
.join('')
.slice(0, 3)
.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{league.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{league.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Teams */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
<Button
as="a"
href="/teams"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
Browse teams
</Button>
</div>
<ul className="space-y-3 text-sm">
{teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center text-xs font-semibold text-white">
{team.tag}
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{team.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Upcoming races */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View schedule
</Button>
</div>
{upcomingRaces.length === 0 ? (
<p className="text-xs text-gray-400">
No races scheduled in this demo snapshot.
</p>
) : (
<ul className="space-y-3 text-sm">
{upcomingRaces.map(race => (
<li key={race.id} className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-xs text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
{race.scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</section>
)}
<DiscordCTA />
<FAQ />
<Footer />
</main>
);
}
export default function HomePage() {
const mode = getAppMode();
if (mode === 'alpha') {
return <AlphaDashboard />;
}
return <LandingPage />;
}

View File

@@ -3,19 +3,18 @@
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, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import CreateDriverForm from '@/components/alpha/CreateDriverForm';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import CreateDriverForm from '@/components/drivers/CreateDriverForm';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import DataWarning from '@/components/alpha/DataWarning';
import ProfileHeader from '@/components/alpha/ProfileHeader';
import ProfileStats from '@/components/alpha/ProfileStats';
import ProfileRaceHistory from '@/components/alpha/ProfileRaceHistory';
import ProfileSettings from '@/components/alpha/ProfileSettings';
import CareerHighlights from '@/components/alpha/CareerHighlights';
import RatingBreakdown from '@/components/alpha/RatingBreakdown';
import { getDriverTeam, getCurrentDriverId } from '@/lib/team-data';
import ProfileHeader from '@/components/profile/ProfileHeader';
import ProfileStats from '@/components/drivers/ProfileStats';
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 '@gridpilot/racing/application';
type Tab = 'overview' | 'statistics' | 'history' | 'settings';
@@ -95,8 +94,6 @@ export default function ProfilePage() {
return (
<div className="max-w-6xl mx-auto">
<DataWarning className="mb-6" />
<Card className="mb-6">
<ProfileHeader
driver={driver}

View File

@@ -5,21 +5,21 @@ import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
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 } from '@/lib/membership-data';
import {
getMembership,
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace,
getRegisteredDrivers
} from '@/lib/registration-data';
getRegisteredDrivers,
} from '@gridpilot/racing/application';
import CompanionStatus from '@/components/alpha/CompanionStatus';
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
export default function RaceDetailPage() {
const router = useRouter();
@@ -73,7 +73,9 @@ export default function RaceDetailPage() {
const drivers = await Promise.all(
registeredDriverIds.map(id => driverRepo.findById(id))
);
setEntryList(drivers.filter((d): d is Driver => d !== null));
setEntryList(
drivers.filter((d: Driver | null): d is Driver => d !== null)
);
// Check user registration status
const userIsRegistered = isRegistered(raceId, currentDriverId);
@@ -186,7 +188,7 @@ export default function RaceDetailPage() {
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 (
@@ -250,7 +252,11 @@ export default function RaceDetailPage() {
<p className="text-gray-400">{league.name}</p>
)}
</div>
<span className={`px-3 py-1 text-sm font-medium rounded border ${statusColors[race.status]}`}>
<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>
@@ -391,8 +397,6 @@ export default function RaceDetailPage() {
</span>
</div>
<DataWarning className="mb-4" />
{entryList.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No drivers registered yet</p>

View File

@@ -4,12 +4,12 @@ import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import ResultsTable from '@/components/alpha/ResultsTable';
import ImportResultsForm from '@/components/alpha/ImportResultsForm';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Result } from '@gridpilot/racing-domain/entities/Result';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import ResultsTable from '@/components/races/ResultsTable';
import ImportResultsForm from '@/components/races/ImportResultsForm';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import {
getRaceRepository,
getLeagueRepository,

View File

@@ -4,10 +4,10 @@ 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/alpha/RaceCard';
import ScheduleRaceForm from '@/components/alpha/ScheduleRaceForm';
import { Race, RaceStatus } from '@gridpilot/racing-domain/entities/Race';
import { League } from '@gridpilot/racing-domain/entities/League';
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';
export default function RacesPage() {

View File

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

View File

@@ -4,12 +4,11 @@ import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import TeamRoster from '@/components/alpha/TeamRoster';
import TeamStandings from '@/components/alpha/TeamStandings';
import TeamAdmin from '@/components/alpha/TeamAdmin';
import JoinTeamButton from '@/components/alpha/JoinTeamButton';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
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,
@@ -20,7 +19,7 @@ import {
removeTeamMember,
updateTeamMemberRole,
TeamRole,
} from '@/lib/team-data';
} from '@gridpilot/racing/application';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -129,8 +128,6 @@ export default function TeamDetailPage() {
]}
/>
<DataWarning className="mb-6" />
<Card className="mb-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-6">

View File

@@ -2,13 +2,12 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import TeamCard from '@/components/alpha/TeamCard';
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/alpha/CreateTeamForm';
import DataWarning from '@/components/alpha/DataWarning';
import { getAllTeams, getTeamMembers, Team } from '@/lib/team-data';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import { getAllTeams, getTeamMembers, type Team } from '@gridpilot/racing/application';
export default function TeamsPage() {
const router = useRouter();
@@ -52,7 +51,6 @@ export default function TeamsPage() {
if (showCreateForm) {
return (
<div className="max-w-4xl mx-auto">
<DataWarning className="mb-6" />
<div className="mb-6">
<Button
@@ -75,9 +73,7 @@ export default function TeamsPage() {
}
return (
<div className="max-w-6xl mx-auto">
<DataWarning className="mb-6" />
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1>

View File

@@ -9,9 +9,8 @@ export default function AlphaFooter() {
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="px-2 py-1 bg-warning-amber/10 text-warning-amber rounded border border-warning-amber/20 font-medium">
Alpha v0.1
Alpha
</span>
<span>In-memory prototype</span>
</div>
<div className="flex items-center gap-6 text-sm">
@@ -29,12 +28,6 @@ export default function AlphaFooter() {
>
Roadmap
</a>
<Link
href="/"
className="text-gray-400 hover:text-primary-blue transition-colors"
>
Back to Landing
</Link>
</div>
</div>
</div>

View File

@@ -1,26 +1,38 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const navLinks = [
{ href: '/', label: 'Dashboard' },
type AlphaNavProps = {
isAuthenticated?: boolean;
};
const nonHomeLinks = [
{ href: '/profile', label: 'Profile' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/teams', label: 'Teams' },
{ href: '/drivers', label: 'Drivers' },
{ href: '/social', label: 'Social' },
] as const;
export function AlphaNav() {
export function AlphaNav({ isAuthenticated }: AlphaNavProps) {
const pathname = usePathname();
const navLinks = isAuthenticated
? ([{ href: '/dashboard', label: 'Dashboard' } as const, ...nonHomeLinks] as const)
: ([{ href: '/', label: 'Home' } as const, ...nonHomeLinks] as const);
const loginHref = '/auth/iracing/start?returnTo=/dashboard';
return (
<nav className="sticky top-0 z-40 bg-deep-graphite/95 backdrop-blur-md border-b border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-14">
<div className="flex items-baseline space-x-3">
<Link href="/" className="text-xl font-semibold text-white hover:text-primary-blue transition-colors">
<Link
href="/"
className="text-xl font-semibold text-white hover:text-primary-blue transition-colors"
>
GridPilot
</Link>
<span className="text-xs text-gray-500 font-light">ALPHA</span>
@@ -35,9 +47,10 @@ export function AlphaNav() {
href={link.href}
className={`
relative px-4 py-2 text-sm font-medium transition-all duration-200
${isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
${
isActive
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
>
@@ -50,9 +63,29 @@ export function AlphaNav() {
})}
</div>
<div className="hidden md:flex items-center space-x-3">
{!isAuthenticated && (
<Link
href={loginHref}
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md bg-primary-blue text-xs font-medium text-white hover:bg-primary-blue/90 transition-colors"
>
Authenticate with iRacing
</Link>
)}
{isAuthenticated && (
<form action="/auth/logout" method="POST">
<button
type="submit"
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-gray-600 text-xs font-medium text-gray-200 hover:bg-gray-800 transition-colors"
>
Logout
</button>
</form>
)}
</div>
<div className="md:hidden w-8" />
</div>
</div>
</nav>
);

View File

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

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Race } from '@gridpilot/racing/domain/entities/Race';
interface CompanionInstructionsProps {
race: Race;

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getDriverRepository } from '../../lib/di-container';
interface FormErrors {
@@ -93,7 +92,6 @@ export default function CreateDriverForm() {
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
@@ -106,7 +104,7 @@ export default function CreateDriverForm() {
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="Max Verstappen"
placeholder="Alex Vermeer"
disabled={loading}
/>
</div>

View File

@@ -0,0 +1,73 @@
import Card from '@/components/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
export interface DriverCardProps {
id: string;
name: string;
rating: number;
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
rank: number;
onClick?: () => void;
}
export default function DriverCard(props: DriverCardProps) {
const {
name,
rating,
nationality,
racesCompleted,
wins,
podiums,
rank,
onClick,
} = props;
return (
<Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
onClick={onClick}
>
<div className="flex items-center justify-between">
<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>
<div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-1">{name}</h3>
<p className="text-sm text-gray-400">
{nationality} {racesCompleted} races
</p>
</div>
</div>
<div className="flex items-center gap-8 text-center">
<div>
<div className="text-2xl font-bold text-primary-blue">{rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-warning-amber">{podiums}</div>
<div className="text-xs text-gray-400">Podiums</div>
</div>
<div>
<div className="text-sm text-gray-400">
{racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0'}%
</div>
<div className="text-xs text-gray-500">Win Rate</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,13 +1,13 @@
'use client';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import Card from '../ui/Card';
import ProfileHeader from './ProfileHeader';
import ProfileHeader from '../profile/ProfileHeader';
import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { getDriverTeam } from '@/lib/team-data';
import { getDriverTeam } from '@gridpilot/racing/application';
import { getDriverStats, getLeagueRankings } from '@/lib/di-container';
interface DriverProfileProps {

View File

@@ -0,0 +1,81 @@
import Card from '@/components/ui/Card';
export interface DriverRanking {
type: 'overall' | 'league';
name: string;
rank: number;
totalDrivers: number;
percentile: number;
rating: number;
}
interface DriverRankingsProps {
rankings: DriverRanking[];
}
export default function DriverRankings({ rankings }: DriverRankingsProps) {
if (!rankings || rankings.length === 0) {
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">Rankings</h3>
<p className="text-sm text-gray-400">
No ranking data available yet. Compete in leagues to earn your first results.
</p>
</div>
</Card>
);
}
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Rankings</h3>
<div className="space-y-3">
{rankings.map((ranking, index) => (
<div
key={`${ranking.type}-${ranking.name}-${index}`}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-deep-graphite/60"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{ranking.name}
</span>
<span className="text-xs text-gray-400">
{ranking.type === 'overall' ? 'Overall' : 'League'} ranking
</span>
</div>
<div className="flex items-center gap-6 text-right text-xs">
<div>
<div className="text-primary-blue text-base font-semibold">
#{ranking.rank}
</div>
<div className="text-gray-500">Position</div>
</div>
<div>
<div className="text-white text-sm font-semibold">
{ranking.totalDrivers}
</div>
<div className="text-gray-500">Drivers</div>
</div>
<div>
<div className="text-green-400 text-sm font-semibold">
{ranking.percentile.toFixed(1)}%
</div>
<div className="text-gray-500">Percentile</div>
</div>
<div>
<div className="text-warning-amber text-sm font-semibold">
{ranking.rating}
</div>
<div className="text-gray-500">Rating</div>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';

View File

@@ -0,0 +1,25 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
export default function FeedEmptyState() {
return (
<Card className="bg-iron-gray/80 border-dashed border-charcoal-outline text-center py-10">
<div className="text-3xl mb-3">🏁</div>
<h3 className="text-lg font-semibold text-white mb-2">
Your feed is warming up
</h3>
<p className="text-sm text-gray-400 mb-4 max-w-md mx-auto">
As leagues, teams, and friends start racing, this feed will show their latest results,
signups, and highlights.
</p>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-xs px-4 py-2"
>
Explore leagues
</Button>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
import { friends } from '@gridpilot/testing-support';
function timeAgo(timestamp: Date): string {
const diffMs = Date.now() - timestamp.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} d ago`;
}
function getActor(item: FeedItem) {
if (item.actorFriendId) {
const friend = friends.find(f => f.driverId === item.actorFriendId);
if (friend) {
return {
name: friend.displayName,
avatarUrl: friend.avatarUrl
};
}
}
return null;
}
interface FeedItemCardProps {
item: FeedItem;
}
export default function FeedItemCard({ item }: FeedItemCardProps) {
const actor = getActor(item);
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
{actor ? (
<div className="w-10 h-10 rounded-full overflow-hidden bg-charcoal-outline">
<img
src={actor.avatarUrl}
alt={actor.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<Card className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-blue/10 border-primary-blue/40 p-0">
<span className="text-xs text-primary-blue font-semibold">
{item.type.startsWith('friend') ? 'FR' : 'LG'}
</span>
</Card>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-400 mt-1">{item.body}</p>
)}
</div>
<span className="text-[11px] text-gray-500 whitespace-nowrap">
{timeAgo(item.timestamp)}
</span>
</div>
{(item.ctaHref && item.ctaLabel) && (
<div className="mt-3">
<Button
as="a"
href={item.ctaHref}
variant="secondary"
className="text-xs px-4 py-2"
>
{item.ctaLabel}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import Card from '@/components/ui/Card';
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import type { RaceWithResultsDTO } from '@gridpilot/testing-support';
import FeedList from '@/components/feed/FeedList';
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
interface FeedLayoutProps {
feedItems: FeedItem[];
upcomingRaces: Race[];
latestResults: RaceWithResultsDTO[];
}
export default function FeedLayout({
feedItems,
upcomingRaces,
latestResults
}: FeedLayoutProps) {
return (
<section className="max-w-7xl mx-auto mt-16 mb-20">
<div className="flex flex-col gap-8 lg:grid lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
<div className="flex items-baseline justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-white">Activity</h2>
<p className="text-sm text-gray-400">
See what your friends and leagues are doing right now.
</p>
</div>
</div>
<Card className="bg-iron-gray/80">
<FeedList items={feedItems} />
</Card>
</div>
<aside className="space-y-6">
<UpcomingRacesSidebar races={upcomingRaces} />
<LatestResultsSidebar results={latestResults} />
</aside>
</div>
</section>
);
}

View File

@@ -0,0 +1,21 @@
import FeedEmptyState from '@/components/feed/FeedEmptyState';
import FeedItemCard from '@/components/feed/FeedItemCard';
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
interface FeedListProps {
items: FeedItem[];
}
export default function FeedList({ items }: FeedListProps) {
if (!items.length) {
return <FeedEmptyState />;
}
return (
<div className="space-y-4">
{items.map(item => (
<FeedItemCard key={item.id} item={item} />
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
export type BreadcrumbItem = {
label: string;
href?: string;
};
interface BreadcrumbsProps {
items: BreadcrumbItem[];
className?: string;
}
export default function Breadcrumbs({ items, className }: BreadcrumbsProps) {
if (!items || items.length === 0) {
return null;
}
const lastIndex = items.length - 1;
return (
<nav
aria-label="Breadcrumb"
className={className ?? 'text-sm text-gray-400 mb-4'}
>
<ol className="flex items-center gap-2 flex-wrap">
{items.map((item, index) => {
const isLast = index === lastIndex;
const content = item.href && !isLast ? (
<Link
href={item.href}
className="hover:text-primary-blue transition-colors"
>
{item.label}
</Link>
) : (
<span className={isLast ? 'text-white' : ''}>{item.label}</span>
);
return (
<li key={`${item.label}-${index}`} className="flex items-center gap-2">
{index > 0 && (
<span className="text-gray-600">/</span>
)}
{content}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -4,8 +4,7 @@ import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { League } from '@gridpilot/racing-domain/entities/League';
import { League } from '@gridpilot/racing/domain/entities/League';
import { getLeagueRepository, getDriverRepository } from '../../lib/di-container';
interface FormErrors {
@@ -98,7 +97,6 @@ export default function CreateLeagueForm() {
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">

View File

@@ -9,7 +9,7 @@ import {
requestToJoin,
getCurrentDriverId,
type MembershipStatus,
} from '@/lib/membership-data';
} from '@gridpilot/racing/application';
interface JoinLeagueButtonProps {
leagueId: string;

View File

@@ -5,8 +5,7 @@ import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LeagueMembers from './LeagueMembers';
import DataWarning from './DataWarning';
import { League } from '@gridpilot/racing-domain/entities/League';
import { League } from '@gridpilot/racing/domain/entities/League';
import {
getJoinRequests,
approveJoinRequest,
@@ -16,9 +15,9 @@ import {
getCurrentDriverId,
type JoinRequest,
type MembershipRole,
} from '@/lib/membership-data';
} from '@gridpilot/racing/application';
import { getDriverRepository } from '@/lib/di-container';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
interface LeagueAdminProps {
league: League;
@@ -104,8 +103,6 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
return (
<div>
<DataWarning />
{error && (
<div className="mb-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}

View File

@@ -1,6 +1,6 @@
'use client';
import { League } from '@gridpilot/racing-domain/entities/League';
import { League } from '@gridpilot/racing/domain/entities/League';
import Card from '../ui/Card';
interface LeagueCardProps {

View File

@@ -1,9 +1,14 @@
'use client';
import { useState, useEffect } from 'react';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { getLeagueMembers, getCurrentDriverId, type LeagueMembership, type MembershipRole } from '@/lib/membership-data';
import {
getLeagueMembers,
getCurrentDriverId,
type LeagueMembership,
type MembershipRole,
} from '@gridpilot/racing/application';
interface LeagueMembersProps {
leagueId: string;

View File

@@ -2,14 +2,14 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { getRaceRepository } from '@/lib/di-container';
import { getCurrentDriverId } from '@/lib/membership-data';
import {
getCurrentDriverId,
isRegistered,
registerForRace,
withdrawFromRace
} from '@/lib/registration-data';
withdrawFromRace,
} from '@gridpilot/racing/application';
interface LeagueScheduleProps {
leagueId: string;

View File

@@ -1,6 +1,6 @@
'use client';
import { getMembership, getCurrentDriverId, type MembershipRole } from '@/lib/membership-data';
import { getMembership, getCurrentDriverId, type MembershipRole } from '@gridpilot/racing/application';
interface MembershipStatusProps {
leagueId: string;

View File

@@ -4,12 +4,11 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import DataWarning from './DataWarning';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { League } from '@gridpilot/racing-domain/entities/League';
import { SessionType } from '@gridpilot/racing-domain/entities/Race';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { SessionType } from '@gridpilot/racing/domain/entities/Race';
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
import { InMemoryRaceRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
interface ScheduleRaceFormProps {
preSelectedLeagueId?: string;
@@ -136,7 +135,6 @@ export default function ScheduleRaceForm({
return (
<>
<DataWarning />
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">

View File

@@ -1,7 +1,7 @@
'use client';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
interface StandingsTableProps {
standings: Standing[];

View File

@@ -1,8 +1,8 @@
'use client';
import { DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import Button from '../ui/Button';
import { getDriverTeam } from '@/lib/team-data';
import { getDriverTeam } from '@gridpilot/racing/application';
interface ProfileHeaderProps {
driver: DriverDTO;

View File

@@ -2,8 +2,7 @@
import { useState } from 'react';
import Button from '../ui/Button';
import DataWarning from './DataWarning';
import { Result } from '@gridpilot/racing-domain/entities/Result';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { v4 as uuidv4 } from 'uuid';
interface ImportResultsFormProps {
@@ -143,7 +142,6 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
return (
<>
<DataWarning />
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">

View File

@@ -0,0 +1,36 @@
import Card from '@/components/ui/Card';
import type { RaceWithResultsDTO } from '@gridpilot/testing-support';
interface LatestResultsSidebarProps {
results: RaceWithResultsDTO[];
}
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
if (!results.length) {
return null;
}
return (
<Card className="bg-iron-gray/80">
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
<ul className="space-y-3">
{results.slice(0, 4).map(result => (
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{result.track}</p>
<p className="text-gray-400 truncate">
{result.winnerName} {result.car}
</p>
</div>
<div className="text-right text-gray-500 whitespace-nowrap">
{result.scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</div>
</li>
))}
</ul>
</Card>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Race } from '@gridpilot/racing/domain/entities/Race';
interface RaceCardProps {
race: Race;

View File

@@ -1,7 +1,7 @@
'use client';
import { Result } from '@gridpilot/racing-domain/entities/Result';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
interface ResultsTableProps {
results: Result[];

View File

@@ -0,0 +1,45 @@
import type { Race } from '@gridpilot/racing/domain/entities/Race';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
interface UpcomingRacesSidebarProps {
races: Race[];
}
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
if (!races.length) {
return null;
}
return (
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-3">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View all
</Button>
</div>
<ul className="space-y-3">
{races.slice(0, 4).map(race => (
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-gray-500 whitespace-nowrap">
{race.scheduledAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</div>
</li>
))}
</ul>
</Card>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { createTeam, getCurrentDriverId } from '@/lib/team-data';
import { createTeam, getCurrentDriverId } from '@gridpilot/racing/application';
interface CreateTeamFormProps {
onCancel?: () => void;

View File

@@ -9,7 +9,7 @@ import {
joinTeam,
requestToJoinTeam,
leaveTeam,
} from '@/lib/team-data';
} from '@gridpilot/racing/application';
interface JoinTeamButtonProps {
teamId: string;

View File

@@ -5,7 +5,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { getDriverRepository } from '@/lib/di-container';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import {
Team,
TeamJoinRequest,
@@ -13,7 +13,7 @@ import {
approveTeamJoinRequest,
rejectTeamJoinRequest,
updateTeam,
} from '@/lib/team-data';
} from '@gridpilot/racing/application';
interface TeamAdminProps {
team: Team;

View File

@@ -3,8 +3,8 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
import { EntityMappers, DriverDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { TeamMembership, TeamRole } from '@/lib/team-data';
import { EntityMappers, DriverDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import { TeamMembership, TeamRole } from '@gridpilot/racing/application';
interface TeamRosterProps {
teamId: string;

View File

@@ -3,8 +3,8 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { getStandingRepository, getLeagueRepository } from '@/lib/di-container';
import { EntityMappers, LeagueDTO } from '@gridpilot/racing-application/mappers/EntityMappers';
import { getTeamMembers } from '@/lib/team-data';
import { EntityMappers, LeagueDTO } from '@gridpilot/racing/application/mappers/EntityMappers';
import { getTeamMembers } from '@gridpilot/racing/application';
interface TeamStandingsProps {
teamId: string;

View File

@@ -0,0 +1,121 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useRouter } from 'next/navigation';
import type { AuthSession } from './AuthService';
type AuthContextValue = {
session: AuthSession | null;
loading: boolean;
login: (returnTo?: string) => void;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
interface AuthProviderProps {
initialSession?: AuthSession | null;
children: ReactNode;
}
export function AuthProvider({ initialSession = null, children }: AuthProviderProps) {
const router = useRouter();
const [session, setSession] = useState<AuthSession | null>(initialSession);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (initialSession) return;
let cancelled = false;
async function loadSession() {
try {
const res = await fetch('/api/auth/session', {
method: 'GET',
credentials: 'include',
});
if (!res.ok) {
if (!cancelled) setSession(null);
return;
}
const data = (await res.json()) as { session: AuthSession | null };
if (!cancelled) {
setSession(data.session ?? null);
}
} catch {
if (!cancelled) {
setSession(null);
}
}
}
loadSession();
return () => {
cancelled = true;
};
}, [initialSession]);
const login = useCallback(
(returnTo?: string) => {
const search = new URLSearchParams();
if (returnTo) {
search.set('returnTo', returnTo);
}
const target = search.toString()
? `/auth/iracing?${search.toString()}`
: '/auth/iracing';
router.push(target);
},
[router],
);
const logout = useCallback(async () => {
setLoading(true);
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
setSession(null);
router.push('/');
router.refresh();
} finally {
setLoading(false);
}
}, [router]);
const value = useMemo(
() => ({
session,
loading,
login,
logout,
}),
[session, loading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}

View File

@@ -0,0 +1,27 @@
export interface AuthUser {
id: string;
displayName: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}
export interface AuthSession {
user: AuthUser;
issuedAt: number;
expiresAt: number;
token: string;
}
export interface AuthService {
getCurrentSession(): Promise<AuthSession | null>;
startIracingAuthRedirect(
returnTo?: string,
): Promise<{ redirectUrl: string; state: string }>;
loginWithIracingCallback(params: {
code: string;
state: string;
returnTo?: string;
}): Promise<AuthSession>;
logout(): Promise<void>;
}

View File

@@ -0,0 +1,96 @@
import { cookies } from 'next/headers';
import { randomUUID } from 'crypto';
import type { AuthService, AuthSession, AuthUser } from './AuthService';
import { createStaticRacingSeed } from '@gridpilot/testing-support';
const SESSION_COOKIE = 'gp_demo_session';
const STATE_COOKIE = 'gp_demo_auth_state';
function parseCookieValue(raw: string | undefined): AuthSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as AuthSession;
if (!parsed.expiresAt || Date.now() > parsed.expiresAt) {
return null;
}
return parsed;
} catch {
return null;
}
}
function serializeSession(session: AuthSession): string {
return JSON.stringify(session);
}
export class InMemoryAuthService implements AuthService {
private readonly seedDriverId: string;
constructor() {
const seed = createStaticRacingSeed(42);
this.seedDriverId = seed.drivers[0]?.id ?? 'driver-1';
}
async getCurrentSession(): Promise<AuthSession | null> {
const store = await cookies();
const raw = store.get(SESSION_COOKIE)?.value;
return parseCookieValue(raw);
}
async startIracingAuthRedirect(
returnTo?: string,
): Promise<{ redirectUrl: string; state: string }> {
const state = randomUUID();
const params = new URLSearchParams();
params.set('code', 'dummy-code');
params.set('state', state);
if (returnTo) {
params.set('returnTo', returnTo);
}
return {
redirectUrl: `/auth/iracing/callback?${params.toString()}`,
state,
};
}
async loginWithIracingCallback(params: {
code: string;
state: string;
returnTo?: string;
}): Promise<AuthSession> {
if (!params.code) {
throw new Error('Missing auth code');
}
if (!params.state) {
throw new Error('Missing auth state');
}
const user: AuthUser = {
id: 'demo-user',
displayName: 'GridPilot Demo Driver',
iracingCustomerId: '000000',
primaryDriverId: this.seedDriverId,
avatarUrl: `/api/avatar/${this.seedDriverId}`,
};
const now = Date.now();
const expiresAt = now + 24 * 60 * 60 * 1000;
const session: AuthSession = {
user,
issuedAt: now,
expiresAt,
token: randomUUID(),
};
return session;
}
async logout(): Promise<void> {
// Intentionally does nothing; cookie deletion is handled by route handlers.
return;
}
}

View File

@@ -0,0 +1,11 @@
import type { AuthService } from './AuthService';
import { InMemoryAuthService } from './InMemoryAuthService';
let authService: AuthService | null = null;
export function getAuthService(): AuthService {
if (!authService) {
authService = new InMemoryAuthService();
}
return authService;
}

View File

@@ -1,27 +1,34 @@
/**
* Dependency Injection Container
*
*
* Initializes all in-memory repositories and provides accessor functions.
* Allows easy swapping to persistent repositories later.
*/
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Result } from '@gridpilot/racing-domain/entities/Result';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import type { IDriverRepository } from '@gridpilot/racing-domain/ports/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing-domain/ports/ILeagueRepository';
import type { IRaceRepository } from '@gridpilot/racing-domain/ports/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing-domain/ports/IResultRepository';
import type { IStandingRepository } from '@gridpilot/racing-domain/ports/IStandingRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@gridpilot/racing/domain/repositories/IStandingRepository';
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
import { InMemoryDriverRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryResultRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryStandingRepository';
import { InMemoryDriverRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryDriverRepository';
import { InMemoryLeagueRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryLeagueRepository';
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
import { InMemoryResultRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryResultRepository';
import { InMemoryStandingRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryStandingRepository';
import { createStaticRacingSeed, type RacingSeedData } from '@gridpilot/testing-support';
import {
InMemoryFeedRepository,
InMemorySocialGraphRepository,
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
/**
* Seed data for development
@@ -49,193 +56,34 @@ export interface DriverStats {
*/
const driverStats: Record<string, DriverStats> = {};
function createSeedData() {
// Create sample drivers (matching membership-data.ts and team-data.ts)
const driver1 = Driver.create({
id: 'driver-1',
iracingId: '123456',
name: 'Max Verstappen',
country: 'NL',
bio: 'Three-time world champion and team owner of Apex Racing',
joinedAt: new Date('2024-01-15'),
function createSeedData(): RacingSeedData {
const seed = createStaticRacingSeed(42);
const { drivers } = seed;
drivers.forEach((driver, index) => {
const totalRaces = 40 + index * 5;
const wins = Math.max(0, Math.floor(totalRaces * 0.2) - index);
const podiums = Math.max(wins * 2, 0);
const dnfs = Math.max(0, Math.floor(index / 2));
const rating = 1500 + index * 25;
driverStats[driver.id] = {
driverId: driver.id,
rating,
totalRaces,
wins,
podiums,
dnfs,
avgFinish: 4,
bestFinish: 1,
worstFinish: 20,
consistency: 80,
overallRank: index + 1,
percentile: Math.max(0, 100 - index),
};
});
const driver2 = Driver.create({
id: 'driver-2',
iracingId: '234567',
name: 'Lewis Hamilton',
country: 'GB',
bio: 'Seven-time world champion leading Speed Demons',
joinedAt: new Date('2024-01-20'),
});
const driver3 = Driver.create({
id: 'driver-3',
iracingId: '345678',
name: 'Charles Leclerc',
country: 'MC',
bio: 'Ferrari race winner and Weekend Warriors team owner',
joinedAt: new Date('2024-02-01'),
});
const driver4 = Driver.create({
id: 'driver-4',
iracingId: '456789',
name: 'Lando Norris',
country: 'GB',
bio: 'Rising star in motorsport',
joinedAt: new Date('2024-02-15'),
});
// Initialize driver stats
driverStats['driver-1'] = {
driverId: 'driver-1',
rating: 3245,
totalRaces: 156,
wins: 45,
podiums: 89,
dnfs: 8,
avgFinish: 3.2,
bestFinish: 1,
worstFinish: 18,
consistency: 87,
overallRank: 1,
percentile: 99
};
driverStats['driver-2'] = {
driverId: 'driver-2',
rating: 3198,
totalRaces: 234,
wins: 78,
podiums: 145,
dnfs: 12,
avgFinish: 2.8,
bestFinish: 1,
worstFinish: 22,
consistency: 92,
overallRank: 2,
percentile: 98
};
driverStats['driver-3'] = {
driverId: 'driver-3',
rating: 2912,
totalRaces: 145,
wins: 34,
podiums: 67,
dnfs: 9,
avgFinish: 4.5,
bestFinish: 1,
worstFinish: 20,
consistency: 84,
overallRank: 3,
percentile: 96
};
driverStats['driver-4'] = {
driverId: 'driver-4',
rating: 2789,
totalRaces: 112,
wins: 23,
podiums: 56,
dnfs: 7,
avgFinish: 5.1,
bestFinish: 1,
worstFinish: 16,
consistency: 81,
overallRank: 5,
percentile: 93
};
// Create sample league (matching membership-data.ts)
const league1 = League.create({
id: 'league-1',
name: 'European GT Championship',
description: 'Weekly GT3 racing with professional drivers',
ownerId: driver1.id,
settings: {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
},
createdAt: new Date('2024-01-20'),
});
// Create sample races
const race1 = Race.create({
id: 'race-1',
leagueId: league1.id,
scheduledAt: new Date('2024-03-15T19:00:00Z'),
track: 'Monza GP',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'completed',
});
const race2 = Race.create({
id: 'race-2',
leagueId: league1.id,
scheduledAt: new Date('2024-03-22T19:00:00Z'),
track: 'Spa-Francorchamps',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'scheduled',
});
const race3 = Race.create({
id: 'race-3',
leagueId: league1.id,
scheduledAt: new Date('2024-04-05T19:00:00Z'),
track: 'Nürburgring GP',
car: 'Porsche 911 GT3 R',
sessionType: 'race',
status: 'scheduled',
});
// Create sample standings
const standing1 = Standing.create({
leagueId: league1.id,
driverId: driver1.id,
position: 1,
points: 25,
wins: 1,
racesCompleted: 1,
});
const standing2 = Standing.create({
leagueId: league1.id,
driverId: driver2.id,
position: 2,
points: 18,
wins: 0,
racesCompleted: 1,
});
const standing3 = Standing.create({
leagueId: league1.id,
driverId: driver3.id,
position: 3,
points: 15,
wins: 0,
racesCompleted: 1,
});
const standing4 = Standing.create({
leagueId: league1.id,
driverId: driver4.id,
position: 4,
points: 12,
wins: 0,
racesCompleted: 1,
});
return {
drivers: [driver1, driver2, driver3, driver4],
leagues: [league1],
races: [race1, race2, race3],
standings: [standing1, standing2, standing3, standing4],
};
return seed;
}
/**
@@ -249,6 +97,8 @@ class DIContainer {
private _raceRepository: IRaceRepository;
private _resultRepository: IResultRepository;
private _standingRepository: IStandingRepository;
private _feedRepository: IFeedRepository;
private _socialRepository: ISocialGraphRepository;
private constructor() {
// Create seed data
@@ -261,7 +111,7 @@ class DIContainer {
// Result repository needs race repository for league-based queries
this._resultRepository = new InMemoryResultRepository(
undefined,
seedData.results,
this._raceRepository
);
@@ -272,6 +122,10 @@ class DIContainer {
this._raceRepository,
this._leagueRepository
);
// Social and feed adapters backed by static seed
this._feedRepository = new InMemoryFeedRepository(seedData);
this._socialRepository = new InMemorySocialGraphRepository(seedData);
}
/**
@@ -313,6 +167,14 @@ class DIContainer {
get standingRepository(): IStandingRepository {
return this._standingRepository;
}
get feedRepository(): IFeedRepository {
return this._feedRepository;
}
get socialRepository(): ISocialGraphRepository {
return this._socialRepository;
}
}
/**
@@ -338,6 +200,14 @@ export function getStandingRepository(): IStandingRepository {
return DIContainer.getInstance().standingRepository;
}
export function getFeedRepository(): IFeedRepository {
return DIContainer.getInstance().feedRepository;
}
export function getSocialRepository(): ISocialGraphRepository {
return DIContainer.getInstance().socialRepository;
}
/**
* Reset function for testing
*/

View File

@@ -1,53 +0,0 @@
import { z } from 'zod';
/**
* Email validation schema using Zod
*/
export const emailSchema = z.string()
.email('Invalid email format')
.min(3, 'Email too short')
.max(254, 'Email too long')
.toLowerCase()
.trim();
/**
* Validates an email address
* @param email - The email address to validate
* @returns Validation result with sanitized email or error
*/
export function validateEmail(email: string): {
success: boolean;
email?: string;
error?: string;
} {
const result = emailSchema.safeParse(email);
if (result.success) {
return {
success: true,
email: result.data,
};
}
return {
success: false,
error: result.error.errors[0]?.message || 'Invalid email',
};
}
/**
* Check if email appears to be from a disposable email service
* Basic check - can be extended with comprehensive list
*/
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'throwaway.email',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
]);
export function isDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase();
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
}

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,12 @@
"clean": "rm -rf .next"
},
"dependencies": {
"@faker-js/faker": "^9.2.0",
"@vercel/kv": "^3.0.0",
"@gridpilot/identity": "0.1.0",
"@gridpilot/racing": "0.1.0",
"@gridpilot/social": "0.1.0",
"@gridpilot/testing-support": "0.1.0",
"framer-motion": "^12.23.25",
"next": "^15.0.0",
"react": "^18.3.0",

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1f5fff"/>
<stop offset="1" stop-color="#23c4ff"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar1)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c3aed"/>
<stop offset="1" stop-color="#ec4899"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar2)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22c55e"/>
<stop offset="1" stop-color="#16a34a"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar3)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f97316"/>
<stop offset="1" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar4)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar5" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#eab308"/>
<stop offset="1" stop-color="#a16207"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar5)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="gpAvatar6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#06b6d4"/>
<stop offset="1" stop-color="#0ea5e9"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#050814"/>
<circle cx="32" cy="24" r="12" fill="url(#gpAvatar6)"/>
<path d="M16 52c3.5-9 9-14 16-14s12.5 5 16 14" fill="#111827"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32">
<defs>
<linearGradient id="gpTeam1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#1f5fff"/>
<stop offset="1" stop-color="#23c4ff"/>
</linearGradient>
</defs>
<rect width="96" height="32" rx="8" fill="#050814"/>
<path d="M10 22L18 10h8l-8 12z" fill="url(#gpTeam1)"/>
<rect x="32" y="10" width="6" height="12" rx="2" fill="#e5e7eb"/>
<rect x="44" y="8" width="6" height="16" rx="2" fill="#e5e7eb"/>
<rect x="56" y="10" width="6" height="12" rx="2" fill="#e5e7eb"/>
</svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32">
<defs>
<linearGradient id="gpTeam2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#7c3aed"/>
<stop offset="1" stop-color="#ec4899"/>
</linearGradient>
</defs>
<rect width="96" height="32" rx="8" fill="#050814"/>
<path d="M12 22L22 10h8l-8 12z" fill="url(#gpTeam2)"/>
<rect x="40" y="9" width="10" height="14" rx="3" fill="#e5e7eb"/>
<rect x="56" y="11" width="12" height="10" rx="3" fill="#e5e7eb"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32">
<defs>
<linearGradient id="gpTeam3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22c55e"/>
<stop offset="1" stop-color="#16a34a"/>
</linearGradient>
</defs>
<rect width="96" height="32" rx="8" fill="#050814"/>
<path d="M14 22L24 10h10l-8 12z" fill="url(#gpTeam3)"/>
<circle cx="54" cy="16" r="6" fill="#e5e7eb"/>
<rect x="66" y="12" width="12" height="8" rx="3" fill="#e5e7eb"/>
</svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 32">
<defs>
<linearGradient id="gpTeam4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#f97316"/>
<stop offset="1" stop-color="#ea580c"/>
</linearGradient>
</defs>
<rect width="96" height="32" rx="8" fill="#050814"/>
<path d="M16 22L28 10h10l-8 12z" fill="url(#gpTeam4)"/>
<rect x="50" y="9" width="8" height="14" rx="3" fill="#e5e7eb"/>
<rect x="62" y="11" width="10" height="10" rx="3" fill="#e5e7eb"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B