wip
41
apps/website/app/auth/iracing/callback/route.ts
Normal 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);
|
||||
}
|
||||
30
apps/website/app/auth/iracing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/website/app/auth/iracing/start/route.ts
Normal 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);
|
||||
}
|
||||
11
apps/website/app/auth/logout/route.ts
Normal 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);
|
||||
}
|
||||
73
apps/website/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
73
apps/website/components/drivers/DriverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
81
apps/website/components/drivers/DriverRankings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
25
apps/website/components/feed/FeedEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
apps/website/components/feed/FeedItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/feed/FeedLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/website/components/feed/FeedList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/layout/Breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
requestToJoin,
|
||||
getCurrentDriverId,
|
||||
type MembershipStatus,
|
||||
} from '@/lib/membership-data';
|
||||
} from '@gridpilot/racing/application';
|
||||
|
||||
interface JoinLeagueButtonProps {
|
||||
leagueId: string;
|
||||
@@ -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}
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
36
apps/website/components/races/LatestResultsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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[];
|
||||
45
apps/website/components/races/UpcomingRacesSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
joinTeam,
|
||||
requestToJoinTeam,
|
||||
leaveTeam,
|
||||
} from '@/lib/team-data';
|
||||
} from '@gridpilot/racing/application';
|
||||
|
||||
interface JoinTeamButtonProps {
|
||||
teamId: string;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
121
apps/website/lib/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
27
apps/website/lib/auth/AuthService.ts
Normal 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>;
|
||||
}
|
||||
96
apps/website/lib/auth/InMemoryAuthService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/auth/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
11
apps/website/public/images/avatars/avatar-1.svg
Normal 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 |
11
apps/website/public/images/avatars/avatar-2.svg
Normal 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 |
11
apps/website/public/images/avatars/avatar-3.svg
Normal 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 |
11
apps/website/public/images/avatars/avatar-4.svg
Normal 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 |
11
apps/website/public/images/avatars/avatar-5.svg
Normal 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 |
11
apps/website/public/images/avatars/avatar-6.svg
Normal 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 |
13
apps/website/public/images/logos/team-1.svg
Normal 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 |
12
apps/website/public/images/logos/team-2.svg
Normal 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 |
12
apps/website/public/images/logos/team-3.svg
Normal 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 |
12
apps/website/public/images/logos/team-4.svg
Normal 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 |