From c1a86348d74df64c7db64a00d7e9f009afc8f87a Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 14 Jan 2026 23:31:57 +0100 Subject: [PATCH] website refactor --- .../templates/AdminDashboardTemplate.tsx | 233 ++--- apps/website/templates/AdminUsersTemplate.tsx | 506 ++++------ apps/website/templates/DashboardTemplate.tsx | 357 +------ .../templates/DriverProfileTemplate.tsx | 894 +++--------------- .../templates/DriverRankingsTemplate.tsx | 262 ++--- apps/website/templates/DriversTemplate.tsx | 259 ++--- apps/website/templates/HomeTemplate.tsx | 467 ++++----- .../templates/LeaderboardsTemplate.tsx | 122 +-- .../templates/LeagueAdminScheduleTemplate.tsx | 255 +++-- .../templates/LeagueDetailTemplate.tsx | 62 +- .../templates/LeagueRulebookTemplate.tsx | 340 +++---- .../templates/LeagueScheduleTemplate.tsx | 106 +-- .../templates/LeagueSettingsTemplate.tsx | 189 ++-- .../templates/LeagueSponsorshipsTemplate.tsx | 237 ++--- .../templates/LeagueStandingsTemplate.tsx | 49 +- .../templates/LeagueWalletTemplate.tsx | 201 ++-- .../templates/ProfileLeaguesTemplate.tsx | 163 ++-- apps/website/templates/ProfileTemplate.tsx | 605 ++++-------- apps/website/templates/RaceDetailTemplate.tsx | 848 ++++------------- .../website/templates/RaceResultsTemplate.tsx | 387 +++----- .../templates/RaceStewardingTemplate.tsx | 471 +++------ apps/website/templates/RacesAllTemplate.tsx | 341 ++----- apps/website/templates/RacesTemplate.tsx | 692 ++------------ .../website/templates/RosterAdminTemplate.tsx | 213 ++--- apps/website/templates/RulebookTemplate.tsx | 158 ++-- .../templates/SponsorDashboardTemplate.tsx | 629 ++++++------ .../templates/SponsorLeagueDetailTemplate.tsx | 791 +++++++--------- .../templates/SponsorLeaguesTemplate.tsx | 535 ++++------- .../templates/SponsorshipRequestsTemplate.tsx | 134 +-- apps/website/templates/StewardingTemplate.tsx | 301 +++--- apps/website/templates/TeamDetailTemplate.tsx | 325 +++---- .../templates/TeamLeaderboardTemplate.tsx | 405 ++------ apps/website/templates/TeamsTemplate.tsx | 164 ++-- .../templates/auth/ForgotPasswordTemplate.tsx | 265 +++--- apps/website/templates/auth/LoginTemplate.tsx | 409 ++++---- .../templates/auth/ResetPasswordTemplate.tsx | 329 ++++--- .../website/templates/auth/SignupTemplate.tsx | 678 ++++++------- apps/website/ui/AuthContainer.tsx | 16 + apps/website/ui/AuthError.tsx | 18 + apps/website/ui/AuthLoading.tsx | 22 + apps/website/ui/Badge.tsx | 25 + apps/website/ui/Box.tsx | 93 ++ apps/website/ui/Button.tsx | 52 +- apps/website/ui/Card.tsx | 37 +- apps/website/ui/Container.tsx | 50 + apps/website/ui/CountryFlag.tsx | 108 +++ apps/website/ui/CountrySelect.tsx | 191 ++++ apps/website/ui/DecorativeBlur.tsx | 46 + apps/website/ui/DurationField.tsx | 71 ++ apps/website/ui/ErrorBanner.tsx | 37 + apps/website/ui/FormField.tsx | 45 + apps/website/ui/Grid.tsx | 52 + apps/website/ui/GridItem.tsx | 25 + apps/website/ui/Header.tsx | 2 +- apps/website/ui/Heading.tsx | 34 + apps/website/ui/Hero.tsx | 30 + apps/website/ui/Icon.tsx | 37 + apps/website/ui/Image.tsx | 23 + apps/website/ui/InfoBanner.tsx | 85 ++ apps/website/ui/InfoBox.tsx | 65 ++ apps/website/ui/Input.tsx | 20 +- apps/website/ui/Link.tsx | 21 +- apps/website/ui/LoadingSpinner.tsx | 27 + apps/website/ui/MockupStack.tsx | 146 +++ apps/website/ui/Modal.tsx | 185 ++++ apps/website/ui/PageHeader.tsx | 49 + apps/website/ui/PlaceholderImage.tsx | 22 + apps/website/ui/PresetCard.tsx | 122 +++ apps/website/ui/QuickActionLink.tsx | 1 - apps/website/ui/RangeField.tsx | 271 ++++++ apps/website/ui/Section.tsx | 42 +- apps/website/ui/SectionHeader.tsx | 47 + apps/website/ui/SegmentedControl.tsx | 72 ++ apps/website/ui/Select.tsx | 9 +- apps/website/ui/Skeleton.tsx | 26 + apps/website/ui/SponsorLogo.tsx | 30 - apps/website/ui/Stack.tsx | 95 ++ apps/website/ui/StatCard.tsx | 49 +- apps/website/ui/StatusBadge.tsx | 35 +- apps/website/ui/Surface.tsx | 70 ++ apps/website/ui/TabContent.tsx | 9 + apps/website/ui/TabNavigation.tsx | 39 + apps/website/ui/Table.tsx | 42 +- apps/website/ui/Text.tsx | 34 +- apps/website/ui/Toggle.tsx | 74 ++ .../ui/onboarding/OnboardingCardAccent.tsx | 5 - .../ui/onboarding/OnboardingContainer.tsx | 11 - .../website/ui/onboarding/OnboardingError.tsx | 12 - apps/website/ui/onboarding/OnboardingForm.tsx | 12 - .../ui/onboarding/OnboardingHeader.tsx | 17 - .../ui/onboarding/OnboardingHelpText.tsx | 7 - .../ui/onboarding/OnboardingNavigation.tsx | 58 -- .../ui/onboarding/PersonalInfoStep.tsx | 151 --- 93 files changed, 7268 insertions(+), 9088 deletions(-) create mode 100644 apps/website/ui/AuthContainer.tsx create mode 100644 apps/website/ui/AuthError.tsx create mode 100644 apps/website/ui/AuthLoading.tsx create mode 100644 apps/website/ui/Badge.tsx create mode 100644 apps/website/ui/Box.tsx create mode 100644 apps/website/ui/Container.tsx create mode 100644 apps/website/ui/CountryFlag.tsx create mode 100644 apps/website/ui/CountrySelect.tsx create mode 100644 apps/website/ui/DecorativeBlur.tsx create mode 100644 apps/website/ui/DurationField.tsx create mode 100644 apps/website/ui/ErrorBanner.tsx create mode 100644 apps/website/ui/FormField.tsx create mode 100644 apps/website/ui/Grid.tsx create mode 100644 apps/website/ui/GridItem.tsx create mode 100644 apps/website/ui/Heading.tsx create mode 100644 apps/website/ui/Hero.tsx create mode 100644 apps/website/ui/Icon.tsx create mode 100644 apps/website/ui/Image.tsx create mode 100644 apps/website/ui/InfoBanner.tsx create mode 100644 apps/website/ui/InfoBox.tsx create mode 100644 apps/website/ui/LoadingSpinner.tsx create mode 100644 apps/website/ui/MockupStack.tsx create mode 100644 apps/website/ui/Modal.tsx create mode 100644 apps/website/ui/PageHeader.tsx create mode 100644 apps/website/ui/PlaceholderImage.tsx create mode 100644 apps/website/ui/PresetCard.tsx create mode 100644 apps/website/ui/RangeField.tsx create mode 100644 apps/website/ui/SectionHeader.tsx create mode 100644 apps/website/ui/SegmentedControl.tsx create mode 100644 apps/website/ui/Skeleton.tsx delete mode 100644 apps/website/ui/SponsorLogo.tsx create mode 100644 apps/website/ui/Stack.tsx create mode 100644 apps/website/ui/Surface.tsx create mode 100644 apps/website/ui/TabContent.tsx create mode 100644 apps/website/ui/TabNavigation.tsx create mode 100644 apps/website/ui/Toggle.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingCardAccent.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingContainer.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingError.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingForm.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingHeader.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingHelpText.tsx delete mode 100644 apps/website/ui/onboarding/OnboardingNavigation.tsx delete mode 100644 apps/website/ui/onboarding/PersonalInfoStep.tsx diff --git a/apps/website/templates/AdminDashboardTemplate.tsx b/apps/website/templates/AdminDashboardTemplate.tsx index 37ba8941a..62c7f86af 100644 --- a/apps/website/templates/AdminDashboardTemplate.tsx +++ b/apps/website/templates/AdminDashboardTemplate.tsx @@ -1,12 +1,20 @@ +'use client'; + +import React from 'react'; import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData'; import { routes } from '@/lib/routing/RouteConfig'; -import { Layout } from '@/ui/Layout'; import { Card } from '@/ui/Card'; import { Text } from '@/ui/Text'; import { Button } from '@/ui/Button'; import { StatCard } from '@/ui/StatCard'; import { QuickActionLink } from '@/ui/QuickActionLink'; import { StatusBadge } from '@/ui/StatusBadge'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { Icon } from '@/ui/Icon'; +import { Heading } from '@/ui/Heading'; import { Users, Shield, @@ -21,122 +29,123 @@ import { * Pure template for admin dashboard. * Accepts ViewData only, no business logic. */ -export function AdminDashboardTemplate(props: { - adminDashboardViewData: AdminDashboardViewData; +export function AdminDashboardTemplate({ + viewData, + onRefresh, + isLoading +}: { + viewData: AdminDashboardViewData; onRefresh: () => void; isLoading: boolean; }) { - const { adminDashboardViewData: viewData, onRefresh, isLoading } = props; - return ( - - {/* Header */} - -
- - Admin Dashboard - - - System overview and statistics - -
- -
+ + + {/* Header */} + + + Admin Dashboard + + System overview and statistics + + + + - {/* Stats Cards */} - - } - variant="blue" - /> - } - variant="purple" - /> - } - variant="green" - /> - } - variant="orange" - /> - + {/* Stats Cards */} + + + + + + - {/* System Status */} - - - System Status - - - - - System Health - - - Healthy - - - - - Suspended Users - - - {viewData.stats.suspendedUsers} - - - - - Deleted Users - - - {viewData.stats.deletedUsers} - - - - - New Users Today - - - {viewData.stats.newUsersToday} - - - - + {/* System Status */} + + + System Status + + + + System Health + + + Healthy + + + + + Suspended Users + + + {viewData.stats.suspendedUsers} + + + + + Deleted Users + + + {viewData.stats.deletedUsers} + + + + + New Users Today + + + {viewData.stats.newUsersToday} + + + + + - {/* Quick Actions */} - - - Quick Actions - - - - View All Users - - - Manage Admins - - - View Audit Log - - - -
+ {/* Quick Actions */} + + + Quick Actions + + + View All Users + + + Manage Admins + + + View Audit Log + + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/AdminUsersTemplate.tsx b/apps/website/templates/AdminUsersTemplate.tsx index c789dd9f1..3390a2992 100644 --- a/apps/website/templates/AdminUsersTemplate.tsx +++ b/apps/website/templates/AdminUsersTemplate.tsx @@ -1,29 +1,30 @@ +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import StatusBadge from '@/components/ui/StatusBadge'; -import { Input } from '@/ui/Input'; -import { Select } from '@/ui/Select'; import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; import { Button } from '@/ui/Button'; import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { StatusBadge } from '@/ui/StatusBadge'; +import { InfoBox } from '@/ui/InfoBox'; import { - Search, - Filter, RefreshCw, - Users, Shield, Trash2, - AlertTriangle + Users } from 'lucide-react'; import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; +import { UserFilters } from '@/components/admin/UserFilters'; +import { UserStatsSummary } from '@/components/admin/UserStatsSummary'; +import { Surface } from '@/ui/Surface'; -/** - * AdminUsersTemplate - * - * Pure template for admin users page. - * Accepts ViewData only, no business logic. - */ -export function AdminUsersTemplate(props: { - adminUsersViewData: AdminUsersViewData; +interface AdminUsersTemplateProps { + viewData: AdminUsersViewData; onRefresh: () => void; onSearch: (search: string) => void; onFilterRole: (role: string) => void; @@ -37,309 +38,216 @@ export function AdminUsersTemplate(props: { loading: boolean; error: string | null; deletingUser: string | null; -}) { - const { - adminUsersViewData: viewData, - onRefresh, - onSearch, - onFilterRole, - onFilterStatus, - onClearFilters, - onUpdateStatus, - onDeleteUser, - search, - roleFilter, - statusFilter, - loading, - error, - deletingUser - } = props; +} - const toStatusBadgeProps = ( - status: string, - ): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => { +export function AdminUsersTemplate({ + viewData, + onRefresh, + onSearch, + onFilterRole, + onFilterStatus, + onClearFilters, + onUpdateStatus, + onDeleteUser, + search, + roleFilter, + statusFilter, + loading, + error, + deletingUser +}: AdminUsersTemplateProps) { + const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => { switch (status) { - case 'active': - return { status: 'success', label: 'Active' }; - case 'suspended': - return { status: 'warning', label: 'Suspended' }; - case 'deleted': - return { status: 'error', label: 'Deleted' }; - default: - return { status: 'neutral', label: status }; + case 'active': return 'success'; + case 'suspended': return 'warning'; + case 'deleted': return 'error'; + default: return 'info'; } }; - const getRoleBadgeClass = (role: string) => { + const getRoleBadgeStyle = (role: string) => { switch (role) { - case 'owner': - return 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; - case 'admin': - return 'bg-blue-500/20 text-blue-300 border border-blue-500/30'; - default: - return 'bg-gray-500/20 text-gray-300 border border-gray-500/30'; - } - }; - - const getRoleBadgeLabel = (role: string) => { - switch (role) { - case 'owner': - return 'Owner'; - case 'admin': - return 'Admin'; - case 'user': - return 'User'; - default: - return role; + case 'owner': return { backgroundColor: 'rgba(168, 85, 247, 0.2)', color: '#d8b4fe', border: '1px solid rgba(168, 85, 247, 0.3)' }; + case 'admin': return { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#93c5fd', border: '1px solid rgba(59, 130, 246, 0.3)' }; + default: return { backgroundColor: 'rgba(115, 115, 115, 0.2)', color: '#d1d5db', border: '1px solid rgba(115, 115, 115, 0.3)' }; } }; return ( -
- {/* Header */} -
-
- User Management - Manage and monitor all system users -
- -
- - {/* Error Banner */} - {error && ( -
- -
- Error - {error} -
+ + + {/* Header */} + + + User Management + Manage and monitor all system users + -
- )} + - {/* Filters Card */} - -
-
-
- - Filters -
- {(search || roleFilter || statusFilter) && ( + {/* Error Banner */} + {error && ( + + )} + + {/* Filters Card */} + + + {/* Users Table */} + + {loading ? ( + + + Loading users... + + ) : !viewData.users || viewData.users.length === 0 ? ( + + + No users found - )} -
- -
-
- - onSearch(e.target.value)} - className="pl-9" - /> -
- - onFilterStatus(e.target.value)} - options={[ - { value: '', label: 'All Status' }, - { value: 'active', label: 'Active' }, - { value: 'suspended', label: 'Suspended' }, - { value: 'deleted', label: 'Deleted' }, - ]} - /> -
-
-
- - {/* Users Table */} - - {loading ? ( -
-
- Loading users... -
- ) : !viewData.users || viewData.users.length === 0 ? ( -
- - No users found - -
- ) : ( - - - - User - Email - Roles - Status - Last Login - Actions - - - - {viewData.users.map((user, index: number) => ( - - -
-
- -
-
-
{user.displayName}
-
ID: {user.id}
- {user.primaryDriverId && ( -
Driver: {user.primaryDriverId}
- )} -
-
-
- -
{user.email}
-
- -
- {user.roles.map((role: string, idx: number) => ( - - {getRoleBadgeLabel(role)} - - ))} -
-
- - {(() => { - const badge = toStatusBadgeProps(user.status); - return ; - })()} - - -
- {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'} -
-
- -
- {user.status === 'active' && ( - - )} - {user.status === 'suspended' && ( - - )} - {user.status !== 'deleted' && ( - - )} -
-
+ + ) : ( +
+ + + User + Email + Roles + Status + Last Login + Actions - ))} - -
- )} -
+ + + {viewData.users.map((user) => ( + + + + + + + + {user.displayName} + ID: {user.id} + {user.primaryDriverId && ( + Driver: {user.primaryDriverId} + )} + + + + + {user.email} + + + + {user.roles.map((role, idx) => { + const style = getRoleBadgeStyle(role); + return ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ); + })} + + + + + {user.status.charAt(0).toUpperCase() + user.status.slice(1)} + + + + + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'} + + + + + {user.status === 'active' && ( + + )} + {user.status === 'suspended' && ( + + )} + {user.status !== 'deleted' && ( + + )} + + + + ))} + + + )} + - {/* Stats Summary */} - {viewData.users.length > 0 && ( -
- -
-
- Total Users - {viewData.total} -
- -
-
- -
-
- Active - - {viewData.activeUserCount} - -
-
βœ“
-
-
- -
-
- Admins - - {viewData.adminCount} - -
- -
-
-
- )} -
+ {/* Stats Summary */} + {viewData.users.length > 0 && ( + + )} + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/DashboardTemplate.tsx b/apps/website/templates/DashboardTemplate.tsx index 5a47648a2..f1d71424f 100644 --- a/apps/website/templates/DashboardTemplate.tsx +++ b/apps/website/templates/DashboardTemplate.tsx @@ -1,18 +1,18 @@ +'use client'; + +import React from 'react'; import type { DashboardViewData } from '@/lib/view-data/DashboardViewData'; -import { - Trophy, - Medal, - Target, - Users, - ChevronRight, - Calendar, - Clock, - Activity, - Award, - UserPlus, - Flag, - User, -} from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { DashboardHero } from '@/components/dashboard/DashboardHero'; +import { NextRaceCard } from '@/components/dashboard/NextRaceCard'; +import { ChampionshipStandings } from '@/components/dashboard/ChampionshipStandings'; +import { ActivityFeed } from '@/components/dashboard/ActivityFeed'; +import { UpcomingRaces } from '@/components/dashboard/UpcomingRaces'; +import { FriendsSidebar } from '@/components/dashboard/FriendsSidebar'; +import { Stack } from '@/ui/Stack'; interface DashboardTemplateProps { viewData: DashboardViewData; @@ -27,7 +27,6 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) { feedItems, friends, activeLeaguesCount, - friendCount, hasUpcomingRaces, hasLeagueStandings, hasFeedItems, @@ -35,312 +34,32 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) { } = viewData; return ( -
- {/* Hero Section */} -
- {/* Background Pattern */} -
-
-
-
+ + -
-
- {/* Welcome Message */} -
-
-
-
- {currentDriver.name} -
-
-
-
-
-

Good morning,

-

- {currentDriver.name} - {currentDriver.country} -

-
-
- {currentDriver.rating} -
-
- #{currentDriver.rank} -
- {currentDriver.totalRaces} races completed -
-
-
- - {/* Quick Actions */} - -
- - {/* Quick Stats Row */} -
-
-
-
- Trophy -
-
-

{currentDriver.wins}

-

Wins

-
-
-
-
-
-
- Medal -
-
-

{currentDriver.podiums}

-

Podiums

-
-
-
-
-
-
- Target -
-
-

{currentDriver.consistency}

-

Consistency

-
-
-
-
-
-
- Users -
-
-

{activeLeaguesCount}

-

Active Leagues

-
-
-
-
-
-
- - {/* Main Content */} -
-
+ + {/* Left Column - Main Content */} -
- {/* Next Race Card */} - {nextRace && ( -
-
-
-
-
- Next Race -
- {nextRace.isMyLeague && ( - - Your League - - )} -
- -
-
-

{nextRace.track}

-

{nextRace.car}

-
- - Calendar - {nextRace.formattedDate} - - - Clock - {nextRace.formattedTime} - -
-
- -
-
-

Starts in

-

{nextRace.timeUntil}

-
- - View Details - ChevronRight - -
-
-
-
- )} - - {/* League Standings Preview */} - {hasLeagueStandings && ( -
-
-

- Award - Your Championship Standings -

- - View all ChevronRight - -
-
- {leagueStandings.map((summary) => ( -
-
-

{summary.leagueName}

-

Position {summary.position} β€’ {summary.points} points

-
- {summary.totalDrivers} drivers -
- ))} -
-
- )} - - {/* Activity Feed */} -
-
-

- Activity - Recent Activity -

-
- {hasFeedItems ? ( -
- {feedItems.slice(0, 5).map((item) => ( -
-
-

{item.headline}

- {item.body &&

{item.body}

} -

{item.formattedTime}

-
- {item.ctaHref && item.ctaLabel && ( - - {item.ctaLabel} - - )} -
- ))} -
- ) : ( -
- Activity -

No activity yet

-

Join leagues and add friends to see activity here

-
- )} -
-
+ + + {nextRace && } + {hasLeagueStandings && } + + + {/* Right Column - Sidebar */} -
- {/* Upcoming Races */} -
-
-

- Calendar - Upcoming Races -

- - View all - -
- {hasUpcomingRaces ? ( -
- {upcomingRaces.slice(0, 5).map((race) => ( -
-

{race.track}

-

{race.car}

-
- {race.formattedDate} - β€’ - {race.formattedTime} -
- {race.isMyLeague && ( - - Your League - - )} -
- ))} -
- ) : ( -

No upcoming races

- )} -
- - {/* Friends */} -
-
-

- Users - Friends -

- {friends.length} friends -
- {hasFriends ? ( -
- {friends.slice(0, 6).map((friend) => ( -
-
- {friend.name} -
-
-

{friend.name}

-

{friend.country}

-
-
- ))} - {friends.length > 6 && ( - - +{friends.length - 6} more - - )} -
- ) : ( -
- UserPlus -

No friends yet

- - Find Drivers - -
- )} -
-
-
-
-
+ + + + + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/DriverProfileTemplate.tsx b/apps/website/templates/DriverProfileTemplate.tsx index a188cb038..23b04669c 100644 --- a/apps/website/templates/DriverProfileTemplate.tsx +++ b/apps/website/templates/DriverProfileTemplate.tsx @@ -1,816 +1,190 @@ 'use client'; -import { useState } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; +import React from 'react'; import { - User, - Trophy, - Star, - Calendar, - Users, - Flag, - Award, - TrendingUp, - UserPlus, - ExternalLink, - Target, - Zap, - Clock, - Medal, - Crown, - ChevronRight, - Globe, - Twitter, - Youtube, - Twitch, - MessageCircle, ArrowLeft, - BarChart3, - Shield, - Percent, - Activity, } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Container } from '@/ui/Container'; +import { LoadingSpinner } from '@/ui/LoadingSpinner'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import { CircularProgress } from '@/components/drivers/CircularProgress'; -import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart'; -import { mediaConfig } from '@/lib/config/mediaConfig'; -import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; +import { ProfileHero } from '@/components/profile/ProfileHero'; +import { ProfileBio } from '@/components/profile/ProfileBio'; +import { TeamMembershipGrid } from '@/components/profile/TeamMembershipGrid'; +import { PerformanceOverview } from '@/components/profile/PerformanceOverview'; +import { ProfileTabs } from '@/components/profile/ProfileTabs'; +import { CareerStats } from '@/components/profile/CareerStats'; +import { RacingProfile } from '@/components/profile/RacingProfile'; +import { AchievementGrid } from '@/components/profile/AchievementGrid'; +import { FriendsPreview } from '@/components/profile/FriendsPreview'; +import type { DriverProfileViewData } from '../../../lib/types/view-data/DriverProfileViewData'; type ProfileTab = 'overview' | 'stats'; -interface Team { - id: string; - name: string; -} - -interface SocialHandle { - platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; - handle: string; - url: string; -} - -interface Achievement { - id: string; - title: string; - description: string; - icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; - rarity: 'common' | 'rare' | 'epic' | 'legendary'; - earnedAt: Date; -} - -interface DriverExtendedProfile { - socialHandles: SocialHandle[]; - achievements: Achievement[]; - racingStyle: string; - favoriteTrack: string; - favoriteCar: string; - timezone: string; - availableHours: string; - lookingForTeam: boolean; - openToRequests: boolean; -} - -interface TeamMembershipInfo { - team: Team; - role: string; - joinedAt: Date; -} - interface DriverProfileTemplateProps { - driverProfile: DriverProfileViewModel; - allTeamMemberships: TeamMembershipInfo[]; + viewData: DriverProfileViewData; isLoading?: boolean; error?: string | null; onBackClick: () => void; onAddFriend: () => void; friendRequestSent: boolean; activeTab: ProfileTab; - setActiveTab: (tab: ProfileTab) => void; + onTabChange: (tab: ProfileTab) => void; isSponsorMode?: boolean; sponsorInsights?: React.ReactNode; } -// Helper functions -function getCountryFlag(countryCode: string): string { - const code = countryCode.toUpperCase(); - if (code.length === 2) { - const codePoints = [...code].map(char => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); - } - return '🏁'; -} - -function getRarityColor(rarity: Achievement['rarity']) { - switch (rarity) { - case 'common': - return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; - case 'rare': - return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30'; - case 'epic': - return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; - case 'legendary': - return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; - } -} - -function getAchievementIcon(icon: Achievement['icon']) { - switch (icon) { - case 'trophy': - return Trophy; - case 'medal': - return Medal; - case 'star': - return Star; - case 'crown': - return Crown; - case 'target': - return Target; - case 'zap': - return Zap; - } -} - -function getSocialIcon(platform: SocialHandle['platform']) { - switch (platform) { - case 'twitter': - return Twitter; - case 'youtube': - return Youtube; - case 'twitch': - return Twitch; - case 'discord': - return MessageCircle; - } -} - -function getSocialColor(platform: SocialHandle['platform']) { - switch (platform) { - case 'twitter': - return 'hover:text-sky-400 hover:bg-sky-400/10'; - case 'youtube': - return 'hover:text-red-500 hover:bg-red-500/10'; - case 'twitch': - return 'hover:text-purple-400 hover:bg-purple-400/10'; - case 'discord': - return 'hover:text-indigo-400 hover:bg-indigo-400/10'; - } -} - export function DriverProfileTemplate({ - driverProfile, - allTeamMemberships, + viewData, isLoading = false, error = null, onBackClick, onAddFriend, friendRequestSent, activeTab, - setActiveTab, + onTabChange, isSponsorMode = false, sponsorInsights = null, }: DriverProfileTemplateProps) { if (isLoading) { return ( -
-
-
-
-

Loading driver profile...

-
-
-
+ + + + Loading driver profile... + + ); } - if (error || !driverProfile?.currentDriver) { + if (error || !viewData?.currentDriver) { return ( -
- - -
{error || 'Driver not found'}
+ + + {error || 'Driver not found'} -
-
+ + ); } - const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? { - socialHandles: driverProfile.extendedProfile.socialHandles, - achievements: driverProfile.extendedProfile.achievements.map((achievement) => ({ - id: achievement.id, - title: achievement.title, - description: achievement.description, - icon: achievement.icon, - rarity: achievement.rarity, - earnedAt: new Date(achievement.earnedAt), - })), - racingStyle: driverProfile.extendedProfile.racingStyle, - favoriteTrack: driverProfile.extendedProfile.favoriteTrack, - favoriteCar: driverProfile.extendedProfile.favoriteCar, - timezone: driverProfile.extendedProfile.timezone, - availableHours: driverProfile.extendedProfile.availableHours, - lookingForTeam: driverProfile.extendedProfile.lookingForTeam, - openToRequests: driverProfile.extendedProfile.openToRequests, - } : { - socialHandles: [], - achievements: [], - racingStyle: 'Unknown', - favoriteTrack: 'Unknown', - favoriteCar: 'Unknown', - timezone: 'UTC', - availableHours: 'Flexible', - lookingForTeam: false, - openToRequests: false, - }; - - const stats = driverProfile?.stats || null; - const globalRank = driverProfile?.currentDriver?.globalRank || 1; - const driver = driverProfile.currentDriver; + const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData; return ( -
- {/* Back Navigation */} - + + + {/* Back Navigation */} + + + - {/* Breadcrumb */} - + {/* Breadcrumb */} + - {/* Sponsor Insights Card */} - {isSponsorMode && sponsorInsights} + {/* Sponsor Insights Card */} + {isSponsorMode && sponsorInsights} - {/* Hero Header Section */} -
- {/* Background Pattern */} -
-
+ + {/* Bio Section */} + {currentDriver.bio && } + + {/* Team Memberships */} + {teamMemberships.length > 0 && ( + ({ + team: { id: m.teamId, name: m.teamName }, + role: m.role, + joinedAt: new Date(m.joinedAt) + }))} /> -
+ )} -
-
- {/* Avatar */} -
-
-
- {driver.name} -
-
-
+ {/* Performance Overview */} + {stats && ( + + )} - {/* Driver Info */} -
-
-

{driver.name}

- - {getCountryFlag(driver.country)} - -
+ {/* Tab Navigation */} + - {/* Rating and Rank */} -
- {stats && ( - <> -
- - {stats.rating} - Rating -
-
- - #{globalRank} - Global -
- - )} -
- - {/* Meta info */} -
- - - iRacing: {driver.iracingId} - - - - Joined{' '} - {new Date(driver.joinedAt).toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - })} - - - - {extendedProfile.timezone} - -
-
- - {/* Action Buttons */} -
- -
-
- - {/* Social Handles */} - {extendedProfile.socialHandles.length > 0 && ( -
-
- Connect: - {extendedProfile.socialHandles.map((social: SocialHandle) => { - const Icon = getSocialIcon(social.platform); - return ( - - - {social.handle} - - - ); - })} -
-
- )} -
-
- - {/* Bio Section */} - {driver.bio && ( - -

- - About -

-

{driver.bio}

-
- )} - - {/* Team Memberships */} - {allTeamMemberships.length > 0 && ( - -

- - Team Memberships - ({allTeamMemberships.length}) -

-
- {allTeamMemberships.map((membership) => ( - -
- -
-
-

- {membership.team.name} -

-
- - {membership.role} - - - Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - -
-
- - - ))} -
-
- )} - - {/* Performance Overview with Diagrams */} - {stats && ( - -

- - Performance Overview -

-
- {/* Circular Progress Charts */} -
-
- - -
-
- - -
-
- - {/* Bar chart and key metrics */} -
-

- - Results Breakdown -

- + + + {extendedProfile && ( + + )} -
-
-
- - Best Finish -
-

P{stats.bestFinish}

-
-
-
- - Avg Finish -
-

- P{(stats.avgFinish ?? 0).toFixed(1)} -

-
-
-
-
-
- )} + {extendedProfile && extendedProfile.achievements.length > 0 && ( + ({ + ...a, + earnedAt: new Date(a.earnedAt) + }))} + /> + )} - {/* Tab Navigation */} -
- - -
+ {socialSummary.friends.length > 0 && ( + + )} + + )} - {/* Tab Content */} - {activeTab === 'overview' && ( - <> - {/* Stats and Profile Grid */} -
- {/* Career Stats */} - -

- - Career Statistics -

- {stats ? ( -
-
-
{stats.totalRaces}
-
Races
-
-
-
{stats.wins}
-
Wins
-
-
-
{stats.podiums}
-
Podiums
-
-
-
{stats.consistency}%
-
Consistency
-
-
- ) : ( -

No race statistics available yet.

- )} -
- - {/* Racing Preferences */} - -

- - Racing Profile -

-
-
- Racing Style -

{extendedProfile.racingStyle}

-
-
- Favorite Track -

{extendedProfile.favoriteTrack}

-
-
- Favorite Car -

{extendedProfile.favoriteCar}

-
-
- Available -

{extendedProfile.availableHours}

-
- - {/* Status badges */} -
- {extendedProfile.lookingForTeam && ( -
- - Looking for Team -
- )} - {extendedProfile.openToRequests && ( -
- - Open to Friend Requests -
- )} -
-
-
-
- - {/* Achievements */} - -

- - Achievements - {extendedProfile.achievements.length} earned -

-
- {extendedProfile.achievements.map((achievement: Achievement) => { - const Icon = getAchievementIcon(achievement.icon); - const rarityClasses = getRarityColor(achievement.rarity); - return ( -
-
-
- -
-
-

{achievement.title}

-

{achievement.description}

-

- {achievement.earnedAt.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
-
- ); - })} -
-
- - {/* Friends Preview */} - {driverProfile.socialSummary.friends.length > 0 && ( - -
-

- - Friends - ({driverProfile.socialSummary.friends.length}) -

-
-
- {driverProfile.socialSummary.friends.slice(0, 8).map((friend) => ( - -
- {friend.name} -
- {friend.name} - {getCountryFlag(friend.country)} - - ))} - {driverProfile.socialSummary.friends.length > 8 && ( -
+{driverProfile.socialSummary.friends.length - 8} more
- )} -
-
- )} - - )} - - {activeTab === 'stats' && stats && ( -
- {/* Detailed Performance Metrics */} - -

- - Detailed Performance Metrics -

- -
- {/* Performance Bars */} -
-

Results Breakdown

- -
- - {/* Key Metrics */} -
-
-
- - Win Rate -
-

- {((stats.wins / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
- - Podium Rate -
-

- {((stats.podiums / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
- - Consistency -
-

{stats.consistency}%

-
-
-
- - Finish Rate -
-

- {(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
-
- - {/* Position Statistics */} - -

- - Position Statistics -

- -
-
-
P{stats.bestFinish}
-
Best Finish
-
-
-
- P{(stats.avgFinish ?? 0).toFixed(1)} -
-
Avg Finish
-
-
-
P{stats.worstFinish}
-
Worst Finish
-
-
-
{stats.dnfs}
-
DNFs
-
-
-
- - {/* Global Rankings */} - -

- - Global Rankings -

- -
-
- -
#{globalRank}
-
Global Rank
-
-
- -
{stats.rating}
-
Rating
-
-
- -
Top {stats.percentile}%
-
Percentile
-
-
-
-
- )} - - {activeTab === 'stats' && !stats && ( - - -

No statistics available yet

-

This driver hasn't completed any races yet

-
- )} -
+ {activeTab === 'stats' && !stats && ( + + No statistics available yet + This driver hasn't completed any races yet + + )} +
+
); -} \ No newline at end of file +} diff --git a/apps/website/templates/DriverRankingsTemplate.tsx b/apps/website/templates/DriverRankingsTemplate.tsx index 736a609b4..4703159a9 100644 --- a/apps/website/templates/DriverRankingsTemplate.tsx +++ b/apps/website/templates/DriverRankingsTemplate.tsx @@ -1,15 +1,18 @@ 'use client'; import React from 'react'; -import { Trophy, ArrowLeft, Medal } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; -import Image from 'next/image'; +import { Trophy, ArrowLeft } from 'lucide-react'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData'; - -// ============================================================================ -// TYPES -// ============================================================================ +import { RankingsPodium } from '@/components/drivers/RankingsPodium'; +import { RankingsTable } from '@/components/drivers/RankingsTable'; interface DriverRankingsTemplateProps { viewData: DriverRankingsViewData; @@ -17,209 +20,62 @@ interface DriverRankingsTemplateProps { onBackToLeaderboards?: () => void; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export function DriverRankingsTemplate({ viewData, onDriverClick, onBackToLeaderboards, }: DriverRankingsTemplateProps): React.ReactElement { return ( -
- {/* Header */} -
- {onBackToLeaderboards && ( - - )} - -
-
- -
-
- - Driver Leaderboard - -

Full rankings of all drivers by performance metrics

-
-
-
- - {/* Top 3 Podium */} - {viewData.podium.length > 0 && ( -
-
- {[1, 0, 2].map((index) => { - const driver = viewData.podium[index]; - if (!driver) return null; - - const position = index === 1 ? 1 : index === 0 ? 2 : 3; - const config = { - 1: { height: 'h-40', color: 'from-yellow-400/20 to-amber-500/10 border-yellow-400/40', crown: 'text-yellow-400', text: 'text-xl text-yellow-400' }, - 2: { height: 'h-32', color: 'from-gray-400/20 to-gray-500/10 border-gray-400/40', crown: 'text-gray-300', text: 'text-lg text-gray-300' }, - 3: { height: 'h-24', color: 'from-amber-600/20 to-amber-700/10 border-amber-600/40', crown: 'text-amber-600', text: 'text-base text-amber-600' }, - }[position]; - - return ( - - ); - })} -
-
- )} - - {/* Leaderboard Table */} -
- {/* Table Header */} -
-
Rank
-
Driver
-
Races
-
Rating
-
Wins
-
Podiums
-
Win Rate
-
- - {/* Table Body */} -
- {viewData.drivers.map((driver) => { - const position = driver.rank; - - return ( - + + )} - {/* Driver Info */} -
-
- {driver.name} -
-
-

- {driver.name} -

-
- - {driver.nationality} - - - {driver.skillLevel} - -
-
-
+ + + + + + Driver Leaderboard + Full rankings of all drivers by performance metrics + + + - {/* Races */} -
- {driver.racesCompleted} -
- - {/* Rating */} -
- - {driver.rating.toString()} - -
- - {/* Wins */} -
- - {driver.wins} - -
- - {/* Podiums */} -
- - {driver.podiums} - -
- - {/* Win Rate */} -
- - {driver.winRate}% - -
- - ); - })} -
- - {/* Empty State */} - {viewData.drivers.length === 0 && ( -
- πŸ” -

No drivers found

-

There are no drivers in the system yet

-
+ {/* Top 3 Podium */} + {viewData.podium.length > 0 && ( + ({ + ...d, + rating: Number(d.rating), + wins: Number(d.wins), + podiums: Number(d.podiums) + }))} + onDriverClick={onDriverClick} + /> )} -
-
+ + {/* Leaderboard Table */} + ({ + ...d, + rating: Number(d.rating), + wins: Number(d.wins) + }))} + onDriverClick={onDriverClick} + /> + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/DriversTemplate.tsx b/apps/website/templates/DriversTemplate.tsx index 531e2b8a7..b271231d3 100644 --- a/apps/website/templates/DriversTemplate.tsx +++ b/apps/website/templates/DriversTemplate.tsx @@ -1,189 +1,122 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import React from 'react'; import { - Trophy, - Users, Search, Crown, } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Card from '@/components/ui/Card'; -import Heading from '@/components/ui/Heading'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; import { FeaturedDriverCard } from '@/components/drivers/FeaturedDriverCard'; import { SkillDistribution } from '@/components/drivers/SkillDistribution'; import { CategoryDistribution } from '@/components/drivers/CategoryDistribution'; import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview'; import { RecentActivity } from '@/components/drivers/RecentActivity'; -import { useDriverSearch } from '@/lib/hooks/useDriverSearch'; -import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import { DriversHero } from '@/components/drivers/DriversHero'; +import { DriversSearch } from '@/components/drivers/DriversSearch'; +import { EmptyState } from '@/components/shared/state/EmptyState'; +import type { DriversViewData } from '@/lib/types/view-data/DriversViewData'; interface DriversTemplateProps { - data: DriverLeaderboardViewModel | null; + viewData: DriversViewData | null; + searchQuery: string; + onSearchChange: (query: string) => void; + filteredDrivers: DriversViewData['drivers']; + onDriverClick: (id: string) => void; + onViewLeaderboard: () => void; } -export function DriversTemplate({ data }: DriversTemplateProps) { - const drivers = data?.drivers || []; - const totalRaces = data?.totalRaces || 0; - const totalWins = data?.totalWins || 0; - const activeCount = data?.activeCount || 0; - const isLoading = false; - - const router = useRouter(); - const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers); - - const handleDriverClick = (driverId: string) => { - router.push(`/drivers/${driverId}`); - }; +export function DriversTemplate({ + viewData, + searchQuery, + onSearchChange, + filteredDrivers, + onDriverClick, + onViewLeaderboard +}: DriversTemplateProps) { + const drivers = viewData?.drivers || []; + const totalRaces = viewData?.totalRaces || 0; + const totalWins = viewData?.totalWins || 0; + const activeCount = viewData?.activeCount || 0; // Featured drivers (top 4) const featuredDrivers = filteredDrivers.slice(0, 4); - if (isLoading) { - return ( -
-
-
-
-

Loading drivers...

-
-
-
- ); - } - return ( -
- {/* Hero Section */} -
- {/* Background decoration */} -
-
-
+ + + {/* Hero Section */} + -
-
-
-
- -
- - Drivers - -
-

- Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid. -

+ {/* Search */} + - {/* Quick Stats */} -
-
-
- - {drivers.length} drivers - -
-
-
- - {activeCount} active - -
-
-
- - {totalWins.toLocaleString()} total wins - -
-
-
- - {totalRaces.toLocaleString()} races - -
-
-
+ {/* Featured Drivers */} + {!searchQuery && ( + + + + + + + Featured Drivers + Top performers on the grid + + - {/* CTA */} -
- -

See full driver rankings

-
-
-
+ + {featuredDrivers.map((driver, index) => ( + + onDriverClick(driver.id)} + /> + + ))} + + + )} - {/* Search */} -
-
- - setSearchQuery(e.target.value)} - className="pl-11" + {/* Active Drivers */} + {!searchQuery && } + + {/* Skill Distribution */} + {!searchQuery && } + + {/* Category Distribution */} + {!searchQuery && } + + {/* Leaderboard Preview */} + + + {/* Empty State */} + {filteredDrivers.length === 0 && ( + onSearchChange(''), + variant: 'secondary' + }} /> -
-
- - {/* Featured Drivers */} - {!searchQuery && ( -
-
-
- -
-
-

Featured Drivers

-

Top performers on the grid

-
-
- -
- {featuredDrivers.map((driver, index) => ( - handleDriverClick(driver.id)} - /> - ))} -
-
- )} - - {/* Active Drivers */} - {!searchQuery && } - - {/* Skill Distribution */} - {!searchQuery && } - - {/* Category Distribution */} - {!searchQuery && } - - {/* Leaderboard Preview */} - - - {/* Empty State */} - {filteredDrivers.length === 0 && ( - -
- -

No drivers found matching "{searchQuery}"

- -
-
- )} -
+ )} + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/HomeTemplate.tsx b/apps/website/templates/HomeTemplate.tsx index d3c2e5fc8..54d524c1c 100644 --- a/apps/website/templates/HomeTemplate.tsx +++ b/apps/website/templates/HomeTemplate.tsx @@ -1,4 +1,6 @@ -import Image from 'next/image'; +'use client'; + +import React from 'react'; import Hero from '@/components/landing/Hero'; import AlternatingSection from '@/components/landing/AlternatingSection'; import FeatureGrid from '@/components/landing/FeatureGrid'; @@ -9,25 +11,50 @@ import CareerProgressionMockup from '@/components/mockups/CareerProgressionMocku import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup'; import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup'; import SimPlatformMockup from '@/components/mockups/SimPlatformMockup'; -import MockupStack from '@/components/ui/MockupStack'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import MockupStack from '@/ui/MockupStack'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Image } from '@/ui/Image'; +import { Link } from '@/ui/Link'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { Surface } from '@/ui/Surface'; import { getMediaUrl } from '@/lib/utilities/media'; +import { routes } from '@/lib/routing/RouteConfig'; +import { FeatureItem, ResultItem, StepItem } from '@/components/landing/LandingItems'; -export interface HomeTemplateData { +export interface HomeViewData { isAlpha: boolean; - upcomingRaces: any[]; - topLeagues: any[]; - teams: any[]; + upcomingRaces: Array<{ + id: string; + track: string; + car: string; + formattedDate: string; + }>; + topLeagues: Array<{ + id: string; + name: string; + description: string; + }>; + teams: Array<{ + id: string; + name: string; + description: string; + logoUrl?: string; + }>; } -export interface HomeTemplateProps { - data: HomeTemplateData; +interface HomeTemplateProps { + viewData: HomeViewData; } -export default function HomeTemplate({ data }: HomeTemplateProps) { +export function HomeTemplate({ viewData }: HomeTemplateProps) { return ( -
+ {/* Section 1: A Persistent Identity */} @@ -35,55 +62,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) { heading="A Persistent Identity" backgroundVideo="/gameplay.mp4" description={ - <> -

+ + Your races, your seasons, your progress β€” finally in one place. -

-
-
-
-
-
- - - -
- - Lifetime stats and season history across all your leagues - -
-
-
-
-
-
- - - -
- - Track your performance, consistency, and team contributions - -
-
-
-
-
-
- - - -
- - Your own rating that reflects real league competition - -
-
-
-

+ + + + + + + iRacing gives you physics. GridPilot gives you a career. -

- + + } mockup={} layout="text-left" @@ -96,55 +87,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) { heading="Results That Actually Stay" backgroundImage="/images/ff1600.jpeg" description={ - <> -

+ + Every race you run stays with you. -

-
-
-
-
-
- - - -
- - Your stats, your team, your story β€” all connected - -
-
-
-
-
-
- - - -
- - One race result updates your profile, team points, rating, and season history - -
-
-
-
-
-
- - - -
- - No more fragmented data across spreadsheets and forums - -
-
-
-

+ + + + + + + Your racing career, finally in one place. -

- + + } mockup={} layout="text-right" @@ -154,49 +109,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) { -

+ + Setting up league races used to mean clicking through iRacing's wizard 20 times. -

-
-
-
-
-
- 1 -
- - Our companion app syncs with your league schedule - -
-
-
-
-
-
- 2 -
- - When it's race time, it creates the iRacing session automatically - -
-
-
-
-
-
- 3 -
- - No clicking through wizards. No manual setup - -
-
-
-

+ + + + + + + Automation instead of repetition. -

- + + } mockup={} layout="text-left" @@ -207,149 +132,145 @@ export default function HomeTemplate({ data }: HomeTemplateProps) { heading="Built for iRacing. Ready for the future." backgroundImage="/images/lmp3.jpeg" description={ - <> -

+ + Right now, we're focused on making iRacing league racing better. -

-

+ + But sims come and go. Your leagues, your teams, your rating β€” those stay. -

-

+ + GridPilot is built to outlast any single platform. -

-

+ + When the next sim arrives, your competitive identity moves with you. -

- + + } mockup={} layout="text-right" /> {/* Alpha-only discovery section */} - {data.isAlpha && ( -
-
-
-

Discover the grid

-

+ {viewData.isAlpha && ( + + + + Discover the grid + Explore leagues, teams, and races that make up the GridPilot ecosystem. -

-
-
+ + -
- {/* Top leagues */} - -
-

Featured leagues

- -
-
    - {data.topLeagues.slice(0, 4).map((league: any) => ( -
  • -
    - {league.name - .split(' ') - .map((word: string) => word[0]) - .join('') - .slice(0, 3) - .toUpperCase()} -
    -
    -

    {league.name}

    -

    - {league.description} -

    -
    -
  • - ))} -
-
+ + {/* Top leagues */} + + + + Featured leagues + + + + + + {viewData.topLeagues.slice(0, 4).map((league) => ( + + + + + {league.name.split(' ').map((word) => word[0]).join('').slice(0, 3).toUpperCase()} + + + + {league.name} + {league.description} + + + + ))} + + + - {/* Teams */} - -
-

Teams on the grid

- -
-
    - {data.teams.slice(0, 4).map(team => ( -
  • -
    - {team.name} -
    -
    -

    {team.name}

    -

    - {team.description} -

    -
    -
  • - ))} -
-
+ {/* Teams */} + + + + Teams on the grid + + + + + + {viewData.teams.slice(0, 4).map(team => ( + + + + {team.name} + + + {team.name} + {team.description} + + + + ))} + + + - {/* Upcoming races */} - -
-

Upcoming races

- -
- {data.upcomingRaces.length === 0 ? ( -

- No races scheduled in this demo snapshot. -

- ) : ( -
    - {data.upcomingRaces.map(race => ( -
  • -
    -

    {race.track}

    -

    {race.car}

    -
    -
    - {race.formattedDate} -
    -
  • - ))} -
- )} -
-
-
+ {/* Upcoming races */} + + + + Upcoming races + + + + + {viewData.upcomingRaces.length === 0 ? ( + + No races scheduled in this demo snapshot. + + ) : ( + + {viewData.upcomingRaces.map(race => ( + + + + {race.track} + {race.car} + + + {race.formattedDate} + + + + ))} + + )} + + + + + )}
-
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeaderboardsTemplate.tsx b/apps/website/templates/LeaderboardsTemplate.tsx index 471f2b223..293d3e50e 100644 --- a/apps/website/templates/LeaderboardsTemplate.tsx +++ b/apps/website/templates/LeaderboardsTemplate.tsx @@ -1,95 +1,55 @@ 'use client'; import React from 'react'; -import { useRouter } from 'next/navigation'; -import { Trophy, Users, Award } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview'; import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; +import { LeaderboardsHero } from '@/components/leaderboards/LeaderboardsHero'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; -import { routes } from '@/lib/routing/RouteConfig'; - -// ============================================================================ -// TYPES -// ============================================================================ interface LeaderboardsTemplateProps { viewData: LeaderboardsViewData; + onDriverClick: (id: string) => void; + onTeamClick: (id: string) => void; + onNavigateToDrivers: () => void; + onNavigateToTeams: () => void; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - -export function LeaderboardsTemplate({ viewData }: LeaderboardsTemplateProps) { - const router = useRouter(); - - const handleDriverClick = (driverId: string) => { - router.push(routes.driver.detail(driverId)); - }; - - const handleTeamClick = (teamId: string) => { - router.push(routes.team.detail(teamId)); - }; - - const handleNavigateToDrivers = () => { - router.push(routes.leaderboards.drivers); - }; - - const handleNavigateToTeams = () => { - router.push(routes.team.leaderboard); - }; - +export function LeaderboardsTemplate({ + viewData, + onDriverClick, + onTeamClick, + onNavigateToDrivers, + onNavigateToTeams +}: LeaderboardsTemplateProps) { return ( -
-
-
-
-
+ + + + -
-
-
- -
-
- - Leaderboards - -

Where champions rise and legends are made

-
-
- -

- Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne? -

- -
- - -
-
-
- -
- - -
-
+ + + + + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueAdminScheduleTemplate.tsx b/apps/website/templates/LeagueAdminScheduleTemplate.tsx index be99fb01b..99407bf0f 100644 --- a/apps/website/templates/LeagueAdminScheduleTemplate.tsx +++ b/apps/website/templates/LeagueAdminScheduleTemplate.tsx @@ -1,20 +1,21 @@ 'use client'; -import { useMemo, useState } from 'react'; -import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel'; -import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel'; -import Card from '@/components/ui/Card'; - -// ============================================================================ -// TYPES -// ============================================================================ +import React, { useMemo } from 'react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Select } from '@/ui/Select'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Surface } from '@/ui/Surface'; +import type { LeagueAdminScheduleViewData } from '@/lib/view-data/LeagueAdminScheduleViewData'; interface LeagueAdminScheduleTemplateProps { - data: { - schedule: LeagueAdminScheduleViewModel; - seasons: LeagueSeasonSummaryViewModel[]; - seasonId: string; - }; + viewData: LeagueAdminScheduleViewData; onSeasonChange: (seasonId: string) => void; onPublishToggle: () => void; onAddOrSave: () => void; @@ -39,12 +40,8 @@ interface LeagueAdminScheduleTemplateProps { setScheduledAtIso: (value: string) => void; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export function LeagueAdminScheduleTemplate({ - data, + viewData, onSeasonChange, onPublishToggle, onAddOrSave, @@ -62,10 +59,10 @@ export function LeagueAdminScheduleTemplate({ setCar, setScheduledAtIso, }: LeagueAdminScheduleTemplateProps) { - const { schedule, seasons, seasonId } = data; + const { races, seasons, seasonId, published } = viewData; const isEditing = editingRaceId !== null; - const publishedLabel = schedule.published ? 'Published' : 'Unpublished'; + const publishedLabel = published ? 'Published' : 'Unpublished'; const selectedSeasonLabel = useMemo(() => { const selected = seasons.find((s) => s.seasonId === seasonId); @@ -73,162 +70,142 @@ export function LeagueAdminScheduleTemplate({ }, [seasons, seasonId]); return ( -
+ -
-
-

Schedule Admin

-

Create, edit, and publish season races.

-
+ + + Schedule Admin + Create, edit, and publish season races. + -
- - {seasons.length > 0 ? ( - - ) : ( - onSeasonChange(e.target.value)} - className="bg-iron-gray text-white px-3 py-2 rounded" - placeholder="season-id" - /> - )} -

Selected: {selectedSeasonLabel}

-
+ + Season + + + Track + setTrack(e.target.value)} - className="bg-iron-gray text-white px-3 py-2 rounded" + placeholder="Track name" /> -
+ -
- - + Car + setCar(e.target.value)} - className="bg-iron-gray text-white px-3 py-2 rounded" + placeholder="Car name" /> -
+ -
- - + Scheduled At (ISO) + setScheduledAtIso(e.target.value)} - className="bg-iron-gray text-white px-3 py-2 rounded" placeholder="2025-01-01T12:00:00.000Z" /> -
-
+ + -
- + {isEditing && ( - + )} -
-
+ + -
-

Races

+ + + Races + - {schedule?.races.length ? ( -
- {schedule.races.map((race) => ( -
0 ? ( + + {races.map((race) => ( + -
-

{race.name}

-

{race.scheduledAt.toISOString()}

-
+ + + {race.name} + {race.scheduledAt} + -
- - -
-
+ + + + + + ))} -
+ ) : ( -
No races yet.
+ + No races yet. + )} -
-
+ + -
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx index a8c784794..460dc7a42 100644 --- a/apps/website/templates/LeagueDetailTemplate.tsx +++ b/apps/website/templates/LeagueDetailTemplate.tsx @@ -1,8 +1,14 @@ -import { Section } from '@/ui/Section'; -import { Layout } from '@/ui/Layout'; +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { Link } from '@/ui/Link'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; +import { LeagueTabs } from '@/components/leagues/LeagueTabs'; +import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; interface Tab { label: string; @@ -11,58 +17,40 @@ interface Tab { } interface LeagueDetailTemplateProps { - leagueId: string; - leagueName: string; - leagueDescription: string; + viewData: LeagueDetailViewData; tabs: Tab[]; children: React.ReactNode; } export function LeagueDetailTemplate({ - leagueId, - leagueName, - leagueDescription, + viewData, tabs, children, }: LeagueDetailTemplateProps) { return ( - -
+ + -
- - {leagueName} + + {viewData.name} + + {viewData.description} - - {leagueDescription} - -
+ -
-
- {tabs.map((tab) => ( - - {tab.label} - - ))} -
-
+ -
+ {children} -
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueRulebookTemplate.tsx b/apps/website/templates/LeagueRulebookTemplate.tsx index 925e6af30..7bae8868c 100644 --- a/apps/website/templates/LeagueRulebookTemplate.tsx +++ b/apps/website/templates/LeagueRulebookTemplate.tsx @@ -1,116 +1,85 @@ 'use client'; -import { useState } from 'react'; -import Card from '@/components/ui/Card'; +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Badge } from '@/ui/Badge'; +import { Grid } from '@/ui/Grid'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; import PointsTable from '@/components/leagues/PointsTable'; -import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties'; +import { RulebookTabs, type RulebookSection } from '@/components/leagues/RulebookTabs'; +import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData'; +import { Surface } from '@/ui/Surface'; interface LeagueRulebookTemplateProps { - viewModel: LeagueDetailPageViewModel; + viewData: LeagueRulebookViewData; + activeSection: RulebookSection; + onSectionChange: (section: RulebookSection) => void; loading?: boolean; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export function LeagueRulebookTemplate({ - viewModel, + viewData, + activeSection, + onSectionChange, loading = false, }: LeagueRulebookTemplateProps) { - const [activeSection, setActiveSection] = useState('scoring'); - if (loading) { return ( -
Loading rulebook...
+ + Loading rulebook... +
); } - if (!viewModel || !viewModel.scoringConfig) { + if (!viewData || !viewData.scoringConfig) { return ( -
Unable to load rulebook
+ + Unable to load rulebook +
); } - const primaryChampionship = viewModel.scoringConfig.championships.find(c => c.type === 'driver') ?? viewModel.scoringConfig.championships[0]; + const { scoringConfig } = viewData; + const primaryChampionship = scoringConfig.championships.find(c => c.type === 'driver') ?? scoringConfig.championships[0]; const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview - .filter((p): p is { sessionType: string; position: number; points: number } => p.sessionType === primaryChampionship.sessionTypes[0]) + .filter((p) => p.sessionType === primaryChampionship.sessionTypes[0]) .map(p => ({ position: p.position, points: p.points })) .sort((a, b) => a.position - b.position) || []; - const sections: { id: RulebookSection; label: string }[] = [ - { id: 'scoring', label: 'Scoring' }, - { id: 'conduct', label: 'Conduct' }, - { id: 'protests', label: 'Protests' }, - { id: 'penalties', label: 'Penalties' }, - ]; - return ( -
+ {/* Header */} -
-
-

Rulebook

-

Official rules and regulations

-
-
- {viewModel.scoringConfig.scoringPresetName || 'Custom Rules'} -
-
+ + + Rulebook + Official rules and regulations + + + {scoringConfig.scoringPresetName || 'Custom Rules'} + + {/* Navigation Tabs */} -
- {sections.map((section) => ( - - ))} -
+ {/* Content Sections */} {activeSection === 'scoring' && ( -
+ {/* Quick Stats */} -
-
-

Platform

-

{viewModel.scoringConfig.gameName}

-
-
-

Championships

-

{viewModel.scoringConfig.championships.length}

-
-
-

Sessions Scored

-

- {primaryChampionship?.sessionTypes.join(', ') || 'Main'} -

-
-
-

Drop Policy

-

- {viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} -

-
-
+ + + + + + {/* Points Table */} @@ -118,134 +87,137 @@ export function LeagueRulebookTemplate({ {/* Bonus Points */} {primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && ( -

Bonus Points

-
- {primaryChampionship.bonusSummary.map((bonus, idx) => ( -
-
- + -
-

{bonus}

-
- ))} -
+ + Bonus Points + + {primaryChampionship.bonusSummary.map((bonus, idx) => ( + + + + + + + {bonus} + + + ))} + +
)} {/* Drop Policy */} - {!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && ( + {!scoringConfig.dropPolicySummary.includes('All results count') && ( -

Drop Policy

-

{viewModel.scoringConfig.dropPolicySummary}

-

- Drop rules are applied automatically when calculating championship standings. -

+ + Drop Policy + {scoringConfig.dropPolicySummary} + + + Drop rules are applied automatically when calculating championship standings. + + +
)} -
+
)} {activeSection === 'conduct' && ( -

Driver Conduct

-
-
-

1. Respect

-

All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.

-
-
-

2. Clean Racing

-

Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.

-
-
-

3. Track Limits

-

Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.

-
-
-

4. Blue Flags

-

Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.

-
-
-

5. Communication

-

Drivers are expected to communicate respectfully in voice and text chat during sessions.

-
-
+ + Driver Conduct + + + + + + + +
)} {activeSection === 'protests' && ( -

Protest Process

-
-
-

Filing a Protest

-

Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.

-
-
-

Evidence

-

Video evidence is highly recommended but not required. Stewards will review available replay data.

-
-
-

Review Process

-

League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.

-
-
-

Outcomes

-

Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.

-
-
+ + Protest Process + + + + + + +
)} {activeSection === 'penalties' && ( -

Penalty Guidelines

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
InfractionTypical Penalty
Causing avoidable contact5-10 second time penalty
Unsafe rejoin5 second time penalty
BlockingWarning or 3 second penalty
Repeated track limit violations5 second penalty
Intentional wreckingDisqualification
Unsportsmanlike conductPoints deduction or ban
-
-

- Penalties are applied at steward discretion based on incident severity and driver history. -

-
+ + Penalty Guidelines + + + + + Infraction + Typical Penalty + + + + + + + + + + +
+ + + Penalties are applied at steward discretion based on incident severity and driver history. + + +
+
)} -
+ ); -} \ No newline at end of file +} + +function StatItem({ label, value }: { label: string, value: string | number }) { + return ( + + {label} + {value} + + ); +} + +function ConductItem({ number, title, text }: { number: number, title: string, text: string }) { + return ( + + {number}. {title} + {text} + + ); +} + +function PenaltyRow({ infraction, penalty, color }: { infraction: string, penalty: string, color?: string }) { + return ( + + + {infraction} + + + {penalty} + + + ); +} diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index ca659a84a..7ce10e4cf 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -1,7 +1,16 @@ -import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData'; +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; -import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Calendar } from 'lucide-react'; +import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData'; +import { ScheduleRaceCard } from '@/components/leagues/ScheduleRaceCard'; +import { Surface } from '@/ui/Surface'; interface LeagueScheduleTemplateProps { viewData: LeagueScheduleViewData; @@ -9,82 +18,33 @@ interface LeagueScheduleTemplateProps { export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) { return ( -
-
-
-

Race Schedule

-

- Upcoming and completed races for this season -

-
-
+ + + Race Schedule + + Upcoming and completed races for this season + + {viewData.races.length === 0 ? ( -
-
- -
-

No Races Scheduled

-

The race schedule will appear here once events are added.

-
+ + + + + + No Races Scheduled + The race schedule will appear here once events are added. + +
) : ( -
+ {viewData.races.map((race) => ( - -
-
-
-
-

{race.name}

- - {race.status === 'completed' ? 'Completed' : 'Scheduled'} - -
- -
-
- - {new Date(race.scheduledAt).toLocaleDateString()} -
- -
- - {new Date(race.scheduledAt).toLocaleTimeString()} -
- - {race.track && ( -
- - {race.track} -
- )} - - {race.car && ( -
- - {race.car} -
- )} -
- - {race.sessionType && ( -
- - {race.sessionType} Session -
- )} -
-
- + ))} -
+
)} -
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueSettingsTemplate.tsx b/apps/website/templates/LeagueSettingsTemplate.tsx index c949cf50c..4961e2e2a 100644 --- a/apps/website/templates/LeagueSettingsTemplate.tsx +++ b/apps/website/templates/LeagueSettingsTemplate.tsx @@ -1,7 +1,17 @@ -import { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData'; +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; import { Settings, Users, Trophy, Shield, Clock } from 'lucide-react'; +import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData'; interface LeagueSettingsTemplateProps { viewData: LeagueSettingsViewData; @@ -9,113 +19,98 @@ interface LeagueSettingsTemplateProps { export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) { return ( -
-
-
-

League Settings

-

- Manage your league configuration and preferences -

-
-
+ + + League Settings + + Manage your league configuration and preferences + + -
+ {/* League Information */} -
-
- -
-
-

League Information

-

Basic league details

-
-
+ + + + + + + League Information + Basic league details + + -
-
- -

{viewData.league.name}

-
-
- -

{viewData.league.visibility}

-
-
- -

{viewData.league.description}

-
-
- -

{new Date(viewData.league.createdAt).toLocaleDateString()}

-
-
- -

{viewData.league.ownerId}

-
-
+ + + + + + + + + +
{/* Configuration */} -
-
- -
-
-

Configuration

-

League rules and limits

-
-
+ + + + + + + Configuration + League rules and limits + + -
-
- -
-

Max Drivers

-

{viewData.config.maxDrivers}

-
-
- -
- -
-

Require Approval

-

{viewData.config.requireApproval ? 'Yes' : 'No'}

-
-
- -
- -
-

Allow Late Join

-

{viewData.config.allowLateJoin ? 'Yes' : 'No'}

-
-
- -
- -
-

Scoring Preset

-

{viewData.config.scoringPresetId}

-
-
-
+ + + + + + +
{/* Note about forms */} -
-
- -
-

Settings Management

-

- Form-based editing and ownership transfer functionality will be implemented in future updates. -

-
+ + + + + + Settings Management + + Form-based editing and ownership transfer functionality will be implemented in future updates. + + +
-
-
+ + ); -} \ No newline at end of file +} + +function InfoItem({ label, value, capitalize, mono }: { label: string, value: string, capitalize?: boolean, mono?: boolean }) { + return ( + + {label} + {value} + + ); +} + +function ConfigItem({ icon, label, value, mono }: { icon: React.ElementType, label: string, value: string | number, mono?: boolean }) { + return ( + + + + {label} + {value} + + + ); +} diff --git a/apps/website/templates/LeagueSponsorshipsTemplate.tsx b/apps/website/templates/LeagueSponsorshipsTemplate.tsx index 0f1b5daa8..2bb61577f 100644 --- a/apps/website/templates/LeagueSponsorshipsTemplate.tsx +++ b/apps/website/templates/LeagueSponsorshipsTemplate.tsx @@ -1,7 +1,18 @@ -import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData'; +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; -import { Building, DollarSign, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Grid } from '@/ui/Grid'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { Building, Clock } from 'lucide-react'; +import type { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData'; +import { SponsorshipSlotCard } from '@/components/leagues/SponsorshipSlotCard'; +import { SponsorshipRequestCard } from '@/components/leagues/SponsorshipRequestCard'; interface LeagueSponsorshipsTemplateProps { viewData: LeagueSponsorshipsViewData; @@ -9,160 +20,96 @@ interface LeagueSponsorshipsTemplateProps { export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTemplateProps) { return ( -
-
-
-

Sponsorships

-

- Manage sponsorship slots and review requests -

-
-
+ + + Sponsorships + + Manage sponsorship slots and review requests + + -
+ {/* Sponsorship Slots */} -
-
- -
-
-

Sponsorship Slots

-

Available sponsorship opportunities

-
-
+ + + + + + + Sponsorship Slots + Available sponsorship opportunities + + - {viewData.sponsorshipSlots.length === 0 ? ( -
- -

No sponsorship slots available

-
- ) : ( -
- {viewData.sponsorshipSlots.map((slot) => ( -
-
-

{slot.name}

- - {slot.isAvailable ? 'Available' : 'Taken'} - -
- -

{slot.description}

- -
- - - {slot.price} {slot.currency} - -
- - {!slot.isAvailable && slot.sponsoredBy && ( -
-

Sponsored by

-

{slot.sponsoredBy.name}

-
- )} -
- ))} -
- )} + {viewData.sponsorshipSlots.length === 0 ? ( + + + No sponsorship slots available + + ) : ( + + {viewData.sponsorshipSlots.map((slot) => ( + + ))} + + )} +
{/* Sponsorship Requests */} -
-
- -
-
-

Sponsorship Requests

-

Pending and processed sponsorship applications

-
-
+ + + + + + + Sponsorship Requests + Pending and processed sponsorship applications + + - {viewData.sponsorshipRequests.length === 0 ? ( -
- -

No sponsorship requests

-
- ) : ( -
- {viewData.sponsorshipRequests.map((request) => { - const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId); - const statusIcon = { - pending: , - approved: , - rejected: , - }[request.status]; - - const statusColor = { - pending: 'border-warning-amber bg-warning-amber/5', - approved: 'border-performance-green bg-performance-green/5', - rejected: 'border-red-400 bg-red-400/5', - }[request.status]; - - return ( -
-
-
-
- {statusIcon} - {request.sponsorName} - - {request.status} - -
- -
- Requested: {slot?.name || 'Unknown slot'} -
- -
- {new Date(request.requestedAt).toLocaleDateString()} -
-
-
-
- ); - })} -
- )} + {viewData.sponsorshipRequests.length === 0 ? ( + + + No sponsorship requests + + ) : ( + + {viewData.sponsorshipRequests.map((request) => { + const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId); + return ( + + ); + })} + + )} +
{/* Note about management */} -
-
- -
-

Sponsorship Management

-

- Interactive management features for approving requests and managing slots will be implemented in future updates. -

-
+ + + + + + Sponsorship Management + + Interactive management features for approving requests and managing slots will be implemented in future updates. + + +
-
-
+ + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueStandingsTemplate.tsx b/apps/website/templates/LeagueStandingsTemplate.tsx index aa3e70aa4..923259e10 100644 --- a/apps/website/templates/LeagueStandingsTemplate.tsx +++ b/apps/website/templates/LeagueStandingsTemplate.tsx @@ -1,14 +1,15 @@ 'use client'; +import React from 'react'; import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats'; import { StandingsTable } from '@/components/leagues/StandingsTable'; -import Card from '@/components/ui/Card'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData'; -// ============================================================================ -// TYPES -// ============================================================================ - interface LeagueStandingsTemplateProps { viewData: LeagueStandingsViewData; onRemoveMember: (driverId: string) => void; @@ -16,10 +17,6 @@ interface LeagueStandingsTemplateProps { loading?: boolean; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export function LeagueStandingsTemplate({ viewData, onRemoveMember, @@ -28,29 +25,31 @@ export function LeagueStandingsTemplate({ }: LeagueStandingsTemplateProps) { if (loading) { return ( -
- Loading standings... -
+ + Loading standings... + ); } return ( -
+ {/* Championship Stats */} -

Championship Standings

- + + Championship Standings + +
-
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/LeagueWalletTemplate.tsx b/apps/website/templates/LeagueWalletTemplate.tsx index 52cd29843..6a478237f 100644 --- a/apps/website/templates/LeagueWalletTemplate.tsx +++ b/apps/website/templates/LeagueWalletTemplate.tsx @@ -1,155 +1,90 @@ -import { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData'; +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; -import { Wallet, TrendingUp, TrendingDown, DollarSign, Calendar, ArrowUpRight, ArrowDownRight } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { Wallet, Calendar } from 'lucide-react'; +import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData'; +import { TransactionRow } from '@/components/leagues/TransactionRow'; interface LeagueWalletTemplateProps { viewData: LeagueWalletViewData; } export function LeagueWalletTemplate({ viewData }: LeagueWalletTemplateProps) { - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: viewData.currency, - }).format(Math.abs(amount)); - }; - - const getTransactionIcon = (type: string) => { - switch (type) { - case 'deposit': - return ; - case 'withdrawal': - return ; - case 'sponsorship': - return ; - case 'prize': - return ; - default: - return ; - } - }; - - const getTransactionColor = (type: string) => { - switch (type) { - case 'deposit': - return 'text-performance-green'; - case 'withdrawal': - return 'text-red-400'; - case 'sponsorship': - return 'text-primary-blue'; - case 'prize': - return 'text-warning-amber'; - default: - return 'text-gray-400'; - } - }; - return ( -
-
-
-

League Wallet

-

- Financial overview and transaction history -

-
-
+ + + League Wallet + + Financial overview and transaction history + + -
+ {/* Balance Card */} -
-
- -
-
-

Current Balance

-

- {formatCurrency(viewData.balance)} -

-
-
+ + + + + + Current Balance + + {viewData.formattedBalance} + + +
{/* Transaction History */} -
-
- -
-
-

Transaction History

-

Recent financial activity

-
-
+ + + + + + + Transaction History + Recent financial activity + + - {viewData.transactions.length === 0 ? ( -
- -

No transactions yet

-
- ) : ( -
- {viewData.transactions.map((transaction) => ( -
-
-
- {getTransactionIcon(transaction.type)} -
-
-

- {transaction.description} -

-
- {new Date(transaction.createdAt).toLocaleDateString()} - β€’ - - {transaction.type} - - β€’ - - {transaction.status} - -
-
-
- -
-

= 0 ? 'text-performance-green' : 'text-red-400' - }`}> - {transaction.amount >= 0 ? '+' : '-'}{formatCurrency(transaction.amount)} -

-
-
- ))} -
- )} + {viewData.transactions.length === 0 ? ( + + + No transactions yet + + ) : ( + + {viewData.transactions.map((transaction) => ( + + ))} + + )} +
{/* Note about features */} -
-
- -
-

Wallet Management

-

- Interactive withdrawal and export features will be implemented in future updates. -

-
+ + + + + + Wallet Management + + Interactive withdrawal and export features will be implemented in future updates. + + +
-
-
+ + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/ProfileLeaguesTemplate.tsx b/apps/website/templates/ProfileLeaguesTemplate.tsx index d17b28da5..bf6412a4a 100644 --- a/apps/website/templates/ProfileLeaguesTemplate.tsx +++ b/apps/website/templates/ProfileLeaguesTemplate.tsx @@ -1,4 +1,14 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Container } from '@/ui/Container'; +import { Surface } from '@/ui/Surface'; import type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData'; +import { LeagueListItem } from '@/components/profile/LeagueListItem'; interface ProfileLeaguesTemplateProps { viewData: ProfileLeaguesViewData; @@ -6,104 +16,67 @@ interface ProfileLeaguesTemplateProps { export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) { return ( -
-
-

Manage leagues

-

- View leagues you own and participate in, and jump into league admin tools. -

-
+ + + + Manage leagues + + View leagues you own and participate in, and jump into league admin tools. + + - {/* Leagues You Own */} -
-
-

Leagues you own

- {viewData.ownedLeagues.length > 0 && ( - - {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} - - )} -
+ {/* Leagues You Own */} + + + + Leagues you own + {viewData.ownedLeagues.length > 0 && ( + + {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} + + )} + - {viewData.ownedLeagues.length === 0 ? ( -

- You don't own any leagues yet in this session. -

- ) : ( -
- {viewData.ownedLeagues.map((league: ProfileLeaguesViewData['ownedLeagues'][number]) => ( -
-
-

{league.name}

-

- {league.description} -

-
- -
- ))} -
- )} -
+ {viewData.ownedLeagues.length === 0 ? ( + + You don't own any leagues yet in this session. + + ) : ( + + {viewData.ownedLeagues.map((league) => ( + + ))} + + )} +
+ - {/* Leagues You're In */} -
-
-

Leagues you're in

- {viewData.memberLeagues.length > 0 && ( - - {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} - - )} -
+ {/* Leagues You're In */} + + + + Leagues you're in + {viewData.memberLeagues.length > 0 && ( + + {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} + + )} + - {viewData.memberLeagues.length === 0 ? ( -

- You're not a member of any other leagues yet. -

- ) : ( -
- {viewData.memberLeagues.map((league: ProfileLeaguesViewData['memberLeagues'][number]) => ( -
-
-

{league.name}

-

- {league.description} -

-

- Your role:{' '} - {league.membershipRole.charAt(0).toUpperCase() + league.membershipRole.slice(1)} -

-
- - View league - -
- ))} -
- )} -
-
+ {viewData.memberLeagues.length === 0 ? ( + + You're not a member of any other leagues yet. + + ) : ( + + {viewData.memberLeagues.map((league) => ( + + ))} + + )} + + + + ); } diff --git a/apps/website/templates/ProfileTemplate.tsx b/apps/website/templates/ProfileTemplate.tsx index 21a3294cc..c7f140641 100644 --- a/apps/website/templates/ProfileTemplate.tsx +++ b/apps/website/templates/ProfileTemplate.tsx @@ -1,447 +1,232 @@ 'use client'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; -import Image from 'next/image'; -import Link from 'next/link'; +import React from 'react'; +import CreateDriverForm from '@/components/drivers/CreateDriverForm'; +import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory'; +import ProfileSettings from '@/components/drivers/ProfileSettings'; +import { AchievementGrid } from '@/components/profile/AchievementGrid'; +import { ProfileHero } from '@/components/profile/ProfileHero'; +import { ProfileStatGrid } from '@/components/profile/ProfileStatGrid'; +import { ProfileTabs } from '@/components/profile/ProfileTabs'; +import { TeamMembershipGrid } from '@/components/profile/TeamMembershipGrid'; import type { ProfileViewData } from '@/lib/view-data/ProfileViewData'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Card } from '@/ui/Card'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; import { Activity, Award, - BarChart3, - Calendar, - ChevronRight, - Clock, - Edit3, - ExternalLink, - Flag, - Globe, History, - MessageCircle, - Percent, - Settings, - Shield, - Star, - Target, - TrendingUp, - Trophy, - Twitch, - Twitter, User, - UserPlus, - Users, - Youtube, - Zap, - Medal, - Crown, } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import ProfileSettings from '@/components/drivers/ProfileSettings'; -import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory'; -import CreateDriverForm from '@/components/drivers/CreateDriverForm'; +import Breadcrumbs from '@/components/layout/Breadcrumbs'; -type ProfileTab = 'overview' | 'history' | 'stats'; +export type ProfileTab = 'overview' | 'history' | 'stats'; interface ProfileTemplateProps { - viewData: ProfileViewData | null; + viewData: ProfileViewData; mode: 'profile-exists' | 'needs-profile'; + activeTab: ProfileTab; + onTabChange: (tab: ProfileTab) => void; + editMode: boolean; + onEditModeChange: (edit: boolean) => void; + friendRequestSent: boolean; + onFriendRequestSend: () => void; onSaveSettings: (updates: { bio?: string; country?: string }) => Promise; } -function getAchievementIcon(icon: NonNullable['achievements'][number]['icon']) { - switch (icon) { - case 'trophy': - return Trophy; - case 'medal': - return Medal; - case 'star': - return Star; - case 'crown': - return Crown; - case 'target': - return Target; - case 'zap': - return Zap; - } -} - -function getSocialIcon(platformLabel: string) { - switch (platformLabel) { - case 'twitter': - return Twitter; - case 'youtube': - return Youtube; - case 'twitch': - return Twitch; - case 'discord': - return MessageCircle; - default: - return Globe; - } -} - -export function ProfileTemplate({ viewData, mode, onSaveSettings }: ProfileTemplateProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const tabParam = searchParams.get('tab') as ProfileTab | null; - - const [editMode, setEditMode] = useState(false); - const [activeTab, setActiveTab] = useState(tabParam || 'overview'); - const [friendRequestSent, setFriendRequestSent] = useState(false); - - useEffect(() => { - if (searchParams.get('tab') !== activeTab) { - const params = new URLSearchParams(searchParams.toString()); - if (activeTab === 'overview') { - params.delete('tab'); - } else { - params.set('tab', activeTab); - } - const query = params.toString(); - router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false }); - } - }, [activeTab, searchParams, router]); - - useEffect(() => { - const tab = searchParams.get('tab') as ProfileTab | null; - if (tab && tab !== activeTab) { - setActiveTab(tab); - } - }, [searchParams]); - +export function ProfileTemplate({ + viewData, + mode, + activeTab, + onTabChange, + editMode, + onEditModeChange, + friendRequestSent, + onFriendRequestSend, + onSaveSettings, +}: ProfileTemplateProps) { if (mode === 'needs-profile') { return ( -
-
-
- -
- Create Your Driver Profile -

Join the GridPilot community and start your racing journey

-
+ + + + + + + Create Your Driver Profile + Join the GridPilot community and start your racing journey + + - -
-

Get Started

-

- Create your driver profile to join leagues, compete in races, and connect with other drivers. -

-
- -
-
- ); - } - - if (!viewData) { - return ( -
- - -

Unable to load profile

-
-
+ + + + + Get Started + + Create your driver profile to join leagues, compete in races, and connect with other drivers. + + + + + + + ); } if (editMode) { return ( -
-
- Edit Profile - -
+ + + + Edit Profile + + - {/* ProfileSettings expects a DriverProfileDriverSummaryViewModel; keep existing component usage by passing a minimal compatible shape */} - { - await onSaveSettings(updates); - setEditMode(false); - }} - /> -
+ { + await onSaveSettings(updates); + onEditModeChange(false); + }} + /> + + ); } return ( -
- {/* Hero */} -
-
-
-
-
-
- {viewData.driver.name} -
-
-
-
+ + + {/* Back Navigation */} + + + -
-
-

{viewData.driver.name}

- {viewData.driver.countryFlag} - {viewData.teamMemberships[0] && ( - - [{viewData.teamMemberships[0].teamTag || 'TEAM'}] - - )} -
+ {/* Breadcrumb */} + - {viewData.stats && ( -
-
- - {viewData.stats.ratingLabel} - Rating -
-
- - {viewData.stats.globalRankLabel} - Global -
-
- )} + ({ ...s, platform: s.platformLabel as any })) || []} + onAddFriend={onFriendRequestSend} + friendRequestSent={friendRequestSent} + /> -
- - - iRacing: {viewData.driver.iracingId ?? 'β€”'} - - - - Joined {viewData.driver.joinedAtLabel} - - {viewData.extendedProfile && ( - - - {viewData.extendedProfile.timezone} - - )} -
-
- -
- - - - - -
-
- - {viewData.extendedProfile && viewData.extendedProfile.socialHandles.length > 0 && ( -
-
- Connect: - {viewData.extendedProfile.socialHandles.map((social) => { - const Icon = getSocialIcon(social.platformLabel); - return ( - - - {social.handle} - - - ); - })} -
-
- )} -
-
- - {viewData.driver.bio && ( - -

- - About -

-

{viewData.driver.bio}

-
- )} - - {viewData.teamMemberships.length > 0 && ( - -

- - Team Memberships - ({viewData.teamMemberships.length}) -

-
- {viewData.teamMemberships.map((membership) => ( - -
- -
-
-

{membership.teamName}

-
- {membership.roleLabel} - Since {membership.joinedAtLabel} -
-
- - - ))} -
-
- )} - - {/* Tabs */} -
- - - -
- - {activeTab === 'history' && ( - -

- - Race History -

- -
- )} - - {activeTab === 'stats' && viewData.stats && ( -
+ {viewData.driver.bio && ( -

- - Performance Overview -

-
-
-
{viewData.stats.totalRacesLabel}
-
Races
-
-
-
{viewData.stats.winsLabel}
-
Wins
-
-
-
{viewData.stats.podiumsLabel}
-
Podiums
-
-
-
{viewData.stats.consistencyLabel}
-
Consistency
-
-
+ + }> + About + + {viewData.driver.bio} +
-
- )} + )} - {activeTab === 'overview' && viewData.extendedProfile && ( - -

- - Achievements - {viewData.extendedProfile.achievements.length} earned -

-
- {viewData.extendedProfile.achievements.map((achievement) => { - const Icon = getAchievementIcon(achievement.icon); - return ( -
-
-
- -
-
-

{achievement.title}

-

{achievement.description}

-

{achievement.earnedAtLabel}

-
-
-
- ); - })} -
-
- )} -
+ {viewData.teamMemberships.length > 0 && ( + ({ + team: { id: m.teamId, name: m.teamName }, + role: m.roleLabel, + joinedAt: new Date() // Placeholder + }))} + /> + )} + + + + {activeTab === 'history' && ( + + + }> + Race History + + + + + )} + + {activeTab === 'stats' && viewData.stats && ( + + + }> + Performance Overview + + + + + )} + + {activeTab === 'overview' && viewData.extendedProfile && ( + + + + }> + Achievements + + {viewData.extendedProfile.achievements.length} earned + + ({ + ...a, + rarity: a.rarityLabel, + earnedAt: new Date() // Placeholder + }))} + /> + + + )} + + ); } diff --git a/apps/website/templates/RaceDetailTemplate.tsx b/apps/website/templates/RaceDetailTemplate.tsx index 1a183f8d1..33638fddb 100644 --- a/apps/website/templates/RaceDetailTemplate.tsx +++ b/apps/website/templates/RaceDetailTemplate.tsx @@ -1,30 +1,35 @@ 'use client'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; +import React from 'react'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Skeleton } from '@/ui/Skeleton'; +import { InfoBox } from '@/ui/InfoBox'; import { RaceJoinButton } from '@/components/races/RaceJoinButton'; +import { RaceHero } from '@/components/races/RaceHero'; +import { RaceUserResult } from '@/components/races/RaceUserResult'; +import { RaceEntryList } from '@/components/races/RaceEntryList'; +import { RaceDetailCard } from '@/components/races/RaceDetailCard'; +import { LeagueSummaryCard } from '@/components/leagues/LeagueSummaryCard'; import { AlertTriangle, ArrowLeft, - ArrowRight, - Calendar, - Car, CheckCircle2, Clock, - Flag, PlayCircle, - Scale, Trophy, - UserMinus, - UserPlus, - Users, XCircle, - Zap, + Scale, } from 'lucide-react'; +import { Surface } from '@/ui/Surface'; +import { Card } from '@/ui/Card'; export interface RaceDetailEntryViewModel { id: string; @@ -69,7 +74,7 @@ export interface RaceDetailRegistration { canRegister: boolean; } -export interface RaceDetailViewModel { +export interface RaceDetailViewData { race: RaceDetailRace; league?: RaceDetailLeague; entryList: RaceDetailEntryViewModel[]; @@ -79,7 +84,7 @@ export interface RaceDetailViewModel { } export interface RaceDetailTemplateProps { - viewModel?: RaceDetailViewModel; + viewData?: RaceDetailViewData; isLoading: boolean; error?: Error | null; // Actions @@ -98,10 +103,7 @@ export interface RaceDetailTemplateProps { currentDriverId?: string; isOwnerOrAdmin?: boolean; // UI State - showProtestModal: boolean; - setShowProtestModal: (show: boolean) => void; - showEndRaceModal: boolean; - setShowEndRaceModal: (show: boolean) => void; + animatedRatingChange: number; // Loading states mutationLoading?: { register?: boolean; @@ -113,7 +115,7 @@ export interface RaceDetailTemplateProps { } export function RaceDetailTemplate({ - viewModel, + viewData, isLoading, error, onBack, @@ -125,183 +127,88 @@ export function RaceDetailTemplate({ onFileProtest, onResultsClick, onStewardingClick, - onLeagueClick, onDriverClick, - currentDriverId, isOwnerOrAdmin = false, - showProtestModal, - setShowProtestModal, - showEndRaceModal, - setShowEndRaceModal, + animatedRatingChange, mutationLoading = {}, }: RaceDetailTemplateProps) { - const [ratingChange, setRatingChange] = useState(null); - const [animatedRatingChange, setAnimatedRatingChange] = useState(0); + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } - // Set rating change when viewModel changes - useEffect(() => { - if (viewModel?.userResult?.ratingChange !== undefined) { - setRatingChange(viewModel.userResult.ratingChange); - } - }, [viewModel?.userResult?.ratingChange]); + if (error || !viewData || !viewData.race) { + return ( + + + - // Animate rating change when it changes - useEffect(() => { - if (ratingChange !== null) { - let start = 0; - const end = ratingChange; - const duration = 1000; - const startTime = performance.now(); + + + + + + + {error instanceof Error ? error.message : error || 'Race not found'} + + The race you're looking for doesn't exist or has been removed. + + + + + + + + ); + } - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - const current = Math.round(start + (end - start) * eased); - setAnimatedRatingChange(current); - - if (progress < 1) { - requestAnimationFrame(animate); - } - }; - - requestAnimationFrame(animate); - } - }, [ratingChange]); - - const formatDate = (date: Date) => { - return new Date(date).toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }); - }; - - const formatTime = (date: Date) => { - return new Date(date).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); - }; - - const getTimeUntil = (date: Date) => { - const now = new Date(); - const target = new Date(date); - const diffMs = target.getTime() - now.getTime(); - - if (diffMs < 0) return null; - - const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); - - if (days > 0) return `${days}d ${hours}h`; - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; - }; + const { race, league, entryList, userResult } = viewData; const statusConfig = { scheduled: { icon: Clock, - color: 'text-primary-blue', - bg: 'bg-primary-blue/10', - border: 'border-primary-blue/30', + variant: 'primary' as const, label: 'Scheduled', description: 'This race is scheduled and waiting to start', }, running: { icon: PlayCircle, - color: 'text-performance-green', - bg: 'bg-performance-green/10', - border: 'border-performance-green/30', + variant: 'success' as const, label: 'LIVE NOW', description: 'This race is currently in progress', }, completed: { icon: CheckCircle2, - color: 'text-gray-400', - bg: 'bg-gray-500/10', - border: 'border-gray-500/30', + variant: 'default' as const, label: 'Completed', description: 'This race has finished', }, cancelled: { icon: XCircle, - color: 'text-warning-amber', - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', + variant: 'warning' as const, label: 'Cancelled', description: 'This race has been cancelled', }, - } as const; - - const getCountryFlag = (countryCode: string): string => { - const codePoints = countryCode - .toUpperCase() - .split('') - .map(char => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); }; - if (isLoading) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (error || !viewModel || !viewModel.race) { - return ( -
-
- - - -
-
- -
-
-

{error instanceof Error ? error.message : error || 'Race not found'}

-

- The race you're looking for doesn't exist or has been removed. -

-
- -
-
-
-
- ); - } - - const race = viewModel.race; - const league = viewModel.league; - const entryList = viewModel.entryList; - const userResult = viewModel.userResult; - const raceSOF = null; // TODO: Add strength of field to race details response - - const config = statusConfig[race.status as keyof typeof statusConfig]; - const StatusIcon = config.icon; - const timeUntil = race.status === 'scheduled' ? getTimeUntil(new Date(race.scheduledAt)) : null; + const config = statusConfig[race.status] || statusConfig.scheduled; const breadcrumbItems = [ { label: 'Races', href: '/races' }, @@ -310,544 +217,109 @@ export function RaceDetailTemplate({ ]; return ( -
-
- {/* Navigation Row: Breadcrumbs left, Back button right */} -
- + + + {/* Navigation Row */} + + -
+ - {/* User Result - Premium Achievement Card */} + {/* User Result */} {userResult && ( -
-
- {/* Decorative elements */} -
-
- - {/* Victory confetti effect for P1 */} - {userResult.position === 1 && ( -
-
-
-
-
-
- )} - -
- {/* Main content grid */} -
- {/* Left: Position and achievement */} -
- {/* Giant position badge */} -
- {userResult.position === 1 && ( - - )} - P{userResult.position} -
- - {/* Achievement text */} -
-

- {userResult.position === 1 - ? 'πŸ† VICTORY!' - : userResult.position === 2 - ? 'πŸ₯ˆ Second Place' - : userResult.position === 3 - ? 'πŸ₯‰ Podium Finish' - : userResult.position <= 5 - ? '⭐ Top 5 Finish' - : userResult.position <= 10 - ? 'Points Finish' - : `P${userResult.position} Finish`} -

-
- Started P{userResult.startPosition} - - - {userResult.incidents}x incidents - {userResult.isClean && ' ✨'} - -
-
-
- - {/* Right: Stats cards */} -
- {/* Position change */} - {userResult.positionChange !== 0 && ( -
0 - ? 'bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40' - : 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40' - } - `} - > -
0 - ? 'text-performance-green' - : 'text-red-400' - } - `} - > - {userResult.positionChange > 0 ? ( - - - - ) : ( - - - - )} - {Math.abs(userResult.positionChange)} -
-
- {userResult.positionChange > 0 ? 'Gained' : 'Lost'} -
-
- )} - - {/* Rating change */} - {ratingChange !== null && ( -
0 - ? 'bg-gradient-to-br from-warning-amber/30 to-warning-amber/10 border border-warning-amber/40' - : 'bg-gradient-to-br from-red-500/30 to-red-500/10 border border-red-500/40' - } - `} - > -
0 ? 'text-warning-amber' : 'text-red-400'} - `} - > - {animatedRatingChange > 0 ? '+' : ''} - {animatedRatingChange} -
-
Rating
-
- )} - - {/* Clean race bonus */} - {userResult.isClean && ( -
-
✨
-
- Clean Race -
-
- )} -
-
-
-
-
+ )} {/* Hero Header */} -
- {/* Live indicator */} - {race.status === 'running' && ( -
- )} + -
+ + + + -
- {/* Status Badge */} -
-
- {race.status === 'running' && ( - - )} - - {config.label} -
- {timeUntil && ( - - Starts in {timeUntil} - - )} -
+ + + - {/* Title */} - - {race.track} - + + + {league && } - {/* Meta */} -
- - - {formatDate(new Date(race.scheduledAt))} - - - - {formatTime(new Date(race.scheduledAt))} - - - - {race.car} - -
-
- {/* Prominent SOF Badge - Electric Design */} - {raceSOF != null && ( -
-
- {/* Glow effect */} -
- -
- {/* Electric bolt with animation */} -
- - -
- -
-
- Strength of Field -
-
- - {raceSOF} - - SOF -
-
-
-
-
- )} -
- -
- {/* Main Content */} -
- {/* Race Details */} - -

- - Race Details -

- -
-
-

Track

-

{race.track}

-
-
-

Car

-

{race.car}

-
-
-

Session Type

-

{race.sessionType}

-
-
-

Status

-

{config.label}

-
-
-

Strength of Field

-

- - {raceSOF ?? 'β€”'} -

-
-
-
- - {/* Entry List */} - -
-

- - Entry List -

- - {entryList.length} driver{entryList.length !== 1 ? 's' : ''} - -
- - {entryList.length === 0 ? ( -
-
- -
-

No drivers registered yet

-

Be the first to sign up!

-
- ) : ( -
- {entryList.map((driver, index) => { - const isCurrentUser = driver.isCurrentUser; - const countryFlag = getCountryFlag(driver.country); - - return ( -
onDriverClick(driver.id)} - className={` - flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200 - ${ - isCurrentUser - ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40 shadow-lg shadow-primary-blue/10' - : 'bg-deep-graphite hover:bg-charcoal-outline/50 border border-transparent' - } - `} - > - {/* Position number */} -
- {index + 1} -
- - {/* Avatar with nation flag */} -
- {driver.name} - {/* Nation flag */} -
- {countryFlag} -
-
- - {/* Driver info */} -
-
-

- {driver.name} -

- {isCurrentUser && ( - - You - - )} -
-

{driver.country}

-
- - {/* Rating badge */} - {driver.rating != null && ( -
- - - {driver.rating} - -
- )} -
- ); - })} -
- )} -
-
- - {/* Sidebar */} -
- {/* League Card - Premium Design */} - {league && ( - -
-
- {league.name} + + Actions + + -
-
-

League

-

{league.name}

-
-
- {league.description && ( -

{league.description}

- )} - -
-
-

Max Drivers

-

{league.settings.maxDrivers ?? 32}

-
-
-

Format

-

- {league.settings.qualifyingFormat ?? 'Open'} -

-
-
- - - View League - - -
- )} - - {/* Quick Actions Card */} - -

Actions

- -
- {/* Registration Actions */} - - - {/* Results and Stewarding for completed races */} - {race.status === 'completed' && ( - <> - - {userResult && ( - + {race.status === 'completed' && ( + <> + + {userResult && ( + + )} + + )} - - - )} -
-
+ + + - {/* Status Info */} - -
-
- -
-
-

{config.label}

-

{config.description}

-
-
-
-
-
-
- - {/* Modals would be rendered by parent */} -
+ {/* Status Info */} + + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/RaceResultsTemplate.tsx b/apps/website/templates/RaceResultsTemplate.tsx index 778c2f0c5..a99847b7c 100644 --- a/apps/website/templates/RaceResultsTemplate.tsx +++ b/apps/website/templates/RaceResultsTemplate.tsx @@ -1,44 +1,24 @@ 'use client'; +import React from 'react'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react'; - -export interface ResultEntry { - position: number; - driverId: string; - driverName: string; - driverAvatar: string; - country: string; - car: string; - laps: number; - time: string; - fastestLap: string; - points: number; - incidents: number; - isCurrentUser: boolean; -} - -export interface PenaltyEntry { - driverId: string; - driverName: string; - type: 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'; - value: number; - reason: string; - notes?: string; -} +import { Button } from '@/ui/Button'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { ArrowLeft, Trophy, Zap } from 'lucide-react'; +import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData'; +import { RaceResultRow } from '@/components/races/RaceResultRow'; +import { RacePenaltyRow } from '@/components/races/RacePenaltyRow'; export interface RaceResultsTemplateProps { - raceTrack?: string; - raceScheduledAt?: string; - totalDrivers?: number; - leagueName?: string; - raceSOF?: number | null; - results: ResultEntry[]; - penalties: PenaltyEntry[]; - pointsSystem: Record; - fastestLapTime: number; + viewData: RaceResultsViewData; currentDriverId: string; isAdmin: boolean; isLoading: boolean; @@ -56,27 +36,15 @@ export interface RaceResultsTemplateProps { } export function RaceResultsTemplate({ - raceTrack, - raceScheduledAt, - totalDrivers, - leagueName, - raceSOF, - results, - penalties, - pointsSystem, - fastestLapTime, + viewData, currentDriverId, - isAdmin, isLoading, error, onBack, onImportResults, - onPenaltyClick, importing, importSuccess, importError, - showImportForm, - setShowImportForm, }: RaceResultsTemplateProps) { const formatDate = (date: string) => { return new Date(date).toLocaleDateString('en-US', { @@ -94,270 +62,167 @@ export function RaceResultsTemplate({ return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`; }; - const getCountryFlag = (countryCode: string): string => { - const codePoints = countryCode - .toUpperCase() - .split('') - .map(char => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); - }; - const breadcrumbItems = [ { label: 'Races', href: '/races' }, - ...(leagueName ? [{ label: leagueName, href: `/leagues/${leagueName}` }] : []), - ...(raceTrack ? [{ label: raceTrack, href: `/races/${raceTrack}` }] : []), + ...(viewData.leagueName ? [{ label: viewData.leagueName, href: `/leagues/${viewData.leagueName}` }] : []), + ...(viewData.raceTrack ? [{ label: viewData.raceTrack, href: `/races/${viewData.raceTrack}` }] : []), { label: 'Results' }, ]; if (isLoading) { return ( -
-
-
Loading results...
-
-
+ + + Loading results... + + ); } - if (error && !raceTrack) { + if (error && !viewData.raceTrack) { return ( -
-
- -
- {error?.message || 'Race not found'} -
+ + + + {error?.message || 'Race not found'} - -
-
+ + + ); } - const hasResults = results.length > 0; + const hasResults = viewData.results.length > 0; return ( -
-
-
- + + + + -
+ {/* Header */} - -
-
- -
-
-

Race Results

-

- {raceTrack} β€’ {raceScheduledAt ? formatDate(raceScheduledAt) : ''} -

-
-
+ + + + + + + Race Results + + {viewData.raceTrack} β€’ {viewData.raceScheduledAt ? formatDate(viewData.raceScheduledAt) : ''} + + + {/* Stats */} -
-
-

Drivers

-

{totalDrivers ?? 0}

-
-
-

League

-

{leagueName ?? 'β€”'}

-
-
-

SOF

-

- - {raceSOF ?? 'β€”'} -

-
-
-

Fastest Lap

-

- {fastestLapTime ? formatTime(fastestLapTime) : 'β€”'} -

-
-
-
+ + + + + + + {importSuccess && ( -
- Success! Results imported and standings updated. -
+ + Success! + Results imported and standings updated. + )} {importError && ( -
- Error: {importError} -
+ + Error: + {importError} + )} {hasResults ? ( -
+ {/* Results Table */} -
- {results.map((result) => { - const isCurrentUser = result.driverId === currentDriverId; - const countryFlag = getCountryFlag(result.country); - const points = pointsSystem[result.position.toString()] ?? 0; - - return ( -
- {/* Position */} -
- {result.position} -
- - {/* Avatar */} -
- {result.driverName} -
- {countryFlag} -
-
- - {/* Driver Info */} -
-
-

- {result.driverName} -

- {isCurrentUser && ( - - You - - )} -
-
- {result.car} - β€’ - Laps: {result.laps} - β€’ - Incidents: {result.incidents} -
-
- - {/* Times */} -
-

{result.time}

-

FL: {result.fastestLap}

-
- - {/* Points */} -
-
- PTS - {points} -
-
-
- ); - })} -
+ + {viewData.results.map((result) => ( + + ))} + {/* Penalties Section */} - {penalties.length > 0 && ( -
-

Penalties

-
- {penalties.map((penalty, index) => ( -
-
- ! -
-
-
- {penalty.driverName} - - {penalty.type.replace('_', ' ')} - -
-

{penalty.reason}

- {penalty.notes && ( -

{penalty.notes}

- )} -
-
- - {penalty.type === 'time_penalty' && `+${penalty.value}s`} - {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} - {penalty.type === 'points_deduction' && `-${penalty.value} pts`} - {penalty.type === 'disqualification' && 'DSQ'} - {penalty.type === 'warning' && 'Warning'} - {penalty.type === 'license_points' && `${penalty.value} LP`} - -
-
+ {viewData.penalties.length > 0 && ( + + + Penalties + + + {viewData.penalties.map((penalty, index) => ( + ))} -
-
+
+ )} -
+ ) : ( - <> -

Import Results

-

- No results imported. Upload CSV to test the standings system. -

+ + + Import Results + + No results imported. Upload CSV to test the standings system. + + {importing ? ( -
- Importing results and updating standings... -
+ + Importing results and updating standings... + ) : ( -
-

+ + This is a placeholder for the import form. In the actual implementation, this would render the ImportResultsForm component. -

- -
+ + + + +
)} - + )}
-
-
+ + ); -} \ No newline at end of file +} + +function StatItem({ label, value, icon, color = 'text-white' }: { label: string, value: string | number, icon?: any, color?: string }) { + return ( + + {label} + + {icon && } + {value} + + + ); +} diff --git a/apps/website/templates/RaceStewardingTemplate.tsx b/apps/website/templates/RaceStewardingTemplate.tsx index b64570f1e..57cde6848 100644 --- a/apps/website/templates/RaceStewardingTemplate.tsx +++ b/apps/website/templates/RaceStewardingTemplate.tsx @@ -1,74 +1,34 @@ 'use client'; -import { useState } from 'react'; +import React from 'react'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import RaceStewardingStats from '@/components/races/RaceStewardingStats'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; import { StewardingTabs } from '@/components/races/StewardingTabs'; +import { ProtestCard } from '@/components/races/ProtestCard'; +import { RacePenaltyRow } from '@/components/races/RacePenaltyRow'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; import { - AlertCircle, AlertTriangle, ArrowLeft, CheckCircle, - Clock, Flag, Gavel, Scale, - Video } from 'lucide-react'; -import Link from 'next/link'; +import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData'; export type StewardingTab = 'pending' | 'resolved' | 'penalties'; -export interface Protest { - id: string; - status: string; - protestingDriverId: string; - accusedDriverId: string; - filedAt: string; - incident: { - lap: number; - description: string; - }; - proofVideoUrl?: string; - decisionNotes?: string; -} - -export interface Penalty { - id: string; - driverId: string; - type: string; - value: number; - reason: string; - notes?: string; -} - -export interface Driver { - id: string; - name: string; -} - -export interface RaceStewardingData { - race?: { - id: string; - track: string; - scheduledAt: string; - } | null; - league?: { - id: string; - } | null; - pendingProtests: Protest[]; - resolvedProtests: Protest[]; - penalties: Penalty[]; - driverMap: Record; - pendingCount: number; - resolvedCount: number; - penaltiesCount: number; -} - -export interface RaceStewardingTemplateProps { - stewardingData?: RaceStewardingData; +interface RaceStewardingTemplateProps { + viewData: RaceStewardingViewData; isLoading: boolean; error?: Error | null; // Actions @@ -82,7 +42,7 @@ export interface RaceStewardingTemplateProps { } export function RaceStewardingTemplate({ - stewardingData, + viewData, isLoading, error, onBack, @@ -91,345 +51,178 @@ export function RaceStewardingTemplate({ activeTab, setActiveTab, }: RaceStewardingTemplateProps) { - const formatDate = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('en-US', { + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); }; - const getStatusBadge = (status: string) => { - switch (status) { - case 'pending': - case 'under_review': - return ( - - Pending - - ); - case 'upheld': - return ( - - Upheld - - ); - case 'dismissed': - return ( - - Dismissed - - ); - case 'withdrawn': - return ( - - Withdrawn - - ); - default: - return null; - } - }; - if (isLoading) { return ( -
-
-
-
-
-
-
-
+ + + Loading stewarding data... + + ); } - if (!stewardingData?.race) { + if (!viewData?.race) { return ( -
-
- -
-
- -
-
-

Race not found

-

- The race you're looking for doesn't exist. -

-
- -
-
-
-
+ + + + + + + + Race not found + The race you're looking for doesn't exist. + + + + + ); } const breadcrumbItems = [ { label: 'Races', href: '/races' }, - { label: stewardingData.race.track, href: `/races/${stewardingData.race.id}` }, + { label: viewData.race.track, href: `/races/${viewData.race.id}` }, { label: 'Stewarding' }, ]; - const pendingProtests = stewardingData.pendingProtests ?? []; - const resolvedProtests = stewardingData.resolvedProtests ?? []; - return ( -
-
+ + {/* Navigation */} -
- + + -
+
{/* Header */} - -
-
- -
-
-

Stewarding

-

- {stewardingData.race.track} β€’ {stewardingData.race.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''} -

-
-
+ + + + + + + Stewarding + + {viewData.race.track} β€’ {formatDate(viewData.race.scheduledAt)} + + + {/* Stats */} -
+ {/* Tab Navigation */} {/* Content */} {activeTab === 'pending' && ( -
- {pendingProtests.length === 0 ? ( - -
- -
-

All Clear!

-

No pending protests to review

+ + {viewData.pendingProtests.length === 0 ? ( + + + + + + + All Clear! + No pending protests to review + + ) : ( - pendingProtests.map((protest) => { - const protester = stewardingData.driverMap[protest.protestingDriverId]; - const accused = stewardingData.driverMap[protest.accusedDriverId]; - const daysSinceFiled = Math.floor( - (Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24) - ); - const isUrgent = daysSinceFiled > 2; - - return ( - -
-
-
- - - {protester?.name || 'Unknown'} - - vs - - {accused?.name || 'Unknown'} - - {getStatusBadge(protest.status)} - {isUrgent && ( - - - {daysSinceFiled}d old - - )} -
-
- Lap {protest.incident.lap} - β€’ - Filed {formatDate(protest.filedAt)} - {protest.proofVideoUrl && ( - <> - β€’ - - - - )} -
-

{protest.incident.description}

-
- {isAdmin && stewardingData?.league && ( - - )} -
-
- ); - }) + viewData.pendingProtests.map((protest) => ( + + )) )} -
+ )} {activeTab === 'resolved' && ( -
- {resolvedProtests.length === 0 ? ( - -
- -
-

No Resolved Protests

-

- Resolved protests will appear here -

+ + {viewData.resolvedProtests.length === 0 ? ( + + + + + + + No Resolved Protests + Resolved protests will appear here + + ) : ( - resolvedProtests.map((protest) => { - const protester = stewardingData.driverMap[protest.protestingDriverId]; - const accused = stewardingData.driverMap[protest.accusedDriverId]; - - return ( - -
-
-
- - - {protester?.name || 'Unknown'} - - vs - - {accused?.name || 'Unknown'} - - {getStatusBadge(protest.status)} -
-
- Lap {protest.incident.lap} - β€’ - Filed {formatDate(protest.filedAt)} -
-

- {protest.incident.description} -

- {protest.decisionNotes && ( -
-

- Steward Decision -

-

{protest.decisionNotes}

-
- )} -
-
-
- ); - }) + viewData.resolvedProtests.map((protest) => ( + + )) )} -
+ )} {activeTab === 'penalties' && ( -
- {stewardingData?.penalties.length === 0 ? ( - -
- -
-

No Penalties

-

- Penalties issued for this race will appear here -

+ + {viewData.penalties.length === 0 ? ( + + + + + + + No Penalties + Penalties issued for this race will appear here + + ) : ( - stewardingData?.penalties.map((penalty) => { - const driver = stewardingData.driverMap[penalty.driverId]; - return ( - -
-
- -
-
-
- - {driver?.name || 'Unknown'} - - - {penalty.type.replace('_', ' ')} - -
-

{penalty.reason}

- {penalty.notes && ( -

{penalty.notes}

- )} -
-
- - {penalty.type === 'time_penalty' && `+${penalty.value}s`} - {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} - {penalty.type === 'points_deduction' && `-${penalty.value} pts`} - {penalty.type === 'disqualification' && 'DSQ'} - {penalty.type === 'warning' && 'Warning'} - {penalty.type === 'license_points' && `${penalty.value} LP`} - -
-
-
- ); - }) + viewData.penalties.map((penalty) => ( + + )) )} -
+ )} -
-
+ + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/RacesAllTemplate.tsx b/apps/website/templates/RacesAllTemplate.tsx index 183feb973..e2fc08766 100644 --- a/apps/website/templates/RacesAllTemplate.tsx +++ b/apps/website/templates/RacesAllTemplate.tsx @@ -1,45 +1,31 @@ 'use client'; -import { useMemo, useEffect } from 'react'; -import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; +import React, { useMemo, useEffect } from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; import { - Calendar, - Clock, Flag, - ChevronRight, - ChevronLeft, - Car, - Trophy, - Zap, - PlayCircle, - CheckCircle2, - XCircle, - Search, SlidersHorizontal, + Calendar, } from 'lucide-react'; import { RaceFilterModal } from '@/components/races/RaceFilterModal'; import { RacePagination } from '@/components/races/RacePagination'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { Skeleton } from '@/ui/Skeleton'; +import { RaceListItem } from '@/components/races/RaceListItem'; +import type { RacesViewData } from '@/lib/view-data/RacesViewData'; export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; -export interface Race { - id: string; - track: string; - car: string; - scheduledAt: string; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - sessionType: string; - leagueId?: string; - leagueName?: string; - strengthOfField?: number | null; -} - -export interface RacesAllTemplateProps { - races: Race[]; +interface RacesAllTemplateProps { + viewData: RacesViewData; isLoading: boolean; // Pagination currentPage: number; @@ -64,7 +50,7 @@ export interface RacesAllTemplateProps { } export function RacesAllTemplate({ - races, + viewData, isLoading, currentPage, totalPages, @@ -81,8 +67,9 @@ export function RacesAllTemplate({ showFilterModal, setShowFilterModal, onRaceClick, - onLeagueClick, }: RacesAllTemplateProps) { + const { races } = viewData; + // Filter races const filteredRaces = useMemo(() => { return races.filter(race => { @@ -119,55 +106,6 @@ export function RacesAllTemplate({ onPageChange(1); }, [statusFilter, leagueFilter, searchQuery]); - const formatDate = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - - const formatTime = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - }); - }; - - const statusConfig = { - scheduled: { - icon: Clock, - color: 'text-primary-blue', - bg: 'bg-primary-blue/10', - border: 'border-primary-blue/30', - label: 'Scheduled', - }, - running: { - icon: PlayCircle, - color: 'text-performance-green', - bg: 'bg-performance-green/10', - border: 'border-performance-green/30', - label: 'LIVE', - }, - completed: { - icon: CheckCircle2, - color: 'text-gray-400', - bg: 'bg-gray-500/10', - border: 'border-gray-500/30', - label: 'Completed', - }, - cancelled: { - icon: XCircle, - color: 'text-warning-amber', - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', - label: 'Cancelled', - }, - }; - const breadcrumbItems = [ { label: 'Races', href: '/races' }, { label: 'All Races' }, @@ -175,214 +113,85 @@ export function RacesAllTemplate({ if (isLoading) { return ( -
-
-
-
-
-
- {[1, 2, 3, 4, 5].map(i => ( -
- ))} -
-
-
-
+ + + + + + {[1, 2, 3, 4, 5].map(i => ( + + ))} + + + ); } return ( -
-
+ + {/* Breadcrumbs */} {/* Header */} -
-
- - + + + }> All Races -

+ {filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found -

-
+ + -
+
- {/* Search & Filters */} - -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - placeholder="Search by track, car, or league..." - className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue" - /> -
- - {/* Filter Row */} -
- {/* Status Filter */} - - - {/* League Filter */} - - - {/* Clear Filters */} - {(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && ( - - )} -
-
-
+ {/* Search & Filters (Simplified for template) */} + {showFilters && ( + + + + Use the filter button to open advanced search and filtering options. + + + + + + + )} {/* Race List */} {paginatedRaces.length === 0 ? ( - -
-
- -
-
-

No races found

-

+ + + + + + + No races found + {races.length === 0 ? 'No races have been scheduled yet' : 'Try adjusting your search or filters'} -

-
-
+ + +
) : ( -
- {paginatedRaces.map(race => { - const config = statusConfig[race.status as keyof typeof statusConfig]; - const StatusIcon = config.icon; - - return ( -
onRaceClick(race.id)} - className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`} - > - {/* Live indicator */} - {race.status === 'running' && ( -
- )} - -
- {/* Date Column */} -
-

- {new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })} -

-

- {new Date(race.scheduledAt).getDate()} -

-

- {formatTime(race.scheduledAt)} -

-
- - {/* Divider */} -
- - {/* Main Content */} -
-
-
-

- {race.track} -

-
- - - {race.car} - - {race.strengthOfField && ( - - - SOF {race.strengthOfField} - - )} - - {formatDate(race.scheduledAt)} - -
- e.stopPropagation()} - className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline" - > - - {race.leagueName} - -
- - {/* Status Badge */} -
- - - {config.label} - -
-
-
- - {/* Arrow */} - -
-
- ); - })} -
+ + {paginatedRaces.map(race => ( + + ))} + )} {/* Pagination */} @@ -406,11 +215,11 @@ export function RacesAllTemplate({ setTimeFilter={() => {}} searchQuery={searchQuery} setSearchQuery={setSearchQuery} - leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]} + leagues={viewData.leagues} showSearch={true} showTimeFilter={false} /> -
-
+ +
); -} \ No newline at end of file +} diff --git a/apps/website/templates/RacesTemplate.tsx b/apps/website/templates/RacesTemplate.tsx index d713cbc67..bb4ca487d 100644 --- a/apps/website/templates/RacesTemplate.tsx +++ b/apps/website/templates/RacesTemplate.tsx @@ -1,53 +1,24 @@ 'use client'; -import { useMemo } from 'react'; -import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Heading from '@/components/ui/Heading'; -import { - Calendar, - Clock, - Flag, - ChevronRight, - MapPin, - Car, - Trophy, - Users, - Zap, - PlayCircle, - CheckCircle2, - XCircle, - CalendarDays, - ArrowRight, -} from 'lucide-react'; +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Container } from '@/ui/Container'; import { RaceFilterModal } from '@/components/races/RaceFilterModal'; -import { RaceJoinButton } from '@/components/races/RaceJoinButton'; +import type { RacesViewData } from '@/lib/view-data/RacesViewData'; +import { RacePageHeader } from '@/components/races/RacePageHeader'; +import { LiveRacesBanner } from '@/components/races/LiveRacesBanner'; +import { RaceFilterBar } from '@/components/races/RaceFilterBar'; +import { RaceList } from '@/components/races/RaceList'; +import { RaceSidebar } from '@/components/races/RaceSidebar'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; -export interface Race { - id: string; - track: string; - car: string; - scheduledAt: string; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - sessionType: string; - leagueId?: string; - leagueName?: string; - strengthOfField?: number | null; - isUpcoming: boolean; - isLive: boolean; - isPast: boolean; -} - export interface RacesTemplateProps { - races: Race[]; - totalCount: number; - scheduledRaces: Race[]; - runningRaces: Race[]; - completedRaces: Race[]; - isLoading: boolean; + viewData: RacesViewData; // Filters statusFilter: RaceStatusFilter; setStatusFilter: (filter: RaceStatusFilter) => void; @@ -58,24 +29,15 @@ export interface RacesTemplateProps { // Actions onRaceClick: (raceId: string) => void; onLeagueClick: (leagueId: string) => void; - onRegister: (raceId: string, leagueId: string) => void; onWithdraw: (raceId: string) => void; onCancel: (raceId: string) => void; // UI State showFilterModal: boolean; setShowFilterModal: (show: boolean) => void; - // User state - currentDriverId?: string; - userMemberships?: Array<{ leagueId: string; role: string }>; } export function RacesTemplate({ - races, - totalCount, - scheduledRaces, - runningRaces, - completedRaces, - isLoading, + viewData, statusFilter, setStatusFilter, leagueFilter, @@ -83,581 +45,71 @@ export function RacesTemplate({ timeFilter, setTimeFilter, onRaceClick, - onLeagueClick, - onRegister, - onWithdraw, - onCancel, showFilterModal, setShowFilterModal, - currentDriverId, - userMemberships, }: RacesTemplateProps) { - // Filter races - const filteredRaces = useMemo(() => { - return races.filter((race) => { - // Status filter - if (statusFilter !== 'all' && race.status !== statusFilter) { - return false; - } - - // League filter - if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) { - return false; - } - - // Time filter - if (timeFilter === 'upcoming' && !race.isUpcoming) { - return false; - } - if (timeFilter === 'live' && !race.isLive) { - return false; - } - if (timeFilter === 'past' && !race.isPast) { - return false; - } - - return true; - }); - }, [races, statusFilter, leagueFilter, timeFilter]); - - // Group races by date for calendar view - const racesByDate = useMemo(() => { - const grouped = new Map(); - filteredRaces.forEach((race) => { - const dateKey = race.scheduledAt.split('T')[0]!; - if (!grouped.has(dateKey)) { - grouped.set(dateKey, []); - } - grouped.get(dateKey)!.push(race); - }); - return grouped; - }, [filteredRaces]); - - const upcomingRaces = filteredRaces.filter(r => r.isUpcoming).slice(0, 5); - const liveRaces = filteredRaces.filter(r => r.isLive); - const recentResults = filteredRaces.filter(r => r.isPast).slice(0, 5); - const stats = { - total: totalCount, - scheduled: scheduledRaces.length, - running: runningRaces.length, - completed: completedRaces.length, - }; - - const formatDate = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - }); - }; - - const formatTime = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - }); - }; - - const formatFullDate = (date: Date | string) => { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }); - }; - - const getRelativeTime = (date?: Date | string) => { - if (!date) return ''; - const now = new Date(); - const targetDate = typeof date === 'string' ? new Date(date) : date; - const diffMs = targetDate.getTime() - now.getTime(); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffMs < 0) return 'Past'; - if (diffHours < 1) return 'Starting soon'; - if (diffHours < 24) return `In ${diffHours}h`; - if (diffDays === 1) return 'Tomorrow'; - if (diffDays < 7) return `In ${diffDays} days`; - return formatDate(targetDate); - }; - - const statusConfig = { - scheduled: { - icon: Clock, - color: 'text-primary-blue', - bg: 'bg-primary-blue/10', - border: 'border-primary-blue/30', - label: 'Scheduled', - }, - running: { - icon: PlayCircle, - color: 'text-performance-green', - bg: 'bg-performance-green/10', - border: 'border-performance-green/30', - label: 'LIVE', - }, - completed: { - icon: CheckCircle2, - color: 'text-gray-400', - bg: 'bg-gray-500/10', - border: 'border-gray-500/30', - label: 'Completed', - }, - cancelled: { - icon: XCircle, - color: 'text-warning-amber', - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', - label: 'Cancelled', - }, - }; - - const isUserRegistered = (race: Race) => { - // This would need actual registration data - return false; - }; - - const canRegister = (race: Race) => { - // This would need actual registration rules - return race.status === 'scheduled'; - }; - - const isOwnerOrAdmin = (leagueId?: string) => { - if (!leagueId || !userMemberships) return false; - const membership = userMemberships.find(m => m.leagueId === leagueId); - return membership?.role === 'owner' || membership?.role === 'admin'; - }; - - if (isLoading) { - return ( -
-
-
-
-
- {[1, 2, 3, 4].map(i => ( -
- ))} -
-
-
-
-
- ); - } - return ( -
-
- {/* Hero Header */} -
-
-
- -
-
-
- -
- - Race Calendar - -
-

- Track upcoming races, view live events, and explore results across all your leagues. -

-
+ + + + - {/* Quick Stats */} -
-
-
- - Total -
-

{stats.total}

-
-
-
- - Scheduled -
-

{stats.scheduled}

-
-
-
- - Live Now -
-

{stats.running}

-
-
-
- - Completed -
-

{stats.completed}

-
-
-
+ - {/* Live Races Banner */} - {liveRaces.length > 0 && ( -
-
- -
-
-
- - LIVE NOW -
-
- -
- {liveRaces.map((race) => ( -
onRaceClick(race.id)} - className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all" - > -
-
- -
-
-

{race.track}

-

{race.leagueName}

-
-
- -
- ))} -
-
-
- )} + + + + setShowFilterModal(true)} + /> -
- {/* Main Content - Race List */} -
- {/* Filters */} - -
- {/* Time Filter Tabs */} -
- {(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => ( - - ))} -
+ + + - {/* League Filter */} - + + + + - {/* Filter Button */} - -
-
- - {/* Race List by Date */} - {filteredRaces.length === 0 ? ( - -
-
- -
-
-

No races found

-

- {totalCount === 0 - ? 'No races have been scheduled yet' - : 'Try adjusting your filters'} -

-
-
-
- ) : ( -
- {Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => ( -
- {/* Date Header */} -
-
- -
- - {formatFullDate(new Date(dateKey))} - - - {dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''} - -
- - {/* Races for this date */} -
- {dayRaces.map((race) => { - const config = statusConfig[race.status as keyof typeof statusConfig]; - const StatusIcon = config.icon; - - return ( -
onRaceClick(race.id)} - > - {/* Live indicator */} - {race.status === 'running' && ( -
- )} - -
- {/* Time Column */} -
-

- {formatTime(race.scheduledAt)} -

-

- {race.status === 'running' - ? 'LIVE' - : getRelativeTime(race.scheduledAt)} -

-
- - {/* Divider */} -
- - {/* Main Content */} -
-
-
-

- {race.track} -

-
- - - {race.car} - - {race.strengthOfField && ( - - - SOF {race.strengthOfField} - - )} -
-
- - {/* Status Badge */} -
- - - {config.label} - -
-
- - {/* League Link */} -
- e.stopPropagation()} - className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline" - > - - {race.leagueName} - - -
-
- - {/* Arrow */} - -
-
- ); - })} -
-
- ))} -
- )} - - {/* View All Link */} - {filteredRaces.length > 0 && ( -
- - View All Races - - -
- )} -
- - {/* Sidebar */} -
- {/* Upcoming This Week */} - -
-

- - Next Up -

- This week -
- - {upcomingRaces.length === 0 ? ( -

- No races scheduled this week -

- ) : ( -
- {upcomingRaces.map((race) => { - if (!race.scheduledAt) { - return null; - } - const scheduledAtDate = new Date(race.scheduledAt); - return ( -
onRaceClick(race.id)} - className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" - > -
- - {scheduledAtDate.getDate()} - -
-
-

{race.track}

-

{formatTime(scheduledAtDate)}

-
- -
- ); - })} -
- )} -
- - {/* Recent Results */} - -
-

- - Recent Results -

-
- - {recentResults.length === 0 ? ( -

- No completed races yet -

- ) : ( -
- {recentResults.map((race) => ( -
onRaceClick(race.id)} - className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors" - > -
- -
-
-

{race.track}

-

{formatDate(new Date(race.scheduledAt))}

-
- -
- ))} -
- )} -
- - {/* Quick Actions */} - -

Quick Actions

-
- -
- -
- Browse Leagues - - - -
- -
- View Leaderboards - - -
-
-
-
- - {/* Filter Modal */} - setShowFilterModal(false)} - statusFilter={statusFilter} - setStatusFilter={setStatusFilter} - leagueFilter={leagueFilter} - setLeagueFilter={setLeagueFilter} - timeFilter={timeFilter} - setTimeFilter={setTimeFilter} - searchQuery="" - setSearchQuery={() => {}} - leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]} - showSearch={false} - showTimeFilter={false} - /> -
-
+ setShowFilterModal(false)} + statusFilter={statusFilter} + setStatusFilter={setStatusFilter} + leagueFilter={leagueFilter} + setLeagueFilter={setLeagueFilter} + timeFilter={timeFilter} + setTimeFilter={setTimeFilter} + searchQuery="" + setSearchQuery={() => {}} + leagues={viewData.leagues} + showSearch={false} + showTimeFilter={false} + /> +
+ + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/RosterAdminTemplate.tsx b/apps/website/templates/RosterAdminTemplate.tsx index 4d073689a..8104ac779 100644 --- a/apps/website/templates/RosterAdminTemplate.tsx +++ b/apps/website/templates/RosterAdminTemplate.tsx @@ -1,15 +1,19 @@ +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; import { Text } from '@/ui/Text'; import { Button } from '@/ui/Button'; import { Select } from '@/ui/Select'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Surface } from '@/ui/Surface'; import type { MembershipRole } from '@/lib/types/MembershipRole'; -import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO'; -import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO'; +import type { LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData'; interface RosterAdminTemplateProps { - joinRequests: LeagueRosterJoinRequestDTO[]; - members: LeagueRosterMemberDTO[]; + viewData: LeagueRosterAdminViewData; loading: boolean; pendingCountLabel: string; onApprove: (requestId: string) => Promise; @@ -20,8 +24,7 @@ interface RosterAdminTemplateProps { } export function RosterAdminTemplate({ - joinRequests, - members, + viewData, loading, pendingCountLabel, onApprove, @@ -30,136 +33,122 @@ export function RosterAdminTemplate({ onRemove, roleOptions, }: RosterAdminTemplateProps) { + const { joinRequests, members } = viewData; + return ( -
+ -
-
- - Roster Admin - - + + + Roster Admin + Manage join requests and member roles. -
+ -
-
- - Pending join requests - - + + + Pending join requests + {pendingCountLabel} -
+ {loading ? ( - - Loading… - - ) : joinRequests.length ? ( -
+ Loading… + ) : joinRequests.length > 0 ? ( + {joinRequests.map((req) => ( -
-
- - {(req.driver as any)?.name || 'Unknown'} - - - {req.requestedAt} - - {req.message && ( - - {req.message} - - )} -
+ + + {req.driver.name} + {req.requestedAt} + {req.message && ( + {req.message} + )} + -
- - -
-
+ + + + +
+ ))} -
+ ) : ( - - No pending join requests. - + No pending join requests. )} -
+ -
- - Members - + + + Members + {loading ? ( - - Loading… - - ) : members.length ? ( -
+ Loading… + ) : members.length > 0 ? ( + {members.map((member) => ( -
-
- - {(member.driver as any)?.name || 'Unknown'} - - - {member.joinedAt} - -
+ + + {member.driver.name} + {member.joinedAt} + -
- - onRoleChange(member.driverId, e.target.value as MembershipRole)} + options={roleOptions.map((role) => ({ value: role, label: role }))} + /> + + + + + ))} -
+
) : ( - - No members found. - + No members found. )} -
-
+ +
-
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/RulebookTemplate.tsx b/apps/website/templates/RulebookTemplate.tsx index d6e02f493..f5c3361e6 100644 --- a/apps/website/templates/RulebookTemplate.tsx +++ b/apps/website/templates/RulebookTemplate.tsx @@ -1,6 +1,16 @@ -import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; +'use client'; + +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Badge } from '@/ui/Badge'; +import { Grid } from '@/ui/Grid'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Surface } from '@/ui/Surface'; +import type { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; interface RulebookTemplateProps { viewData: RulebookViewData; @@ -8,95 +18,103 @@ interface RulebookTemplateProps { export function RulebookTemplate({ viewData }: RulebookTemplateProps) { return ( -
+ {/* Header */} -
-
-

Rulebook

-

Official rules and regulations

-
-
- {viewData.scoringPresetName || 'Custom Rules'} -
-
+ + + Rulebook + Official rules and regulations + + + {viewData.scoringPresetName || 'Custom Rules'} + + {/* Quick Stats */} -
-
-

Platform

-

{viewData.gameName}

-
-
-

Championships

-

{viewData.championshipsCount}

-
-
-

Sessions Scored

-

- {viewData.sessionTypes} -

-
-
-

Drop Policy

-

- {viewData.hasActiveDropPolicy ? 'Active' : 'None'} -

-
-
+ + + + + + {/* Points Table */} -

Points System

-
- - - - - - - - - {viewData.positionPoints.map((point) => ( - - - - - ))} - -
PositionPoints
{point.position}{point.points}
-
+ + Points System + + + + + Position + Points + + + + {viewData.positionPoints.map((point) => ( + + + {point.position} + + + {point.points} + + + ))} + +
{/* Bonus Points */} {viewData.hasBonusPoints && ( -

Bonus Points

-
+ + Bonus Points + + {viewData.bonusPoints.map((bonus, idx) => ( -
-
- + -
-

{bonus}

-
+ + + + + + {bonus} + + ))} -
+
)} {/* Drop Policy */} {viewData.hasActiveDropPolicy && ( -

Drop Policy

-

{viewData.dropPolicySummary}

-

- Drop rules are applied automatically when calculating championship standings. -

+ + Drop Policy + + {viewData.dropPolicySummary} + + + Drop rules are applied automatically when calculating championship standings. + +
)} -
+ ); -} \ No newline at end of file +} + +function StatItem({ label, value, capitalize }: { label: string, value: string | number, capitalize?: boolean }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/website/templates/SponsorDashboardTemplate.tsx b/apps/website/templates/SponsorDashboardTemplate.tsx index d4895f4cb..45d6988a4 100644 --- a/apps/website/templates/SponsorDashboardTemplate.tsx +++ b/apps/website/templates/SponsorDashboardTemplate.tsx @@ -1,25 +1,31 @@ -import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import StatusBadge from '@/components/ui/StatusBadge'; -import InfoBanner from '@/components/ui/InfoBanner'; -import MetricCard from '@/components/sponsors/MetricCard'; -import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard'; -import ActivityItem from '@/components/sponsors/ActivityItem'; -import RenewalAlert from '@/components/sponsors/RenewalAlert'; +'use client'; + +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Badge } from '@/ui/Badge'; +import { MetricCard } from '@/components/sponsors/MetricCard'; +import { SponsorshipCategoryCard } from '@/components/sponsors/SponsorshipCategoryCard'; +import { ActivityItem } from '@/components/sponsors/ActivityItem'; +import { RenewalAlert } from '@/components/sponsors/RenewalAlert'; import { - BarChart3, Eye, Users, Trophy, TrendingUp, - Calendar, DollarSign, Target, - ArrowUpRight, - ArrowDownRight, ExternalLink, - Loader2, Car, Flag, Megaphone, @@ -29,308 +35,325 @@ import { Settings, CreditCard, FileText, - RefreshCw + RefreshCw, + BarChart3, + Calendar } from 'lucide-react'; -import Link from 'next/link'; import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData'; +import { routes } from '@/lib/routing/RouteConfig'; interface SponsorDashboardTemplateProps { viewData: SponsorDashboardViewData; } export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) { - const shouldReduceMotion = useReducedMotion(); - const categoryData = viewData.categoryData; return ( -
- {/* Header */} -
-
-

Sponsor Dashboard

-

Welcome back, {viewData.sponsorName}

-
-
- {/* Time Range Selector */} -
- {(['7d', '30d', '90d', 'all'] as const).map((range) => ( - - ))} -
- - {/* Quick Actions */} - - - - -
-
- - {/* Key Metrics */} -
- - - - -
- - {/* Sponsorship Categories */} -
-
-

Your Sponsorships

- - - -
- -
- - - - - -
-
- - {/* Main Content Grid */} -
- {/* Left Column - Sponsored Entities */} -
- {/* Top Performing Sponsorships */} - -
-

Top Performing

- - - -
-
- {/* Mock data for now */} -
-
-
- Main -
-
-
- - Sample League -
-
Sample details
-
-
-
-
-
1.2k
-
impressions
-
- -
-
-
-
- - {/* Upcoming Events */} - -
-

- - Upcoming Sponsored Events -

-
-
-
- -

No upcoming sponsored events

-
-
-
-
- - {/* Right Column - Activity & Quick Actions */} -
- {/* Quick Actions */} - -

Quick Actions

-
- - - - - - - - - - - - - - - -
-
- - {/* Renewal Alerts */} - {viewData.upcomingRenewals.length > 0 && ( - -

- - Upcoming Renewals -

-
- {viewData.upcomingRenewals.map((renewal: any) => ( - ))} -
-
- )} + + - {/* Recent Activity */} - -

Recent Activity

-
- {viewData.recentActivity.map((activity: any) => ( - - ))} -
-
+ + + + + + + + - {/* Investment Summary */} - -

- - Investment Summary -

-
-
- Active Sponsorships - {viewData.activeSponsorships} -
-
- Total Investment - {viewData.formattedTotalInvestment} -
-
- Cost per 1K Views - - {viewData.costPerThousandViews} - -
-
- Next Invoice - Jan 1, 2026 -
-
- - - -
-
-
-
-
-
+ {/* Key Metrics */} + + + + + + + + {/* Sponsorship Categories */} + + + Your Sponsorships + + + + + + + + + + + + + + + + + {/* Main Content Grid */} + + + + {/* Top Performing Sponsorships */} + + + + Top Performing + + + + + + + + + + + + Main + + + + Sample League + + Sample details + + + + + 1.2k + impressions + + + + + + + + + {/* Upcoming Events */} + + + }> + Upcoming Sponsored Events + + + + + + No upcoming sponsored events + + + + + + + + + {/* Quick Actions */} + + + Quick Actions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Renewal Alerts */} + {viewData.upcomingRenewals.length > 0 && ( + + + }> + Upcoming Renewals + + + {viewData.upcomingRenewals.map((renewal) => ( + + ))} + + + + )} + + {/* Recent Activity */} + + + Recent Activity + + {viewData.recentActivity.map((activity) => ( + + ))} + + + + + {/* Investment Summary */} + + + }> + Investment Summary + + + + Active Sponsorships + {viewData.activeSponsorships} + + + Total Investment + {viewData.formattedTotalInvestment} + + + Cost per 1K Views + + {viewData.costPerThousandViews} + + + + Next Invoice + Jan 1, 2026 + + + + + + + + + + + + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/SponsorLeagueDetailTemplate.tsx b/apps/website/templates/SponsorLeagueDetailTemplate.tsx index 9a762aefe..a348e70a7 100644 --- a/apps/website/templates/SponsorLeagueDetailTemplate.tsx +++ b/apps/website/templates/SponsorLeagueDetailTemplate.tsx @@ -1,32 +1,36 @@ 'use client'; -import { useState } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { siteConfig } from '@/lib/siteConfig'; +import React, { useState } from 'react'; import { Trophy, Users, Calendar, Eye, TrendingUp, - Download, - Image as ImageIcon, ExternalLink, - ChevronRight, Star, - Clock, - CheckCircle2, Flag, - Car, BarChart3, - ArrowUpRight, Megaphone, CreditCard, FileText } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Badge } from '@/ui/Badge'; +import { SponsorTierCard } from '@/components/sponsors/SponsorTierCard'; +import { siteConfig } from '@/lib/siteConfig'; +import { routes } from '@/lib/routing/RouteConfig'; interface SponsorLeagueDetailData { league: { @@ -94,485 +98,322 @@ interface SponsorLeagueDetailData { } interface SponsorLeagueDetailTemplateProps { - data: SponsorLeagueDetailData; + viewData: SponsorLeagueDetailData; } type TabType = 'overview' | 'drivers' | 'races' | 'sponsor'; -export function SponsorLeagueDetailTemplate({ data }: SponsorLeagueDetailTemplateProps) { - const shouldReduceMotion = useReducedMotion(); +export function SponsorLeagueDetailTemplate({ viewData }: SponsorLeagueDetailTemplateProps) { const [activeTab, setActiveTab] = useState('overview'); const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main'); - const league = data.league; - const config = league.tierConfig; + const league = viewData.league; return ( -
- {/* Breadcrumb */} -
- Dashboard - - Leagues - - {league.name} -
+ + + {/* Breadcrumb */} + + + + Dashboard + + / + + Leagues + + / + {league.name} + + - {/* Header */} -
-
-
- - ⭐ {league.tier} - - - Active Season - -
- - {league.rating} -
-
-

{league.name}

-

{league.game} β€’ {league.season} β€’ {league.completedRaces}/{league.races} races completed

-

{league.description}

-
- -
- - - - {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && ( - - )} -
-
+ {/* Header */} + + + + ⭐ {league.tier} + Active Season + + + + {league.rating} + + + + {league.name} + + {league.game} β€’ {league.season} β€’ {league.completedRaces}/{league.races} races completed + + + {league.description} + + + + + + + + {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && ( + + )} + + - {/* Quick Stats */} -
- - -
-
- -
-
-
{league.formattedTotalImpressions}
-
Total Views
-
-
-
-
- - -
-
- -
-
-
{league.formattedAvgViewsPerRace}
-
Avg/Race
-
-
-
-
- - -
-
- -
-
-
{league.drivers}
-
Drivers
-
-
-
-
- - -
-
- -
-
-
{league.engagement}%
-
Engagement
-
-
-
-
- - -
-
- -
-
-
{league.racesLeft}
-
Races Left
-
-
-
-
-
+ {/* Quick Stats */} + + + + + + + - {/* Tabs */} -
- {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => ( - - ))} -
+ {/* Tabs */} + + + {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => ( + setActiveTab(tab)} + pb={3} + style={{ + cursor: 'pointer', + borderBottom: activeTab === tab ? '2px solid #3b82f6' : '2px solid transparent', + color: activeTab === tab ? '#3b82f6' : '#9ca3af' + }} + > + + {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab} + + + ))} + + - {/* Tab Content */} - {activeTab === 'overview' && ( -
- -

- - League Information -

-
-
- Platform - {league.game} -
-
- Season - {league.season} -
-
- Duration - Oct 2025 - Feb 2026 -
-
- Drivers - {league.drivers} -
-
- Races - {league.races} -
-
+ {/* Tab Content */} + {activeTab === 'overview' && ( + + + + }> + League Information + + + + + + + + + + + + + + }> + Sponsorship Value + + + + + + + + + + + + {league.nextRace && ( + + + + }> + Next Race + + + + + + + + + + {league.nextRace.name} + {league.nextRace.date} + + + + + + + + )} + + )} + + {activeTab === 'drivers' && ( + + + Championship Standings + Top drivers carrying sponsor branding + + + {viewData.drivers.map((driver, index) => ( + + + + + {driver.position} + + + {driver.name} + {driver.team} β€’ {driver.country} + + + + + {driver.races} + races + + + {driver.formattedImpressions} + views + + + + + ))} + + )} - -

- - Sponsorship Value -

-
-
- Total Season Views - {league.formattedTotalImpressions} -
-
- Projected Total - {league.formattedProjectedTotal} -
-
- Main Sponsor CPM - - {league.formattedMainSponsorCpm} - -
-
- Engagement Rate - {league.engagement}% -
-
- League Rating -
- - {league.rating}/5.0 -
-
-
+ {activeTab === 'races' && ( + + + Race Calendar + Season schedule with view statistics + + + {viewData.races.map((race, index) => ( + + + + + + {race.name} + {race.formattedDate} + + + + {race.status === 'completed' ? ( + + {race.views.toLocaleString()} + views + + ) : ( + Upcoming + )} + + + + ))} + + )} - {/* Next Race */} - {league.nextRace && ( - -

- - Next Race -

-
-
-
- -
-
-

{league.nextRace.name}

-

{league.nextRace.date}

-
-
- -
+ +
- )} -
- )} - - {activeTab === 'drivers' && ( - -
-

Championship Standings

-

Top drivers carrying sponsor branding

-
-
- {data.drivers.map((driver) => ( -
-
-
- {driver.position} -
-
-
{driver.name}
-
{driver.team} β€’ {driver.country}
-
-
-
-
-
{driver.races}
-
races
-
-
-
{driver.formattedImpressions}
-
views
-
-
-
- ))} -
-
- )} - - {activeTab === 'races' && ( - -
-

Race Calendar

-

Season schedule with view statistics

-
-
- {data.races.map((race) => ( -
-
-
-
-
{race.name}
-
{race.formattedDate}
-
-
-
- {race.status === 'completed' ? ( -
-
{race.views.toLocaleString()}
-
views
-
- ) : ( - - Upcoming - - )} -
-
- ))} -
- - )} - - {activeTab === 'sponsor' && ( -
- {/* Tier Selection */} -
- {/* Main Sponsor */} - league.sponsorSlots.main.available && setSelectedTier('main')} - > -
-
-
- -

Main Sponsor

-
-

Primary branding position

-
- {league.sponsorSlots.main.available ? ( - - Available - - ) : ( - - Filled - - )} -
- -
- ${league.sponsorSlots.main.price} - /season -
- -
    - {league.sponsorSlots.main.benefits.map((benefit: string, i: number) => ( -
  • - - {benefit} -
  • - ))} -
- - {selectedTier === 'main' && league.sponsorSlots.main.available && ( -
- -
- )} -
- - {/* Secondary Sponsor */} - league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')} - > -
-
-
- -

Secondary Sponsor

-
-

Supporting branding position

-
- {league.sponsorSlots.secondary.available > 0 ? ( - - {league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available - - ) : ( - - Full - - )} -
- -
- ${league.sponsorSlots.secondary.price} - /season -
- -
    - {league.sponsorSlots.secondary.benefits.map((benefit: string, i: number) => ( -
  • - - {benefit} -
  • - ))} -
- - {selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && ( -
- -
- )} -
-
- - {/* Checkout Summary */} - -

- - Sponsorship Summary -

- -
-
- Selected Tier - {selectedTier} Sponsor -
-
- Season Price - - ${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price} - -
-
- Platform Fee ({siteConfig.fees.platformFeePercent}%) - - ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)} - -
-
- Total (excl. VAT) - - ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)} - -
-
- -

- {siteConfig.vat.notice} -

- -
- - -
-
-
- )} -
+
+ )} + +
); -} \ No newline at end of file +} + +function StatCard({ icon, label, value, color }: { icon: any, label: string, value: string | number, color: string }) { + return ( + + + + + + + {value} + {label} + + + + ); +} + +function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) { + return ( + + + {label} + {value} + + + ); +} diff --git a/apps/website/templates/SponsorLeaguesTemplate.tsx b/apps/website/templates/SponsorLeaguesTemplate.tsx index 5697dc584..b9ca1de17 100644 --- a/apps/website/templates/SponsorLeaguesTemplate.tsx +++ b/apps/website/templates/SponsorLeaguesTemplate.tsx @@ -1,27 +1,29 @@ 'use client'; -import { useState, useMemo } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { siteConfig } from '@/lib/siteConfig'; +import React, { useState, useMemo } from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; import { Trophy, Users, - Eye, Search, - Star, ChevronRight, - Filter, Car, - Flag, - TrendingUp, - CheckCircle2, - Clock, Megaphone, - ArrowUpDown } from 'lucide-react'; +import { siteConfig } from '@/lib/siteConfig'; +import { routes } from '@/lib/routing/RouteConfig'; +import { AvailableLeagueCard } from '@/components/sponsors/AvailableLeagueCard'; interface AvailableLeague { id: string; @@ -39,8 +41,6 @@ interface AvailableLeague { formattedAvgViews: string; formattedCpm: string; cpm: number; - tierConfig: any; - statusConfig: any; } type SortOption = 'rating' | 'drivers' | 'price' | 'views'; @@ -48,7 +48,7 @@ type TierFilter = 'all' | 'premium' | 'standard' | 'starter'; type AvailabilityFilter = 'all' | 'main' | 'secondary'; interface SponsorLeaguesTemplateProps { - data: { + viewData: { leagues: AvailableLeague[]; stats: { total: number; @@ -60,367 +60,170 @@ interface SponsorLeaguesTemplateProps { }; } -function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) { - const shouldReduceMotion = useReducedMotion(); - - const tierConfig = { - premium: { - bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5', - border: 'border-yellow-500/30', - badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', - icon: '⭐' - }, - standard: { - bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5', - border: 'border-primary-blue/30', - badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', - icon: 'πŸ†' - }, - starter: { - bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5', - border: 'border-gray-500/30', - badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30', - icon: 'πŸš€' - }, - }; - - const statusConfig = { - active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' }, - upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' }, - completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' }, - }; - - const config = league.tierConfig; - const status = league.statusConfig; - - return ( - - -
- {/* Header */} -
-
-
- - {config.icon} {league.tier} - - - {status.label} - -
-

{league.name}

-

{league.game}

-
-
- - {league.rating} -
-
- - {/* Description */} -

{league.description}

- - {/* Stats Grid */} -
-
-
{league.drivers}
-
Drivers
-
-
-
{league.formattedAvgViews}
-
Avg Views
-
-
-
{league.formattedCpm}
-
CPM
-
-
- - {/* Next Race */} - {league.nextRace && ( -
- - Next: - {league.nextRace} -
- )} - - {/* Sponsorship Slots */} -
-
-
-
- Main Sponsor -
-
- {league.mainSponsorSlot.available ? ( - ${league.mainSponsorSlot.price}/season - ) : ( - - Filled - - )} -
-
-
-
-
0 ? 'bg-performance-green' : 'bg-racing-red'}`} /> - Secondary Slots -
-
- {league.secondarySlots.available > 0 ? ( - - {league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price} - - ) : ( - - Full - - )} -
-
-
- - {/* Actions */} -
- - - - {(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && ( - - - - )} -
-
- - - ); -} - -export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) { - const shouldReduceMotion = useReducedMotion(); - +export function SponsorLeaguesTemplate({ viewData }: SponsorLeaguesTemplateProps) { const [searchQuery, setSearchQuery] = useState(''); const [tierFilter, setTierFilter] = useState('all'); const [availabilityFilter, setAvailabilityFilter] = useState('all'); const [sortBy, setSortBy] = useState('rating'); // Filter and sort leagues - const filteredLeagues = data.leagues - .filter((league: any) => { - if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - if (tierFilter !== 'all' && league.tier !== tierFilter) { - return false; - } - if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) { - return false; - } - if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) { - return false; - } - return true; - }) - .sort((a: any, b: any) => { - switch (sortBy) { - case 'rating': return b.rating - a.rating; - case 'drivers': return b.drivers - a.drivers; - case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price; - case 'views': return b.avgViewsPerRace - a.avgViewsPerRace; - default: return 0; - } - }); + const filteredLeagues = useMemo(() => { + return viewData.leagues + .filter((league) => { + if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + if (tierFilter !== 'all' && league.tier !== tierFilter) { + return false; + } + if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) { + return false; + } + if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) { + return false; + } + return true; + }) + .sort((a, b) => { + switch (sortBy) { + case 'rating': return b.rating - a.rating; + case 'drivers': return b.drivers - a.drivers; + case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price; + case 'views': return b.avgViewsPerRace - a.avgViewsPerRace; + default: return 0; + } + }); + }, [viewData.leagues, searchQuery, tierFilter, availabilityFilter, sortBy]); - const stats = data.stats; + const stats = viewData.stats; return ( -
- {/* Breadcrumb */} -
- Dashboard - - Browse Leagues -
+ + + {/* Breadcrumb */} + + + + Dashboard + + / + Browse Leagues + + - {/* Header */} -
-

- - League Sponsorship Marketplace -

-

- Discover racing leagues looking for sponsors. All prices shown exclude VAT. -

-
+ {/* Header */} + + }> + League Sponsorship Marketplace + + + Discover racing leagues looking for sponsors. All prices shown exclude VAT. + + - {/* Stats Overview */} -
- - -
{stats.total}
-
Leagues
-
-
- - -
{stats.mainAvailable}
-
Main Slots
-
-
- - -
{stats.secondaryAvailable}
-
Secondary Slots
-
-
- - -
{stats.totalDrivers}
-
Total Drivers
-
-
- - -
${stats.avgCpm}
-
Avg CPM
-
-
-
+ {/* Stats Overview */} + + + + + + + - {/* Filters */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" - /> -
- - {/* Tier Filter */} - - - {/* Availability Filter */} - - - {/* Sort */} - -
- - {/* Results Count */} -
-

- Showing {filteredLeagues.length} of {data.leagues.length} leagues -

-
- - - - - - -
-
- - {/* League Grid */} - {filteredLeagues.length > 0 ? ( -
- {filteredLeagues.map((league: any, index: number) => ( - - ))} -
- ) : ( - - -

No leagues found

-

Try adjusting your filters to see more results

- + {/* Filters (Simplified for template) */} + + + + Use the search and filter options to find the perfect league for your brand. + + + + setSearchQuery(e.target.value)} + className="w-full px-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" + /> + + {/* Selects would go here, using standard Select UI if available */} + + - )} - {/* Platform Fee Notice */} -
-
- -
-

Platform Fee

-

- A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} -

-
-
-
-
+ {/* Results Count */} + + + Showing {filteredLeagues.length} of {viewData.leagues.length} leagues + + + + + + + + + + + + {/* League Grid */} + {filteredLeagues.length > 0 ? ( + + {filteredLeagues.map((league) => ( + + + + ))} + + ) : ( + + + + + + + No leagues found + Try adjusting your filters to see more results + + + + + )} + + {/* Platform Fee Notice */} + + + + + Platform Fee + + A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} + + + + + + ); -} \ No newline at end of file +} + +function StatCard({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) { + return ( + + + {value} + {label} + + + ); +} diff --git a/apps/website/templates/SponsorshipRequestsTemplate.tsx b/apps/website/templates/SponsorshipRequestsTemplate.tsx index f2ff61f79..8d8c7871f 100644 --- a/apps/website/templates/SponsorshipRequestsTemplate.tsx +++ b/apps/website/templates/SponsorshipRequestsTemplate.tsx @@ -1,8 +1,15 @@ +'use client'; + +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Surface } from '@/ui/Surface'; import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; export interface SponsorshipRequestsTemplateProps { viewData: SponsorshipRequestsViewData; @@ -16,65 +23,72 @@ export function SponsorshipRequestsTemplate({ onReject, }: SponsorshipRequestsTemplateProps) { return ( - -
- - Sponsorship Requests - -

- Manage pending sponsorship requests for your profile. -

-
+ + + + Sponsorship Requests + + Manage pending sponsorship requests for your profile. + + - {viewData.sections.map((section) => ( - -
- - {section.entityName} - - - {section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'} - -
+ {viewData.sections.map((section) => ( + + + + {section.entityName} + + {section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'} + + - {section.requests.length === 0 ? ( -

No pending requests.

- ) : ( -
- {section.requests.map((request) => ( -
-
-

{request.sponsorName}

- {request.message && ( -

{request.message}

- )} -

- {new Date(request.createdAtIso).toLocaleDateString()} -

-
-
- - -
-
- ))} -
- )} -
- ))} + + + {request.sponsorName} + {request.message && ( + {request.message} + )} + + {new Date(request.createdAtIso).toLocaleDateString()} + + + + + + + + + ))} +
+ )} + + + ))} +
); } diff --git a/apps/website/templates/StewardingTemplate.tsx b/apps/website/templates/StewardingTemplate.tsx index b0ab9ab7c..a408968e2 100644 --- a/apps/website/templates/StewardingTemplate.tsx +++ b/apps/website/templates/StewardingTemplate.tsx @@ -1,9 +1,16 @@ -/* eslint-disable gridpilot-rules/no-raw-html-in-app */ +'use client'; -import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; +import React from 'react'; import { Card } from '@/ui/Card'; -import { Section } from '@/ui/Section'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Grid } from '@/ui/Grid'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; import { Flag, AlertCircle, Calendar, MapPin, Gavel } from 'lucide-react'; +import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; interface StewardingTemplateProps { viewData: StewardingViewData; @@ -11,146 +18,166 @@ interface StewardingTemplateProps { export function StewardingTemplate({ viewData }: StewardingTemplateProps) { return ( -
+ -
-
-

Stewarding

-

+ + + Stewarding + Quick overview of protests and penalties across all races -

-
-
+ + - {/* Stats summary */} -
-
-
{viewData.totalPending}
-
Pending
-
-
-
{viewData.totalResolved}
-
Resolved
-
-
-
{viewData.totalPenalties}
-
Penalties
-
-
+ {/* Stats summary */} + + + + + - {/* Content */} - {viewData.races.length === 0 ? ( -
-
- -
-

All Clear!

-

No protests or penalties to review.

-
- ) : ( -
- {viewData.races.map((race) => ( -
- {/* Race Header */} -
-
-
- - {race.track} -
-
- - {new Date(race.scheduledAt).toLocaleDateString()} -
- - {race.pendingProtests.length} pending - -
-
+ {/* Content */} + {viewData.races.length === 0 ? ( + + + + + + All Clear! + No protests or penalties to review. + + + ) : ( + + {viewData.races.map((race) => ( + + {/* Race Header */} + + + + + {race.track} + + + + {new Date(race.scheduledAt).toLocaleDateString()} + + + {race.pendingProtests.length} pending + + + - {/* Race Content */} -
- {race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? ( -

No items to display

- ) : ( - <> - {race.pendingProtests.map((protest) => { - const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId); - const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId); + {/* Race Content */} + + {race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? ( + + No items to display + + ) : ( + + {race.pendingProtests.map((protest) => { + const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId); + const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId); - return ( -
-
-
-
- - - {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} - - Pending -
-
- Lap {protest.incident.lap} - β€’ - Filed {new Date(protest.filedAt).toLocaleDateString()} -
-

- {protest.incident.description} -

-
-
- Review needed -
-
-
- ); - })} + return ( + + + + + + + {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} + + + Pending + + + + Lap {protest.incident.lap} + β€’ + Filed {new Date(protest.filedAt).toLocaleDateString()} + + {protest.incident.description} + + Review needed + + + ); + })} - {race.penalties.map((penalty) => { - const driver = viewData.drivers.find(d => d.id === penalty.driverId); - return ( -
-
-
- -
-
-
- {driver?.name || 'Unknown'} - - {penalty.type.replace('_', ' ')} - -
-

{penalty.reason}

-
-
- - {penalty.type === 'time_penalty' && `+${penalty.value}s`} - {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} - {penalty.type === 'points_deduction' && `-${penalty.value} pts`} - {penalty.type === 'disqualification' && 'DSQ'} - {penalty.type === 'warning' && 'Warning'} - {penalty.type === 'license_points' && `${penalty.value} LP`} - -
-
-
- ); - })} - - )} -
-
- ))} -
- )} + {race.penalties.map((penalty) => { + const driver = viewData.drivers.find(d => d.id === penalty.driverId); + return ( + + + + + + + + + {driver?.name || 'Unknown'} + + + {penalty.type.replace('_', ' ')} + + + + {penalty.reason} + + + + + {penalty.type === 'time_penalty' && `+${penalty.value}s`} + {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} + {penalty.type === 'points_deduction' && `-${penalty.value} pts`} + {penalty.type === 'disqualification' && 'DSQ'} + {penalty.type === 'warning' && 'Warning'} + {penalty.type === 'license_points' && `${penalty.value} LP`} + + + + + ); + })} +
+ )} + + + ))} + + )} + -
+ ); -} \ No newline at end of file +} + +function StatItem({ label, value, color }: { label: string, value: string | number, color: string }) { + return ( + + {value} + {label} + + ); +} diff --git a/apps/website/templates/TeamDetailTemplate.tsx b/apps/website/templates/TeamDetailTemplate.tsx index 4e2b131f8..9077a01c0 100644 --- a/apps/website/templates/TeamDetailTemplate.tsx +++ b/apps/website/templates/TeamDetailTemplate.tsx @@ -1,28 +1,27 @@ 'use client'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard'; import { SlotTemplates } from '@/components/sponsors/SlotTemplates'; -import { useSponsorMode } from '@/components/sponsors/useSponsorMode'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Image from 'next/image'; +import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard'; +import { useSponsorMode } from '@/hooks/sponsor/useSponsorMode'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Card } from '@/ui/Card'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Heading } from '@/ui/Heading'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; -import JoinTeamButton from '@/components/teams/JoinTeamButton'; import TeamAdmin from '@/components/teams/TeamAdmin'; +import { TeamHero } from '@/components/teams/TeamHero'; import TeamRoster from '@/components/teams/TeamRoster'; import TeamStandings from '@/components/teams/TeamStandings'; -import StatItem from '@/components/teams/StatItem'; -import { getMediaUrl } from '@/lib/utilities/media'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData'; type Tab = 'overview' | 'roster' | 'standings' | 'admin'; -// ============================================================================ -// TEMPLATE PROPS -// ============================================================================ - export interface TeamDetailTemplateProps { viewData: TeamDetailViewData; activeTab: Tab; @@ -36,10 +35,6 @@ export interface TeamDetailTemplateProps { onGoBack: () => void; } -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export function TeamDetailTemplate({ viewData, activeTab, @@ -55,28 +50,32 @@ export function TeamDetailTemplate({ // Show loading state if (loading) { return ( -
-
Loading team...
-
+ + + Loading team... + + ); } // Show not found state if (!viewData.team) { return ( -
+ -
-

Team Not Found

-

- The team you're looking for doesn't exist or has been disbanded. -

+ + + Team Not Found + + The team you're looking for doesn't exist or has been disbanded. + + -
+
-
+
); } @@ -90,164 +89,128 @@ export function TeamDetailTemplate({ const visibleTabs = tabs.filter(tab => tab.visible); return ( -
- {/* Breadcrumb */} - - - {/* Sponsor Insights Card - Consistent placement at top */} - {isSponsorMode && viewData.team && ( - + + {/* Breadcrumb */} + - )} - -
-
-
- {viewData.team.name} -
- -
-
-

{viewData.team.name}

- {viewData.team.tag && ( - - [{viewData.team.tag}] - - )} -
- -

{viewData.team.description}

- -
- {viewData.memberships.length} {viewData.memberships.length === 1 ? 'member' : 'members'} - {viewData.team.category && ( - - - {viewData.team.category} - - )} - {viewData.team.createdAt && ( - - Founded {new Date(viewData.team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - - )} - {viewData.team.leagues && viewData.team.leagues.length > 0 && ( - - Active in {viewData.team.leagues.length} {viewData.team.leagues.length === 1 ? 'league' : 'leagues'} - - )} -
-
-
- - -
-
- -
-
- {visibleTabs.map((tab) => ( - - ))} -
-
- -
- {activeTab === 'overview' && ( -
-
- -

About

-

{viewData.team.description}

-
- - -

Quick Stats

-
- - {viewData.team.category && ( - - )} - {viewData.team.leagues && viewData.team.leagues.length > 0 && ( - - )} - {viewData.team.createdAt && ( - - )} -
-
-
- - -

Recent Activity

-
- No recent activity to display -
-
-
- )} - - {activeTab === 'roster' && ( - )} - {activeTab === 'standings' && ( - - )} + - {activeTab === 'admin' && viewData.isAdmin && ( - - )} -
-
+ {/* Tabs */} + + + {visibleTabs.map((tab) => ( + onTabChange(tab.id)} + pb={3} + style={{ + cursor: 'pointer', + borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent', + color: activeTab === tab.id ? '#3b82f6' : '#9ca3af' + }} + > + {tab.label} + + ))} + + + + + {activeTab === 'overview' && ( + + + + + + About + + {viewData.team.description} + + + + + + + Quick Stats + + + + {viewData.team.category && ( + + )} + {viewData.team.leagues && viewData.team.leagues.length > 0 && ( + + )} + {viewData.team.createdAt && ( + + )} + + + + + + + + Recent Activity + + + No recent activity to display + + + + )} + + {activeTab === 'roster' && ( + + )} + + {activeTab === 'standings' && ( + + )} + + {activeTab === 'admin' && viewData.isAdmin && ( + + )} + + + ); } diff --git a/apps/website/templates/TeamLeaderboardTemplate.tsx b/apps/website/templates/TeamLeaderboardTemplate.tsx index 2ea37522c..6751b180a 100644 --- a/apps/website/templates/TeamLeaderboardTemplate.tsx +++ b/apps/website/templates/TeamLeaderboardTemplate.tsx @@ -1,19 +1,19 @@ 'use client'; -import React from 'react'; -import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Target, Globe, Languages } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import React, { useMemo } from 'react'; +import { Award, ArrowLeft } from 'lucide-react'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Container } from '@/ui/Container'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; import TopThreePodium from '@/components/teams/TopThreePodium'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import TeamRankingsFilter from '@/components/TeamRankingsFilter'; -import Image from 'next/image'; -import { getMediaUrl } from '@/lib/utilities/media'; - -// ============================================================================ -// TYPES -// ============================================================================ +import { TeamRankingsTable } from '@/components/teams/TeamRankingsTable'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; @@ -30,56 +30,6 @@ interface TeamLeaderboardTemplateProps { onBackToTeams: () => void; } -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -const getSafeRating = (team: TeamSummaryViewModel): number => { - return 0; -}; - -const getSafeTotalWins = (team: TeamSummaryViewModel): number => { - const raw = team.totalWins; - const value = typeof raw === 'number' ? raw : 0; - return Number.isFinite(value) ? value : 0; -}; - -const getSafeTotalRaces = (team: TeamSummaryViewModel): number => { - const raw = team.totalRaces; - const value = typeof raw === 'number' ? raw : 0; - return Number.isFinite(value) ? value : 0; -}; - -const getMedalColor = (position: number) => { - switch (position) { - case 0: - return 'text-yellow-400'; - case 1: - return 'text-gray-300'; - case 2: - return 'text-amber-600'; - default: - return 'text-gray-500'; - } -}; - -const getMedalBg = (position: number) => { - switch (position) { - case 0: - return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40'; - case 1: - return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40'; - case 2: - return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40'; - default: - return 'bg-iron-gray/50 border-charcoal-outline'; - } -}; - -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - export default function TeamLeaderboardTemplate({ teams, searchQuery, @@ -92,283 +42,78 @@ export default function TeamLeaderboardTemplate({ onBackToTeams, }: TeamLeaderboardTemplateProps) { // Filter and sort teams - const filteredAndSortedTeams = teams - .filter((team) => { - // Search filter - if (searchQuery) { - const query = searchQuery.toLowerCase(); - if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) { + const filteredAndSortedTeams = useMemo(() => { + return teams + .filter((team) => { + if (searchQuery) { + const query = searchQuery.toLowerCase(); + if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) { + return false; + } + } + if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) { return false; } - } - // Level filter - if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) { - return false; - } - return true; - }) - .sort((a, b) => { - switch (sortBy) { - case 'rating': { - const aRating = getSafeRating(a); - const bRating = getSafeRating(b); - return bRating - aRating; + return true; + }) + .sort((a, b) => { + switch (sortBy) { + case 'rating': return 0; // Placeholder + case 'wins': return (b.totalWins || 0) - (a.totalWins || 0); + case 'races': return (b.totalRaces || 0) - (a.totalRaces || 0); + default: return 0; } - case 'wins': { - const aWinsSort = getSafeTotalWins(a); - const bWinsSort = getSafeTotalWins(b); - return bWinsSort - aWinsSort; - } - case 'winRate': { - const aRaces = getSafeTotalRaces(a); - const bRaces = getSafeTotalRaces(b); - const aWins = getSafeTotalWins(a); - const bWins = getSafeTotalWins(b); - const aRate = aRaces > 0 ? aWins / aRaces : 0; - const bRate = bRaces > 0 ? bWins / bRaces : 0; - return bRate - aRate; - } - case 'races': { - const aRacesSort = getSafeTotalRaces(a); - const bRacesSort = getSafeTotalRaces(b); - return bRacesSort - aRacesSort; - } - default: - return 0; - } - }); + }); + }, [teams, searchQuery, filterLevel, sortBy]); return ( -
- {/* Header */} -
- - -
-
- -
-
- - Team Leaderboard - -

Rankings of all teams by performance metrics

-
-
-
- - {/* Filters and Search */} - - - {/* Podium for Top 3 - only show when viewing by rating without filters */} - {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( - - )} - - {/* Stats Summary */} -
-
-
- - Total Teams -
-

{filteredAndSortedTeams.length}

-
-
-
- - Pro Teams -
-

- {filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length} -

-
-
-
- - Total Wins -
-

- {filteredAndSortedTeams.reduce( - (sum, t) => sum + getSafeTotalWins(t), - 0, - )} -

-
-
-
- - Total Races -
-

- {filteredAndSortedTeams.reduce( - (sum, t) => sum + getSafeTotalRaces(t), - 0, - )} -

-
-
- - {/* Leaderboard Table */} -
- {/* Table Header */} -
-
Rank
-
Team
-
Members
-
Rating
-
Wins
-
Win Rate
-
- - {/* Table Body */} -
- {filteredAndSortedTeams.map((team, index) => { - const levelConfig = ['beginner', 'intermediate', 'advanced', 'pro'].find((l) => l === team.performanceLevel); - const LevelIcon = levelConfig === 'pro' ? Crown : levelConfig === 'advanced' ? Crown : levelConfig === 'intermediate' ? Crown : () => null; - const totalRaces = getSafeTotalRaces(team); - const totalWins = getSafeTotalWins(team); - const winRate = - totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0'; - - return ( - - ); - })} -
- - {/* Empty State */} - {filteredAndSortedTeams.length === 0 && ( -
- -

No teams found

-

Try adjusting your filters or search query

+ + + {/* Header */} + + -
+ + + + + + + + Team Leaderboard + Rankings of all teams by performance metrics + + + + + {/* Filters and Search */} + + + {/* Podium for Top 3 */} + {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( + )} -
-
+ + {/* Leaderboard Table */} + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/TeamsTemplate.tsx b/apps/website/templates/TeamsTemplate.tsx index ff8b894f8..e8bb057b2 100644 --- a/apps/website/templates/TeamsTemplate.tsx +++ b/apps/website/templates/TeamsTemplate.tsx @@ -1,102 +1,82 @@ 'use client'; -import { Trophy, Users } from 'lucide-react'; -import Link from 'next/link'; +import React from 'react'; +import { Users } from 'lucide-react'; +import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreview'; +import { Button } from '@/ui/Button'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Container } from '@/ui/Container'; +import { Grid } from '@/ui/Grid'; +import { TeamCard } from '@/components/teams/TeamCard'; +import { EmptyState } from '@/components/shared/state/EmptyState'; +import type { TeamSummaryData, TeamsViewData } from '@/lib/view-data/TeamsViewData'; -import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import type { TeamSummaryData, TeamsViewData } from '../lib/view-data/TeamsViewData'; - -interface TeamsTemplateProps extends TeamsViewData { - searchQuery?: string; - showCreateForm?: boolean; - onSearchChange?: (query: string) => void; - onShowCreateForm?: () => void; - onHideCreateForm?: () => void; +interface TeamsTemplateProps { + viewData: TeamsViewData; onTeamClick?: (teamId: string) => void; - onCreateSuccess?: (teamId: string) => void; - onBrowseTeams?: () => void; - onSkillLevelClick?: (level: string) => void; + onViewFullLeaderboard: () => void; + onCreateTeam: () => void; } -export function TeamsTemplate({ teams }: TeamsTemplateProps) { +export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, onCreateTeam }: TeamsTemplateProps) { + const { teams } = viewData; + return ( -
-
- {/* Header */} -
-
-

Teams

-

Browse and manage your racing teams

-
- - - -
+ + + + {/* Header */} + + + Teams + Browse and manage your racing teams + + + + + - {/* Teams Grid */} - {teams.length > 0 ? ( -
- {teams.map((team: TeamSummaryData) => ( - -
-
- {team.logoUrl ? ( - {team.teamName} - ) : ( -
- -
- )} -
-

{team.teamName}

-

{team.leagueName}

-
-
-
- -
- - - {team.memberCount} members - -
+ {/* Teams Grid */} + {teams.length > 0 ? ( + + {teams.map((team: TeamSummaryData) => ( + onTeamClick?.(team.teamId)} + /> + ))} + + ) : ( + + )} -
- - - -
-
- ))} -
- ) : ( -
- -

No teams yet

-

Get started by creating your first racing team

- - - -
- )} - - {/* Team Leaderboard Preview */} -
-

- - Top Teams -

- {}} /> -
-
-
+ {/* Team Leaderboard Preview */} + + onTeamClick?.(id)} + onViewFullLeaderboard={onViewFullLeaderboard} + /> + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/auth/ForgotPasswordTemplate.tsx b/apps/website/templates/auth/ForgotPasswordTemplate.tsx index cc52bdec2..6d3586529 100644 --- a/apps/website/templates/auth/ForgotPasswordTemplate.tsx +++ b/apps/website/templates/auth/ForgotPasswordTemplate.tsx @@ -1,14 +1,6 @@ -/** - * Forgot Password Template - * - * Pure presentation component that accepts ViewData only. - * No business logic, no state management. - */ - 'use client'; -import Link from 'next/link'; -import { motion } from 'framer-motion'; +import React from 'react'; import { Mail, ArrowLeft, @@ -17,11 +9,17 @@ import { Shield, CheckCircle2, } from 'lucide-react'; - -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { LoadingSpinner } from '@/ui/LoadingSpinner'; import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData'; interface ForgotPasswordTemplateProps { @@ -39,156 +37,145 @@ interface ForgotPasswordTemplateProps { export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) { return ( -
+ {/* Background Pattern */} -
-
-
-
- -
+ + + {/* Header */} -
-
- -
- Reset Password -

+ + + + + Reset Password + Enter your email and we will send you a reset link -

-
+ +
- + {/* Background accent */} -
+ {!viewData.showSuccess ? ( -
- {/* Email */} -
- -
- - -
-
+ + + {/* Email */} + + + Email Address + + + + + + + + {viewData.formState.fields.email.error && ( + + {viewData.formState.fields.email.error} + + )} + - {/* Error Message */} - {mutationState.error && ( - - -

{mutationState.error}

-
- )} - - {/* Submit Button */} - - {/* Back to Login */} -
- : } > - - Back to Login - -
+ {mutationState.isPending ? 'Sending...' : 'Send Reset Link'} + + + {/* Back to Login */} + + + + + Back to Login + + + +
) : ( - -
- -
-

{viewData.successMessage}

- {viewData.magicLink && ( -
-

Development Mode - Magic Link:

-
- - {viewData.magicLink} - -
-

- In production, this would be sent via email -

-
- )} -
-
+ + + + + + {viewData.successMessage} + {viewData.magicLink && ( + + Development Mode - Magic Link: + + {viewData.magicLink} + + + In production, this would be sent via email + + + )} + + + -
+ )} {/* Trust Indicators */} -
-
- - Secure reset process -
-
- - 15 minute expiration -
-
+ + + + Secure reset process + + + + 15 minute expiration + + {/* Footer */} -

- Need help?{' '} - - Contact support - -

-
-
+ + + Need help?{' '} + + Contact support + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/auth/LoginTemplate.tsx b/apps/website/templates/auth/LoginTemplate.tsx index a097a0ca8..313bc1514 100644 --- a/apps/website/templates/auth/LoginTemplate.tsx +++ b/apps/website/templates/auth/LoginTemplate.tsx @@ -1,14 +1,6 @@ -/** - * Login Template - * - * Pure presentation component that accepts ViewData only. - * No business logic, no state management. - */ - 'use client'; -import Link from 'next/link'; -import { motion, AnimatePresence } from 'framer-motion'; +import React from 'react'; import { Mail, Lock, @@ -19,11 +11,17 @@ import { Flag, Shield, } from 'lucide-react'; - -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { LoadingSpinner } from '@/ui/LoadingSpinner'; import { EnhancedFormError } from '@/components/errors/EnhancedFormError'; import UserRolesPreview from '@/components/auth/UserRolesPreview'; import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; @@ -47,141 +45,151 @@ interface LoginTemplateProps { export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) { return ( -
+ {/* Background Pattern */} -
-
-
-
- + + {/* Left Side - Info Panel (Hidden on mobile) */} -
-
+ + {/* Logo */} -
-
- -
- GridPilot -
+ + + + + GridPilot + - - Your Sim Racing Infrastructure - + + + Your Sim Racing Infrastructure + + -

+ Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles. -

+ {/* Role Cards */} {/* Workflow Mockup */} - + + + {/* Trust Indicators */} -
-
- - Secure login -
-
- iRacing verified -
-
-
-
+ + + + Secure login + + iRacing verified + +
+ {/* Right Side - Login Form */} -
-
+ + {/* Mobile Logo/Header */} -
-
- -
- Welcome Back -

+ + + + + Welcome Back + Sign in to continue to GridPilot -

-
+ +
{/* Desktop Header */} -
- Welcome Back -

+ + Welcome Back + Sign in to access your racing dashboard -

-
+ +
- + {/* Background accent */} -
+ -
- {/* Email */} -
- -
- - -
-
+ + + {/* Email */} + + + Email Address + + + + + + + + {viewData.formState.fields.email.error && ( + + {viewData.formState.fields.email.error} + + )} + - {/* Password */} -
-
- - - Forgot password? - -
-
- - - -
-
+ {/* Password */} + + + + Password + + + Forgot password? + + + + + + + + formActions.setShowPassword(!viewData.showPassword)} + style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + + {viewData.formState.fields.password.error && ( + + {viewData.formState.fields.password.error} + + )} + - {/* Remember Me */} -
- -
+ Keep me signed in +
- {/* Insufficient Permissions Message */} - + {/* Insufficient Permissions Message */} {viewData.hasInsufficientPermissions && ( - -
- -
- Insufficient Permissions -

+ + + + + Insufficient Permissions + You don't have permission to access that page. Please log in with an account that has the required role. -

-
-
-
+ + + + )} -
- {/* Enhanced Error Display */} - + {/* Enhanced Error Display */} {viewData.submitError && ( )} - - {/* Submit Button */} - + {/* Submit Button */} + +
{/* Divider */} -
-
-
-
-
- or continue with -
-
+ + + + + + + or continue with + + + {/* Sign Up Link */} -

- Don't have an account?{''} - - Create one - -

+ + + Don't have an account?{' '} + + Create one + + + {/* Name Immutability Notice */} -
-
- -
- Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account. -
-
-
+ + + + + + Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account. + + + + {/* Footer */} -

- By signing in, you agree to our{''} - Terms of Service - {''}and{''} - Privacy Policy -

+ + + By signing in, you agree to our{' '} + + Terms of Service + + {' '}and{' '} + + Privacy Policy + + + {/* Mobile Role Info */} - -
-
-
+ + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/auth/ResetPasswordTemplate.tsx b/apps/website/templates/auth/ResetPasswordTemplate.tsx index 0b64fd749..5319cca39 100644 --- a/apps/website/templates/auth/ResetPasswordTemplate.tsx +++ b/apps/website/templates/auth/ResetPasswordTemplate.tsx @@ -1,14 +1,6 @@ -/** - * Reset Password Template - * - * Pure presentation component that accepts ViewData only. - * No business logic, no state management. - */ - 'use client'; -import Link from 'next/link'; -import { motion } from 'framer-motion'; +import React from 'react'; import { Lock, Eye, @@ -19,11 +11,17 @@ import { CheckCircle2, ArrowLeft, } from 'lucide-react'; - -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { LoadingSpinner } from '@/ui/LoadingSpinner'; import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData'; interface ResetPasswordTemplateProps extends ResetPasswordViewData { @@ -48,184 +46,183 @@ export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) { const { formActions, uiState, mutationState, ...viewData } = props; return ( -
+ {/* Background Pattern */} -
-
-
-
- -
+ + + {/* Header */} -
-
- -
- Reset Password -

+ + + + + Reset Password + Create a new secure password for your account -

-
+ +
- + {/* Background accent */} -
+ {!viewData.showSuccess ? ( -
- {/* New Password */} -
- -
- - - -
-
+ + + {/* New Password */} + + + New Password + + + + + + + formActions.setShowPassword(!uiState.showPassword)} + style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + + {viewData.formState.fields.newPassword.error && ( + + {viewData.formState.fields.newPassword.error} + + )} + - {/* Confirm Password */} -
- -
- - - -
-
+ {/* Confirm Password */} + + + Confirm Password + + + + + + + formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} + style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + + {viewData.formState.fields.confirmPassword.error && ( + + {viewData.formState.fields.confirmPassword.error} + + )} + - {/* Error Message */} - {mutationState.error && ( - - -

{mutationState.error}

-
- )} - - {/* Submit Button */} - - {/* Back to Login */} -
- : } > - - Back to Login - -
+ {mutationState.isPending ? 'Resetting...' : 'Reset Password'} + + + {/* Back to Login */} + + + + + Back to Login + + + +
) : ( - -
- -
-

{viewData.successMessage}

-

- Your password has been successfully reset -

-
-
+ + + + + + {viewData.successMessage} + + Your password has been successfully reset + + + + -
+ )} {/* Trust Indicators */} -
-
- - Secure password reset -
-
- - Encrypted transmission -
-
+ + + + Secure password reset + + + + Encrypted transmission + + {/* Footer */} -

- Need help?{' '} - - Contact support - -

-
-
+ + + Need help?{' '} + + Contact support + + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/templates/auth/SignupTemplate.tsx b/apps/website/templates/auth/SignupTemplate.tsx index 44c35896d..ab05ee497 100644 --- a/apps/website/templates/auth/SignupTemplate.tsx +++ b/apps/website/templates/auth/SignupTemplate.tsx @@ -1,14 +1,6 @@ -/** - * Signup Template - * - * Pure presentation component that accepts ViewData only. - * No business logic, no state management. - */ - 'use client'; -import Link from 'next/link'; -import { motion } from 'framer-motion'; +import React from 'react'; import { Mail, Lock, @@ -26,11 +18,17 @@ import { Shield, Sparkles, } from 'lucide-react'; - -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { LoadingSpinner } from '@/ui/LoadingSpinner'; import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData'; import { checkPasswordStrength } from '@/lib/utils/validation'; @@ -57,19 +55,19 @@ const USER_ROLES = [ icon: Car, title: 'Driver', description: 'Race, track stats, join teams', - color: 'primary-blue', + color: '#3b82f6', }, { icon: Trophy, title: 'League Admin', description: 'Organize leagues and events', - color: 'performance-green', + color: '#10b981', }, { icon: Users, title: 'Team Manager', description: 'Manage team and drivers', - color: 'purple-400', + color: '#a855f7', }, ]; @@ -93,362 +91,380 @@ export function SignupTemplate({ viewData, formActions, uiState, mutationState } ]; return ( -
+ {/* Background Pattern */} -
-
-
-
- + + {/* Left Side - Info Panel (Hidden on mobile) */} -
-
+ + {/* Logo */} -
-
- -
- GridPilot -
+ + + + + GridPilot + - - Start Your Racing Journey - + + Start Your Racing Journey + -

+ Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams. -

+ {/* Role Cards */} -
- {USER_ROLES.map((role, index) => ( - + {USER_ROLES.map((role) => ( + -
- -
-
-

{role.title}

-

{role.description}

-
-
+ + + + + + {role.title} + {role.description} + + + ))} -
+ {/* Features List */} -
-
- - What you'll get -
-
    - {FEATURES.map((feature, index) => ( -
  • - - {feature} -
  • - ))} -
-
+ + + + + What you'll get + + + {FEATURES.map((feature, index) => ( + + + {feature} + + ))} + + + {/* Trust Indicators */} -
-
- - Secure signup -
-
- iRacing integration -
-
-
-
+ + + + Secure signup + + iRacing integration + +
+ {/* Right Side - Signup Form */} -
-
+ + {/* Mobile Logo/Header */} -
-
- -
- Join GridPilot -

+ + + + + Join GridPilot + Create your account and start racing -

-
+ +
{/* Desktop Header */} -
- Create Account -

+ + Create Account + Get started with your free account -

-
+ +
- + {/* Background accent */} -
+ -
- {/* First Name */} -
- -
- - -
-
+ + + {/* First Name */} + + + First Name + + + + + + + + {viewData.formState.fields.firstName.error && ( + + {viewData.formState.fields.firstName.error} + + )} + - {/* Last Name */} -
- -
- - -
-

Your name will be used as-is and cannot be changed later

-
+ {/* Last Name */} + + + Last Name + + + + + + + + {viewData.formState.fields.lastName.error && ( + + {viewData.formState.fields.lastName.error} + + )} + Your name will be used as-is and cannot be changed later + - {/* Name Immutability Warning */} -
- -
- Important: Your name cannot be changed after signup. Please ensure it's correct. -
-
+ {/* Name Immutability Warning */} + + + + + Important: Your name cannot be changed after signup. Please ensure it's correct. + + + - {/* Email */} -
- -
- - -
-
+ {/* Email */} + + + Email Address + + + + + + + + {viewData.formState.fields.email.error && ( + + {viewData.formState.fields.email.error} + + )} + - {/* Password */} -
- -
- - - -
+ {/* Password */} + + + Password + + + + + + + formActions.setShowPassword(!uiState.showPassword)} + style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + + {viewData.formState.fields.password.error && ( + + {viewData.formState.fields.password.error} + + )} - {/* Password Strength */} - {viewData.formState.fields.password.value && ( -
-
-
- -
- - {passwordStrength.label} - -
-
- {passwordRequirements.map((req, index) => ( -
- {req.met ? ( - - ) : ( - - )} - - {req.label} - -
- ))} -
-
- )} -
+ {/* Password Strength */} + {viewData.formState.fields.password.value && ( + + + + + + + {passwordStrength.label} + + + + {passwordRequirements.map((req, index) => ( + + + + {req.label} + + + ))} + + + )} + - {/* Confirm Password */} -
- -
- - - -
- {viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( -

- Passwords match -

- )} -
+ {/* Confirm Password */} + + + Confirm Password + + + + + + + formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} + style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + + {viewData.formState.fields.confirmPassword.error && ( + + {viewData.formState.fields.confirmPassword.error} + + )} + {viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( + + + Passwords match + + )} + - {/* Submit Button */} - + {/* Submit Button */} + +
{/* Divider */} -
-
-
-
-
- or continue with -
-
+ + + + + + + or continue with + + + {/* Login Link */} -

- Already have an account?{' '} - - Sign in - -

+ + + Already have an account?{' '} + + Sign in + + + {/* Footer */} -

- By creating an account, you agree to our{' '} - Terms of Service - {' '}and{' '} - Privacy Policy -

+ + + By creating an account, you agree to our{' '} + + Terms of Service + + {' '}and{' '} + + Privacy Policy + + + {/* Mobile Role Info */} -
-

One account for all roles

-
+ + One account for all roles + {USER_ROLES.map((role) => ( -
-
- -
- {role.title} -
+ + + + + {role.title} + ))} -
-
-
-
-
+ + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/ui/AuthContainer.tsx b/apps/website/ui/AuthContainer.tsx new file mode 100644 index 000000000..a6197452c --- /dev/null +++ b/apps/website/ui/AuthContainer.tsx @@ -0,0 +1,16 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; + +interface AuthContainerProps { + children: React.ReactNode; +} + +export function AuthContainer({ children }: AuthContainerProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/AuthError.tsx b/apps/website/ui/AuthError.tsx new file mode 100644 index 000000000..3a24f98a6 --- /dev/null +++ b/apps/website/ui/AuthError.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; +import { ErrorBanner } from './ErrorBanner'; + +interface AuthErrorProps { + action: string; +} + +export function AuthError({ action }: AuthErrorProps) { + return ( + + ); +} diff --git a/apps/website/ui/AuthLoading.tsx b/apps/website/ui/AuthLoading.tsx new file mode 100644 index 000000000..38f95c361 --- /dev/null +++ b/apps/website/ui/AuthLoading.tsx @@ -0,0 +1,22 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface AuthLoadingProps { + message?: string; +} + +export function AuthLoading({ message = 'Authenticating...' }: AuthLoadingProps) { + return ( + + + + {message} + + + ); +} diff --git a/apps/website/ui/Badge.tsx b/apps/website/ui/Badge.tsx new file mode 100644 index 000000000..24bed7dd0 --- /dev/null +++ b/apps/website/ui/Badge.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from 'react'; +import { Box } from './Box'; + +interface BadgeProps { + children: ReactNode; + className?: string; + variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'; +} + +export function Badge({ children, className = '', variant = 'default' }: BadgeProps) { + const baseClasses = 'flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium'; + + const variantClasses = { + default: 'bg-gray-500/10 border-gray-500/30 text-gray-400', + primary: 'bg-primary-blue/10 border-primary-blue/30 text-primary-blue', + success: 'bg-performance-green/10 border-performance-green/30 text-performance-green', + warning: 'bg-warning-amber/10 border-warning-amber/30 text-warning-amber', + danger: 'bg-red-600/10 border-red-600/30 text-red-500', + info: 'bg-neon-aqua/10 border-neon-aqua/30 text-neon-aqua' + }; + + const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' '); + + return {children}; +} diff --git a/apps/website/ui/Box.tsx b/apps/website/ui/Box.tsx new file mode 100644 index 000000000..5d84c687a --- /dev/null +++ b/apps/website/ui/Box.tsx @@ -0,0 +1,93 @@ +import React, { forwardRef, ForwardedRef, ElementType, ComponentPropsWithoutRef } from 'react'; + +type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96; + +interface BoxProps { + as?: T; + children?: React.ReactNode; + className?: string; + center?: boolean; + fullWidth?: boolean; + fullHeight?: boolean; + m?: Spacing; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + mx?: Spacing | 'auto'; + my?: Spacing; + p?: Spacing; + pt?: Spacing; + pb?: Spacing; + pl?: Spacing; + pr?: Spacing; + px?: Spacing; + py?: Spacing; + display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none'; + position?: 'relative' | 'absolute' | 'fixed' | 'sticky'; + overflow?: 'visible' | 'hidden' | 'scroll' | 'auto'; + maxWidth?: string; +} + +export const Box = forwardRef(( + { + as, + children, + className = '', + center = false, + fullWidth = false, + fullHeight = false, + m, mt, mb, ml, mr, mx, my, + p, pt, pb, pl, pr, px, py, + display, + position, + overflow, + maxWidth, + ...props + }: BoxProps & ComponentPropsWithoutRef, + ref: ForwardedRef +) => { + const Tag = (as as any) || 'div'; + + const spacingMap: Record = { + 0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4', + 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14', + 16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44', + 48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96', + 'auto': 'auto' + }; + + const classes = [ + center ? 'flex items-center justify-center' : '', + fullWidth ? 'w-full' : '', + fullHeight ? 'h-full' : '', + m !== undefined ? `m-${spacingMap[m]}` : '', + mt !== undefined ? `mt-${spacingMap[mt]}` : '', + mb !== undefined ? `mb-${spacingMap[mb]}` : '', + ml !== undefined ? `ml-${spacingMap[ml]}` : '', + mr !== undefined ? `mr-${spacingMap[mr]}` : '', + mx !== undefined ? `mx-${spacingMap[mx]}` : '', + my !== undefined ? `my-${spacingMap[my]}` : '', + p !== undefined ? `p-${spacingMap[p]}` : '', + pt !== undefined ? `pt-${spacingMap[pt]}` : '', + pb !== undefined ? `pb-${spacingMap[pb]}` : '', + pl !== undefined ? `pl-${spacingMap[pl]}` : '', + pr !== undefined ? `pr-${spacingMap[pr]}` : '', + px !== undefined ? `px-${spacingMap[px]}` : '', + py !== undefined ? `py-${spacingMap[py]}` : '', + display ? display : '', + position ? position : '', + overflow ? `overflow-${overflow}` : '', + className + ].filter(Boolean).join(' '); + + const style = maxWidth ? { maxWidth, ...((props as any).style || {}) } : (props as any).style; + + return ( + + {children} + + ); +}); + +Box.displayName = 'Box'; diff --git a/apps/website/ui/Button.tsx b/apps/website/ui/Button.tsx index 85f7af4c9..1cc2edfc8 100644 --- a/apps/website/ui/Button.tsx +++ b/apps/website/ui/Button.tsx @@ -1,13 +1,18 @@ -import React, { ReactNode, MouseEventHandler } from 'react'; +import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes } from 'react'; +import { Stack } from './Stack'; -interface ButtonProps { +interface ButtonProps extends ButtonHTMLAttributes { children: ReactNode; onClick?: MouseEventHandler; className?: string; - variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final'; size?: 'sm' | 'md' | 'lg'; disabled?: boolean; type?: 'button' | 'submit' | 'reset'; + icon?: ReactNode; + fullWidth?: boolean; + as?: 'button' | 'a'; + href?: string; } export function Button({ @@ -17,15 +22,22 @@ export function Button({ variant = 'primary', size = 'md', disabled = false, - type = 'button' + type = 'button', + icon, + fullWidth = false, + as = 'button', + href, + ...props }: ButtonProps) { - const baseClasses = 'inline-flex items-center rounded-lg transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2'; + const baseClasses = 'inline-flex items-center rounded-lg transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.02] active:scale-95'; const variantClasses = { - primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue', + primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)]', secondary: 'bg-iron-gray text-white border border-charcoal-outline hover:bg-iron-gray/80 focus-visible:outline-primary-blue', danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600', - ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400' + ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400', + 'race-performance': 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white shadow-[0_0_15px_rgba(251,191,36,0.4)] hover:from-yellow-500 hover:to-orange-600 focus-visible:outline-yellow-400', + 'race-final': 'bg-gradient-to-r from-purple-400 to-pink-500 text-white shadow-[0_0_15px_rgba(168,85,247,0.4)] hover:from-purple-500 hover:to-pink-600 focus-visible:outline-purple-400' }; const sizeClasses = { @@ -35,23 +47,45 @@ export function Button({ }; const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'; + const widthClasses = fullWidth ? 'w-full' : ''; const classes = [ baseClasses, variantClasses[variant], sizeClasses[size], disabledClasses, + widthClasses, className ].filter(Boolean).join(' '); + const content = icon ? ( + + {icon} + {children} + + ) : children; + + if (as === 'a') { + return ( + )} + > + {content} + + ); + } + return ( ); -} \ No newline at end of file +} diff --git a/apps/website/ui/Card.tsx b/apps/website/ui/Card.tsx index 4e8c47b44..bf023b97f 100644 --- a/apps/website/ui/Card.tsx +++ b/apps/website/ui/Card.tsx @@ -1,35 +1,60 @@ -import React, { ReactNode, MouseEventHandler } from 'react'; +import React, { ReactNode, MouseEventHandler, HTMLAttributes } from 'react'; -interface CardProps { +type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96; + +interface CardProps extends HTMLAttributes { children: ReactNode; className?: string; onClick?: MouseEventHandler; variant?: 'default' | 'highlight'; + p?: Spacing; + px?: Spacing; + py?: Spacing; + pt?: Spacing; + pb?: Spacing; + pl?: Spacing; + pr?: Spacing; } export function Card({ children, className = '', onClick, - variant = 'default' + variant = 'default', + p, px, py, pt, pb, pl, pr, + ...props }: CardProps) { - const baseClasses = 'rounded-lg p-6 shadow-card border duration-200'; + const baseClasses = 'rounded-lg shadow-card border duration-200'; const variantClasses = { default: 'bg-iron-gray border-charcoal-outline', highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 border-blue-500/30' }; + + const spacingMap: Record = { + 0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4', + 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14', + 16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44', + 48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96' + }; const classes = [ baseClasses, variantClasses[variant], onClick ? 'cursor-pointer hover:scale-[1.02]' : '', + p !== undefined ? `p-${spacingMap[p]}` : (px === undefined && py === undefined && pt === undefined && pb === undefined && pl === undefined && pr === undefined ? 'p-6' : ''), + px !== undefined ? `px-${spacingMap[px]}` : '', + py !== undefined ? `py-${spacingMap[py]}` : '', + pt !== undefined ? `pt-${spacingMap[pt]}` : '', + pb !== undefined ? `pb-${spacingMap[pb]}` : '', + pl !== undefined ? `pl-${spacingMap[pl]}` : '', + pr !== undefined ? `pr-${spacingMap[pr]}` : '', className ].filter(Boolean).join(' '); return ( -
+
{children}
); -} \ No newline at end of file +} diff --git a/apps/website/ui/Container.tsx b/apps/website/ui/Container.tsx new file mode 100644 index 000000000..464c5366e --- /dev/null +++ b/apps/website/ui/Container.tsx @@ -0,0 +1,50 @@ +import React, { ReactNode, HTMLAttributes } from 'react'; +import { Box } from './Box'; + +type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96; + +interface ContainerProps extends HTMLAttributes { + children: ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + padding?: boolean; + className?: string; + py?: Spacing; +} + +export function Container({ + children, + size = 'lg', + padding = true, + className = '', + py, + ...props +}: ContainerProps) { + const sizeClasses = { + sm: 'max-w-2xl', + md: 'max-w-4xl', + lg: 'max-w-7xl', + xl: 'max-w-[1400px]', + full: 'max-w-full' + }; + + const spacingMap: Record = { + 0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4', + 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14', + 16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44', + 48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96' + }; + + const classes = [ + 'mx-auto', + sizeClasses[size], + padding ? 'px-4 sm:px-6 lg:px-8' : '', + py !== undefined ? `py-${spacingMap[py]}` : '', + className + ].filter(Boolean).join(' '); + + return ( + + {children} + + ); +} diff --git a/apps/website/ui/CountryFlag.tsx b/apps/website/ui/CountryFlag.tsx new file mode 100644 index 000000000..3a2c52002 --- /dev/null +++ b/apps/website/ui/CountryFlag.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React, { useState } from 'react'; + +// ISO 3166-1 alpha-2 country code to full country name mapping +const countryNames: Record = { + 'US': 'United States', + 'GB': 'United Kingdom', + 'CA': 'Canada', + 'AU': 'Australia', + 'NZ': 'New Zealand', + 'DE': 'Germany', + 'FR': 'France', + 'IT': 'Italy', + 'ES': 'Spain', + 'NL': 'Netherlands', + 'BE': 'Belgium', + 'SE': 'Sweden', + 'NO': 'Norway', + 'DK': 'Denmark', + 'FI': 'Finland', + 'PL': 'Poland', + 'CZ': 'Czech Republic', + 'AT': 'Austria', + 'CH': 'Switzerland', + 'PT': 'Portugal', + 'IE': 'Ireland', + 'BR': 'Brazil', + 'MX': 'Mexico', + 'AR': 'Argentina', + 'JP': 'Japan', + 'CN': 'China', + 'KR': 'South Korea', + 'IN': 'India', + 'SG': 'Singapore', + 'TH': 'Thailand', + 'MY': 'Malaysia', + 'ID': 'Indonesia', + 'PH': 'Philippines', + 'ZA': 'South Africa', + 'RU': 'Russia', + 'MC': 'Monaco', + 'TR': 'Turkey', + 'GR': 'Greece', + 'HU': 'Hungary', + 'RO': 'Romania', + 'BG': 'Bulgaria', + 'HR': 'Croatia', + 'SI': 'Slovenia', + 'SK': 'Slovakia', + 'LT': 'Lithuania', + 'LV': 'Latvia', + 'EE': 'Estonia', +}; + +// ISO 3166-1 alpha-2 country code to flag emoji conversion +const countryCodeToFlag = (countryCode: string): string => { + if (!countryCode || countryCode.length !== 2) return '🏁'; + + // Convert ISO 3166-1 alpha-2 to regional indicator symbols + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + +interface CountryFlagProps { + countryCode: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; + showTooltip?: boolean; +} + +export function CountryFlag({ + countryCode, + size = 'md', + className = '', + showTooltip = true +}: CountryFlagProps) { + const [showTooltipState, setShowTooltipState] = useState(false); + + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + }; + + const flag = countryCodeToFlag(countryCode); + const countryName = countryNames[countryCode.toUpperCase()] || countryCode; + + return ( + setShowTooltipState(true)} + onMouseLeave={() => setShowTooltipState(false)} + title={showTooltip ? countryName : undefined} + > + {flag} + {showTooltip && showTooltipState && ( + + {countryName} + + + )} + + ); +} diff --git a/apps/website/ui/CountrySelect.tsx b/apps/website/ui/CountrySelect.tsx new file mode 100644 index 000000000..67b3cfdbd --- /dev/null +++ b/apps/website/ui/CountrySelect.tsx @@ -0,0 +1,191 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import { Globe, Search, ChevronDown, Check } from 'lucide-react'; +import { CountryFlag } from './CountryFlag'; + +export interface Country { + code: string; + name: string; +} + +export const COUNTRIES: Country[] = [ + { code: 'US', name: 'United States' }, + { code: 'GB', name: 'United Kingdom' }, + { code: 'DE', name: 'Germany' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'FR', name: 'France' }, + { code: 'IT', name: 'Italy' }, + { code: 'ES', name: 'Spain' }, + { code: 'AU', name: 'Australia' }, + { code: 'CA', name: 'Canada' }, + { code: 'BR', name: 'Brazil' }, + { code: 'JP', name: 'Japan' }, + { code: 'BE', name: 'Belgium' }, + { code: 'AT', name: 'Austria' }, + { code: 'CH', name: 'Switzerland' }, + { code: 'SE', name: 'Sweden' }, + { code: 'NO', name: 'Norway' }, + { code: 'DK', name: 'Denmark' }, + { code: 'FI', name: 'Finland' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'CZ', name: 'Czech Republic' }, + { code: 'HU', name: 'Hungary' }, + { code: 'RU', name: 'Russia' }, + { code: 'MX', name: 'Mexico' }, + { code: 'AR', name: 'Argentina' }, + { code: 'CL', name: 'Chile' }, + { code: 'NZ', name: 'New Zealand' }, + { code: 'ZA', name: 'South Africa' }, + { code: 'IN', name: 'India' }, + { code: 'KR', name: 'South Korea' }, + { code: 'SG', name: 'Singapore' }, + { code: 'MY', name: 'Malaysia' }, + { code: 'TH', name: 'Thailand' }, + { code: 'AE', name: 'United Arab Emirates' }, + { code: 'SA', name: 'Saudi Arabia' }, + { code: 'IE', name: 'Ireland' }, + { code: 'GR', name: 'Greece' }, + { code: 'TR', name: 'Turkey' }, + { code: 'RO', name: 'Romania' }, + { code: 'UA', name: 'Ukraine' }, +]; + +interface CountrySelectProps { + value: string; + onChange: (value: string) => void; + error?: boolean; + errorMessage?: string; + disabled?: boolean; + placeholder?: string; +} + +export function CountrySelect({ + value, + onChange, + error, + errorMessage, + disabled, + placeholder = 'Select country', +}: CountrySelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const selectedCountry = COUNTRIES.find(c => c.code === value); + + const filteredCountries = COUNTRIES.filter(country => + country.name.toLowerCase().includes(search.toLowerCase()) || + country.code.toLowerCase().includes(search.toLowerCase()) + ); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearch(''); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + const handleSelect = (code: string) => { + onChange(code); + setIsOpen(false); + setSearch(''); + }; + + return ( +
+ {/* Trigger Button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Search Input */} +
+
+ + setSearch(e.target.value)} + placeholder="Search countries..." + className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue" + /> +
+
+ + {/* Country List */} +
+ {filteredCountries.length > 0 ? ( + filteredCountries.map((country) => ( + + )) + ) : ( +
+ No countries found +
+ )} +
+
+ )} + + {/* Error Message */} + {error && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +} diff --git a/apps/website/ui/DecorativeBlur.tsx b/apps/website/ui/DecorativeBlur.tsx new file mode 100644 index 000000000..06c3cecd4 --- /dev/null +++ b/apps/website/ui/DecorativeBlur.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Box } from './Box'; + +interface DecorativeBlurProps { + color?: 'blue' | 'green' | 'purple' | 'yellow' | 'red'; + size?: 'sm' | 'md' | 'lg' | 'xl'; + position?: 'top-right' | 'bottom-left' | 'center'; + opacity?: number; +} + +export function DecorativeBlur({ + color = 'blue', + size = 'md', + position = 'center', + opacity = 10 +}: DecorativeBlurProps) { + const colorClasses = { + blue: 'bg-primary-blue', + green: 'bg-performance-green', + purple: 'bg-purple-600', + yellow: 'bg-yellow-400', + red: 'bg-racing-red' + }; + + const sizeClasses = { + sm: 'w-32 h-32 blur-xl', + md: 'w-48 h-48 blur-2xl', + lg: 'w-64 h-64 blur-3xl', + xl: 'w-96 h-96 blur-[64px]' + }; + + const positionClasses = { + 'top-right': 'absolute top-0 right-0', + 'bottom-left': 'absolute bottom-0 left-0', + 'center': 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2' + }; + + const opacityStyle = { opacity: opacity / 100 }; + + return ( + + ); +} diff --git a/apps/website/ui/DurationField.tsx b/apps/website/ui/DurationField.tsx new file mode 100644 index 000000000..b529e16ce --- /dev/null +++ b/apps/website/ui/DurationField.tsx @@ -0,0 +1,71 @@ +'use client'; + +import Input from '@/ui/Input'; + +interface DurationFieldProps { + label: string; + value: number | ''; + onChange: (value: number | '') => void; + helperText?: string; + required?: boolean; + disabled?: boolean; + unit?: 'minutes' | 'laps'; + error?: string; +} + +export default function DurationField({ + label, + value, + onChange, + helperText, + required, + disabled, + unit = 'minutes', + error, +}: DurationFieldProps) { + const handleChange = (raw: string) => { + if (raw.trim() === '') { + onChange(''); + return; + } + + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + onChange(''); + return; + } + + onChange(parsed); + }; + + const unitLabel = unit === 'laps' ? 'laps' : 'min'; + + return ( +
+ +
+
+ handleChange(e.target.value)} + disabled={disabled} + min={1} + className="pr-16" + error={!!error} + /> +
+ {unitLabel} +
+ {helperText && ( +

{helperText}

+ )} + {error && ( +

{error}

+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/ui/ErrorBanner.tsx b/apps/website/ui/ErrorBanner.tsx new file mode 100644 index 000000000..d09f8153c --- /dev/null +++ b/apps/website/ui/ErrorBanner.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Surface } from './Surface'; + +export interface ErrorBannerProps { + message: string; + title?: string; + variant?: 'error' | 'warning' | 'info'; +} + +export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) { + const variantColors = { + error: { bg: 'rgba(239, 68, 68, 0.1)', border: '#ef4444', text: '#ef4444' }, + warning: { bg: 'rgba(245, 158, 11, 0.1)', border: '#f59e0b', text: '#fcd34d' }, + info: { bg: 'rgba(59, 130, 246, 0.1)', border: '#3b82f6', text: '#3b82f6' }, + }; + + const colors = variantColors[variant]; + + return ( + + + {title && {title}} + {message} + + + ); +} diff --git a/apps/website/ui/FormField.tsx b/apps/website/ui/FormField.tsx new file mode 100644 index 000000000..e2f77f398 --- /dev/null +++ b/apps/website/ui/FormField.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React from 'react'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Icon } from './Icon'; + +import { LucideIcon } from 'lucide-react'; + +interface FormFieldProps { + label: string; + icon?: LucideIcon; + children: React.ReactNode; + required?: boolean; + error?: string; + hint?: string; +} + +export function FormField({ + label, + icon, + children, + required = false, + error, + hint, +}: FormFieldProps) { + return ( + + + {children} + {error && ( + {error} + )} + {hint && !error && ( + {hint} + )} + + ); +} diff --git a/apps/website/ui/Grid.tsx b/apps/website/ui/Grid.tsx new file mode 100644 index 000000000..3b93b3638 --- /dev/null +++ b/apps/website/ui/Grid.tsx @@ -0,0 +1,52 @@ +import React, { ReactNode, HTMLAttributes } from 'react'; +import { Box } from './Box'; + +interface GridProps extends HTMLAttributes { + children: ReactNode; + cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12; + gap?: number; + className?: string; +} + +export function Grid({ + children, + cols = 1, + gap = 4, + className = '', + ...props +}: GridProps) { + const colClasses: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 md:grid-cols-2', + 3: 'grid-cols-1 md:grid-cols-3', + 4: 'grid-cols-2 md:grid-cols-4', + 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5', + 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6', + 12: 'grid-cols-12' + }; + + const gapClasses: Record = { + 0: 'gap-0', + 1: 'gap-1', + 2: 'gap-2', + 3: 'gap-3', + 4: 'gap-4', + 6: 'gap-6', + 8: 'gap-8', + 12: 'gap-12', + 16: 'gap-16' + }; + + const classes = [ + 'grid', + colClasses[cols] || 'grid-cols-1', + gapClasses[gap] || 'gap-4', + className + ].filter(Boolean).join(' '); + + return ( + + {children} + + ); +} diff --git a/apps/website/ui/GridItem.tsx b/apps/website/ui/GridItem.tsx new file mode 100644 index 000000000..62aa5dab9 --- /dev/null +++ b/apps/website/ui/GridItem.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Box } from './Box'; + +interface GridItemProps { + children: React.ReactNode; + colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + mdSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + lgSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + className?: string; +} + +export function GridItem({ children, colSpan, mdSpan, lgSpan, className = '' }: GridItemProps) { + const spanClasses = [ + colSpan ? `col-span-${colSpan}` : '', + mdSpan ? `md:col-span-${mdSpan}` : '', + lgSpan ? `lg:col-span-${lgSpan}` : '', + className + ].filter(Boolean).join(' '); + + return ( + + {children} + + ); +} diff --git a/apps/website/ui/Header.tsx b/apps/website/ui/Header.tsx index 05335750a..547abb25f 100644 --- a/apps/website/ui/Header.tsx +++ b/apps/website/ui/Header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Container from '@/components/ui/Container'; +import Container from '@/ui/Container'; interface HeaderProps { children: React.ReactNode; diff --git a/apps/website/ui/Heading.tsx b/apps/website/ui/Heading.tsx new file mode 100644 index 000000000..b00ba324f --- /dev/null +++ b/apps/website/ui/Heading.tsx @@ -0,0 +1,34 @@ +import React, { ReactNode, HTMLAttributes } from 'react'; +import { Stack } from './Stack'; + +interface HeadingProps extends HTMLAttributes { + level: 1 | 2 | 3 | 4 | 5 | 6; + children: ReactNode; + className?: string; + style?: React.CSSProperties; + icon?: ReactNode; +} + +export function Heading({ level, children, className = '', style, icon, ...props }: HeadingProps) { + const Tag = `h${level}` as 'h1'; + + const levelClasses = { + 1: 'text-3xl md:text-4xl font-bold text-white', + 2: 'text-xl font-semibold text-white', + 3: 'text-lg font-semibold text-white', + 4: 'text-base font-semibold text-white', + 5: 'text-sm font-semibold text-white', + 6: 'text-xs font-semibold text-white', + }; + + const classes = [levelClasses[level], className].filter(Boolean).join(' '); + + const content = icon ? ( + + {icon} + {children} + + ) : children; + + return {content}; +} diff --git a/apps/website/ui/Hero.tsx b/apps/website/ui/Hero.tsx new file mode 100644 index 000000000..b642eec01 --- /dev/null +++ b/apps/website/ui/Hero.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from 'react'; +import { Box } from './Box'; + +interface HeroProps { + children: ReactNode; + className?: string; + variant?: 'default' | 'primary' | 'secondary'; +} + +export function Hero({ children, className = '', variant = 'default' }: HeroProps) { + const baseClasses = 'relative overflow-hidden rounded-2xl border p-8'; + + const variantClasses = { + default: 'bg-iron-gray border-charcoal-outline', + primary: 'bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border-charcoal-outline', + secondary: 'bg-gradient-to-br from-primary-blue/10 to-purple-600/10 border-primary-blue/20' + }; + + const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' '); + + return ( + + + + + {children} + + + ); +} diff --git a/apps/website/ui/Icon.tsx b/apps/website/ui/Icon.tsx new file mode 100644 index 000000000..62180fd38 --- /dev/null +++ b/apps/website/ui/Icon.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { LucideIcon } from 'lucide-react'; + +interface IconProps { + icon: LucideIcon; + size?: number | string; + color?: string; + className?: string; + style?: React.CSSProperties; +} + +export function Icon({ icon: LucideIcon, size = 4, color, className = '', style, ...props }: IconProps) { + const sizeMap: Record = { + 3: 'w-3 h-3', + 3.5: 'w-3.5 h-3.5', + 4: 'w-4 h-4', + 5: 'w-5 h-5', + 6: 'w-6 h-6', + 7: 'w-7 h-7', + 8: 'w-8 h-8', + 10: 'w-10 h-10', + 12: 'w-12 h-12', + 16: 'w-16 h-16' + }; + + const sizeClass = sizeMap[size] || 'w-4 h-4'; + + const combinedStyle = color ? { color, ...style } : style; + + return ( + + ); +} diff --git a/apps/website/ui/Image.tsx b/apps/website/ui/Image.tsx new file mode 100644 index 000000000..78e11d07a --- /dev/null +++ b/apps/website/ui/Image.tsx @@ -0,0 +1,23 @@ +import React, { ImgHTMLAttributes } from 'react'; + +interface ImageProps extends ImgHTMLAttributes { + src: string; + alt: string; + width?: number; + height?: number; + className?: string; +} + +export function Image({ src, alt, width, height, className = '', ...props }: ImageProps) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} diff --git a/apps/website/ui/InfoBanner.tsx b/apps/website/ui/InfoBanner.tsx new file mode 100644 index 000000000..a129906d4 --- /dev/null +++ b/apps/website/ui/InfoBanner.tsx @@ -0,0 +1,85 @@ +'use client'; + +import React from 'react'; +import { Info, AlertTriangle, CheckCircle, XCircle, LucideIcon } from 'lucide-react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Surface } from './Surface'; +import { Icon } from './Icon'; + +type BannerType = 'info' | 'warning' | 'success' | 'error'; + +interface InfoBannerProps { + type?: BannerType; + title?: string; + children: React.ReactNode; + icon?: LucideIcon; +} + +export function InfoBanner({ + type = 'info', + title, + children, + icon: CustomIcon, +}: InfoBannerProps) { + const bannerConfig: Record = { + info: { + icon: Info, + bg: 'rgba(38, 38, 38, 0.3)', + border: 'rgba(38, 38, 38, 0.5)', + titleColor: 'text-gray-300', + iconColor: '#9ca3af', + }, + warning: { + icon: AlertTriangle, + bg: 'rgba(245, 158, 11, 0.1)', + border: 'rgba(245, 158, 11, 0.3)', + titleColor: 'text-warning-amber', + iconColor: '#f59e0b', + }, + success: { + icon: CheckCircle, + bg: 'rgba(16, 185, 129, 0.1)', + border: 'rgba(16, 185, 129, 0.3)', + titleColor: 'text-performance-green', + iconColor: '#10b981', + }, + error: { + icon: XCircle, + bg: 'rgba(239, 68, 68, 0.1)', + border: 'rgba(239, 68, 68, 0.3)', + titleColor: 'text-error-red', + iconColor: '#ef4444', + }, + }; + + const config = bannerConfig[type]; + const BannerIcon = CustomIcon || config.icon; + + return ( + + + + + {title && ( + {title} + )} + {children} + + + + ); +} diff --git a/apps/website/ui/InfoBox.tsx b/apps/website/ui/InfoBox.tsx new file mode 100644 index 000000000..56fc80665 --- /dev/null +++ b/apps/website/ui/InfoBox.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Surface } from './Surface'; +import { Stack } from './Stack'; +import { Box } from './Box'; +import { Icon } from './Icon'; +import { Text } from './Text'; +import { LucideIcon } from 'lucide-react'; + +interface InfoBoxProps { + icon: LucideIcon; + title: string; + description: string; + variant?: 'primary' | 'success' | 'warning' | 'default'; +} + +export function InfoBox({ icon, title, description, variant = 'default' }: InfoBoxProps) { + const variantColors = { + primary: { + bg: 'rgba(59, 130, 246, 0.1)', + border: '#3b82f6', + text: '#3b82f6', + icon: '#3b82f6' + }, + success: { + bg: 'rgba(16, 185, 129, 0.1)', + border: '#10b981', + text: '#10b981', + icon: '#10b981' + }, + warning: { + bg: 'rgba(245, 158, 11, 0.1)', + border: '#f59e0b', + text: '#f59e0b', + icon: '#f59e0b' + }, + default: { + bg: 'rgba(38, 38, 38, 0.3)', + border: '#262626', + text: 'white', + icon: '#9ca3af' + } + }; + + const colors = variantColors[variant]; + + return ( + + + + + + + {title} + {description} + + + + ); +} diff --git a/apps/website/ui/Input.tsx b/apps/website/ui/Input.tsx index 6fafdbccf..3135133ab 100644 --- a/apps/website/ui/Input.tsx +++ b/apps/website/ui/Input.tsx @@ -1,16 +1,28 @@ import { forwardRef } from 'react'; +import { Text } from './Text'; +import { Box } from './Box'; interface InputProps extends React.InputHTMLAttributes { variant?: 'default' | 'error'; + errorMessage?: string; } export const Input = forwardRef( - ({ className = '', variant = 'default', ...props }, ref) => { - const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors'; - const variantClasses = variant === 'error' ? 'border-racing-red' : 'border-charcoal-outline'; + ({ className = '', variant = 'default', errorMessage, ...props }, ref) => { + const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors w-full'; + const variantClasses = (variant === 'error' || errorMessage) ? 'border-racing-red' : 'border-charcoal-outline'; const classes = `${baseClasses} ${variantClasses} ${className}`; - return ; + return ( + + + {errorMessage && ( + + {errorMessage} + + )} + + ); } ); diff --git a/apps/website/ui/Link.tsx b/apps/website/ui/Link.tsx index 5b2ad4321..31e41d170 100644 --- a/apps/website/ui/Link.tsx +++ b/apps/website/ui/Link.tsx @@ -1,13 +1,14 @@ -import React, { ReactNode } from 'react'; -import NextLink from 'next/link'; +import React, { ReactNode, AnchorHTMLAttributes } from 'react'; -interface LinkProps { +interface LinkProps extends AnchorHTMLAttributes { href: string; children: ReactNode; className?: string; variant?: 'primary' | 'secondary' | 'ghost'; target?: '_blank' | '_self' | '_parent' | '_top'; rel?: string; + onClick?: (e: React.MouseEvent) => void; + style?: React.CSSProperties; } export function Link({ @@ -16,7 +17,10 @@ export function Link({ className = '', variant = 'primary', target = '_self', - rel = '' + rel = '', + onClick, + style, + ...props }: LinkProps) { const baseClasses = 'inline-flex items-center transition-colors'; @@ -33,13 +37,16 @@ export function Link({ ].filter(Boolean).join(' '); return ( - {children} - + ); -} \ No newline at end of file +} diff --git a/apps/website/ui/LoadingSpinner.tsx b/apps/website/ui/LoadingSpinner.tsx new file mode 100644 index 000000000..4542ea786 --- /dev/null +++ b/apps/website/ui/LoadingSpinner.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface LoadingSpinnerProps { + size?: number; + color?: string; + className?: string; +} + +export function LoadingSpinner({ size = 8, color = '#3b82f6', className = '' }: LoadingSpinnerProps) { + const style: React.CSSProperties = { + width: `${size * 0.25}rem`, + height: `${size * 0.25}rem`, + border: '2px solid transparent', + borderTopColor: color, + borderLeftColor: color, + borderRadius: '9999px', + }; + + return ( +
+ ); +} diff --git a/apps/website/ui/MockupStack.tsx b/apps/website/ui/MockupStack.tsx new file mode 100644 index 000000000..5523627c1 --- /dev/null +++ b/apps/website/ui/MockupStack.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { motion, useReducedMotion } from 'framer-motion'; +import { ReactNode, useEffect, useState } from 'react'; + +interface MockupStackProps { + children: ReactNode; + index?: number; +} + +export default function MockupStack({ children, index = 0 }: MockupStackProps) { + const shouldReduceMotion = useReducedMotion(); + const [isMounted, setIsMounted] = useState(false); + const [isMobile, setIsMobile] = useState(true); // Default to mobile (no animations) + + useEffect(() => { + setIsMounted(true); + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + const seed = index * 1337; + const rotation1 = ((seed * 17) % 80 - 40) / 20; + const rotation2 = ((seed * 23) % 80 - 40) / 20; + + // On mobile or before mount, render without animations + if (!isMounted || isMobile) { + return ( +
+
+ +
+ +
+ {children} +
+
+ ); + } + + // Desktop: render with animations + return ( +
+ + + + + + + {children} + +
+ ); +} \ No newline at end of file diff --git a/apps/website/ui/Modal.tsx b/apps/website/ui/Modal.tsx new file mode 100644 index 000000000..72c77517f --- /dev/null +++ b/apps/website/ui/Modal.tsx @@ -0,0 +1,185 @@ +'use client'; + +import React, { + useEffect, + useRef, + type ReactNode, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Heading } from './Heading'; +import { Button } from './Button'; + +interface ModalProps { + title: string; + description?: string; + children?: ReactNode; + primaryActionLabel?: string; + secondaryActionLabel?: string; + onPrimaryAction?: () => void | Promise; + onSecondaryAction?: () => void; + onOpenChange?: (open: boolean) => void; + isOpen: boolean; +} + +export function Modal({ + title, + description, + children, + primaryActionLabel, + secondaryActionLabel, + onPrimaryAction, + onSecondaryAction, + onOpenChange, + isOpen, +}: ModalProps) { + const dialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + + useEffect(() => { + if (isOpen) { + previouslyFocusedElementRef.current = document.activeElement; + const focusable = getFirstFocusable(dialogRef.current); + if (focusable) { + focusable.focus(); + } else if (dialogRef.current) { + dialogRef.current.focus(); + } + return; + } + + if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) { + previouslyFocusedElementRef.current.focus(); + } + }, [isOpen]); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + if (onOpenChange) { + onOpenChange(false); + } + return; + } + + if (event.key === 'Tab') { + const focusable = getFocusableElements(dialogRef.current); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1] ?? first; + + if (!first || !last) { + return; + } + + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } + } + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget && onOpenChange) { + onOpenChange(false); + } + }; + + if (!isOpen) { + return null; + } + + return ( + + + + {title} + {description && ( + + {description} + + )} + + + + {children} + + + {(primaryActionLabel || secondaryActionLabel) && ( + + {secondaryActionLabel && ( + + )} + {primaryActionLabel && ( + + )} + + )} + + + ); +} + +function getFocusableElements(root: HTMLElement | null): HTMLElement[] { + if (!root) return []; + const selectors = [ + 'a[href]', + 'button:not([disabled])', + 'textarea:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ]; + const nodes = Array.from( + root.querySelectorAll(selectors.join(',')), + ); + return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')); +} + +function getFirstFocusable(root: HTMLElement | null): HTMLElement | null { + const elements = getFocusableElements(root); + return elements[0] ?? null; +} diff --git a/apps/website/ui/PageHeader.tsx b/apps/website/ui/PageHeader.tsx new file mode 100644 index 000000000..224f631bd --- /dev/null +++ b/apps/website/ui/PageHeader.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Heading } from './Heading'; +import { Surface } from './Surface'; +import { Icon } from './Icon'; +import { LucideIcon } from 'lucide-react'; + +interface PageHeaderProps { + icon: LucideIcon; + title: string; + description?: string; + action?: React.ReactNode; + iconGradient?: string; + iconBorder?: string; +} + +export function PageHeader({ + icon, + title, + description, + action, + iconGradient = 'from-iron-gray to-deep-graphite', + iconBorder = 'border-charcoal-outline', +}: PageHeaderProps) { + return ( + + + + + + + + + {title} + {description && ( + {description} + )} + + + + {action && {action}} + + + ); +} diff --git a/apps/website/ui/PlaceholderImage.tsx b/apps/website/ui/PlaceholderImage.tsx new file mode 100644 index 000000000..4ef35020c --- /dev/null +++ b/apps/website/ui/PlaceholderImage.tsx @@ -0,0 +1,22 @@ +'use client'; + +import React from 'react'; +import { User } from 'lucide-react'; +import { Box } from './Box'; +import { Icon } from './Icon'; + +export interface PlaceholderImageProps { + size?: number; + className?: string; +} + +export function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) { + return ( + + + + ); +} diff --git a/apps/website/ui/PresetCard.tsx b/apps/website/ui/PresetCard.tsx new file mode 100644 index 000000000..251d16fd7 --- /dev/null +++ b/apps/website/ui/PresetCard.tsx @@ -0,0 +1,122 @@ +'use client'; + +import type { MouseEventHandler, ReactNode } from 'react'; +import Card from './Card'; + +interface PresetCardStat { + label: string; + value: string; +} + +export interface PresetCardProps { + title: string; + subtitle?: string; + primaryTag?: string; + description?: string; + stats?: PresetCardStat[]; + selected?: boolean; + disabled?: boolean; + onSelect?: () => void; + className?: string; + children?: ReactNode; +} + +export default function PresetCard({ + title, + subtitle, + primaryTag, + description, + stats, + selected, + disabled, + onSelect, + className = '', + children, +}: PresetCardProps) { + const isInteractive = typeof onSelect === 'function' && !disabled; + + const handleClick: MouseEventHandler = (event) => { + if (!isInteractive) { + return; + } + event.preventDefault(); + onSelect?.(); + }; + + const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline'; + const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray'; + const baseRing = selected ? 'ring-2 ring-primary-blue/40' : ''; + const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : ''; + const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : ''; + + const content = ( +
+
+
+
{title}
+ {subtitle && ( +
{subtitle}
+ )} +
+
+ {primaryTag && ( + + {primaryTag} + + )} + {selected && ( + + + Selected + + )} +
+
+ + {description && ( +

{description}

+ )} + + {children} + + {stats && stats.length > 0 && ( +
+
+ {stats.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+
+ )} +
+ ); + + const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`; + + if (isInteractive) { + return ( + + ); + } + + return ( + } + > + {content} + + ); +} \ No newline at end of file diff --git a/apps/website/ui/QuickActionLink.tsx b/apps/website/ui/QuickActionLink.tsx index cea735db3..c6bd338ec 100644 --- a/apps/website/ui/QuickActionLink.tsx +++ b/apps/website/ui/QuickActionLink.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Text } from './Text'; interface QuickActionLinkProps { href: string; diff --git a/apps/website/ui/RangeField.tsx b/apps/website/ui/RangeField.tsx new file mode 100644 index 000000000..1332ff497 --- /dev/null +++ b/apps/website/ui/RangeField.tsx @@ -0,0 +1,271 @@ +'use client'; + +import React, { useCallback, useRef, useState, useEffect } from 'react'; + +interface RangeFieldProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + helperText?: string; + error?: string | undefined; + disabled?: boolean; + unitLabel?: string; + rangeHint?: string; + /** Show large value display above slider */ + showLargeValue?: boolean; + /** Compact mode - single line */ + compact?: boolean; +} + +export default function RangeField({ + label, + value, + min, + max, + step = 1, + onChange, + helperText, + error, + disabled, + unitLabel = 'min', + rangeHint, + showLargeValue = false, + compact = false, +}: RangeFieldProps) { + const [localValue, setLocalValue] = useState(value); + const [isDragging, setIsDragging] = useState(false); + const sliderRef = useRef(null); + const inputRef = useRef(null); + + // Sync local value with prop when not dragging + useEffect(() => { + if (!isDragging) { + setLocalValue(value); + } + }, [value, isDragging]); + + const clampedValue = Number.isFinite(localValue) + ? Math.min(Math.max(localValue, min), max) + : min; + + const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100; + + const effectiveRangeHint = + rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`); + + const calculateValueFromPosition = useCallback( + (clientX: number) => { + if (!sliderRef.current) return clampedValue; + const rect = sliderRef.current.getBoundingClientRect(); + const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); + const rawValue = min + percent * (max - min); + const steppedValue = Math.round(rawValue / step) * step; + return Math.min(Math.max(steppedValue, min), max); + }, + [min, max, step, clampedValue] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(true); + const newValue = calculateValueFromPosition(e.clientX); + setLocalValue(newValue); + onChange(newValue); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [disabled, calculateValueFromPosition, onChange] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDragging || disabled) return; + const newValue = calculateValueFromPosition(e.clientX); + setLocalValue(newValue); + onChange(newValue); + }, + [isDragging, disabled, calculateValueFromPosition, onChange] + ); + + const handlePointerUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === '') { + setLocalValue(min); + return; + } + const parsed = parseInt(raw, 10); + if (!Number.isNaN(parsed)) { + const clamped = Math.min(Math.max(parsed, min), max); + setLocalValue(clamped); + onChange(clamped); + } + }; + + const handleInputBlur = () => { + // Ensure value is synced on blur + onChange(clampedValue); + }; + + // Quick preset buttons for common values + const quickPresets = [ + Math.round(min + (max - min) * 0.25), + Math.round(min + (max - min) * 0.5), + Math.round(min + (max - min) * 0.75), + ].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue); + + if (compact) { + return ( +
+
+ +
+
+ {/* Track background */} +
+ {/* Track fill */} +
+ {/* Thumb */} +
+
+
+ {clampedValue} + {unitLabel} +
+
+
+ {error &&

{error}

} +
+ ); + } + + return ( +
+
+ + {effectiveRangeHint} +
+ + {showLargeValue && ( +
+ {clampedValue} + {unitLabel} +
+ )} + + {/* Custom slider */} +
+ {/* Track background */} +
+ + {/* Track fill with gradient */} +
+ + {/* Tick marks */} +
+ {[0, 25, 50, 75, 100].map((tick) => ( +
= tick ? 'bg-white/40' : 'bg-charcoal-outline' + }`} + /> + ))} +
+ + {/* Thumb */} +
+
+ + {/* Value input and quick presets */} +
+
+ + {unitLabel} +
+ + {quickPresets.length > 0 && ( +
+ {quickPresets.slice(0, 3).map((preset) => ( + + ))} +
+ )} +
+ + {helperText &&

{helperText}

} + {error &&

{error}

} +
+ ); +} \ No newline at end of file diff --git a/apps/website/ui/Section.tsx b/apps/website/ui/Section.tsx index a3fc5a325..9e198e560 100644 --- a/apps/website/ui/Section.tsx +++ b/apps/website/ui/Section.tsx @@ -1,11 +1,18 @@ +'use client'; + import React, { ReactNode } from 'react'; +import { Box } from './Box'; +import { Heading } from './Heading'; +import { Text } from './Text'; interface SectionProps { children: ReactNode; className?: string; title?: string; description?: string; - variant?: 'default' | 'card' | 'highlight'; + variant?: 'default' | 'card' | 'highlight' | 'dark' | 'light'; + id?: string; + py?: number; } export function Section({ @@ -13,31 +20,34 @@ export function Section({ className = '', title, description, - variant = 'default' + variant = 'default', + id, + py = 16 }: SectionProps) { - const baseClasses = 'space-y-4'; - const variantClasses = { default: '', card: 'bg-iron-gray rounded-lg p-6 border border-charcoal-outline', - highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30' + highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30', + dark: 'bg-iron-gray', + light: 'bg-charcoal-outline' }; const classes = [ - baseClasses, variantClasses[variant], className ].filter(Boolean).join(' '); return ( -
- {title && ( -

{title}

- )} - {description && ( -

{description}

- )} - {children} -
+ + + {(title || description) && ( + + {title && {title}} + {description && {description}} + + )} + {children} + + ); -} \ No newline at end of file +} diff --git a/apps/website/ui/SectionHeader.tsx b/apps/website/ui/SectionHeader.tsx new file mode 100644 index 000000000..850ac74e2 --- /dev/null +++ b/apps/website/ui/SectionHeader.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Heading } from './Heading'; +import { Surface } from './Surface'; +import { Icon } from './Icon'; +import { LucideIcon } from 'lucide-react'; + +interface SectionHeaderProps { + icon: LucideIcon; + title: string; + description?: string; + action?: React.ReactNode; + color?: string; +} + +export function SectionHeader({ + icon, + title, + description, + action, + color = '#3b82f6' +}: SectionHeaderProps) { + return ( + + + + + + + + + {title} + {description && ( + {description} + )} + + + + {action && {action}} + + + ); +} diff --git a/apps/website/ui/SegmentedControl.tsx b/apps/website/ui/SegmentedControl.tsx new file mode 100644 index 000000000..7c8e50f25 --- /dev/null +++ b/apps/website/ui/SegmentedControl.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; + +interface SegmentedControlOption { + value: string; + label: string; + description?: string; + disabled?: boolean; +} + +interface SegmentedControlProps { + options: SegmentedControlOption[]; + value: string; + onChange?: (value: string) => void; +} + +export function SegmentedControl({ + options, + value, + onChange, +}: SegmentedControlProps) { + const handleSelect = (optionValue: string, optionDisabled?: boolean) => { + if (!onChange || optionDisabled) return; + if (optionValue === value) return; + onChange(optionValue); + }; + + return ( + + {options.map((option) => { + const isSelected = option.value === value; + + return ( + handleSelect(option.value, option.disabled)} + aria-pressed={isSelected} + disabled={option.disabled} + style={{ + flex: 1, + minWidth: '140px', + padding: '0.375rem 0.75rem', + borderRadius: '9999px', + transition: 'all 0.2s', + textAlign: 'left', + backgroundColor: isSelected ? '#3b82f6' : 'transparent', + color: isSelected ? 'white' : '#d1d5db', + opacity: option.disabled ? 0.5 : 1, + cursor: option.disabled ? 'not-allowed' : 'pointer', + border: 'none' + }} + > + + {option.label} + {option.description && ( + + {option.description} + + )} + + + ); + })} + + ); +} diff --git a/apps/website/ui/Select.tsx b/apps/website/ui/Select.tsx index 72fa243e3..fdc1ad138 100644 --- a/apps/website/ui/Select.tsx +++ b/apps/website/ui/Select.tsx @@ -5,13 +5,14 @@ interface SelectOption { label: string; } -interface SelectProps { +interface SelectProps extends React.SelectHTMLAttributes { id?: string; 'aria-label'?: string; value?: string; onChange?: (e: ChangeEvent) => void; options: SelectOption[]; className?: string; + style?: React.CSSProperties; } export function Select({ @@ -21,6 +22,8 @@ export function Select({ onChange, options, className = '', + style, + ...props }: SelectProps) { const defaultClasses = 'w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors'; const classes = className ? `${defaultClasses} ${className}` : defaultClasses; @@ -32,6 +35,8 @@ export function Select({ value={value} onChange={onChange} className={classes} + style={style} + {...props} > {options.map((option) => (