diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index 3dae44cb3..7e0ca5912 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -2,6 +2,41 @@ import { redirect } from 'next/navigation'; import { routes } from '@/lib/routing/RouteConfig'; import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery'; 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 { + 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 }> }) { const { id } = await params; @@ -21,9 +56,24 @@ export default async function DriverProfilePage({ params }: { params: Promise<{ } 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 ( - + <> + {jsonLd && } + + ); -} \ No newline at end of file +} diff --git a/apps/website/app/drivers/page.tsx b/apps/website/app/drivers/page.tsx index 100dfd221..f58b3f5af 100644 --- a/apps/website/app/drivers/page.tsx +++ b/apps/website/app/drivers/page.tsx @@ -2,6 +2,14 @@ import { redirect } from 'next/navigation'; import { routes } from '@/lib/routing/RouteConfig'; import { DriversPageQuery } from '@/lib/page-queries/DriversPageQuery'; 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() { const result = await DriversPageQuery.execute(); diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index 844d79da6..18adaf523 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -3,6 +3,15 @@ import { LeaderboardsPageQuery } from '@/lib/page-queries/LeaderboardsPageQuery' import { LeaderboardsPageClient } from '@/client-wrapper/LeaderboardsPageClient'; import { routes } from '@/lib/routing/RouteConfig'; 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() { const result = await LeaderboardsPageQuery.execute(); @@ -24,5 +33,27 @@ export default async function LeaderboardsPage() { // Success const viewData = result.unwrap(); - return ; -} \ No newline at end of file + + 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 ( + <> + + + + ); +} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index fffb22dfc..43bcce209 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -3,11 +3,36 @@ import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; import { ErrorBanner } from '@/ui/ErrorBanner'; +import { Metadata } from 'next'; +import { MetadataHelper } from '@/lib/seo/MetadataHelper'; +import { JsonLd } from '@/ui/JsonLd'; interface Props { params: Promise<{ id: string }>; } +export async function generateMetadata({ params }: Props): Promise { + 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) { const { id } = await params; // Execute the PageQuery @@ -36,6 +61,7 @@ export default async function Page({ params }: Props) { } const data = result.unwrap(); + const league = data.league; // Build ViewData using the builder // 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: [], sponsors: [], }); + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'SportsOrganization', + name: league.name, + description: league.description, + url: `https://gridpilot.com/leagues/${league.id}`, + }; return ( - + <> + + + ); } diff --git a/apps/website/app/leagues/page.tsx b/apps/website/app/leagues/page.tsx index b06a7e449..521b1099d 100644 --- a/apps/website/app/leagues/page.tsx +++ b/apps/website/app/leagues/page.tsx @@ -1,6 +1,14 @@ import { notFound } from 'next/navigation'; import { LeaguesPageClient } from './LeaguesPageClient'; 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() { // Execute the PageQuery diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index e144db2eb..649115143 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -3,6 +3,15 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; import { HomePageQuery } from '@/lib/page-queries/HomePageQuery'; import { notFound, redirect } from 'next/navigation'; 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() { if (await HomePageQuery.shouldRedirectToDashboard()) { @@ -17,6 +26,24 @@ export default async function Page() { if (!data) { 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 ; + return ( + <> + + + + ); } diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 2217dcfe4..a5b47910e 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -2,6 +2,8 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery'; import { RaceDetailPageClient } from '@/client-wrapper/RaceDetailPageClient'; +import { Metadata } from 'next'; +import { MetadataHelper } from '@/lib/seo/MetadataHelper'; interface RaceDetailPageProps { params: Promise<{ @@ -9,6 +11,30 @@ interface RaceDetailPageProps { }>; } +export async function generateMetadata({ params }: RaceDetailPageProps): Promise { + 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) { const { id: raceId } = await params; diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx index 97c56fe79..e11f4ba9c 100644 --- a/apps/website/app/races/page.tsx +++ b/apps/website/app/races/page.tsx @@ -1,6 +1,14 @@ import { notFound } from 'next/navigation'; import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery'; 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() { const query = new RacesPageQuery(); diff --git a/apps/website/app/teams/[id]/page.tsx b/apps/website/app/teams/[id]/page.tsx index 4d28b064c..5119ce1c5 100644 --- a/apps/website/app/teams/[id]/page.tsx +++ b/apps/website/app/teams/[id]/page.tsx @@ -1,6 +1,32 @@ import { notFound } from 'next/navigation'; import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery'; 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 { + 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 }> }) { const { id } = await params; @@ -15,5 +41,30 @@ export default async function Page({ params }: { params: Promise<{ id: string }> notFound(); } - return ; + 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 ( + <> + + + + ); } diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index 9d7636767..d757fb1df 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -1,6 +1,14 @@ import { notFound } from 'next/navigation'; import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery'; 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() { const query = new TeamLeaderboardPageQuery(); diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index 68475ac4c..fe0e828d2 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,6 +1,14 @@ import { notFound } from 'next/navigation'; import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery'; 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() { const query = new TeamsPageQuery(); diff --git a/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx b/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx index b9a3d3df0..6d49b2220 100644 --- a/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx +++ b/apps/website/client-wrapper/TeamLeaderboardPageWrapper.tsx @@ -34,12 +34,31 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps { + 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 = { teams: viewData.teams, searchQuery, filterLevel, sortBy, - filteredAndSortedTeams: viewData.teams, + filteredAndSortedTeams, }; return ( diff --git a/apps/website/components/drivers/DriverGrid.tsx b/apps/website/components/drivers/DriverGrid.tsx new file mode 100644 index 000000000..15b3cf022 --- /dev/null +++ b/apps/website/components/drivers/DriverGrid.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/website/components/home/Hero.tsx b/apps/website/components/home/Hero.tsx index 677e84951..2a747280d 100644 --- a/apps/website/components/home/Hero.tsx +++ b/apps/website/components/home/Hero.tsx @@ -1,12 +1,6 @@ 'use client'; -import { Heading } from '@/ui/Heading'; -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'; +import { LandingHero } from '@/ui/LandingHero'; /** * Hero - Refined with Dieter Rams principles. @@ -15,51 +9,12 @@ import { Box } from '@/ui/Box'; */ export function Hero() { return ( -
- - - - Sim Racing Infrastructure - - - - Professional League Management.
- Engineered for Control. -
-
- - - - GridPilot eliminates the administrative overhead of running iRacing leagues. - No spreadsheets. No manual points. No protest chaos. - Just pure competition, structured for growth. - - - - - - - -
-
+ ); } diff --git a/apps/website/components/home/LeagueIdentityPreview.tsx b/apps/website/components/home/LeagueIdentityPreview.tsx index 806a67062..ec1d032e0 100644 --- a/apps/website/components/home/LeagueIdentityPreview.tsx +++ b/apps/website/components/home/LeagueIdentityPreview.tsx @@ -12,11 +12,22 @@ import { Box } from '@/ui/Box'; import { StatusBadge } from '@/ui/StatusBadge'; 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. * 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 (
@@ -48,7 +59,7 @@ export function LeagueIdentityPreview() { Custom Subdomains - yourleague.gridpilot.racing + {subdomain} @@ -77,8 +88,8 @@ export function LeagueIdentityPreview() { - Apex Racing League - apex.gridpilot.racing + {leagueName} + {subdomain} diff --git a/apps/website/components/home/StewardingPreview.tsx b/apps/website/components/home/StewardingPreview.tsx index cbf129086..7fb70780a 100644 --- a/apps/website/components/home/StewardingPreview.tsx +++ b/apps/website/components/home/StewardingPreview.tsx @@ -13,11 +13,30 @@ import { Grid } from '@/ui/Grid'; import { Box } from '@/ui/Box'; 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. * 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 (
@@ -36,9 +55,9 @@ export function StewardingPreview() { Incident Report - ID: 402-WG + ID: {incidentId} - Turn 1 Contact: Miller vs Chen + Turn 1 Contact: {teamName} vs David Chen UNDER REVIEW @@ -50,8 +69,8 @@ export function StewardingPreview() { Protestor - Alex Miller - #42 - Porsche 911 GT3 R + {teamName} + #42 - {carName} @@ -71,7 +90,7 @@ export function StewardingPreview() { Session Info Lap 1, 00:42.150 - Watkins Glen - Cup + {trackName} diff --git a/apps/website/components/home/TelemetryStrip.tsx b/apps/website/components/home/TelemetryStrip.tsx index 8865f06ae..76bc1d2a9 100644 --- a/apps/website/components/home/TelemetryStrip.tsx +++ b/apps/website/components/home/TelemetryStrip.tsx @@ -38,7 +38,7 @@ export function TelemetryStrip() { ]; return ( -
+
); diff --git a/apps/website/components/layout/CommandModal.tsx b/apps/website/components/layout/CommandModal.tsx index c595a2b8c..67aac6476 100644 --- a/apps/website/components/layout/CommandModal.tsx +++ b/apps/website/components/layout/CommandModal.tsx @@ -2,6 +2,8 @@ import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; +import { Input } from '@/ui/Input'; +import { Button } from '@/ui/Button'; import { Search, Command, ArrowRight, X } from 'lucide-react'; import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; @@ -46,71 +48,81 @@ export function CommandModal({ isOpen, onClose }: CommandModalProps) { if (!isOpen) return null; return createPortal( -
+ {/* Backdrop */} -
{/* Modal Content */} -
-
+ + - setQuery(e.target.value)} /> - -
+ + -
+ {results.length > 0 ? ( -
-
- Suggestions -
+ + + + Suggestions + + {results.map((result, i) => ( - + + ))} -
+
) : ( -
- No results found. -
+ + + No results found. + + )} -
+ -
-
- ↑↓ to navigate - to select -
- GridPilot Command -
-
-
, + + + + ↑↓ to navigate + + + to select + + + GridPilot Command + +
+ , document.body ); } diff --git a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx index 0584a163c..caab23391 100644 --- a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx @@ -18,6 +18,8 @@ interface TeamLeaderboardPreviewProps { totalWins: number; logoUrl: string; position: number; + rating?: number; + performanceLevel: string; }[]; onTeamClick: (id: string) => void; onNavigateToTeams: () => void; @@ -28,12 +30,12 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams } return ( @@ -72,7 +74,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams } border borderColor="border-charcoal-outline" overflow="hidden" - groupHoverBorderColor="purple-400/50" + groupHoverBorderColor="primary-blue/50" transition > {team.name} - {team.category && ( - - - {team.category} - - )} + {team.performanceLevel} - {team.memberCount} members + {team.memberCount} - {team.memberCount} - Members + {team.rating?.toFixed(0) || '1000'} + Rating {team.totalWins} diff --git a/apps/website/components/races/RaceFilterModal.tsx b/apps/website/components/races/RaceFilterModal.tsx index d635dded4..7c4bfafb0 100644 --- a/apps/website/components/races/RaceFilterModal.tsx +++ b/apps/website/components/races/RaceFilterModal.tsx @@ -8,6 +8,7 @@ import { Stack } from '@/ui/Stack'; import { Select } from '@/ui/Select'; import { Text } from '@/ui/Text'; import { StatusDot } from '@/ui/StatusDot'; +import { Box } from '@/ui/Box'; import { Filter, Search } from 'lucide-react'; export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; @@ -77,9 +78,9 @@ export function RaceFilterModal({ onClick={() => setTimeFilter(filter)} > {filter === 'live' && ( - + - + )} {filter.charAt(0).toUpperCase() + filter.slice(1)} diff --git a/apps/website/components/races/RacesLiveRail.tsx b/apps/website/components/races/RacesLiveRail.tsx index 8e5585043..e55442830 100644 --- a/apps/website/components/races/RacesLiveRail.tsx +++ b/apps/website/components/races/RacesLiveRail.tsx @@ -1,4 +1,4 @@ -'use thought'; +'use client'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; diff --git a/apps/website/components/shared/PageHeader.tsx b/apps/website/components/shared/PageHeader.tsx index 0e33df542..c386351a7 100644 --- a/apps/website/components/shared/PageHeader.tsx +++ b/apps/website/components/shared/PageHeader.tsx @@ -1,7 +1,10 @@ import React, { ReactNode } from 'react'; import { Heading } from '@/ui/Heading'; 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 { title: string; @@ -15,34 +18,35 @@ interface PageHeaderProps { */ export function PageHeader({ title, subtitle, action }: PageHeaderProps) { return ( - - - - - {title} - - {subtitle && ( - - {subtitle} - + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + {action && ( + + {action} + )} - - - {action && ( - - {action} - - )} - + + ); } diff --git a/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx b/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx index 7e37ef9ef..5494d5159 100644 --- a/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx +++ b/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx @@ -1,19 +1,10 @@ import React from 'react'; import { getMediaUrl } from '@/lib/utilities/media'; import { TeamLeaderboardPreview as SemanticTeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; +import type { LeaderboardTeamItem } from '@/lib/view-data/LeaderboardTeamItem'; interface TeamLeaderboardPreviewProps { - topTeams: Array<{ - id: string; - name: string; - logoUrl?: string; - category?: string; - memberCount: number; - totalWins: number; - isRecruiting: boolean; - rating?: number; - performanceLevel: string; - }>; + topTeams: LeaderboardTeamItem[]; onTeamClick: (id: string) => void; onViewFullLeaderboard: () => void; } @@ -27,15 +18,17 @@ export function TeamLeaderboardPreview({ return ( ({ + teams={topTeams.map((team) => ({ id: team.id, name: team.name, - tag: '', // Not available in this view data + tag: team.tag, memberCount: team.memberCount, category: team.category, totalWins: team.totalWins, logoUrl: team.logoUrl || getMediaUrl('team-logo', team.id), - position: index + 1 + position: team.position, + rating: team.rating, + performanceLevel: team.performanceLevel }))} onTeamClick={onTeamClick} onNavigateToTeams={onViewFullLeaderboard} diff --git a/apps/website/components/teams/TeamsHeader.tsx b/apps/website/components/teams/TeamsHeader.tsx index ef4668977..1ce88f3c0 100644 --- a/apps/website/components/teams/TeamsHeader.tsx +++ b/apps/website/components/teams/TeamsHeader.tsx @@ -1,6 +1,7 @@ import React, { ReactNode } from 'react'; import { Heading } from '@/ui/Heading'; import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; interface TeamsHeaderProps { title: string; @@ -10,24 +11,32 @@ interface TeamsHeaderProps { export function TeamsHeader({ title, subtitle, action }: TeamsHeaderProps) { return ( -
-
-
-
+ + + + {title} -
+ {subtitle && ( {subtitle} )} -
+ {action && ( -
+ {action} -
+ )} -
+ ); } diff --git a/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts index 085b8c03e..c50fb36b1 100644 --- a/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeaderboardsViewDataBuilder.ts @@ -1,9 +1,10 @@ import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; +import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; export class LeaderboardsViewDataBuilder { static build( - apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: { teams: [] } } + apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: { teams: TeamListItemDTO[] } } ): LeaderboardsViewData { return { drivers: apiDto.drivers.drivers.slice(0, 10).map(driver => ({ @@ -17,7 +18,19 @@ export class LeaderboardsViewDataBuilder { avatarUrl: driver.avatarUrl || '', 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, + })), }; } } \ No newline at end of file diff --git a/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts b/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts index c6ff9a580..22d41b075 100644 --- a/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts +++ b/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts @@ -2,7 +2,7 @@ import { Result } from '@/lib/contracts/Result'; import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError'; 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 { teams: TeamSummaryViewModel[]; @@ -18,15 +18,7 @@ export class TeamLeaderboardPageQuery implements PageQuery ({ - 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)); + const teams = result.unwrap().map((t: any) => new TeamSummaryViewModel(t)); return Result.ok({ teams }); } catch (error) { diff --git a/apps/website/lib/seo/MetadataHelper.ts b/apps/website/lib/seo/MetadataHelper.ts new file mode 100644 index 000000000..06629f0f2 --- /dev/null +++ b/apps/website/lib/seo/MetadataHelper.ts @@ -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}`], + }, + }; + } +} diff --git a/apps/website/lib/services/leaderboards/LeaderboardsService.ts b/apps/website/lib/services/leaderboards/LeaderboardsService.ts index a61029073..733c70ef2 100644 --- a/apps/website/lib/services/leaderboards/LeaderboardsService.ts +++ b/apps/website/lib/services/leaderboards/LeaderboardsService.ts @@ -1,4 +1,5 @@ import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; +import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient'; import { Result } from '@/lib/contracts/Result'; import { Service, DomainError } from '@/lib/contracts/services/Service'; import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; @@ -15,8 +16,12 @@ export class LeaderboardsService implements Service { const logger = new ConsoleLogger(); 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) { return Result.err({ type: 'notFound', message: 'No leaderboard data available' }); @@ -24,7 +29,7 @@ export class LeaderboardsService implements Service { const data: LeaderboardsData = { drivers: driverResult, - teams: { teams: [] }, // Teams leaderboard not implemented + teams: teamResult, }; return Result.ok(data); diff --git a/apps/website/lib/types/LeaderboardsData.ts b/apps/website/lib/types/LeaderboardsData.ts index c7a01178b..344dc75a7 100644 --- a/apps/website/lib/types/LeaderboardsData.ts +++ b/apps/website/lib/types/LeaderboardsData.ts @@ -1,6 +1,7 @@ import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; +import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO'; export interface LeaderboardsData { drivers: { drivers: DriverLeaderboardItemDTO[] }; - teams: { teams: [] }; + teams: { teams: TeamListItemDTO[] }; } \ No newline at end of file diff --git a/apps/website/lib/view-data/LeaderboardTeamItem.ts b/apps/website/lib/view-data/LeaderboardTeamItem.ts index fb4468c64..4cf2bcbe1 100644 --- a/apps/website/lib/view-data/LeaderboardTeamItem.ts +++ b/apps/website/lib/view-data/LeaderboardTeamItem.ts @@ -7,4 +7,7 @@ export interface LeaderboardTeamItem { totalWins: number; logoUrl: string; position: number; + isRecruiting: boolean; + performanceLevel: string; + rating?: number; } \ No newline at end of file diff --git a/apps/website/templates/DriversTemplate.tsx b/apps/website/templates/DriversTemplate.tsx index 2bde53b83..12f09cb65 100644 --- a/apps/website/templates/DriversTemplate.tsx +++ b/apps/website/templates/DriversTemplate.tsx @@ -3,9 +3,11 @@ import { DriversViewData } from '@/lib/types/view-data/DriversViewData'; import { DriverCard } from '@/components/drivers/DriverCard'; import { DriverStatsHeader } from '@/components/drivers/DriverStatsHeader'; +import { DriverGrid } from '@/components/drivers/DriverGrid'; import { PageHeader } from '@/ui/PageHeader'; 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 { EmptyState } from '@/ui/EmptyState'; @@ -28,32 +30,29 @@ export function DriversTemplate({ }: DriversTemplateProps) { return (
- - - Leaderboard - - } - /> - + + Leaderboard + + } + /> - + - + - + - + {filteredDrivers.length > 0 ? ( - + {filteredDrivers.map(driver => ( ))} - + ) : ( {/* Stewarding Workflow Preview */} - + {/* League Identity Showcase */} - + {/* Migration Offer */} diff --git a/apps/website/templates/LeaderboardsTemplate.tsx b/apps/website/templates/LeaderboardsTemplate.tsx index e9a2a4003..180bdedc4 100644 --- a/apps/website/templates/LeaderboardsTemplate.tsx +++ b/apps/website/templates/LeaderboardsTemplate.tsx @@ -3,11 +3,11 @@ import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview'; import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; -import { Container } from '@/ui/Container'; -import { GridItem } from '@/ui/GridItem'; +import { Section } from '@/ui/Section'; import { PageHero } from '@/ui/PageHero'; -import { Grid } from '@/ui/Grid'; -import { Trophy, Users } from 'lucide-react'; +import { FeatureGrid } from '@/ui/FeatureGrid'; +import { Trophy, Users, Activity } from 'lucide-react'; +import React from 'react'; interface LeaderboardsTemplateProps { viewData: LeaderboardsViewData; @@ -25,11 +25,11 @@ export function LeaderboardsTemplate({ onNavigateToTeams }: LeaderboardsTemplateProps) { return ( - +
- - - - - - ({ - ...team, - isRecruiting: false, - performanceLevel: 'N/A' - }))} - onTeamClick={onTeamClick} - onViewFullLeaderboard={onNavigateToTeams} - /> - - - + + + + +
); } diff --git a/apps/website/templates/LeaguesTemplate.tsx b/apps/website/templates/LeaguesTemplate.tsx index 83118d228..3bf874c05 100644 --- a/apps/website/templates/LeaguesTemplate.tsx +++ b/apps/website/templates/LeaguesTemplate.tsx @@ -7,7 +7,6 @@ import { PageHeader } from '@/ui/PageHeader'; import { Input } from '@/ui/Input'; import { Button } from '@/ui/Button'; import { Group } from '@/ui/Group'; -import { Grid } from '@/ui/Grid'; import { Container } from '@/ui/Container'; import { Text } from '@/ui/Text'; import { Icon } from '@/ui/Icon'; @@ -15,8 +14,7 @@ import { Section } from '@/ui/Section'; import { ControlBar } from '@/ui/ControlBar'; import { SegmentedControl } from '@/ui/SegmentedControl'; import { MetricCard } from '@/ui/MetricCard'; -import { Stack } from '@/ui/Stack'; -import { Box } from '@/ui/Box'; +import { FeatureGrid } from '@/ui/FeatureGrid'; import { Plus, Search, @@ -73,27 +71,27 @@ export function LeaguesTemplate({ onClearFilters, }: LeaguesTemplateProps) { return ( - - - {/* Header Section */} - } - > - Create League - - } - /> +
+ {/* Header Section */} + } + > + Create League + + } + /> - {/* Stats Overview */} - + {/* Stats Overview */} + + - + + - {/* Control Bar */} - - - ({ - id: c.id, - label: c.label, - icon: - }))} - activeId={activeCategory} - onChange={(id) => onCategoryChange(id as CategoryId)} - /> - - } - > - - ) => onSearchChange(e.target.value)} - icon={} - size="sm" + {/* Control Bar */} + + + ({ + id: c.id, + label: c.label, + icon: + }))} + activeId={activeCategory} + onChange={(id) => onCategoryChange(id as CategoryId)} /> - - + + } + > + ) => onSearchChange(e.target.value)} + icon={} + size="sm" + width="300px" + /> + - {/* Results */} - - {filteredLeagues.length > 0 ? ( - - {filteredLeagues.map((league) => ( - onLeagueClick(league.id)} - /> - ))} - - ) : ( -
- - - - No results found - Adjust filters to find matching infrastructure. - - - -
- )} -
- - + {/* Results */} + + {filteredLeagues.length > 0 ? ( + + {filteredLeagues.map((league) => ( + onLeagueClick(league.id)} + /> + ))} + + ) : ( +
+ + + + No results found + Adjust filters to find matching infrastructure. + + + +
+ )} +
+
); } diff --git a/apps/website/templates/RacesIndexTemplate.tsx b/apps/website/templates/RacesIndexTemplate.tsx index 36db45387..1673cea9a 100644 --- a/apps/website/templates/RacesIndexTemplate.tsx +++ b/apps/website/templates/RacesIndexTemplate.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Container } from '@/ui/Container'; -import { Stack } from '@/ui/Stack'; +import { Section } from '@/ui/Section'; import { RacesLiveRail } from '@/components/races/RacesLiveRail'; import { RacesCommandBar } from '@/components/races/RacesCommandBar'; import { NextUpRacePanel } from '@/components/races/NextUpRacePanel'; @@ -10,24 +10,19 @@ import { RacesDayGroup } from '@/components/races/RacesDayGroup'; import { RacesEmptyState } from '@/components/races/RacesEmptyState'; import { RaceFilterModal } from '@/components/races/RaceFilterModal'; 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 { viewData: RacesViewData & { - racesByDate: Array<{ - dateKey: string; - dateLabel: string; - races: any[]; - }>; - nextUpRace?: any; + nextUpRace?: RaceViewData; }; // Filters statusFilter: string; - setStatusFilter: (filter: any) => void; + setStatusFilter: (filter: string) => void; leagueFilter: string; setLeagueFilter: (filter: string) => void; timeFilter: string; - setTimeFilter: (filter: any) => void; + setTimeFilter: (filter: string) => void; // Actions onRaceClick: (raceId: string) => void; // UI State @@ -50,20 +45,22 @@ export function RacesIndexTemplate({ const hasRaces = viewData.racesByDate.length > 0; return ( - - - +
+ - {/* 1. Status Rail: Live sessions first */} + {/* 1. Status Rail: Live sessions first */} + + - {/* 2. Command Bar: Fast filters */} + {/* 2. Command Bar: Fast filters */} + setShowFilterModal(true)} /> + - {/* 3. Next Up: High signal panel */} - {timeFilter === 'upcoming' && viewData.nextUpRace && ( + {/* 3. Next Up: High signal panel */} + {timeFilter === 'upcoming' && viewData.nextUpRace && ( + - )} + + )} - {/* 4. Browse by Day: Grouped schedule */} - {hasRaces ? ( - - {viewData.racesByDate.map((group) => ( + {/* 4. Browse by Day: Grouped schedule */} + {hasRaces ? ( + + {viewData.racesByDate.map((group) => ( + - ))} - - ) : ( - - )} + + ))} + + ) : ( + + )} - setShowFilterModal(false)} - statusFilter={statusFilter as any} - setStatusFilter={setStatusFilter} - leagueFilter={leagueFilter} - setLeagueFilter={setLeagueFilter} - timeFilter={timeFilter as any} - setTimeFilter={setTimeFilter} - searchQuery="" - setSearchQuery={() => {}} - leagues={viewData.leagues} - showSearch={true} - showTimeFilter={false} - /> - - + setShowFilterModal(false)} + statusFilter={statusFilter as any} + setStatusFilter={setStatusFilter} + leagueFilter={leagueFilter} + setLeagueFilter={setLeagueFilter} + timeFilter={timeFilter as any} + setTimeFilter={setTimeFilter} + searchQuery="" + setSearchQuery={() => {}} + leagues={viewData.leagues} + showSearch={true} + showTimeFilter={false} + /> +
); } diff --git a/apps/website/templates/RacesTemplate.tsx b/apps/website/templates/RacesTemplate.tsx index c5867fda1..5621a1456 100644 --- a/apps/website/templates/RacesTemplate.tsx +++ b/apps/website/templates/RacesTemplate.tsx @@ -50,7 +50,7 @@ export function RacesTemplate({ }: RacesTemplateProps) { return ( - + setShowFilterModal(false)} - statusFilter={statusFilter} - setStatusFilter={setStatusFilter} + statusFilter={statusFilter as any} + setStatusFilter={setStatusFilter as any} leagueFilter={leagueFilter} setLeagueFilter={setLeagueFilter} - timeFilter={timeFilter} - setTimeFilter={setTimeFilter} + timeFilter={timeFilter as any} + setTimeFilter={setTimeFilter as any} searchQuery="" setSearchQuery={() => {}} leagues={viewData.leagues} diff --git a/apps/website/templates/TeamLeaderboardTemplate.tsx b/apps/website/templates/TeamLeaderboardTemplate.tsx index 2c14851ab..d679dca3c 100644 --- a/apps/website/templates/TeamLeaderboardTemplate.tsx +++ b/apps/website/templates/TeamLeaderboardTemplate.tsx @@ -11,7 +11,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Text } from '@/ui/Text'; import { Panel } from '@/ui/Panel'; 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'; interface TeamLeaderboardTemplateProps { @@ -26,13 +27,30 @@ interface TeamLeaderboardTemplateProps { export function TeamLeaderboardTemplate({ viewData, onSearchChange, + filterLevelChange, + onSortChange, onTeamClick, onBackToTeams, }: 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 ( - +
{/* Header */} @@ -41,28 +59,44 @@ export function TeamLeaderboardTemplate({ Back - Global Standings - Team Performance Index + Team Standings + Global Performance Index - + + > + + onSortChange(e.target.value as SortBy)} + /> + + - + - Rank - Team - Personnel - Races - Rating + Rank + Team + Personnel + Races + Wins + Rating @@ -80,10 +114,13 @@ export function TeamLeaderboardTemplate({ - - {team.name.substring(0, 2).toUpperCase()} + + - {team.name} + + {team.name} + {team.performanceLevel} + @@ -92,14 +129,19 @@ export function TeamLeaderboardTemplate({ {team.totalRaces} + + {team.totalWins} + - 1450 + + {team.rating?.toFixed(0) || '1000'} + )) ) : ( - +
@@ -114,6 +156,6 @@ export function TeamLeaderboardTemplate({
- +
); } diff --git a/apps/website/templates/TeamsTemplate.tsx b/apps/website/templates/TeamsTemplate.tsx index 615b184a7..89fc812fc 100644 --- a/apps/website/templates/TeamsTemplate.tsx +++ b/apps/website/templates/TeamsTemplate.tsx @@ -10,9 +10,7 @@ import { TeamCard } from '@/components/teams/TeamCard'; import { TeamSearchBar } from '@/components/teams/TeamSearchBar'; import { EmptyState } from '@/ui/EmptyState'; import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; -import { Text } from '@/ui/Text'; -import { Box } from '@/ui/Box'; +import { Section } from '@/ui/Section'; import { Carousel } from '@/components/shared/Carousel'; interface TeamsTemplateProps extends TemplateProps { @@ -65,22 +63,21 @@ export function TeamsTemplate({ }, [teams, filteredTeams, searchQuery]); return ( -
- - - - - - +
+ + + + + - {clusters.length > 0 ? ( -
- {clusters.map((cluster) => ( + {clusters.length > 0 ? ( + + {clusters.map((cluster, index) => ( + @@ -92,23 +89,21 @@ export function TeamsTemplate({ /> ))} - ))} -
- ) : ( -
- -
- )} - -
+
+ ))} +
+ ) : ( + + )} +
); } diff --git a/apps/website/ui/JsonLd.tsx b/apps/website/ui/JsonLd.tsx new file mode 100644 index 000000000..97a407b6a --- /dev/null +++ b/apps/website/ui/JsonLd.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +interface JsonLdProps { + data: Record; +} + +export function JsonLd({ data }: JsonLdProps) { + return ( +