website refactor
This commit is contained in:
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,8 +73,19 @@ export default async function Page({ params }: Props) {
|
|||||||
races: [],
|
races: [],
|
||||||
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} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()) {
|
||||||
@@ -17,6 +26,24 @@ export default async function Page() {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <HomePageClient viewData={data} />;
|
return (
|
||||||
|
<>
|
||||||
|
<JsonLd data={jsonLd} />
|
||||||
|
<HomePageClient viewData={data} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
19
apps/website/components/drivers/DriverGrid.tsx
Normal file
19
apps/website/components/drivers/DriverGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</Group>
|
||||||
|
{subtitle && (
|
||||||
|
<Text variant="low" size="lg" uppercase weight="bold" letterSpacing="widest">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<Group align="center">
|
||||||
|
{action}
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Group>
|
||||||
|
</Container>
|
||||||
{action && (
|
|
||||||
<Box display="flex" alignItems="center">
|
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
58
apps/website/lib/seo/MetadataHelper.ts
Normal file
58
apps/website/lib/seo/MetadataHelper.ts
Normal 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}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[] };
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
|
<Container size="full" padding="none" py={12}>
|
||||||
<Box marginBottom={12}>
|
<TeamSearchBar
|
||||||
<TeamSearchBar
|
searchQuery={searchQuery}
|
||||||
searchQuery={searchQuery}
|
onSearchChange={onSearchChange}
|
||||||
onSearchChange={onSearchChange}
|
/>
|
||||||
/>
|
</Container>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/website/ui/JsonLd.tsx
Normal file
14
apps/website/ui/JsonLd.tsx
Normal 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) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
apps/website/ui/VerticalBar.tsx
Normal file
26
apps/website/ui/VerticalBar.tsx
Normal 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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user