website refactor

This commit is contained in:
2026-01-20 21:35:50 +01:00
parent 06207bf835
commit 51288234f4
42 changed files with 892 additions and 449 deletions

View File

@@ -2,6 +2,41 @@ import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery'; import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery';
import { DriverProfilePageClient } from '@/client-wrapper/DriverProfilePageClient'; import { DriverProfilePageClient } from '@/client-wrapper/DriverProfilePageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const result = await DriverProfilePageQuery.execute(id);
if (result.isErr()) {
return MetadataHelper.generate({
title: 'Driver Not Found',
description: 'The requested driver profile could not be found on GridPilot.',
path: `/drivers/${id}`,
});
}
const viewData = result.unwrap();
const driver = viewData.currentDriver;
if (!driver) {
return MetadataHelper.generate({
title: 'Driver Not Found',
description: 'The requested driver profile could not be found on GridPilot.',
path: `/drivers/${id}`,
});
}
return MetadataHelper.generate({
title: driver.name,
description: driver.bio || `View the professional sim racing profile of ${driver.name} on GridPilot. Career statistics, race history, and performance metrics in the iRacing community.`,
path: `/drivers/${id}`,
image: driver.avatarUrl,
type: 'profile',
});
}
export default async function DriverProfilePage({ params }: { params: Promise<{ id: string }> }) { export default async function DriverProfilePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -21,9 +56,24 @@ export default async function DriverProfilePage({ params }: { params: Promise<{
} }
const viewData = result.unwrap(); const viewData = result.unwrap();
const driver = viewData.currentDriver;
const jsonLd = driver ? {
'@context': 'https://schema.org',
'@type': 'Person',
name: driver.name,
description: driver.bio,
image: driver.avatarUrl,
url: `https://gridpilot.com/drivers/${driver.id}`,
knowsAbout: ['Sim Racing', 'iRacing'],
} : null;
return ( return (
<DriverProfilePageClient <>
viewData={viewData} {jsonLd && <JsonLd data={jsonLd} />}
/> <DriverProfilePageClient
viewData={viewData}
/>
</>
); );
} }

View File

@@ -2,6 +2,14 @@ import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { DriversPageQuery } from '@/lib/page-queries/DriversPageQuery'; import { DriversPageQuery } from '@/lib/page-queries/DriversPageQuery';
import { DriversPageClient } from '@/client-wrapper/DriversPageClient'; import { DriversPageClient } from '@/client-wrapper/DriversPageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Sim Racing Drivers',
description: 'Explore the elite roster of sim racing drivers on GridPilot. Detailed performance metrics, career history, and professional driver profiles for the iRacing community.',
path: '/drivers',
});
export default async function Page() { export default async function Page() {
const result = await DriversPageQuery.execute(); const result = await DriversPageQuery.execute();

View File

@@ -3,6 +3,15 @@ import { LeaderboardsPageQuery } from '@/lib/page-queries/LeaderboardsPageQuery'
import { LeaderboardsPageClient } from '@/client-wrapper/LeaderboardsPageClient'; import { LeaderboardsPageClient } from '@/client-wrapper/LeaderboardsPageClient';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { logger } from '@/lib/infrastructure/logging/logger'; import { logger } from '@/lib/infrastructure/logging/logger';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Global Leaderboards',
description: 'See who leads the pack on GridPilot. Comprehensive global leaderboards for drivers and teams, featuring performance rankings and career statistics.',
path: '/leaderboards',
});
export default async function LeaderboardsPage() { export default async function LeaderboardsPage() {
const result = await LeaderboardsPageQuery.execute(); const result = await LeaderboardsPageQuery.execute();
@@ -24,5 +33,27 @@ export default async function LeaderboardsPage() {
// Success // Success
const viewData = result.unwrap(); const viewData = result.unwrap();
return <LeaderboardsPageClient viewData={viewData} />;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Global Driver Leaderboard',
description: 'Top performing sim racing drivers on GridPilot',
itemListElement: viewData.drivers.slice(0, 10).map((d, i) => ({
'@type': 'ListItem',
position: i + 1,
item: {
'@type': 'Person',
name: d.name,
url: `https://gridpilot.com/drivers/${d.id}`,
},
})),
};
return (
<>
<JsonLd data={jsonLd} />
<LeaderboardsPageClient viewData={viewData} />
</>
);
} }

View File

@@ -3,11 +3,36 @@ import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { ErrorBanner } from '@/ui/ErrorBanner'; import { ErrorBanner } from '@/ui/ErrorBanner';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const result = await LeagueDetailPageQuery.execute(id);
if (result.isErr()) {
return MetadataHelper.generate({
title: 'League Not Found',
description: 'The requested league could not be found on GridPilot.',
path: `/leagues/${id}`,
});
}
const data = result.unwrap();
const league = data.league;
return MetadataHelper.generate({
title: league.name,
description: league.description || `Join ${league.name} on GridPilot. Professional iRacing league with automated results, standings, and obsessive attention to detail.`,
path: `/leagues/${id}`,
});
}
export default async function Page({ params }: Props) { export default async function Page({ params }: Props) {
const { id } = await params; const { id } = await params;
// Execute the PageQuery // Execute the PageQuery
@@ -36,6 +61,7 @@ export default async function Page({ params }: Props) {
} }
const data = result.unwrap(); const data = result.unwrap();
const league = data.league;
// Build ViewData using the builder // Build ViewData using the builder
// Note: This would need additional data (owner, scoring config, etc.) in real implementation // Note: This would need additional data (owner, scoring config, etc.) in real implementation
@@ -48,7 +74,18 @@ export default async function Page({ params }: Props) {
sponsors: [], sponsors: [],
}); });
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SportsOrganization',
name: league.name,
description: league.description,
url: `https://gridpilot.com/leagues/${league.id}`,
};
return ( return (
<LeagueOverviewTemplate viewData={viewData} /> <>
<JsonLd data={jsonLd} />
<LeagueOverviewTemplate viewData={viewData} />
</>
); );
} }

View File

@@ -1,6 +1,14 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { LeaguesPageClient } from './LeaguesPageClient'; import { LeaguesPageClient } from './LeaguesPageClient';
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery'; import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
export const metadata: Metadata = MetadataHelper.generate({
title: 'iRacing Leagues',
description: 'Find and join the most professional iRacing leagues on GridPilot. Automated results, professional race control, and obsessive attention to detail for every series.',
path: '/leagues',
});
export default async function Page() { export default async function Page() {
// Execute the PageQuery // Execute the PageQuery

View File

@@ -3,6 +3,15 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { HomePageQuery } from '@/lib/page-queries/HomePageQuery'; import { HomePageQuery } from '@/lib/page-queries/HomePageQuery';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Professional iRacing League Management Platform',
description: 'Experience the pinnacle of sim racing organization. GridPilot provides obsessive detail in race management, automated standings, and professional-grade tools for serious iRacing leagues.',
path: '/',
});
export default async function Page() { export default async function Page() {
if (await HomePageQuery.shouldRedirectToDashboard()) { if (await HomePageQuery.shouldRedirectToDashboard()) {
@@ -18,5 +27,23 @@ export default async function Page() {
notFound(); notFound();
} }
return <HomePageClient viewData={data} />; const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'GridPilot',
url: 'https://gridpilot.com',
description: 'Professional iRacing League Management Platform',
potentialAction: {
'@type': 'SearchAction',
target: 'https://gridpilot.com/search?q={search_term_string}',
'query-input': 'required name=search_term_string',
},
};
return (
<>
<JsonLd data={jsonLd} />
<HomePageClient viewData={data} />
</>
);
} }

View File

@@ -2,6 +2,8 @@ import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery'; import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
import { RaceDetailPageClient } from '@/client-wrapper/RaceDetailPageClient'; import { RaceDetailPageClient } from '@/client-wrapper/RaceDetailPageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
interface RaceDetailPageProps { interface RaceDetailPageProps {
params: Promise<{ params: Promise<{
@@ -9,6 +11,30 @@ interface RaceDetailPageProps {
}>; }>;
} }
export async function generateMetadata({ params }: RaceDetailPageProps): Promise<Metadata> {
const { id: raceId } = await params;
const result = await RaceDetailPageQuery.execute({ raceId, driverId: '' });
if (result.isErr()) {
return MetadataHelper.generate({
title: 'Race Not Found',
description: 'The requested race details could not be found on GridPilot.',
path: `/races/${raceId}`,
});
}
const viewData = result.unwrap();
const race = viewData.race;
const leagueName = viewData.league?.name;
const title = leagueName ? `${race.track} - ${leagueName}` : `${race.track} - ${race.car}`;
return MetadataHelper.generate({
title: `${title} | Race Results`,
description: `Detailed race results, standings, and session reports for the ${race.car} race at ${race.track}${leagueName ? ` in ${leagueName}` : ''} on GridPilot. Professional iRacing event coverage with obsessive detail.`,
path: `/races/${raceId}`,
});
}
export default async function RaceDetailPage({ params }: RaceDetailPageProps) { export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
const { id: raceId } = await params; const { id: raceId } = await params;

View File

@@ -1,6 +1,14 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery'; import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery';
import { RacesPageClient } from '@/client-wrapper/RacesPageClient'; import { RacesPageClient } from '@/client-wrapper/RacesPageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Upcoming & Recent Races',
description: 'Stay updated with the latest sim racing action on GridPilot. View upcoming events, live race results, and detailed session reports from professional iRacing leagues.',
path: '/races',
});
export default async function Page() { export default async function Page() {
const query = new RacesPageQuery(); const query = new RacesPageQuery();

View File

@@ -1,6 +1,32 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery'; import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery';
import { TeamDetailPageClient } from '@/client-wrapper/TeamDetailPageClient'; import { TeamDetailPageClient } from '@/client-wrapper/TeamDetailPageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const result = await TeamDetailPageQuery.execute(id);
if (result.isErr()) {
return MetadataHelper.generate({
title: 'Team Not Found',
description: 'The requested team could not be found on GridPilot.',
path: `/teams/${id}`,
});
}
const viewData = result.unwrap();
const team = viewData.team;
return MetadataHelper.generate({
title: team.name,
description: team.description || `Explore ${team.name} on GridPilot. View team roster, race history, and performance statistics in professional iRacing leagues.`,
path: `/teams/${id}`,
// image: team.logoUrl, // If logoUrl exists in viewData
});
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -15,5 +41,30 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
notFound(); notFound();
} }
return <TeamDetailPageClient viewData={result.unwrap()} />; const viewData = result.unwrap();
const team = viewData.team;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SportsTeam',
name: team.name,
description: team.description,
url: `https://gridpilot.com/teams/${team.id}`,
member: viewData.memberships.map(m => ({
'@type': 'OrganizationRole',
member: {
'@type': 'Person',
name: m.driverName,
url: `https://gridpilot.com/drivers/${m.driverId}`,
},
roleName: m.role,
})),
};
return (
<>
<JsonLd data={jsonLd} />
<TeamDetailPageClient viewData={viewData} />
</>
);
} }

View File

@@ -1,6 +1,14 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery'; import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery';
import { TeamLeaderboardPageWrapper } from '@/client-wrapper/TeamLeaderboardPageWrapper'; import { TeamLeaderboardPageWrapper } from '@/client-wrapper/TeamLeaderboardPageWrapper';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Team Leaderboard',
description: 'The definitive ranking of sim racing teams on GridPilot. Compare team performance, championship points, and overall standing in the professional iRacing community.',
path: '/teams/leaderboard',
});
export default async function TeamLeaderboardPage() { export default async function TeamLeaderboardPage() {
const query = new TeamLeaderboardPageQuery(); const query = new TeamLeaderboardPageQuery();

View File

@@ -1,6 +1,14 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery'; import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery';
import { TeamsPageClient } from '@/client-wrapper/TeamsPageClient'; import { TeamsPageClient } from '@/client-wrapper/TeamsPageClient';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Sim Racing Teams',
description: 'Discover the most competitive sim racing teams on GridPilot. Track team performance, rosters, and achievements across the professional iRacing landscape.',
path: '/teams',
});
export default async function Page() { export default async function Page() {
const query = new TeamsPageQuery(); const query = new TeamsPageQuery();

View File

@@ -34,12 +34,31 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
router.push('/teams'); router.push('/teams');
}; };
// Apply filtering and sorting
const filteredAndSortedTeams = viewData.teams
.filter((team) => {
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesLevel = filterLevel === 'all' || team.performanceLevel === filterLevel;
return matchesSearch && matchesLevel;
})
.sort((a, b) => {
if (sortBy === 'rating') return (b.rating || 0) - (a.rating || 0);
if (sortBy === 'wins') return b.totalWins - a.totalWins;
if (sortBy === 'winRate') {
const rateA = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0;
const rateB = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0;
return rateB - rateA;
}
if (sortBy === 'races') return b.totalRaces - a.totalRaces;
return 0;
});
const templateViewData = { const templateViewData = {
teams: viewData.teams, teams: viewData.teams,
searchQuery, searchQuery,
filterLevel, filterLevel,
sortBy, sortBy,
filteredAndSortedTeams: viewData.teams, filteredAndSortedTeams,
}; };
return ( return (

View File

@@ -0,0 +1,19 @@
'use client';
import React, { ReactNode } from 'react';
import { Grid } from '@/ui/Grid';
interface DriverGridProps {
children: ReactNode;
}
/**
* DriverGrid - A semantic layout for displaying driver cards.
*/
export function DriverGrid({ children }: DriverGridProps) {
return (
<Grid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} gap={4}>
{children}
</Grid>
);
}

View File

@@ -1,12 +1,6 @@
'use client'; 'use client';
import { Heading } from '@/ui/Heading'; import { LandingHero } from '@/ui/LandingHero';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Section } from '@/ui/Section';
import { ButtonGroup } from '@/ui/ButtonGroup';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
/** /**
* Hero - Refined with Dieter Rams principles. * Hero - Refined with Dieter Rams principles.
@@ -15,51 +9,12 @@ import { Box } from '@/ui/Box';
*/ */
export function Hero() { export function Hero() {
return ( return (
<Section variant="default" py={32}> <LandingHero
<Box maxWidth="54rem"> subtitle="Sim Racing Infrastructure"
<Box marginBottom={24}> title="Professional League Management. Engineered for Control."
<Text description="GridPilot eliminates the administrative overhead of running iRacing leagues. No spreadsheets. No manual points. No protest chaos. Just pure competition, structured for growth."
variant="primary" primaryAction={{ label: 'Create Your League', href: '#' }}
weight="bold" secondaryAction={{ label: 'View Demo', href: '#' }}
uppercase />
size="xs"
leading="none"
block
letterSpacing="0.2em"
marginBottom={10}
>
Sim Racing Infrastructure
</Text>
<Heading level={1} weight="bold" size="4xl">
Professional League Management.<br />
Engineered for Control.
</Heading>
</Box>
<Box marginBottom={24}>
<Text size="xl" variant="med" leading="relaxed" block maxWidth="42rem">
GridPilot eliminates the administrative overhead of running iRacing leagues.
No spreadsheets. No manual points. No protest chaos.
Just pure competition, structured for growth.
</Text>
</Box>
<ButtonGroup gap={10}>
<Button
variant="primary"
size="lg"
>
Create Your League
</Button>
<Button
variant="secondary"
size="lg"
>
View Demo
</Button>
</ButtonGroup>
</Box>
</Section>
); );
} }

View File

@@ -12,11 +12,22 @@ import { Box } from '@/ui/Box';
import { StatusBadge } from '@/ui/StatusBadge'; import { StatusBadge } from '@/ui/StatusBadge';
import { Trophy, Globe, Settings2, Palette, ShieldCheck, BarChart3 } from 'lucide-react'; import { Trophy, Globe, Settings2, Palette, ShieldCheck, BarChart3 } from 'lucide-react';
interface LeagueIdentityPreviewProps {
league?: {
id: string;
name: string;
description: string;
};
}
/** /**
* LeagueIdentityPreview - Radically redesigned for "Modern Precision" and "Dieter Rams" style. * LeagueIdentityPreview - Radically redesigned for "Modern Precision" and "Dieter Rams" style.
* Focuses on the professional identity and deep customization options for admins. * Focuses on the professional identity and deep customization options for admins.
*/ */
export function LeagueIdentityPreview() { export function LeagueIdentityPreview({ league }: LeagueIdentityPreviewProps) {
const leagueName = league?.name || 'Apex Racing League';
const subdomain = league ? `${league.name.toLowerCase().replace(/\s+/g, '')}.gridpilot.racing` : 'apex.gridpilot.racing';
return ( return (
<Section variant="default" py={32}> <Section variant="default" py={32}>
<Box> <Box>
@@ -48,7 +59,7 @@ export function LeagueIdentityPreview() {
<Globe size={20} className="text-[var(--ui-color-text-low)]" /> <Globe size={20} className="text-[var(--ui-color-text-low)]" />
<Stack gap={1}> <Stack gap={1}>
<Text weight="bold">Custom Subdomains</Text> <Text weight="bold">Custom Subdomains</Text>
<Text size="xs" variant="low">yourleague.gridpilot.racing</Text> <Text size="xs" variant="low">{subdomain}</Text>
</Stack> </Stack>
</Group> </Group>
</Panel> </Panel>
@@ -77,8 +88,8 @@ export function LeagueIdentityPreview() {
<Trophy size={20} className="text-[var(--ui-color-text-low)]" /> <Trophy size={20} className="text-[var(--ui-color-text-low)]" />
</Box> </Box>
<Stack gap={0}> <Stack gap={0}>
<Text weight="bold" size="sm">Apex Racing League</Text> <Text weight="bold" size="sm">{leagueName}</Text>
<Text size="xs" variant="low">apex.gridpilot.racing</Text> <Text size="xs" variant="low">{subdomain}</Text>
</Stack> </Stack>
</Group> </Group>
</Panel> </Panel>

View File

@@ -13,11 +13,30 @@ import { Grid } from '@/ui/Grid';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Gavel, Clock, User, MessageSquare } from 'lucide-react'; import { Gavel, Clock, User, MessageSquare } from 'lucide-react';
interface StewardingPreviewProps {
race?: {
id: string;
track: string;
car: string;
formattedDate: string;
};
team?: {
id: string;
name: string;
description: string;
};
}
/** /**
* StewardingPreview - Refined for "Modern Precision" and "Dieter Rams" style. * StewardingPreview - Refined for "Modern Precision" and "Dieter Rams" style.
* Thorough down to the last detail. * Thorough down to the last detail.
*/ */
export function StewardingPreview() { export function StewardingPreview({ race, team }: StewardingPreviewProps) {
const incidentId = race ? `${race.id.slice(0, 3).toUpperCase()}-WG` : '402-WG';
const trackName = race?.track || 'Watkins Glen - Cup';
const carName = race?.car || 'Porsche 911 GT3 R';
const teamName = team?.name || 'Alex Miller';
return ( return (
<Section variant="muted" py={32}> <Section variant="muted" py={32}>
<Box> <Box>
@@ -36,9 +55,9 @@ export function StewardingPreview() {
<Group gap={2}> <Group gap={2}>
<Text variant="low" size="xs" uppercase weight="bold" letterSpacing="0.1em">Incident Report</Text> <Text variant="low" size="xs" uppercase weight="bold" letterSpacing="0.1em">Incident Report</Text>
<Text variant="low" size="xs"></Text> <Text variant="low" size="xs"></Text>
<Text variant="low" size="xs" uppercase weight="bold" letterSpacing="0.1em">ID: 402-WG</Text> <Text variant="low" size="xs" uppercase weight="bold" letterSpacing="0.1em">ID: {incidentId}</Text>
</Group> </Group>
<Heading level={3} weight="bold">Turn 1 Contact: Miller vs Chen</Heading> <Heading level={3} weight="bold">Turn 1 Contact: {teamName} vs David Chen</Heading>
</Stack> </Stack>
<StatusBadge variant="warning">UNDER REVIEW</StatusBadge> <StatusBadge variant="warning">UNDER REVIEW</StatusBadge>
</Group> </Group>
@@ -50,8 +69,8 @@ export function StewardingPreview() {
<User size={14} className="text-[var(--ui-color-intent-primary)]" /> <User size={14} className="text-[var(--ui-color-intent-primary)]" />
<Text size="xs" uppercase weight="bold" variant="low">Protestor</Text> <Text size="xs" uppercase weight="bold" variant="low">Protestor</Text>
</Group> </Group>
<Text weight="bold">Alex Miller</Text> <Text weight="bold">{teamName}</Text>
<Text size="sm" variant="low">#42 - Porsche 911 GT3 R</Text> <Text size="sm" variant="low">#42 - {carName}</Text>
</Stack> </Stack>
</Panel> </Panel>
<Panel variant="bordered" padding="md"> <Panel variant="bordered" padding="md">
@@ -71,7 +90,7 @@ export function StewardingPreview() {
<Text size="xs" uppercase weight="bold" variant="low">Session Info</Text> <Text size="xs" uppercase weight="bold" variant="low">Session Info</Text>
</Group> </Group>
<Text weight="bold">Lap 1, 00:42.150</Text> <Text weight="bold">Lap 1, 00:42.150</Text>
<Text size="sm" variant="low">Watkins Glen - Cup</Text> <Text size="sm" variant="low">{trackName}</Text>
</Stack> </Stack>
</Panel> </Panel>
</Grid> </Grid>

View File

@@ -38,7 +38,7 @@ export function TelemetryStrip() {
]; ];
return ( return (
<Section variant="default" py={16}> <Section variant="default" padding="md">
<StatsStrip stats={stats} /> <StatsStrip stats={stats} />
</Section> </Section>
); );

View File

@@ -2,6 +2,8 @@
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Input } from '@/ui/Input';
import { Button } from '@/ui/Button';
import { Search, Command, ArrowRight, X } from 'lucide-react'; import { Search, Command, ArrowRight, X } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
@@ -46,71 +48,81 @@ export function CommandModal({ isOpen, onClose }: CommandModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[20vh] px-4"> <Box className="fixed inset-0 z-[9999] flex items-start justify-center pt-[20vh] px-4">
{/* Backdrop */} {/* Backdrop */}
<div <Box
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose} onClick={onClose}
/> />
{/* Modal Content */} {/* Modal Content */}
<div className="relative w-full max-w-lg bg-surface-charcoal border border-outline-steel rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"> <Box className="relative w-full max-w-lg bg-surface-charcoal border border-outline-steel rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center border-b border-outline-steel px-4 py-3 gap-3"> <Box display="flex" alignItems="center" className="border-b border-outline-steel px-4 py-3 gap-3">
<Search className="text-text-low" size={18} /> <Search className="text-text-low" size={18} />
<input <Input
autoFocus autoFocus
type="text" variant="ghost"
placeholder="Type a command or search..." placeholder="Type a command or search..."
className="flex-1 bg-transparent border-none outline-none text-text-high placeholder:text-text-low/50 text-base h-6" className="flex-1 text-base h-6"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
<button onClick={onClose} className="text-text-low hover:text-text-high transition-colors"> <Button variant="ghost" size="sm" onClick={onClose} className="text-text-low hover:text-text-high transition-colors">
<span className="sr-only">Close</span> <Text as="span" className="sr-only">Close</Text>
<kbd className="hidden sm:inline-block px-1.5 py-0.5 text-[10px] font-mono bg-white/5 rounded border border-white/5">ESC</kbd> <Text as="kbd" className="hidden sm:inline-block px-1.5 py-0.5 text-[10px] font-mono bg-white/5 rounded border border-white/5">ESC</Text>
</button> </Button>
</div> </Box>
<div className="p-2"> <Box p={2}>
{results.length > 0 ? ( {results.length > 0 ? (
<div className="flex flex-col gap-1"> <Box display="flex" flexDirection="col" gap={1}>
<div className="px-2 py-1.5 text-[10px] font-mono uppercase tracking-wider text-text-low/50 font-bold"> <Box paddingX={2} paddingY={1.5}>
Suggestions <Text size="xs" weight="bold" uppercase mono className="tracking-wider text-text-low/50">
</div> Suggestions
</Text>
</Box>
{results.map((result, i) => ( {results.map((result, i) => (
<button <Button
key={i} key={i}
variant="ghost"
fullWidth
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-white/5 text-left group transition-colors" className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-white/5 text-left group transition-colors"
onClick={onClose} onClick={onClose}
> >
<span className="text-sm text-text-med group-hover:text-text-high font-medium"> <Text size="sm" weight="medium" className="text-text-med group-hover:text-text-high">
{result.label} {result.label}
</span> </Text>
<div className="flex items-center gap-2"> <Box display="flex" alignItems="center" gap={2}>
<span className="text-[10px] font-mono text-text-low bg-white/5 px-1.5 py-0.5 rounded border border-white/5"> <Text as="span" size="xs" mono className="text-text-low bg-white/5 px-1.5 py-0.5 rounded border border-white/5">
{result.shortcut} {result.shortcut}
</span> </Text>
<ArrowRight size={14} className="text-text-low opacity-0 group-hover:opacity-100 transition-opacity -translate-x-2 group-hover:translate-x-0" /> <ArrowRight size={14} className="text-text-low opacity-0 group-hover:opacity-100 transition-opacity -translate-x-2 group-hover:translate-x-0" />
</div> </Box>
</button> </Button>
))} ))}
</div> </Box>
) : ( ) : (
<div className="px-4 py-8 text-center text-text-low text-sm"> <Box paddingX={4} paddingY={8} className="text-center">
No results found. <Text size="sm" variant="low">
</div> No results found.
</Text>
</Box>
)} )}
</div> </Box>
<div className="px-4 py-2 bg-white/2 border-t border-white/5 flex items-center justify-between text-[10px] text-text-low"> <Box paddingX={4} paddingY={2} display="flex" alignItems="center" justifyContent="space-between" className="bg-white/2 border-t border-white/5">
<div className="flex gap-3"> <Box display="flex" gap={3}>
<span><strong className="text-text-med"></strong> to navigate</span> <Text size="xs" variant="low">
<span><strong className="text-text-med"></strong> to select</span> <Text as="strong" variant="med"></Text> to navigate
</div> </Text>
<span>GridPilot Command</span> <Text size="xs" variant="low">
</div> <Text as="strong" variant="med"></Text> to select
</div> </Text>
</div>, </Box>
<Text size="xs" variant="low">GridPilot Command</Text>
</Box>
</Box>
</Box>,
document.body document.body
); );
} }

View File

@@ -18,6 +18,8 @@ interface TeamLeaderboardPreviewProps {
totalWins: number; totalWins: number;
logoUrl: string; logoUrl: string;
position: number; position: number;
rating?: number;
performanceLevel: string;
}[]; }[];
onTeamClick: (id: string) => void; onTeamClick: (id: string) => void;
onNavigateToTeams: () => void; onNavigateToTeams: () => void;
@@ -28,12 +30,12 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
return ( return (
<LeaderboardPreviewShell <LeaderboardPreviewShell
title="Team Rankings" title="Team Standings"
subtitle="Top Performing Teams" subtitle="Top Performing Teams"
onViewFull={onNavigateToTeams} onViewFull={onNavigateToTeams}
icon={Users} icon={Users}
iconColor="var(--neon-purple)" iconColor="var(--ui-color-intent-primary)"
iconBgGradient="linear-gradient(to bottom right, rgba(168, 85, 247, 0.2), rgba(168, 85, 247, 0.1))" iconBgGradient="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.1))"
viewFullLabel="View All" viewFullLabel="View All"
> >
<LeaderboardList> <LeaderboardList>
@@ -72,7 +74,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
border border
borderColor="border-charcoal-outline" borderColor="border-charcoal-outline"
overflow="hidden" overflow="hidden"
groupHoverBorderColor="purple-400/50" groupHoverBorderColor="primary-blue/50"
transition transition
> >
<Image <Image
@@ -91,31 +93,26 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
weight="semibold" weight="semibold"
color="text-white" color="text-white"
truncate truncate
groupHoverTextColor="text-purple-400" groupHoverTextColor="text-primary-blue"
transition transition
block block
> >
{team.name} {team.name}
</Text> </Text>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap"> <Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
{team.category && ( <Text size="xs" variant="low" uppercase font="mono">{team.performanceLevel}</Text>
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
<Text size="xs" weight="medium">{team.category}</Text>
</Box>
)}
<Box w="1" h="1" rounded="full" bg="bg-gray-700" /> <Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<Icon icon={Users} size={3} color="text-gray-500" /> <Icon icon={Users} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text> <Text size="xs" color="text-gray-500">{team.memberCount}</Text>
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box display="flex" alignItems="center" gap={6}> <Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right"> <Box textAlign="right">
<Text color="text-purple-400" font="mono" weight="bold" block size="sm">{team.memberCount}</Text> <Text color="text-primary-blue" font="mono" weight="bold" block size="sm">{team.rating?.toFixed(0) || '1000'}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Members</Text> <Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Rating</Text>
</Box> </Box>
<Box textAlign="right" minWidth="12"> <Box textAlign="right" minWidth="12">
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text> <Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text>

View File

@@ -8,6 +8,7 @@ import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot'; import { StatusDot } from '@/ui/StatusDot';
import { Box } from '@/ui/Box';
import { Filter, Search } from 'lucide-react'; import { Filter, Search } from 'lucide-react';
export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
@@ -77,9 +78,9 @@ export function RaceFilterModal({
onClick={() => setTimeFilter(filter)} onClick={() => setTimeFilter(filter)}
> >
{filter === 'live' && ( {filter === 'live' && (
<Stack mr={2}> <Box mr={2}>
<StatusDot intent="success" size={1.5} pulse /> <StatusDot intent="success" size={1.5} pulse />
</Stack> </Box>
)} )}
{filter.charAt(0).toUpperCase() + filter.slice(1)} {filter.charAt(0).toUpperCase() + filter.slice(1)}
</Button> </Button>

View File

@@ -1,4 +1,4 @@
'use thought'; 'use client';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';

View File

@@ -1,7 +1,10 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box'; import { Container } from '@/ui/Container';
import { Group } from '@/ui/Group';
import { VerticalBar } from '@/ui/VerticalBar';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
@@ -15,34 +18,35 @@ interface PageHeaderProps {
*/ */
export function PageHeader({ title, subtitle, action }: PageHeaderProps) { export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return ( return (
<Box <Container
marginBottom={12} size="full"
display="flex" padding="none"
flexDirection={{ base: 'col', md: 'row' }} py={12}
alignItems={{ base: 'start', md: 'end' }}
justifyContent="between"
gap={6}
borderBottom
borderColor="var(--ui-color-border-muted)"
paddingBottom={8}
> >
<Box display="flex" flexDirection="col" gap={2}> <Group
<Box display="flex" alignItems="center" gap={3}> justify="between"
<Box width="4px" height="32px" bg="var(--ui-color-intent-primary)" /> align="end"
<Heading level={1} weight="bold" uppercase>{title}</Heading> wrap
</Box> gap={6}
{subtitle && ( >
<Text variant="low" size="lg" uppercase weight="bold" letterSpacing="widest"> <Group direction="col" gap={2}>
{subtitle} <Group align="center" gap={3}>
</Text> <VerticalBar height="2rem" />
)} <Heading level={1} weight="bold" uppercase>{title}</Heading>
</Box> </Group>
{subtitle && (
<Text variant="low" size="lg" uppercase weight="bold" letterSpacing="widest">
{subtitle}
</Text>
)}
</Group>
{action && ( {action && (
<Box display="flex" alignItems="center"> <Group align="center">
{action} {action}
</Box> </Group>
)} )}
</Box> </Group>
</Container>
); );
} }

View File

@@ -1,19 +1,10 @@
import React from 'react'; import React from 'react';
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
import { TeamLeaderboardPreview as SemanticTeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; import { TeamLeaderboardPreview as SemanticTeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview';
import type { LeaderboardTeamItem } from '@/lib/view-data/LeaderboardTeamItem';
interface TeamLeaderboardPreviewProps { interface TeamLeaderboardPreviewProps {
topTeams: Array<{ topTeams: LeaderboardTeamItem[];
id: string;
name: string;
logoUrl?: string;
category?: string;
memberCount: number;
totalWins: number;
isRecruiting: boolean;
rating?: number;
performanceLevel: string;
}>;
onTeamClick: (id: string) => void; onTeamClick: (id: string) => void;
onViewFullLeaderboard: () => void; onViewFullLeaderboard: () => void;
} }
@@ -27,15 +18,17 @@ export function TeamLeaderboardPreview({
return ( return (
<SemanticTeamLeaderboardPreview <SemanticTeamLeaderboardPreview
teams={topTeams.map((team, index) => ({ teams={topTeams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
tag: '', // Not available in this view data tag: team.tag,
memberCount: team.memberCount, memberCount: team.memberCount,
category: team.category, category: team.category,
totalWins: team.totalWins, totalWins: team.totalWins,
logoUrl: team.logoUrl || getMediaUrl('team-logo', team.id), logoUrl: team.logoUrl || getMediaUrl('team-logo', team.id),
position: index + 1 position: team.position,
rating: team.rating,
performanceLevel: team.performanceLevel
}))} }))}
onTeamClick={onTeamClick} onTeamClick={onTeamClick}
onNavigateToTeams={onViewFullLeaderboard} onNavigateToTeams={onViewFullLeaderboard}

View File

@@ -1,6 +1,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface TeamsHeaderProps { interface TeamsHeaderProps {
title: string; title: string;
@@ -10,24 +11,32 @@ interface TeamsHeaderProps {
export function TeamsHeader({ title, subtitle, action }: TeamsHeaderProps) { export function TeamsHeader({ title, subtitle, action }: TeamsHeaderProps) {
return ( return (
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-[var(--ui-color-border-muted)] pb-8"> <Box
<div className="space-y-2"> marginBottom={12}
<div className="flex items-center gap-3"> display="flex"
<div className="w-1 h-8 bg-[var(--ui-color-intent-primary)]" /> flexDirection={{ base: 'col', md: 'row' }}
alignItems={{ md: 'end' }}
justifyContent="space-between"
gap={6}
className="border-b border-[var(--ui-color-border-muted)] pb-8"
>
<Box className="space-y-2">
<Box display="flex" alignItems="center" gap={3}>
<Box width={1} height={8} className="bg-[var(--ui-color-intent-primary)]" />
<Heading level={1} weight="bold" uppercase>{title}</Heading> <Heading level={1} weight="bold" uppercase>{title}</Heading>
</div> </Box>
{subtitle && ( {subtitle && (
<Text variant="low" size="lg" uppercase mono className="tracking-[0.2em]"> <Text variant="low" size="lg" uppercase mono className="tracking-[0.2em]">
{subtitle} {subtitle}
</Text> </Text>
)} )}
</div> </Box>
{action && ( {action && (
<div className="flex items-center"> <Box display="flex" alignItems="center">
{action} {action}
</div> </Box>
)} )}
</div> </Box>
); );
} }

View File

@@ -1,9 +1,10 @@
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
export class LeaderboardsViewDataBuilder { export class LeaderboardsViewDataBuilder {
static build( static build(
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: { teams: [] } } apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: { teams: TeamListItemDTO[] } }
): LeaderboardsViewData { ): LeaderboardsViewData {
return { return {
drivers: apiDto.drivers.drivers.slice(0, 10).map(driver => ({ drivers: apiDto.drivers.drivers.slice(0, 10).map(driver => ({
@@ -17,7 +18,19 @@ export class LeaderboardsViewDataBuilder {
avatarUrl: driver.avatarUrl || '', avatarUrl: driver.avatarUrl || '',
position: driver.rank, position: driver.rank,
})), })),
teams: [], // Teams leaderboard not implemented teams: apiDto.teams.teams.slice(0, 10).map((team, index) => ({
id: team.id,
name: team.name,
tag: team.tag,
memberCount: team.memberCount,
category: team.category,
totalWins: team.totalWins || 0,
logoUrl: team.logoUrl || '',
position: index + 1,
isRecruiting: team.isRecruiting,
performanceLevel: team.performanceLevel || 'N/A',
rating: team.rating,
})),
}; };
} }
} }

View File

@@ -2,7 +2,7 @@ import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError'; import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { TeamService } from '@/lib/services/teams/TeamService'; import { TeamService } from '@/lib/services/teams/TeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
export interface TeamLeaderboardPageData { export interface TeamLeaderboardPageData {
teams: TeamSummaryViewModel[]; teams: TeamSummaryViewModel[];
@@ -18,15 +18,7 @@ export class TeamLeaderboardPageQuery implements PageQuery<TeamLeaderboardPageDa
return Result.err(mapToPresentationError(result.getError())); return Result.err(mapToPresentationError(result.getError()));
} }
const teams = result.unwrap().map((t: any) => ({ const teams = result.unwrap().map((t: any) => new TeamSummaryViewModel(t));
id: t.id,
name: t.name,
logoUrl: t.logoUrl,
memberCount: t.memberCount,
totalWins: t.totalWins,
totalRaces: t.totalRaces,
rating: 1450, // Mocked as in original
} as TeamSummaryViewModel));
return Result.ok({ teams }); return Result.ok({ teams });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,58 @@
import { Metadata } from 'next';
import { getWebsitePublicEnv } from '@/lib/config/env';
interface MetadataOptions {
title: string;
description: string;
path: string;
image?: string;
type?: 'website' | 'article' | 'profile';
}
export class MetadataHelper {
private static readonly DEFAULT_IMAGE = '/og-image.png';
private static readonly SITE_NAME = 'GridPilot';
static generate({
title,
description,
path,
image = this.DEFAULT_IMAGE,
type = 'website',
}: MetadataOptions): Metadata {
const env = getWebsitePublicEnv();
const baseUrl = env.NEXT_PUBLIC_SITE_URL || 'https://gridpilot.com';
const url = `${baseUrl}${path}`;
const fullTitle = `${title} | ${this.SITE_NAME}`;
return {
title: fullTitle,
description,
alternates: {
canonical: url,
},
openGraph: {
title: fullTitle,
description,
url,
siteName: this.SITE_NAME,
images: [
{
url: image.startsWith('http') ? image : `${baseUrl}${image}`,
width: 1200,
height: 630,
alt: title,
},
],
locale: 'en_US',
type,
},
twitter: {
card: 'summary_large_image',
title: fullTitle,
description,
images: [image.startsWith('http') ? image : `${baseUrl}${image}`],
},
};
}
}

View File

@@ -1,4 +1,5 @@
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { Result } from '@/lib/contracts/Result'; import { Result } from '@/lib/contracts/Result';
import { Service, DomainError } from '@/lib/contracts/services/Service'; import { Service, DomainError } from '@/lib/contracts/services/Service';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
@@ -15,8 +16,12 @@ export class LeaderboardsService implements Service {
const logger = new ConsoleLogger(); const logger = new ConsoleLogger();
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
const driverResult = await driversApiClient.getLeaderboard(); const [driverResult, teamResult] = await Promise.all([
driversApiClient.getLeaderboard(),
teamsApiClient.getAll()
]);
if (!driverResult) { if (!driverResult) {
return Result.err({ type: 'notFound', message: 'No leaderboard data available' }); return Result.err({ type: 'notFound', message: 'No leaderboard data available' });
@@ -24,7 +29,7 @@ export class LeaderboardsService implements Service {
const data: LeaderboardsData = { const data: LeaderboardsData = {
drivers: driverResult, drivers: driverResult,
teams: { teams: [] }, // Teams leaderboard not implemented teams: teamResult,
}; };
return Result.ok(data); return Result.ok(data);

View File

@@ -1,6 +1,7 @@
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
export interface LeaderboardsData { export interface LeaderboardsData {
drivers: { drivers: DriverLeaderboardItemDTO[] }; drivers: { drivers: DriverLeaderboardItemDTO[] };
teams: { teams: [] }; teams: { teams: TeamListItemDTO[] };
} }

View File

@@ -7,4 +7,7 @@ export interface LeaderboardTeamItem {
totalWins: number; totalWins: number;
logoUrl: string; logoUrl: string;
position: number; position: number;
isRecruiting: boolean;
performanceLevel: string;
rating?: number;
} }

View File

@@ -3,9 +3,11 @@
import { DriversViewData } from '@/lib/types/view-data/DriversViewData'; import { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { DriverCard } from '@/components/drivers/DriverCard'; import { DriverCard } from '@/components/drivers/DriverCard';
import { DriverStatsHeader } from '@/components/drivers/DriverStatsHeader'; import { DriverStatsHeader } from '@/components/drivers/DriverStatsHeader';
import { DriverGrid } from '@/components/drivers/DriverGrid';
import { PageHeader } from '@/ui/PageHeader'; import { PageHeader } from '@/ui/PageHeader';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box'; import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Search, Users } from 'lucide-react'; import { Search, Users } from 'lucide-react';
import { EmptyState } from '@/ui/EmptyState'; import { EmptyState } from '@/ui/EmptyState';
@@ -28,32 +30,29 @@ export function DriversTemplate({
}: DriversTemplateProps) { }: DriversTemplateProps) {
return ( return (
<main> <main>
<Box marginBottom={8}> <PageHeader
<PageHeader icon={Users}
icon={Users} title="Drivers"
title="Drivers" description="Global driver roster and statistics."
description="Global driver roster and statistics." action={
action={ <Button
<Box variant="secondary"
as="button" onClick={onViewLeaderboard}
onClick={onViewLeaderboard} >
className="px-4 py-2 rounded-md bg-[var(--ui-color-bg-surface)] border border-[var(--ui-color-border-default)] text-sm font-medium hover:bg-[var(--ui-color-bg-surface-muted)] transition-colors" Leaderboard
> </Button>
Leaderboard }
</Box> />
}
/>
</Box>
<Box marginBottom={8}> <Container size="full" padding="none" py={8}>
<DriverStatsHeader <DriverStatsHeader
totalDrivers={viewData.totalDriversLabel} totalDrivers={viewData.totalDriversLabel}
activeDrivers={viewData.activeCountLabel} activeDrivers={viewData.activeCountLabel}
totalRaces={viewData.totalRacesLabel} totalRaces={viewData.totalRacesLabel}
/> />
</Box> </Container>
<Box marginBottom={6} className="w-full"> <Container size="full" padding="none" py={6}>
<Input <Input
placeholder="Search drivers by name or nationality..." placeholder="Search drivers by name or nationality..."
value={searchQuery} value={searchQuery}
@@ -61,10 +60,10 @@ export function DriversTemplate({
icon={Search} icon={Search}
variant="search" variant="search"
/> />
</Box> </Container>
{filteredDrivers.length > 0 ? ( {filteredDrivers.length > 0 ? (
<Box display="grid" gap={4} className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <DriverGrid>
{filteredDrivers.map(driver => ( {filteredDrivers.map(driver => (
<DriverCard <DriverCard
key={driver.id} key={driver.id}
@@ -72,7 +71,7 @@ export function DriversTemplate({
onClick={onDriverClick} onClick={onDriverClick}
/> />
))} ))}
</Box> </DriverGrid>
) : ( ) : (
<EmptyState <EmptyState
title="No drivers found" title="No drivers found"

View File

@@ -52,10 +52,15 @@ export function HomeTemplate({ viewData }: HomeTemplateProps) {
<ValuePillars /> <ValuePillars />
{/* Stewarding Workflow Preview */} {/* Stewarding Workflow Preview */}
<StewardingPreview /> <StewardingPreview
race={viewData.upcomingRaces[0]}
team={viewData.teams[0]}
/>
{/* League Identity Showcase */} {/* League Identity Showcase */}
<LeagueIdentityPreview /> <LeagueIdentityPreview
league={viewData.topLeagues[0]}
/>
{/* Migration Offer */} {/* Migration Offer */}
<MigrationSection /> <MigrationSection />

View File

@@ -3,11 +3,11 @@
import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview'; import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview';
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper'; import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { Container } from '@/ui/Container'; import { Section } from '@/ui/Section';
import { GridItem } from '@/ui/GridItem';
import { PageHero } from '@/ui/PageHero'; import { PageHero } from '@/ui/PageHero';
import { Grid } from '@/ui/Grid'; import { FeatureGrid } from '@/ui/FeatureGrid';
import { Trophy, Users } from 'lucide-react'; import { Trophy, Users, Activity } from 'lucide-react';
import React from 'react';
interface LeaderboardsTemplateProps { interface LeaderboardsTemplateProps {
viewData: LeaderboardsViewData; viewData: LeaderboardsViewData;
@@ -25,11 +25,11 @@ export function LeaderboardsTemplate({
onNavigateToTeams onNavigateToTeams
}: LeaderboardsTemplateProps) { }: LeaderboardsTemplateProps) {
return ( return (
<Container size="lg" py={8}> <Section variant="default" padding="lg">
<PageHero <PageHero
title="Leaderboards" title="Global Standings"
description="Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics." description="Consolidated performance metrics for drivers and teams. Data-driven rankings based on competitive results and technical consistency."
icon={Trophy} icon={Activity}
actions={[ actions={[
{ {
label: 'Driver Rankings', label: 'Driver Rankings',
@@ -38,7 +38,7 @@ export function LeaderboardsTemplate({
variant: 'primary' variant: 'primary'
}, },
{ {
label: 'Team Rankings', label: 'Team Standings',
onClick: onNavigateToTeams, onClick: onNavigateToTeams,
icon: Users, icon: Users,
variant: 'secondary' variant: 'secondary'
@@ -46,26 +46,18 @@ export function LeaderboardsTemplate({
]} ]}
/> />
<Grid cols={12} gap={6} mt={10}> <FeatureGrid columns={{ base: 1, lg: 2 }} gap={8}>
<GridItem colSpan={12} lgSpan={6}> <DriverLeaderboardPreview
<DriverLeaderboardPreview drivers={viewData.drivers}
drivers={viewData.drivers} onDriverClick={onDriverClick}
onDriverClick={onDriverClick} onNavigateToDrivers={onNavigateToDrivers}
onNavigateToDrivers={onNavigateToDrivers} />
/> <TeamLeaderboardPreview
</GridItem> topTeams={viewData.teams}
<GridItem colSpan={12} lgSpan={6}> onTeamClick={onTeamClick}
<TeamLeaderboardPreview onViewFullLeaderboard={onNavigateToTeams}
topTeams={viewData.teams.map(team => ({ />
...team, </FeatureGrid>
isRecruiting: false, </Section>
performanceLevel: 'N/A'
}))}
onTeamClick={onTeamClick}
onViewFullLeaderboard={onNavigateToTeams}
/>
</GridItem>
</Grid>
</Container>
); );
} }

View File

@@ -7,7 +7,6 @@ import { PageHeader } from '@/ui/PageHeader';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import { Grid } from '@/ui/Grid';
import { Container } from '@/ui/Container'; import { Container } from '@/ui/Container';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
@@ -15,8 +14,7 @@ import { Section } from '@/ui/Section';
import { ControlBar } from '@/ui/ControlBar'; import { ControlBar } from '@/ui/ControlBar';
import { SegmentedControl } from '@/ui/SegmentedControl'; import { SegmentedControl } from '@/ui/SegmentedControl';
import { MetricCard } from '@/ui/MetricCard'; import { MetricCard } from '@/ui/MetricCard';
import { Stack } from '@/ui/Stack'; import { FeatureGrid } from '@/ui/FeatureGrid';
import { Box } from '@/ui/Box';
import { import {
Plus, Plus,
Search, Search,
@@ -73,27 +71,27 @@ export function LeaguesTemplate({
onClearFilters, onClearFilters,
}: LeaguesTemplateProps) { }: LeaguesTemplateProps) {
return ( return (
<Container size="xl" py={8}> <Section variant="default" padding="lg">
<Stack gap={8}> {/* Header Section */}
{/* Header Section */} <PageHeader
<PageHeader icon={Trophy}
icon={Trophy} title="Leagues"
title="Leagues" description="Infrastructure for competitive sim racing."
description="Infrastructure for competitive sim racing." action={
action={ <Button
<Button onClick={onCreateLeague}
onClick={onCreateLeague} variant="primary"
variant="primary" size="lg"
size="lg" icon={<Plus size={16} />}
icon={<Plus size={16} />} >
> Create League
Create League </Button>
</Button> }
} />
/>
{/* Stats Overview */} {/* Stats Overview */}
<Grid cols={{ base: 1, md: 3 }} gap={4}> <Container size="full" padding="none" py={8}>
<FeatureGrid columns={{ base: 1, md: 3 }} gap={4}>
<MetricCard <MetricCard
label="Active Leagues" label="Active Leagues"
value={viewData.leagues.length} value={viewData.leagues.length}
@@ -112,68 +110,67 @@ export function LeaguesTemplate({
icon={Trophy} icon={Trophy}
intent="success" intent="success"
/> />
</Grid> </FeatureGrid>
</Container>
{/* Control Bar */} {/* Control Bar */}
<ControlBar <ControlBar
leftContent={ leftContent={
<Group gap={4} align="center"> <Group gap={4} align="center">
<Icon icon={Filter} size={4} intent="low" /> <Icon icon={Filter} size={4} intent="low" />
<SegmentedControl <SegmentedControl
options={categories.map(c => ({ options={categories.map(c => ({
id: c.id, id: c.id,
label: c.label, label: c.label,
icon: <Icon icon={c.icon} size={3} /> icon: <Icon icon={c.icon} size={3} />
}))} }))}
activeId={activeCategory} activeId={activeCategory}
onChange={(id) => onCategoryChange(id as CategoryId)} onChange={(id) => onCategoryChange(id as CategoryId)}
/>
</Group>
}
>
<Box width="300px">
<Input
type="text"
placeholder="Search infrastructure..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)}
icon={<Search size={16} />}
size="sm"
/> />
</Box> </Group>
</ControlBar> }
>
<Input
type="text"
placeholder="Search infrastructure..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)}
icon={<Search size={16} />}
size="sm"
width="300px"
/>
</ControlBar>
{/* Results */} {/* Results */}
<Stack gap={6}> <Container size="full" padding="none" py={6}>
{filteredLeagues.length > 0 ? ( {filteredLeagues.length > 0 ? (
<Grid cols={{ base: 1, md: 2, lg: 3 }} gap={6}> <FeatureGrid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
{filteredLeagues.map((league) => ( {filteredLeagues.map((league) => (
<LeagueCard <LeagueCard
key={league.id} key={league.id}
league={league as unknown as LeagueSummaryViewModel} league={league as unknown as LeagueSummaryViewModel}
onClick={() => onLeagueClick(league.id)} onClick={() => onLeagueClick(league.id)}
/> />
))} ))}
</Grid> </FeatureGrid>
) : ( ) : (
<Section variant="dark" padding="lg"> <Section variant="dark" padding="lg">
<Stack align="center" justify="center" gap={4}> <Group direction="col" align="center" justify="center" gap={4}>
<Icon icon={Search} size={12} intent="low" /> <Icon icon={Search} size={12} intent="low" />
<Stack align="center" gap={1}> <Group direction="col" align="center" gap={1}>
<Text size="lg" weight="bold">No results found</Text> <Text size="lg" weight="bold">No results found</Text>
<Text variant="low" size="sm">Adjust filters to find matching infrastructure.</Text> <Text variant="low" size="sm">Adjust filters to find matching infrastructure.</Text>
</Stack> </Group>
<Button <Button
variant="secondary" variant="secondary"
onClick={onClearFilters} onClick={onClearFilters}
> >
Reset Filters Reset Filters
</Button> </Button>
</Stack> </Group>
</Section> </Section>
)} )}
</Stack> </Container>
</Stack> </Section>
</Container>
); );
} }

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { Container } from '@/ui/Container'; import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack'; import { Section } from '@/ui/Section';
import { RacesLiveRail } from '@/components/races/RacesLiveRail'; import { RacesLiveRail } from '@/components/races/RacesLiveRail';
import { RacesCommandBar } from '@/components/races/RacesCommandBar'; import { RacesCommandBar } from '@/components/races/RacesCommandBar';
import { NextUpRacePanel } from '@/components/races/NextUpRacePanel'; import { NextUpRacePanel } from '@/components/races/NextUpRacePanel';
@@ -10,24 +10,19 @@ import { RacesDayGroup } from '@/components/races/RacesDayGroup';
import { RacesEmptyState } from '@/components/races/RacesEmptyState'; import { RacesEmptyState } from '@/components/races/RacesEmptyState';
import { RaceFilterModal } from '@/components/races/RaceFilterModal'; import { RaceFilterModal } from '@/components/races/RaceFilterModal';
import { PageHeader } from '@/components/shared/PageHeader'; import { PageHeader } from '@/components/shared/PageHeader';
import type { RacesViewData } from '@/lib/view-data/RacesViewData'; import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData';
export interface RacesIndexTemplateProps { export interface RacesIndexTemplateProps {
viewData: RacesViewData & { viewData: RacesViewData & {
racesByDate: Array<{ nextUpRace?: RaceViewData;
dateKey: string;
dateLabel: string;
races: any[];
}>;
nextUpRace?: any;
}; };
// Filters // Filters
statusFilter: string; statusFilter: string;
setStatusFilter: (filter: any) => void; setStatusFilter: (filter: string) => void;
leagueFilter: string; leagueFilter: string;
setLeagueFilter: (filter: string) => void; setLeagueFilter: (filter: string) => void;
timeFilter: string; timeFilter: string;
setTimeFilter: (filter: any) => void; setTimeFilter: (filter: string) => void;
// Actions // Actions
onRaceClick: (raceId: string) => void; onRaceClick: (raceId: string) => void;
// UI State // UI State
@@ -50,20 +45,22 @@ export function RacesIndexTemplate({
const hasRaces = viewData.racesByDate.length > 0; const hasRaces = viewData.racesByDate.length > 0;
return ( return (
<Container size="lg"> <Section variant="default" padding="lg">
<Stack gap={8} paddingY={12}> <PageHeader
<PageHeader title="Races"
title="Races" subtitle="Live Sessions & Upcoming Events"
subtitle="Live Sessions & Upcoming Events" />
/>
{/* 1. Status Rail: Live sessions first */} {/* 1. Status Rail: Live sessions first */}
<Container size="full" padding="none" py={8}>
<RacesLiveRail <RacesLiveRail
liveRaces={viewData.liveRaces} liveRaces={viewData.liveRaces}
onRaceClick={onRaceClick} onRaceClick={onRaceClick}
/> />
</Container>
{/* 2. Command Bar: Fast filters */} {/* 2. Command Bar: Fast filters */}
<Container size="full" padding="none" py={4}>
<RacesCommandBar <RacesCommandBar
timeFilter={timeFilter} timeFilter={timeFilter}
setTimeFilter={setTimeFilter} setTimeFilter={setTimeFilter}
@@ -72,47 +69,50 @@ export function RacesIndexTemplate({
leagues={viewData.leagues} leagues={viewData.leagues}
onShowMoreFilters={() => setShowFilterModal(true)} onShowMoreFilters={() => setShowFilterModal(true)}
/> />
</Container>
{/* 3. Next Up: High signal panel */} {/* 3. Next Up: High signal panel */}
{timeFilter === 'upcoming' && viewData.nextUpRace && ( {timeFilter === 'upcoming' && viewData.nextUpRace && (
<Container size="full" padding="none" py={8}>
<NextUpRacePanel <NextUpRacePanel
race={viewData.nextUpRace} race={viewData.nextUpRace}
onRaceClick={onRaceClick} onRaceClick={onRaceClick}
/> />
)} </Container>
)}
{/* 4. Browse by Day: Grouped schedule */} {/* 4. Browse by Day: Grouped schedule */}
{hasRaces ? ( {hasRaces ? (
<Stack gap={8}> <Container size="full" padding="none" py={8}>
{viewData.racesByDate.map((group) => ( {viewData.racesByDate.map((group) => (
<Container key={group.dateKey} size="full" padding="none" py={4}>
<RacesDayGroup <RacesDayGroup
key={group.dateKey}
dateLabel={group.dateLabel} dateLabel={group.dateLabel}
races={group.races} races={group.races}
onRaceClick={onRaceClick} onRaceClick={onRaceClick}
/> />
))} </Container>
</Stack> ))}
) : ( </Container>
<RacesEmptyState /> ) : (
)} <RacesEmptyState />
)}
<RaceFilterModal <RaceFilterModal
isOpen={showFilterModal} isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)} onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter as any} statusFilter={statusFilter as any}
setStatusFilter={setStatusFilter} setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter} leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter} setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter as any} timeFilter={timeFilter as any}
setTimeFilter={setTimeFilter} setTimeFilter={setTimeFilter}
searchQuery="" searchQuery=""
setSearchQuery={() => {}} setSearchQuery={() => {}}
leagues={viewData.leagues} leagues={viewData.leagues}
showSearch={true} showSearch={true}
showTimeFilter={false} showTimeFilter={false}
/> />
</Stack> </Section>
</Container>
); );
} }

View File

@@ -50,7 +50,7 @@ export function RacesTemplate({
}: RacesTemplateProps) { }: RacesTemplateProps) {
return ( return (
<Container size="lg"> <Container size="lg">
<Stack gap={8}> <Stack gap={8} paddingY={12}>
<RacePageHeader <RacePageHeader
totalCount={viewData.totalCount} totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount} scheduledCount={viewData.scheduledCount}
@@ -101,12 +101,12 @@ export function RacesTemplate({
<RaceFilterModal <RaceFilterModal
isOpen={showFilterModal} isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)} onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter} statusFilter={statusFilter as any}
setStatusFilter={setStatusFilter} setStatusFilter={setStatusFilter as any}
leagueFilter={leagueFilter} leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter} setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter} timeFilter={timeFilter as any}
setTimeFilter={setTimeFilter} setTimeFilter={setTimeFilter as any}
searchQuery="" searchQuery=""
setSearchQuery={() => {}} setSearchQuery={() => {}}
leagues={viewData.leagues} leagues={viewData.leagues}

View File

@@ -11,7 +11,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Panel } from '@/ui/Panel'; import { Panel } from '@/ui/Panel';
import { Section } from '@/ui/Section'; import { Section } from '@/ui/Section';
import { Award, ChevronLeft } from 'lucide-react'; import { Select } from '@/ui/Select';
import { Award, ChevronLeft, Users } from 'lucide-react';
import React from 'react'; import React from 'react';
interface TeamLeaderboardTemplateProps { interface TeamLeaderboardTemplateProps {
@@ -26,13 +27,30 @@ interface TeamLeaderboardTemplateProps {
export function TeamLeaderboardTemplate({ export function TeamLeaderboardTemplate({
viewData, viewData,
onSearchChange, onSearchChange,
filterLevelChange,
onSortChange,
onTeamClick, onTeamClick,
onBackToTeams, onBackToTeams,
}: TeamLeaderboardTemplateProps) { }: TeamLeaderboardTemplateProps) {
const { searchQuery, filteredAndSortedTeams } = viewData; const { searchQuery, filterLevel, sortBy, filteredAndSortedTeams } = viewData;
const levelOptions = [
{ value: 'all', label: 'All Levels' },
{ value: 'pro', label: 'Professional' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'beginner', label: 'Beginner' },
];
const sortOptions = [
{ value: 'rating', label: 'Rating' },
{ value: 'wins', label: 'Wins' },
{ value: 'winRate', label: 'Win Rate' },
{ value: 'races', label: 'Races' },
];
return ( return (
<Container size="lg" py={12}> <Section variant="default" padding="lg">
<Group direction="column" gap={8} fullWidth> <Group direction="column" gap={8} fullWidth>
{/* Header */} {/* Header */}
<Group direction="row" align="center" justify="between" fullWidth> <Group direction="row" align="center" justify="between" fullWidth>
@@ -41,28 +59,44 @@ export function TeamLeaderboardTemplate({
Back Back
</Button> </Button>
<Group direction="column"> <Group direction="column">
<Heading level={1} weight="bold">Global Standings</Heading> <Heading level={1} weight="bold">Team Standings</Heading>
<Text variant="low" size="sm" font="mono" uppercase letterSpacing="widest">Team Performance Index</Text> <Text variant="low" size="sm" font="mono" uppercase letterSpacing="widest">Global Performance Index</Text>
</Group> </Group>
</Group> </Group>
<Icon icon={Award} size={8} color="var(--ui-color-intent-warning)" /> <Icon icon={Award} size={8} intent="warning" />
</Group> </Group>
<LeaderboardFiltersBar <LeaderboardFiltersBar
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
placeholder="Search teams..." placeholder="Search teams..."
/> >
<Group gap={4}>
<Select
size="sm"
value={filterLevel}
options={levelOptions}
onChange={(e) => filterLevelChange(e.target.value as SkillLevel | 'all')}
/>
<Select
size="sm"
value={sortBy}
options={sortOptions}
onChange={(e) => onSortChange(e.target.value as SortBy)}
/>
</Group>
</LeaderboardFiltersBar>
<Panel variant="dark" padding={0}> <Panel variant="dark" padding="none">
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableHeader w="20">Rank</TableHeader> <TableCell w="80px">Rank</TableCell>
<TableHeader>Team</TableHeader> <TableCell>Team</TableCell>
<TableHeader textAlign="center">Personnel</TableHeader> <TableCell textAlign="center">Personnel</TableCell>
<TableHeader textAlign="center">Races</TableHeader> <TableCell textAlign="center">Races</TableCell>
<TableHeader textAlign="right">Rating</TableHeader> <TableCell textAlign="center">Wins</TableCell>
<TableCell textAlign="right">Rating</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -80,10 +114,13 @@ export function TeamLeaderboardTemplate({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Group direction="row" align="center" gap={3}> <Group direction="row" align="center" gap={3}>
<Panel variant="muted" padding={2}> <Panel variant="muted" padding="sm">
<Text size="xs" weight="bold" color="text-primary-accent">{team.name.substring(0, 2).toUpperCase()}</Text> <Icon icon={Users} size={4} intent="low" />
</Panel> </Panel>
<Text weight="bold" size="sm">{team.name}</Text> <Group direction="column" gap={0}>
<Text weight="bold" size="sm">{team.name}</Text>
<Text size="xs" variant="low" uppercase font="mono">{team.performanceLevel}</Text>
</Group>
</Group> </Group>
</TableCell> </TableCell>
<TableCell textAlign="center"> <TableCell textAlign="center">
@@ -92,14 +129,19 @@ export function TeamLeaderboardTemplate({
<TableCell textAlign="center"> <TableCell textAlign="center">
<Text size="xs" variant="low" font="mono">{team.totalRaces}</Text> <Text size="xs" variant="low" font="mono">{team.totalRaces}</Text>
</TableCell> </TableCell>
<TableCell textAlign="center">
<Text size="xs" variant="low" font="mono">{team.totalWins}</Text>
</TableCell>
<TableCell textAlign="right"> <TableCell textAlign="right">
<Text font="mono" weight="bold" color="text-primary-accent">1450</Text> <Text font="mono" weight="bold" variant="primary">
{team.rating?.toFixed(0) || '1000'}
</Text>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={5} textAlign="center"> <TableCell colSpan={6} textAlign="center">
<Section variant="dark" padding="lg"> <Section variant="dark" padding="lg">
<Group align="center" justify="center" fullWidth> <Group align="center" justify="center" fullWidth>
<Text variant="low" font="mono" size="xs" uppercase letterSpacing="widest"> <Text variant="low" font="mono" size="xs" uppercase letterSpacing="widest">
@@ -114,6 +156,6 @@ export function TeamLeaderboardTemplate({
</Table> </Table>
</Panel> </Panel>
</Group> </Group>
</Container> </Section>
); );
} }

View File

@@ -10,9 +10,7 @@ import { TeamCard } from '@/components/teams/TeamCard';
import { TeamSearchBar } from '@/components/teams/TeamSearchBar'; import { TeamSearchBar } from '@/components/teams/TeamSearchBar';
import { EmptyState } from '@/ui/EmptyState'; import { EmptyState } from '@/ui/EmptyState';
import { Container } from '@/ui/Container'; import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading'; import { Section } from '@/ui/Section';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Carousel } from '@/components/shared/Carousel'; import { Carousel } from '@/components/shared/Carousel';
interface TeamsTemplateProps extends TemplateProps<TeamsViewData> { interface TeamsTemplateProps extends TemplateProps<TeamsViewData> {
@@ -65,22 +63,21 @@ export function TeamsTemplate({
}, [teams, filteredTeams, searchQuery]); }, [teams, filteredTeams, searchQuery]);
return ( return (
<div className="min-h-screen bg-[var(--ui-color-bg-base)] py-12"> <Section variant="default" padding="lg">
<Container size="xl"> <TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
<Box marginBottom={12}> <Container size="full" padding="none" py={12}>
<TeamSearchBar <TeamSearchBar
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
/> />
</Box> </Container>
{clusters.length > 0 ? ( {clusters.length > 0 ? (
<div className="space-y-20"> <Container size="full" padding="none">
{clusters.map((cluster) => ( {clusters.map((cluster, index) => (
<Container key={cluster.title} size="full" padding="none" py={index === 0 ? 0 : 10}>
<Carousel <Carousel
key={cluster.title}
title={cluster.title} title={cluster.title}
count={cluster.teams.length} count={cluster.teams.length}
> >
@@ -92,23 +89,21 @@ export function TeamsTemplate({
/> />
))} ))}
</Carousel> </Carousel>
))} </Container>
</div> ))}
) : ( </Container>
<div className="py-20 border border-dashed border-[var(--ui-color-border-muted)] flex flex-col items-center justify-center text-center"> ) : (
<EmptyState <EmptyState
icon={Users} icon={Users}
title={searchQuery ? "No matching teams" : "No teams yet"} title={searchQuery ? "No matching teams" : "No teams yet"}
description={searchQuery ? "Try adjusting your search filters" : "Get started by creating your first racing team"} description={searchQuery ? "Try adjusting your search filters" : "Get started by creating your first racing team"}
action={{ action={{
label: 'Create Team', label: 'Create Team',
onClick: onCreateTeam, onClick: onCreateTeam,
variant: 'primary' variant: 'primary'
}} }}
/> />
</div> )}
)} </Section>
</Container>
</div>
); );
} }

View File

@@ -0,0 +1,14 @@
import React from 'react';
interface JsonLdProps {
data: Record<string, any>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}

View File

@@ -1,6 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { useSidebar } from '@/components/layout/SidebarContext';
export interface LayoutProps { export interface LayoutProps {
children: ReactNode; children: ReactNode;
@@ -23,9 +22,9 @@ export const Layout = ({
sidebar, sidebar,
fixedSidebar = true, fixedSidebar = true,
fixedHeader = true, fixedHeader = true,
fixedFooter = true fixedFooter = true,
}: LayoutProps) => { isCollapsed = false
const { isCollapsed } = useSidebar(); }: LayoutProps & { isCollapsed?: boolean }) => {
const sidebarWidth = isCollapsed ? '20' : '64'; // 5rem vs 16rem const sidebarWidth = isCollapsed ? '20' : '64'; // 5rem vs 16rem
const sidebarWidthClass = isCollapsed ? 'lg:w-20' : 'lg:w-64'; const sidebarWidthClass = isCollapsed ? 'lg:w-20' : 'lg:w-64';
const contentMarginClass = isCollapsed ? 'lg:ml-20' : 'lg:ml-64'; const contentMarginClass = isCollapsed ? 'lg:ml-20' : 'lg:ml-64';

View File

@@ -1,7 +1,7 @@
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import Link from 'next/link';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import Link from 'next/link';
import { LucideIcon, ChevronRight } from 'lucide-react'; import { LucideIcon, ChevronRight } from 'lucide-react';
interface NavLinkProps { interface NavLinkProps {
@@ -11,9 +11,10 @@ interface NavLinkProps {
isActive?: boolean; isActive?: boolean;
variant?: 'sidebar' | 'top'; variant?: 'sidebar' | 'top';
collapsed?: boolean; collapsed?: boolean;
LinkComponent?: React.ComponentType<{ href: string; children: React.ReactNode; className?: string }>;
} }
export function NavLink({ href, label, icon, isActive, variant = 'sidebar', collapsed = false }: NavLinkProps) { export function NavLink({ href, label, icon, isActive, variant = 'sidebar', collapsed = false, LinkComponent = Link as any }: NavLinkProps) {
const isTop = variant === 'top'; const isTop = variant === 'top';
// Radical "Game Menu" Style // Radical "Game Menu" Style
@@ -72,11 +73,11 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
); );
return ( return (
<Link <LinkComponent
href={href} href={href}
className={`w-full group block ${!isTop ? '' : ''}`} className={`w-full group block ${!isTop ? '' : ''}`}
> >
{content} {content}
</Link> </LinkComponent>
); );
} }

View File

@@ -0,0 +1,26 @@
import React from 'react';
interface VerticalBarProps {
intent?: 'primary' | 'secondary' | 'success' | 'warning' | 'critical';
height?: string | number;
}
/**
* VerticalBar - A semantic decorative bar.
*/
export function VerticalBar({ intent = 'primary', height = '2rem' }: VerticalBarProps) {
const intentClasses = {
primary: 'bg-[var(--ui-color-intent-primary)]',
secondary: 'bg-[var(--ui-color-intent-secondary)]',
success: 'bg-[var(--ui-color-intent-success)]',
warning: 'bg-[var(--ui-color-intent-warning)]',
critical: 'bg-[var(--ui-color-intent-critical)]',
};
return (
<div
className={`w-1 ${intentClasses[intent]}`}
style={{ height: typeof height === 'number' ? `${height * 0.25}rem` : height }}
/>
);
}