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
-
-
-
-
- Refresh
-
-
+
+
+ {/* Header */}
+
+
+ Admin Dashboard
+
+ System overview and statistics
+
+
+ }
+ >
+ Refresh
+
+
- {/* 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
-
-
-
- Refresh
-
-
-
- {/* Error Banner */}
- {error && (
-
-
-
- Error
- {error}
-
+
+
+ {/* Header */}
+
+
+ User Management
+ Manage and monitor all system users
+
{}}
+ onClick={onRefresh}
+ disabled={loading}
variant="secondary"
- className="text-racing-red hover:opacity-70 p-0"
+ icon={ }
>
- Γ
+ Refresh
-
- )}
+
- {/* 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
- Clear all
+ Clear filters
- )}
-
-
-
-
-
- onSearch(e.target.value)}
- className="pl-9"
- />
-
-
-
onFilterRole(e.target.value)}
- options={[
- { value: '', label: 'All Roles' },
- { value: 'owner', label: 'Owner' },
- { value: 'admin', label: 'Admin' },
- { value: 'user', label: 'User' },
- ]}
- />
-
- onFilterStatus(e.target.value)}
- options={[
- { value: '', label: 'All Status' },
- { value: 'active', label: 'Active' },
- { value: 'suspended', label: 'Suspended' },
- { value: 'deleted', label: 'Deleted' },
- ]}
- />
-
-
-
-
- {/* Users Table */}
-
- {loading ? (
-
- ) : !viewData.users || viewData.users.length === 0 ? (
-
-
- No users found
-
- Clear filters
-
-
- ) : (
-
-
-
- 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' && (
- onUpdateStatus(user.id, 'suspended')}
- variant="secondary"
- className="px-3 py-1 text-xs bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30"
- >
- Suspend
-
- )}
- {user.status === 'suspended' && (
- onUpdateStatus(user.id, 'active')}
- variant="secondary"
- className="px-3 py-1 text-xs bg-performance-green/20 text-performance-green hover:bg-performance-green/30"
- >
- Activate
-
- )}
- {user.status !== 'deleted' && (
- onDeleteUser(user.id)}
- disabled={deletingUser === user.id}
- variant="secondary"
- className="px-3 py-1 text-xs bg-racing-red/20 text-racing-red hover:bg-racing-red/30 flex items-center gap-1"
- >
-
- {deletingUser === user.id ? 'Deleting...' : 'Delete'}
-
- )}
-
-
+
+ ) : (
+
+
+
+ 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' && (
+ onUpdateStatus(user.id, 'suspended')}
+ variant="secondary"
+ size="sm"
+ >
+ Suspend
+
+ )}
+ {user.status === 'suspended' && (
+ onUpdateStatus(user.id, 'active')}
+ variant="secondary"
+ size="sm"
+ >
+ Activate
+
+ )}
+ {user.status !== 'deleted' && (
+ onDeleteUser(user.id)}
+ disabled={deletingUser === user.id}
+ variant="secondary"
+ size="sm"
+ icon={ }
+ >
+ {deletingUser === user.id ? 'Deleting...' : 'Delete'}
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
- {/* 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 */}
-
-
-
-
-
-
-
-
-
-
-
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}
-
-
-
-
-
-
-
-
- )}
-
- {/* League Standings Preview */}
- {hasLeagueStandings && (
-
-
-
- {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 */}
-
-
- {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.country}
-
-
- ))}
- {friends.length > 6 && (
-
- +{friends.length - 6} more
-
- )}
-
- ) : (
-
- )}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
-}
\ 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'}
Back to Drivers
-
-
+
+
);
}
- 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 to Drivers
-
+
+
+ {/* Back Navigation */}
+
+ }
+ >
+ Back to Drivers
+
+
- {/* 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 */}
-
+ {/* 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 */}
-
-
-
- {friendRequestSent ? 'Request Sent' : 'Add Friend'}
-
-
-
-
- {/* 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 */}
-
- setActiveTab('overview')}
- className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
- activeTab === 'overview'
- ? 'bg-primary-blue text-white'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray'
- }`}
- >
-
- Overview
-
- setActiveTab('stats')}
- className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
- activeTab === 'stats'
- ? 'bg-primary-blue text-white'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray'
- }`}
- >
-
- Detailed Stats
-
-
+ {socialSummary.friends.length > 0 && (
+
+ )}
+
+ )}
- {/* Tab Content */}
- {activeTab === 'overview' && (
- <>
- {/* Stats and Profile Grid */}
-
- {/* Career Stats */}
-
-
-
- Career Statistics
-
- {stats ? (
-
-
-
{stats.totalRaces}
-
Races
-
-
-
-
{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}
-
{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 */}
-
-
-
-
- {((stats.wins / stats.totalRaces) * 100).toFixed(1)}%
-
-
-
-
-
- {((stats.podiums / stats.totalRaces) * 100).toFixed(1)}%
-
-
-
-
-
{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
-
-
-
-
-
- {/* 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 && (
-
-
- Back to Leaderboards
-
- )}
-
-
-
-
-
-
-
- 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 (
-
onDriverClick?.(driver.id)}
- className="flex flex-col items-center group"
- >
-
-
-
- {driver.name}
-
-
-
- {driver.rating.toString()}
-
-
-
-
- π
- {driver.wins}
-
- β’
-
- π
- {driver.podiums}
-
-
-
-
-
- {position}
-
-
-
- );
- })}
-
-
- )}
-
- {/* Leaderboard Table */}
-
- {/* Table Header */}
-
-
Rank
-
Driver
-
Races
-
Rating
-
Wins
-
Podiums
-
Win Rate
-
-
- {/* Table Body */}
-
- {viewData.drivers.map((driver) => {
- const position = driver.rank;
-
- return (
-
onDriverClick?.(driver.id)}
- className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
+
+
+ {/* Header */}
+
+ {onBackToLeaderboards && (
+
+ }
>
- {/* Position */}
-
-
- {position <= 3 ? : position}
-
-
+ Back to Leaderboards
+
+
+ )}
- {/* Driver Info */}
-
-
-
-
-
-
- {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 (
-
- );
- }
-
return (
-
- {/* Hero Section */}
-
- {/* Background decoration */}
-
-
-
+
+
+ {/* Hero Section */}
+
-
-
-
-
- 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 */}
-
-
router.push('/leaderboards/drivers')}
- className="flex items-center gap-2 px-6 py-3"
- >
-
- View Leaderboard
-
-
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}"
-
setSearchQuery('')}>
- Clear search
-
-
-
- )}
-
+ )}
+
+
);
-}
\ 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
-
- View all
-
-
-
-
+
+ {/* Top leagues */}
+
+
+
+ Featured leagues
+
+
+ View all
+
+
+
+
+ {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
-
- Browse teams
-
-
-
- {data.teams.slice(0, 4).map(team => (
-
-
-
-
-
-
{team.name}
-
- {team.description}
-
-
-
- ))}
-
-
+ {/* Teams */}
+
+
+
+ Teams on the grid
+
+
+ Browse teams
+
+
+
+
+ {viewData.teams.slice(0, 4).map(team => (
+
+
+
+
+
+
+ {team.name}
+ {team.description}
+
+
+
+ ))}
+
+
+
- {/* Upcoming races */}
-
-
-
Upcoming races
-
- View schedule
-
-
- {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
+
+
+ View schedule
+
+
+
+ {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?
-
-
-
-
-
- Driver Rankings
-
-
-
- Team Rankings
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
-}
\ 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.
+
-
-
- Season
-
- {seasons.length > 0 ? (
-
onSeasonChange(e.target.value)}
- className="bg-iron-gray text-white px-3 py-2 rounded"
- >
- {seasons.map((s) => (
-
- {s.name}
-
- ))}
-
- ) : (
-
onSeasonChange(e.target.value)}
- className="bg-iron-gray text-white px-3 py-2 rounded"
- placeholder="season-id"
- />
- )}
-
Selected: {selectedSeasonLabel}
-
+
+ Season
+ onSeasonChange(e.target.value)}
+ options={seasons.map(s => ({ value: s.seasonId, label: s.name }))}
+ />
+ Selected: {selectedSeasonLabel}
+
-
-
- Status: {publishedLabel}
-
-
+
+ Status: {publishedLabel}
+
+
- {isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')}
-
-
+ {isPublishing ? 'Processing...' : (published ? 'Unpublish' : 'Publish')}
+
+
-
-
{isEditing ? 'Edit race' : 'Add race'}
+
+
+ {isEditing ? 'Edit race' : 'Add race'}
+
-
+
+
-
-
+
{isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
-
+
{isEditing && (
-
Cancel
-
+
)}
-
-
+
+
-
-
Races
+
+
+ Races
+
- {schedule?.races.length ? (
-
- {schedule.races.map((race) => (
-
0 ? (
+
+ {races.map((race) => (
+
-
-
{race.name}
-
{race.scheduledAt.toISOString()}
-
+
+
+ {race.name}
+ {race.scheduledAt}
+
-
- onEdit(race.id)}
- className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
- >
- Edit
-
- onDelete(race.id)}
- disabled={isDeleting === race.id}
- className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
- >
- {isDeleting === race.id ? 'Deleting...' : 'Delete'}
-
-
-
+
+ onEdit(race.id)}
+ variant="secondary"
+ size="sm"
+ >
+ Edit
+
+ onDelete(race.id)}
+ disabled={isDeleting === race.id}
+ variant="secondary"
+ size="sm"
+ >
+ {isDeleting === race.id ? 'Deleting...' : 'Delete'}
+
+
+
+
))}
-
+
) : (
- 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}
-
- ))}
-
-
+
-
-
-
+
+
+
);
-}
\ 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) => (
- setActiveSection(section.id)}
- className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
- activeSection === section.id
- ? 'bg-iron-gray text-white'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray/50'
- }`}
- >
- {section.label}
-
- ))}
-
+
{/* 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 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
-
-
-
-
-
- Infraction
- Typical Penalty
-
-
-
-
- Causing avoidable contact
- 5-10 second time penalty
-
-
- Unsafe rejoin
- 5 second time penalty
-
-
- Blocking
- Warning or 3 second penalty
-
-
- Repeated track limit violations
- 5 second penalty
-
-
- Intentional wrecking
- Disqualification
-
-
- Unsportsmanlike conduct
- Points 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
+
+
-
-
-
Name
-
{viewData.league.name}
-
-
-
Visibility
-
{viewData.league.visibility}
-
-
-
Description
-
{viewData.league.description}
-
-
-
Created
-
{new Date(viewData.league.createdAt).toLocaleDateString()}
-
-
-
Owner ID
-
{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
- setEditMode(false)}>
- Cancel
-
-
+
+
+
+ Edit Profile
+ onEditModeChange(false)}>
+ Cancel
+
+
- {/* 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 */}
-
-
-
-
+
+
+ {/* Back Navigation */}
+
+ {}}
+ icon={ }
+ >
+ Back to Drivers
+
+
-
-
-
{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}
-
- )}
-
-
-
-
- setEditMode(true)} className="flex items-center gap-2">
-
- Edit Profile
-
- setFriendRequestSent(true)}
- disabled={friendRequestSent}
- className="w-full flex items-center gap-2"
- >
-
- {friendRequestSent ? 'Request Sent' : 'Add Friend'}
-
-
-
-
- My Leagues
-
-
-
-
-
- {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 */}
-
- setActiveTab('overview')}
- className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
- activeTab === 'overview'
- ? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
- }`}
- >
-
- Overview
-
- setActiveTab('history')}
- className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
- activeTab === 'history'
- ? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
- }`}
- >
-
- Race History
-
- setActiveTab('stats')}
- className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
- activeTab === 'stats'
- ? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
- : 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
- }`}
- >
-
- Detailed Stats
-
-
-
- {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.
+
+
+
+ Back to Races
+
+
+
+
+
+ );
+ }
- 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.
-
-
-
- Back to Races
-
-
-
-
-
- );
- }
-
- 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 */}
+
+
}
>
-
Back
-
+
- {/* 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 && (
-
- )}
-
-
-
-
-
+
)}
{/* 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
-
-
-
-
-
-
-
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 */}
-
-
- {/* Nation flag */}
-
- {countryFlag}
-
-
-
- {/* Driver info */}
-
-
-
- {driver.name}
-
- {isCurrentUser && (
-
- You
-
- )}
-
-
{driver.country}
-
-
- {/* Rating badge */}
- {driver.rating != null && (
-
-
-
- {driver.rating}
-
-
- )}
-
- );
- })}
-
- )}
-
-
-
- {/* Sidebar */}
-
- {/* League Card - Premium Design */}
- {league && (
-
-
-
-
+
+ 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' && (
- <>
-
-
- View Results
-
- {userResult && (
-
-
- File Protest
-
+ {race.status === 'completed' && (
+ <>
+ }>
+ View Results
+
+ {userResult && (
+ }>
+ File Protest
+
+ )}
+ }>
+ Stewarding
+
+ >
)}
-
-
- Stewarding
-
- >
- )}
-
-
+
+
+
- {/* 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...
+
+
);
}
- if (error && !raceTrack) {
+ if (error && !viewData.raceTrack) {
return (
-
-
-
-
- {error?.message || 'Race not found'}
-
+
+
+
+ {error?.message || 'Race not found'}
Back to Races
-
-
-
+
+
+
);
}
- 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 */}
-
-
-
- {countryFlag}
-
-
-
- {/* Driver Info */}
-
-
-
- {result.driverName}
-
- {isCurrentUser && (
-
- You
-
- )}
-
-
- {result.car}
- β’
- Laps: {result.laps}
- β’
- Incidents: {result.incidents}
-
-
-
- {/* Times */}
-
-
{result.time}
-
FL: {result.fastestLap}
-
-
- {/* 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.
-
-
{
- // Mock import for demo
- onImportResults([]);
- }}
- disabled={importing}
- >
- Import Results (Demo)
-
-
+
+
+ onImportResults([])}
+ disabled={importing}
+ >
+ Import Results (Demo)
+
+
+
)}
- >
+
)}
-
-
+
+
);
-}
\ 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.
-
-
-
- Back to Races
-
-
-
-
-
+
+
+
+
+
+
+
+ Race not found
+ The race you're looking for doesn't exist.
+
+
+ Back to Races
+
+
+
+
);
}
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 */}
-
-
+
+
onBack()}
- className="flex items-center gap-2 text-sm"
+ onClick={onBack}
+ icon={ }
>
-
Back to Race
-
+
{/* 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 && (
- <>
-
β’
-
-
- Video Evidence
-
- >
- )}
-
-
{protest.incident.description}
-
- {isAdmin && stewardingData?.league && (
-
onReviewProtest(protest.id)}
- >
- Review
-
- )}
-
-
- );
- })
+ 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
-
-
+
+
setShowFilters(!showFilters)}
- className="flex items-center gap-2"
+ icon={ }
>
-
Filters
-
+
- {/* 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 */}
- setStatusFilter(e.target.value as StatusFilter)}
- className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
- >
- All Statuses
- Scheduled
- Live
- Completed
- Cancelled
-
-
- {/* League Filter */}
- setLeagueFilter(e.target.value)}
- className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
- >
- All Leagues
- {races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
- const race = races.find(r => r.leagueId === leagueId);
- return race ? (
-
- {race.leagueName}
-
- ) : null;
- })}
-
-
- {/* Clear Filters */}
- {(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
- {
- setStatusFilter('all');
- setLeagueFilter('all');
- setSearchQuery('');
- }}
- className="px-4 py-2 text-sm text-primary-blue hover:underline"
- >
- Clear filters
-
- )}
-
-
-
+ {/* Search & Filters (Simplified for template) */}
+ {showFilters && (
+
+
+
+ Use the filter button to open advanced search and filtering options.
+
+
+ setShowFilterModal(true)}>
+ Open Filters
+
+
+
+
+ )}
{/* 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 && (
-
-
-
-
-
-
-
- {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 => (
- setTimeFilter(filter)}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
- timeFilter === filter
- ? 'bg-primary-blue text-white'
- : 'text-gray-400 hover:text-white'
- }`}
- >
- {filter === 'live' && }
- {filter.charAt(0).toUpperCase() + filter.slice(1)}
-
- ))}
-
+
+
+
- {/* League Filter */}
-
setLeagueFilter(e.target.value)}
- className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
- >
- All Leagues
- {races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
- const item = races.find(r => r.leagueId === leagueId);
- return item ? (
-
- {item.leagueName}
-
- ) : null;
- })}
-
+
+
+
+
- {/* Filter Button */}
-
setShowFilterModal(true)}
- className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white text-sm hover:border-primary-blue transition-colors"
- >
- More Filters
-
-
-
-
- {/* 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 && (
-
- )}
-
-
- {/* 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}
+ )}
+
-
- onApprove(req.id)}
- className="bg-primary-blue text-white"
- >
- Approve
-
- onReject(req.id)}
- className="bg-iron-gray text-gray-200"
- >
- Reject
-
-
-
+
+ onApprove(req.id)}
+ variant="primary"
+ size="sm"
+ >
+ Approve
+
+ onReject(req.id)}
+ variant="secondary"
+ size="sm"
+ >
+ Reject
+
+
+
+
))}
-
+
) : (
-
- 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}
+
-
-
- Role for {(member.driver as any)?.name || 'Unknown'}
-
- onRoleChange(member.driverId, e.target.value as MembershipRole)}
- options={roleOptions.map((role) => ({ value: role, label: role }))}
- className="bg-iron-gray text-white px-3 py-2 rounded"
- />
- onRemove(member.driverId)}
- className="bg-iron-gray text-gray-200"
- >
- Remove
-
-
-
+
+
+ onRoleChange(member.driverId, e.target.value as MembershipRole)}
+ options={roleOptions.map((role) => ({ value: role, label: role }))}
+ />
+
+ onRemove(member.driverId)}
+ variant="secondary"
+ size="sm"
+ >
+ Remove
+
+
+
+
))}
-
+
) : (
-
- 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
-
-
-
-
- Position
- Points
-
-
-
- {viewData.positionPoints.map((point) => (
-
- {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}
+
+
))}
-
+
)}
{/* 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) => (
- {}}
- className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
- false
- ? 'bg-primary-blue text-white'
- : 'text-gray-400 hover:text-white'
- }`}
- >
- {range === 'all' ? 'All' : range}
-
- ))}
-
-
- {/* Quick Actions */}
-
-
-
-
-
-
-
-
-
-
-
- {/* Key Metrics */}
-
-
-
-
-
-
-
- {/* Sponsorship Categories */}
-
-
-
Your Sponsorships
-
-
- View All
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Main Content Grid */}
-
- {/* Left Column - Sponsored Entities */}
-
- {/* Top Performing Sponsorships */}
-
-
-
Top Performing
-
-
-
- Find More
-
-
-
-
- {/* Mock data for now */}
-
-
-
- Main
-
-
-
-
- Sample League
-
-
Sample details
-
-
-
-
-
-
+
+
+ {/* Header */}
+
+
+ Sponsor Dashboard
+ Welcome back, {viewData.sponsorName}
+
+
+ {/* Time Range Selector */}
+
+
+ {(['7d', '30d', '90d', 'all'] as const).map((range) => (
+
+ {range === 'all' ? 'All' : range}
-
-
-
-
-
- {/* Upcoming Events */}
-
-
-
-
- Upcoming Sponsored Events
-
-
-
-
-
-
No upcoming sponsored events
-
-
-
-
-
- {/* Right Column - Activity & Quick Actions */}
-
- {/* Quick Actions */}
-
- Quick Actions
-
-
-
-
- Find Leagues to Sponsor
-
-
-
-
-
- Browse Teams
-
-
-
-
-
- Discover Drivers
-
-
-
-
-
- Manage Billing
-
-
-
-
-
- View Analytics
-
-
-
-
-
- {/* 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
-
-
-
-
-
- View Billing Details
-
-
-
-
-
-
-
-
+ {/* Key Metrics */}
+
+
+
+
+
+
+
+ {/* Sponsorship Categories */}
+
+
+ Your Sponsorships
+
+
+ }>
+ View All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Main Content Grid */}
+
+
+
+ {/* Top Performing Sponsorships */}
+
+
+
+ Top Performing
+
+
+ }>
+ Find More
+
+
+
+
+
+
+
+
+
+ Main
+
+
+
+ Sample League
+
+ Sample details
+
+
+
+
+ 1.2k
+ impressions
+
+
+
+
+
+
+
+
+
+
+ {/* Upcoming Events */}
+
+
+ }>
+ Upcoming Sponsored Events
+
+
+
+
+
+ No upcoming sponsored events
+
+
+
+
+
+
+
+
+ {/* Quick Actions */}
+
+
+ Quick Actions
+
+
+
+ }>
+ Find Leagues to Sponsor
+
+
+
+
+
+ }>
+ Browse Teams
+
+
+
+
+
+ }>
+ Discover Drivers
+
+
+
+
+
+ }>
+ Manage Billing
+
+
+
+
+
+ }>
+ View Analytics
+
+
+
+
+
+
+
+ {/* 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
+
+
+
+
+ }>
+ View Billing Details
+
+
+
+
+
+
+
+
+
+
+
+
);
-}
\ 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}
-
-
-
-
-
-
- View League
-
-
- {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
- setActiveTab('sponsor')}>
-
- Become a Sponsor
-
- )}
-
-
+ {/* Header */}
+
+
+
+ β {league.tier}
+ Active Season
+
+
+
+ {league.rating}
+
+
+
+ {league.name}
+
+ {league.game} β’ {league.season} β’ {league.completedRaces}/{league.races} races completed
+
+
+ {league.description}
+
+
+
+
+
+ }>
+ View League
+
+
+ {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
+ setActiveTab('sponsor')} icon={ }>
+ Become a Sponsor
+
+ )}
+
+
- {/* 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) => (
- setActiveTab(tab)}
- className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
- activeTab === tab
- ? 'text-primary-blue border-primary-blue'
- : 'text-gray-400 border-transparent hover:text-white'
- }`}
- >
- {tab === 'sponsor' ? 'π― Become a Sponsor' : 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}
+
+
+
+ View Schedule
+
+
+
+
+
+ )}
+
+ )}
+
+ {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}
-
-
-
- View Schedule
+ {activeTab === 'sponsor' && (
+
+
+ setSelectedTier('main')}
+ />
+ 0}
+ availableCount={league.sponsorSlots.secondary.available}
+ totalCount={league.sponsorSlots.secondary.total}
+ price={league.sponsorSlots.secondary.price}
+ benefits={league.sponsorSlots.secondary.benefits}
+ isSelected={selectedTier === 'secondary'}
+ onClick={() => setSelectedTier('secondary')}
+ />
+
+
+
+
+ }>
+ Sponsorship Summary
+
+
+
+
+
+
+
+
+
+ Total (excl. VAT)
+
+ ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
+
+
+
+
+
+
+ {siteConfig.vat.notice}
+
+
+
+ }>
+ Request Sponsorship
-
+ }>
+ Download Info Pack
+
+
- )}
-
- )}
-
- {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}
-
-
-
-
-
- Request Sponsorship
-
-
-
- Download Info Pack
-
-
-
-
- )}
-
+
+ )}
+
+
);
-}
\ 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 */}
-
-
-
-
- {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 */}
-
-
-
- View Details
-
-
- {(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
-
-
- Sponsor
-
-
- )}
-
-
-
-
- );
-}
-
-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 */}
-
setTierFilter(e.target.value as TierFilter)}
- className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
- >
- All Tiers
- β Premium
- π Standard
- π Starter
-
-
- {/* Availability Filter */}
-
setAvailabilityFilter(e.target.value as AvailabilityFilter)}
- className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
- >
- All Slots
- Main Available
- Secondary Available
-
-
- {/* Sort */}
-
setSortBy(e.target.value as SortOption)}
- className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
- >
- Sort by Rating
- Sort by Drivers
- Sort by Views
- Sort by Price
-
-
-
- {/* Results Count */}
-
-
- Showing {filteredLeagues.length} of {data.leagues.length} leagues
-
-
-
-
-
- Browse Teams
-
-
-
-
-
- Browse Drivers
-
-
-
-
-
- {/* League Grid */}
- {filteredLeagues.length > 0 ? (
-
- {filteredLeagues.map((league: any, index: number) => (
-
- ))}
-
- ) : (
-
-
- No leagues found
- Try adjusting your filters to see more results
- {
- setSearchQuery('');
- setTierFilter('all');
- setAvailabilityFilter('all');
- }}>
- Clear Filters
-
+ {/* 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
+
+
+
+ }>
+ Browse Teams
+
+
+
+ }>
+ Browse Drivers
+
+
+
+
+
+ {/* League Grid */}
+ {filteredLeagues.length > 0 ? (
+
+ {filteredLeagues.map((league) => (
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
+ No leagues found
+ Try adjusting your filters to see more results
+
+ {
+ setSearchQuery('');
+ setTierFilter('all');
+ setAvailabilityFilter('all');
+ }}>
+ Clear Filters
+
+
+
+ )}
+
+ {/* 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()}
-
-
-
- onAccept(request.id)}
+ {section.requests.length === 0 ? (
+ No pending requests.
+ ) : (
+
+ {section.requests.map((request) => (
+
- Accept
-
- onReject(request.id)}
- >
- Reject
-
-
-
- ))}
-
- )}
-
- ))}
+
+
+ {request.sponsorName}
+ {request.message && (
+ {request.message}
+ )}
+
+ {new Date(request.createdAtIso).toLocaleDateString()}
+
+
+
+ onAccept(request.id)}
+ size="sm"
+ >
+ Accept
+
+ onReject(request.id)}
+ size="sm"
+ >
+ Reject
+
+
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
);
}
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...
+
+
);
}
// 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.
+
+
Go Back
-
+
-
+
);
}
@@ -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.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) => (
- onTabChange(tab.id)}
- className={`
- px-4 py-3 font-medium transition-all relative
- ${activeTab === tab.id
- ? 'text-primary-blue'
- : 'text-gray-400 hover:text-white'
- }
- `}
- >
- {tab.label}
- {activeTab === tab.id && (
-
- )}
-
- ))}
-
-
-
-
- {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 */}
-
-
-
- Back to Teams
-
-
-
-
-
-
- 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 (
-
onTeamClick(team.id)}
- className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
- >
- {/* Position */}
-
-
- {index < 3 ? (
-
- ) : (
- index + 1
- )}
-
-
-
- {/* Team Info */}
-
-
-
-
-
-
- {team.name}
-
-
-
- {team.performanceLevel}
-
- {team.category && (
-
-
- {team.category}
-
- )}
- {team.region && (
-
-
- {team.region}
-
- )}
- {team.languages && team.languages.length > 0 && (
-
-
- {team.languages.slice(0, 2).join(', ')}
- {team.languages.length > 2 && ` +${team.languages.length - 2}`}
-
- )}
- {team.isRecruiting && (
-
-
- Recruiting
-
- )}
-
-
-
-
- {/* Members */}
-
-
-
- {team.memberCount}
-
-
-
- {/* Rating */}
-
-
- {getSafeRating(team).toLocaleString()}
-
-
-
- {/* Wins */}
-
-
- {getSafeTotalWins(team)}
-
-
-
- {/* Win Rate */}
-
-
- {winRate}%
-
-
-
- );
- })}
-
-
- {/* Empty State */}
- {filteredAndSortedTeams.length === 0 && (
-
-
-
No teams found
-
Try adjusting your filters or search query
+
+
+ {/* Header */}
+
+
{
- onSearchChange('');
- onFilterLevelChange('all');
- }}
- className="mt-4"
+ onClick={onBackToTeams}
+ icon={ }
>
- Clear Filters
+ Back to Teams
-
+
+
+
+
+
+
+
+ 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
-
-
-
Create Team
-
-
+
+
+
+ {/* Header */}
+
+
+ Teams
+ Browse and manage your racing teams
+
+
+ Create Team
+
+
- {/* Teams Grid */}
- {teams.length > 0 ? (
-
- {teams.map((team: TeamSummaryData) => (
-
-
-
- {team.logoUrl ? (
-
- ) : (
-
-
-
- )}
-
-
{team.teamName}
-
{team.leagueName}
-
-
-
-
-
-
-
- {team.memberCount} members
-
-
+ {/* Teams Grid */}
+ {teams.length > 0 ? (
+
+ {teams.map((team: TeamSummaryData) => (
+ onTeamClick?.(team.teamId)}
+ />
+ ))}
+
+ ) : (
+
+ )}
-
-
-
- View Team
-
-
-
-
- ))}
-
- ) : (
-
-
-
No teams yet
-
Get started by creating your first racing team
-
-
Create 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 ? (
-
) : (
-
-
-
-
-
{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
+
+
+ )}
+
+
+
window.location.href = '/auth/login'}
- className="w-full"
+ fullWidth
>
Return to Login
-
+
)}
{/* 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
+
-
- 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 */}
-
+
-
{/* 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 ? (
-
) : (
-
-
-
-
-
{viewData.successMessage}
-
- Your password has been successfully reset
-
-
-
+
+
+
+
+
+ {viewData.successMessage}
+
+ Your password has been successfully reset
+
+
+
+
window.location.href = '/auth/login'}
- className="w-full"
+ fullWidth
>
Return to Login
-
+
)}
{/* 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
+
-
- 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 */}
-
+
-
{/* 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}
+
))}
-
-
-
-
-
+
+
+
+
+
);
-}
\ 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 (
- {children}
+ {content}
);
-}
\ 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 */}
+
!disabled && setIsOpen(!isOpen)}
+ disabled={disabled}
+ className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
+ error
+ ? 'ring-warning-amber focus:ring-warning-amber'
+ : 'ring-charcoal-outline focus:ring-primary-blue'
+ } ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
+ >
+
+
+ {selectedCountry ? (
+
+
+ {selectedCountry.name}
+
+ ) : (
+ {placeholder}
+ )}
+
+
+
+
+ {/* 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) => (
+
handleSelect(country.code)}
+ className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
+ value === country.code
+ ? 'bg-primary-blue/20 text-white'
+ : 'text-gray-300 hover:bg-deep-graphite'
+ }`}
+ >
+
+
+ {country.name}
+
+ {value === country.code && (
+
+ )}
+
+ ))
+ ) : (
+
+ 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 (
+
+
+ {label}
+ {required && * }
+
+
+
+ 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 (
+
+
+
+ {icon && }
+ {label}
+ {required && * }
+
+
+ {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
+
+ );
+}
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 && (
+ {
+ onSecondaryAction?.();
+ onOpenChange?.(false);
+ }}
+ variant="secondary"
+ size="sm"
+ >
+ {secondaryActionLabel}
+
+ )}
+ {primaryActionLabel && (
+ {
+ if (onPrimaryAction) {
+ await onPrimaryAction();
+ }
+ }}
+ variant="primary"
+ size="sm"
+ >
+ {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 (
+ }
+ disabled={disabled}
+ className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
+ >
+
+ {content}
+
+
+ );
+ }
+
+ 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 (
+
+
+
{label}
+
+
+ {/* Track background */}
+
+ {/* Track fill */}
+
+ {/* Thumb */}
+
+
+
+ {clampedValue}
+ {unitLabel}
+
+
+
+ {error &&
{error}
}
+
+ );
+ }
+
+ return (
+
+
+ {label}
+ {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) => (
+ {
+ setLocalValue(preset);
+ onChange(preset);
+ }}
+ disabled={disabled}
+ className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
+ >
+ {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) => (
@@ -40,4 +45,4 @@ export function Select({
))}
);
-}
\ No newline at end of file
+}
diff --git a/apps/website/ui/Skeleton.tsx b/apps/website/ui/Skeleton.tsx
new file mode 100644
index 000000000..97c145f17
--- /dev/null
+++ b/apps/website/ui/Skeleton.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+interface SkeletonProps {
+ width?: string | number;
+ height?: string | number;
+ circle?: boolean;
+ className?: string;
+}
+
+export function Skeleton({ width, height, circle, className = '' }: SkeletonProps) {
+ const style: React.CSSProperties = {
+ width: width,
+ height: height,
+ borderRadius: circle ? '9999px' : '0.375rem',
+ backgroundColor: 'rgba(38, 38, 38, 0.4)',
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/website/ui/SponsorLogo.tsx b/apps/website/ui/SponsorLogo.tsx
deleted file mode 100644
index 67f92bfd8..000000000
--- a/apps/website/ui/SponsorLogo.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * SponsorLogo
- *
- * Pure UI component for displaying sponsor logos.
- * Renders an optimized image with fallback on error.
- */
-
-import Image from 'next/image';
-
-export interface SponsorLogoProps {
- sponsorId: string;
- alt: string;
- className?: string;
-}
-
-export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) {
- return (
- {
- // Fallback to default logo
- (e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
- }}
- />
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/Stack.tsx b/apps/website/ui/Stack.tsx
new file mode 100644
index 000000000..d12331edf
--- /dev/null
+++ b/apps/website/ui/Stack.tsx
@@ -0,0 +1,95 @@
+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 StackProps extends HTMLAttributes {
+ children: ReactNode;
+ className?: string;
+ direction?: 'row' | 'col';
+ gap?: number;
+ align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
+ justify?: 'start' | 'center' | 'end' | 'between' | 'around';
+ wrap?: boolean;
+ center?: boolean;
+ m?: Spacing;
+ mt?: Spacing;
+ mb?: Spacing;
+ ml?: Spacing;
+ mr?: Spacing;
+ p?: Spacing;
+ pt?: Spacing;
+ pb?: Spacing;
+ pl?: Spacing;
+ pr?: Spacing;
+ px?: Spacing;
+ py?: Spacing;
+ rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
+}
+
+export function Stack({
+ children,
+ className = '',
+ direction = 'col',
+ gap = 4,
+ align = 'stretch',
+ justify = 'start',
+ wrap = false,
+ center = false,
+ m, mt, mb, ml, mr,
+ p, pt, pb, pl, pr, px, py,
+ rounded,
+ ...props
+}: StackProps) {
+ 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'
+ };
+
+ 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 roundedClasses = {
+ none: 'rounded-none',
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ xl: 'rounded-xl',
+ '2xl': 'rounded-2xl',
+ full: 'rounded-full'
+ };
+
+ const classes = [
+ 'flex',
+ direction === 'col' ? 'flex-col' : 'flex-row',
+ gapClasses[gap] || 'gap-4',
+ center ? 'items-center justify-center' : `items-${align} justify-${justify}`,
+ wrap ? 'flex-wrap' : '',
+ 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]}` : '',
+ 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]}` : '',
+ rounded ? roundedClasses[rounded] : '',
+ className
+ ].filter(Boolean).join(' ');
+
+ return {children} ;
+}
diff --git a/apps/website/ui/StatCard.tsx b/apps/website/ui/StatCard.tsx
index 683c8a1cf..379c39331 100644
--- a/apps/website/ui/StatCard.tsx
+++ b/apps/website/ui/StatCard.tsx
@@ -1,21 +1,30 @@
-import React, { ReactNode } from 'react';
+import React from 'react';
import { Card } from './Card';
import { Text } from './Text';
+import { Icon } from './Icon';
+import { LucideIcon } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
- icon?: ReactNode;
+ subValue?: string;
+ icon?: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange';
className?: string;
+ trend?: {
+ value: number;
+ isPositive: boolean;
+ };
}
export function StatCard({
label,
value,
+ subValue,
icon,
variant = 'blue',
- className = ''
+ className = '',
+ trend,
}: StatCardProps) {
const variantClasses = {
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30',
@@ -25,28 +34,38 @@ export function StatCard({
};
const iconColorClasses = {
- blue: 'text-blue-400',
- purple: 'text-purple-400',
- green: 'text-green-400',
- orange: 'text-orange-400'
+ blue: '#60a5fa',
+ purple: '#a78bfa',
+ green: '#34d399',
+ orange: '#fb923c'
};
return (
-
+
-
+
{label}
-
+
{value}
+ {subValue && (
+
+ {subValue}
+
+ )}
+
+
+ {icon && (
+
+ )}
+ {trend && (
+
+ {trend.isPositive ? 'β' : 'β'}{Math.abs(trend.value)}%
+
+ )}
- {icon && (
-
- {icon}
-
- )}
);
diff --git a/apps/website/ui/StatusBadge.tsx b/apps/website/ui/StatusBadge.tsx
index 8538aa627..396319bac 100644
--- a/apps/website/ui/StatusBadge.tsx
+++ b/apps/website/ui/StatusBadge.tsx
@@ -1,33 +1,46 @@
import React from 'react';
-import { Text } from './Text';
+import { Icon } from './Icon';
+import { LucideIcon } from 'lucide-react';
+import { Stack } from './Stack';
interface StatusBadgeProps {
children: React.ReactNode;
- variant?: 'success' | 'warning' | 'error' | 'info';
+ variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
className?: string;
+ icon?: LucideIcon;
}
export function StatusBadge({
children,
variant = 'success',
- className = ''
+ className = '',
+ icon,
}: StatusBadgeProps) {
const variantClasses = {
- success: 'bg-performance-green/20 text-performance-green',
- warning: 'bg-warning-amber/20 text-warning-amber',
- error: 'bg-red-600/20 text-red-400',
- info: 'bg-blue-500/20 text-blue-400'
+ success: 'bg-performance-green/20 text-performance-green border-performance-green/30',
+ warning: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
+ error: 'bg-red-600/20 text-red-400 border-red-600/30',
+ info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ neutral: 'bg-iron-gray text-gray-400 border-charcoal-outline',
+ pending: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
};
const classes = [
- 'px-2 py-1 text-xs rounded-full',
+ 'px-2 py-0.5 text-xs rounded-full border font-medium inline-flex items-center',
variantClasses[variant],
className
].filter(Boolean).join(' ');
- return (
-
+ const content = icon ? (
+
+
{children}
-
+
+ ) : children;
+
+ return (
+
+ {content}
+
);
}
\ No newline at end of file
diff --git a/apps/website/ui/Surface.tsx b/apps/website/ui/Surface.tsx
new file mode 100644
index 000000000..5bdec188f
--- /dev/null
+++ b/apps/website/ui/Surface.tsx
@@ -0,0 +1,70 @@
+import React, { ReactNode, HTMLAttributes } from 'react';
+import { Box } from './Box';
+
+interface SurfaceProps extends HTMLAttributes
{
+ children: ReactNode;
+ variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple';
+ rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
+ border?: boolean;
+ padding?: number;
+ className?: string;
+ display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
+}
+
+export function Surface({
+ children,
+ variant = 'default',
+ rounded = 'lg',
+ border = false,
+ padding = 0,
+ className = '',
+ display,
+ ...props
+}: SurfaceProps) {
+ const variantClasses = {
+ default: 'bg-iron-gray',
+ muted: 'bg-iron-gray/50',
+ dark: 'bg-deep-graphite',
+ glass: 'bg-deep-graphite/60 backdrop-blur-md',
+ 'gradient-blue': 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite',
+ 'gradient-gold': 'bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite',
+ 'gradient-purple': 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite'
+ };
+
+ const roundedClasses = {
+ none: 'rounded-none',
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ xl: 'rounded-xl',
+ '2xl': 'rounded-2xl',
+ full: 'rounded-full'
+ };
+
+ const paddingClasses: Record = {
+ 0: 'p-0',
+ 1: 'p-1',
+ 2: 'p-2',
+ 3: 'p-3',
+ 4: 'p-4',
+ 6: 'p-6',
+ 8: 'p-8',
+ 10: 'p-10',
+ 12: 'p-12'
+ };
+
+ const classes = [
+ variantClasses[variant],
+ roundedClasses[rounded],
+ border ? 'border border-charcoal-outline' : '',
+ paddingClasses[padding] || 'p-0',
+ display ? display : '',
+ className
+ ].filter(Boolean).join(' ');
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/website/ui/TabContent.tsx b/apps/website/ui/TabContent.tsx
new file mode 100644
index 000000000..62d49bdc7
--- /dev/null
+++ b/apps/website/ui/TabContent.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+export function TabContent({ children, className = '' }: { children: React.ReactNode, className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/ui/TabNavigation.tsx b/apps/website/ui/TabNavigation.tsx
new file mode 100644
index 000000000..13c20dd00
--- /dev/null
+++ b/apps/website/ui/TabNavigation.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+interface Tab {
+ id: string;
+ label: string;
+ icon?: React.ComponentType<{ className?: string }>;
+}
+
+interface TabNavigationProps {
+ tabs: Tab[];
+ activeTab: string;
+ onTabChange: (tabId: string) => void;
+ className?: string;
+}
+
+export function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
+ return (
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ onTabChange(tab.id)}
+ className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
+ activeTab === tab.id
+ ? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
+ : 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
+ }`}
+ >
+ {Icon && }
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/ui/Table.tsx b/apps/website/ui/Table.tsx
index 1b693f6af..6a8e80369 100644
--- a/apps/website/ui/Table.tsx
+++ b/apps/website/ui/Table.tsx
@@ -1,88 +1,88 @@
-import { ReactNode } from 'react';
+import React, { ReactNode, HTMLAttributes } from 'react';
-interface TableProps {
+interface TableProps extends HTMLAttributes {
children: ReactNode;
className?: string;
}
-export function Table({ children, className = '' }: TableProps) {
+export function Table({ children, className = '', ...props }: TableProps) {
return (
-
-
+
);
}
-interface TableHeadProps {
+interface TableHeadProps extends HTMLAttributes {
children: ReactNode;
}
-export function TableHead({ children }: TableHeadProps) {
+export function TableHead({ children, ...props }: TableHeadProps) {
return (
-
+
{children}
);
}
-interface TableBodyProps {
+interface TableBodyProps extends HTMLAttributes {
children: ReactNode;
}
-export function TableBody({ children }: TableBodyProps) {
+export function TableBody({ children, ...props }: TableBodyProps) {
return (
-
+
{children}
);
}
-interface TableRowProps {
+interface TableRowProps extends HTMLAttributes {
children: ReactNode;
className?: string;
}
-export function TableRow({ children, className = '' }: TableRowProps) {
+export function TableRow({ children, className = '', ...props }: TableRowProps) {
const baseClasses = 'border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
-
+
{children}
);
}
-interface TableHeaderProps {
+interface TableHeaderProps extends HTMLAttributes {
children: ReactNode;
className?: string;
}
-export function TableHeader({ children, className = '' }: TableHeaderProps) {
+export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
const baseClasses = 'text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
-
+
{children}
);
}
-interface TableCellProps {
+interface TableCellProps extends HTMLAttributes {
children: ReactNode;
className?: string;
}
-export function TableCell({ children, className = '' }: TableCellProps) {
+export function TableCell({ children, className = '', ...props }: TableCellProps) {
const baseClasses = 'py-3 px-4';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
-
+
{children}
);
-}
\ No newline at end of file
+}
diff --git a/apps/website/ui/Text.tsx b/apps/website/ui/Text.tsx
index d345471f9..a30f98931 100644
--- a/apps/website/ui/Text.tsx
+++ b/apps/website/ui/Text.tsx
@@ -1,6 +1,8 @@
-import React, { ReactNode } from 'react';
+import React, { ReactNode, HTMLAttributes } from 'react';
-interface TextProps {
+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 TextProps extends HTMLAttributes {
children: ReactNode;
className?: string;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
@@ -9,6 +11,12 @@ interface TextProps {
font?: 'mono' | 'sans';
align?: 'left' | 'center' | 'right';
truncate?: boolean;
+ style?: React.CSSProperties;
+ block?: boolean;
+ ml?: Spacing;
+ mr?: Spacing;
+ mt?: Spacing;
+ mb?: Spacing;
}
export function Text({
@@ -19,7 +27,11 @@ export function Text({
color = '',
font = 'sans',
align = 'left',
- truncate = false
+ truncate = false,
+ style,
+ block = false,
+ ml, mr, mt, mb,
+ ...props
}: TextProps) {
const sizeClasses = {
xs: 'text-xs',
@@ -49,16 +61,28 @@ export function Text({
center: 'text-center',
right: 'text-right'
};
+
+ 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 = [
+ block ? 'block' : 'inline',
sizeClasses[size],
weightClasses[weight],
fontClasses[font],
alignClasses[align],
color,
truncate ? 'truncate' : '',
+ ml !== undefined ? `ml-${spacingMap[ml]}` : '',
+ mr !== undefined ? `mr-${spacingMap[mr]}` : '',
+ mt !== undefined ? `mt-${spacingMap[mt]}` : '',
+ mb !== undefined ? `mb-${spacingMap[mb]}` : '',
className
].filter(Boolean).join(' ');
- return {children} ;
-}
\ No newline at end of file
+ return {children} ;
+}
diff --git a/apps/website/ui/Toggle.tsx b/apps/website/ui/Toggle.tsx
new file mode 100644
index 000000000..b43d4fa50
--- /dev/null
+++ b/apps/website/ui/Toggle.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import React from 'react';
+import { motion, useReducedMotion } from 'framer-motion';
+import { Box } from './Box';
+import { Text } from './Text';
+
+interface ToggleProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ label: string;
+ description?: string;
+ disabled?: boolean;
+}
+
+export function Toggle({
+ checked,
+ onChange,
+ label,
+ description,
+ disabled = false,
+}: ToggleProps) {
+ const shouldReduceMotion = useReducedMotion();
+
+ return (
+
+
+ {label}
+ {description && (
+ {description}
+ )}
+
+ !disabled && onChange(!checked)}
+ disabled={disabled}
+ className={`relative w-12 h-6 rounded-full transition-colors duration-200 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-primary-blue/50 ${
+ checked
+ ? 'bg-primary-blue'
+ : 'bg-iron-gray'
+ } ${disabled ? 'cursor-not-allowed' : ''}`}
+ >
+ {/* Glow effect when active */}
+ {checked && (
+
+ )}
+
+ {/* Knob */}
+
+
+
+ );
+}
diff --git a/apps/website/ui/onboarding/OnboardingCardAccent.tsx b/apps/website/ui/onboarding/OnboardingCardAccent.tsx
deleted file mode 100644
index c65b208e1..000000000
--- a/apps/website/ui/onboarding/OnboardingCardAccent.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export function OnboardingCardAccent() {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingContainer.tsx b/apps/website/ui/onboarding/OnboardingContainer.tsx
deleted file mode 100644
index d3e9a2572..000000000
--- a/apps/website/ui/onboarding/OnboardingContainer.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-interface OnboardingContainerProps {
- children: React.ReactNode;
-}
-
-export function OnboardingContainer({ children }: OnboardingContainerProps) {
- return (
-
- {children}
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingError.tsx b/apps/website/ui/onboarding/OnboardingError.tsx
deleted file mode 100644
index 613d56da8..000000000
--- a/apps/website/ui/onboarding/OnboardingError.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-interface OnboardingErrorProps {
- message: string;
-}
-
-export function OnboardingError({ message }: OnboardingErrorProps) {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingForm.tsx b/apps/website/ui/onboarding/OnboardingForm.tsx
deleted file mode 100644
index 20bc2890e..000000000
--- a/apps/website/ui/onboarding/OnboardingForm.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-interface OnboardingFormProps {
- children: React.ReactNode;
- onSubmit: (e: React.FormEvent) => void | Promise;
-}
-
-export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingHeader.tsx b/apps/website/ui/onboarding/OnboardingHeader.tsx
deleted file mode 100644
index 7ceac3257..000000000
--- a/apps/website/ui/onboarding/OnboardingHeader.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-interface OnboardingHeaderProps {
- title: string;
- subtitle: string;
- emoji: string;
-}
-
-export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) {
- return (
-
-
- {emoji}
-
-
{title}
-
{subtitle}
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingHelpText.tsx b/apps/website/ui/onboarding/OnboardingHelpText.tsx
deleted file mode 100644
index f58a58e0f..000000000
--- a/apps/website/ui/onboarding/OnboardingHelpText.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export function OnboardingHelpText() {
- return (
-
- Your avatar will be AI-generated based on your photo and chosen suit color
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/OnboardingNavigation.tsx b/apps/website/ui/onboarding/OnboardingNavigation.tsx
deleted file mode 100644
index 426d3c7ee..000000000
--- a/apps/website/ui/onboarding/OnboardingNavigation.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import Button from '@/components/ui/Button';
-
-interface OnboardingNavigationProps {
- onBack: () => void;
- onNext?: () => void;
- isLastStep: boolean;
- canSubmit: boolean;
- loading: boolean;
-}
-
-export function OnboardingNavigation({ onBack, onNext, isLastStep, canSubmit, loading }: OnboardingNavigationProps) {
- return (
-
-
- β
- Back
-
-
- {!isLastStep ? (
-
- Continue
- β
-
- ) : (
-
- {loading ? (
- <>
- β³
- Creating Profile...
- >
- ) : (
- <>
- β
- Complete Setup
- >
- )}
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/apps/website/ui/onboarding/PersonalInfoStep.tsx b/apps/website/ui/onboarding/PersonalInfoStep.tsx
deleted file mode 100644
index d73a2311a..000000000
--- a/apps/website/ui/onboarding/PersonalInfoStep.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { User, Clock, ChevronRight } from 'lucide-react';
-import Input from '@/components/ui/Input';
-import Heading from '@/components/ui/Heading';
-import CountrySelect from '@/components/ui/CountrySelect';
-
-export interface PersonalInfo {
- firstName: string;
- lastName: string;
- displayName: string;
- country: string;
- timezone: string;
-}
-
-interface FormErrors {
- [key: string]: string | undefined;
-}
-
-interface PersonalInfoStepProps {
- personalInfo: PersonalInfo;
- setPersonalInfo: (info: PersonalInfo) => void;
- errors: FormErrors;
- loading: boolean;
-}
-
-const TIMEZONES = [
- { value: 'America/New_York', label: 'Eastern Time (ET)' },
- { value: 'America/Chicago', label: 'Central Time (CT)' },
- { value: 'America/Denver', label: 'Mountain Time (MT)' },
- { value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
- { value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' },
- { value: 'Europe/Berlin', label: 'Central European Time (CET)' },
- { value: 'Europe/Paris', label: 'Central European Time (CET)' },
- { value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' },
- { value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' },
- { value: 'America/Sao_Paulo', label: 'BrasΓlia Time (BRT)' },
-];
-
-export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loading }: PersonalInfoStepProps) {
- return (
-
-
-
-
- Personal Information
-
-
- Tell us a bit about yourself
-
-
-
-
-
-
-
- Display Name * (shown publicly)
-
-
- setPersonalInfo({ ...personalInfo, displayName: e.target.value })
- }
- error={!!errors.displayName}
- errorMessage={errors.displayName}
- placeholder="SpeedyRacer42"
- disabled={loading}
- />
-
-
-
-
-
- Country *
-
-
- setPersonalInfo({ ...personalInfo, country: value })
- }
- error={!!errors.country}
- errorMessage={errors.country ?? ''}
- disabled={loading}
- />
-
-
-
-
- Timezone
-
-
-
-
- setPersonalInfo({ ...personalInfo, timezone: e.target.value })
- }
- className="block w-full rounded-md border-0 px-4 py-3 pl-10 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm appearance-none cursor-pointer"
- disabled={loading}
- >
- Select timezone
- {TIMEZONES.map((tz) => (
-
- {tz.label}
-
- ))}
-
-
-
-
-
-
- );
-}
\ No newline at end of file