website refactor

This commit is contained in:
2026-01-14 23:31:57 +01:00
parent fbae5e6185
commit c1a86348d7
93 changed files with 7268 additions and 9088 deletions

View File

@@ -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 (
<Layout padding="p-6" gap="gap-6" className="container mx-auto">
{/* Header */}
<Layout flex flexCol={false} items="center" justify="between">
<div>
<Text size="2xl" weight="bold" color="text-white">
Admin Dashboard
</Text>
<Text size="sm" color="text-gray-400" className="mt-1">
System overview and statistics
</Text>
</div>
<Button
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
className="flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</Layout>
<Container size="lg" py={6}>
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Admin Dashboard</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
System overview and statistics
</Text>
</Box>
<Button
onClick={onRefresh}
disabled={isLoading}
variant="secondary"
icon={<Icon icon={RefreshCw} size={4} className={isLoading ? 'animate-spin' : ''} />}
>
Refresh
</Button>
</Stack>
{/* Stats Cards */}
<Layout grid gridCols={4} gap="gap-4">
<StatCard
label="Total Users"
value={viewData.stats.totalUsers}
icon={<Users className="w-8 h-8" />}
variant="blue"
/>
<StatCard
label="Admins"
value={viewData.stats.systemAdmins}
icon={<Shield className="w-8 h-8" />}
variant="purple"
/>
<StatCard
label="Active Users"
value={viewData.stats.activeUsers}
icon={<Activity className="w-8 h-8" />}
variant="green"
/>
<StatCard
label="Recent Logins"
value={viewData.stats.recentLogins}
icon={<Clock className="w-8 h-8" />}
variant="orange"
/>
</Layout>
{/* Stats Cards */}
<Grid cols={4} gap={4}>
<StatCard
label="Total Users"
value={viewData.stats.totalUsers}
icon={Users}
variant="blue"
/>
<StatCard
label="Admins"
value={viewData.stats.systemAdmins}
icon={Shield}
variant="purple"
/>
<StatCard
label="Active Users"
value={viewData.stats.activeUsers}
icon={Activity}
variant="green"
/>
<StatCard
label="Recent Logins"
value={viewData.stats.recentLogins}
icon={Clock}
variant="orange"
/>
</Grid>
{/* System Status */}
<Card>
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
System Status
</Text>
<Layout flex flexCol gap="gap-4">
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
System Health
</Text>
<StatusBadge variant="success">
Healthy
</StatusBadge>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
Suspended Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.suspendedUsers}
</Text>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
Deleted Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.deletedUsers}
</Text>
</Layout>
<Layout flex flexCol={false} items="center" justify="between">
<Text size="sm" color="text-gray-400">
New Users Today
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.newUsersToday}
</Text>
</Layout>
</Layout>
</Card>
{/* System Status */}
<Card>
<Stack gap={4}>
<Heading level={3}>System Status</Heading>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
System Health
</Text>
<StatusBadge variant="success">
Healthy
</StatusBadge>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
Suspended Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.suspendedUsers}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
Deleted Users
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.deletedUsers}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
New Users Today
</Text>
<Text size="base" weight="medium" color="text-white">
{viewData.stats.newUsersToday}
</Text>
</Stack>
</Stack>
</Stack>
</Card>
{/* Quick Actions */}
<Card>
<Text size="lg" weight="semibold" color="text-white" className="mb-4">
Quick Actions
</Text>
<Layout grid gridCols={3} gap="gap-3">
<QuickActionLink href={routes.admin.users} variant="blue">
View All Users
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
Manage Admins
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
View Audit Log
</QuickActionLink>
</Layout>
</Card>
</Layout>
{/* Quick Actions */}
<Card>
<Stack gap={4}>
<Heading level={3}>Quick Actions</Heading>
<Grid cols={3} gap={3}>
<QuickActionLink href={routes.admin.users} variant="blue">
View All Users
</QuickActionLink>
<QuickActionLink href="/admin" variant="purple">
Manage Admins
</QuickActionLink>
<QuickActionLink href="/admin" variant="orange">
View Audit Log
</QuickActionLink>
</Grid>
</Stack>
</Card>
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Text size="2xl" weight="bold" color="text-white">User Management</Text>
<Text size="sm" color="text-gray-400" className="mt-1">Manage and monitor all system users</Text>
</div>
<Button
onClick={onRefresh}
disabled={loading}
variant="secondary"
className="flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Error Banner */}
{error && (
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<Text weight="medium">Error</Text>
<Text size="sm" className="opacity-90">{error}</Text>
</div>
<Container size="lg" py={6}>
<Stack gap={6}>
{/* Header */}
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>User Management</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Manage and monitor all system users</Text>
</Box>
<Button
onClick={() => {}}
onClick={onRefresh}
disabled={loading}
variant="secondary"
className="text-racing-red hover:opacity-70 p-0"
icon={<Icon icon={RefreshCw} size={4} className={loading ? 'animate-spin' : ''} />}
>
×
Refresh
</Button>
</div>
)}
</Stack>
{/* Filters Card */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<Text weight="medium" color="text-white">Filters</Text>
</div>
{(search || roleFilter || statusFilter) && (
{/* Error Banner */}
{error && (
<InfoBox
icon={Users}
title="Error"
description={error}
variant="warning"
/>
)}
{/* Filters Card */}
<UserFilters
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
onSearch={onSearch}
onFilterRole={onFilterRole}
onFilterStatus={onFilterStatus}
onClearFilters={onClearFilters}
/>
{/* Users Table */}
<Card p={0}>
{loading ? (
<Stack center py={12} gap={3}>
<Box className="animate-spin" style={{ borderRadius: '9999px', height: '2rem', width: '2rem', borderBottom: '2px solid #3b82f6' }} />
<Text color="text-gray-400">Loading users...</Text>
</Stack>
) : !viewData.users || viewData.users.length === 0 ? (
<Stack center py={12} gap={3}>
<Icon icon={Users} size={12} color="#525252" />
<Text color="text-gray-400">No users found</Text>
<Button
onClick={onClearFilters}
variant="secondary"
className="text-xs p-0"
variant="ghost"
size="sm"
>
Clear all
Clear filters
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
options={[
{ value: '', label: 'All Roles' },
{ value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
options={[
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
]}
/>
</div>
</div>
</Card>
{/* Users Table */}
<Card>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
<Text color="text-gray-400">Loading users...</Text>
</div>
) : !viewData.users || viewData.users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<Users className="w-12 h-12 text-gray-600" />
<Text color="text-gray-400">No users found</Text>
<Button
onClick={onClearFilters}
variant="secondary"
className="text-sm p-0"
>
Clear filters
</Button>
</div>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Email</TableHeader>
<TableHeader>Roles</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Last Login</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{viewData.users.map((user, index: number) => (
<TableRow
key={user.id}
className={index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}
>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-primary-blue" />
</div>
<div>
<div className="font-medium text-white">{user.displayName}</div>
<div className="text-xs text-gray-500">ID: {user.id}</div>
{user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-300">{user.email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.roles.map((role: string, idx: number) => (
<span
key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${getRoleBadgeClass(role)}`}
>
{getRoleBadgeLabel(role)}
</span>
))}
</div>
</TableCell>
<TableCell>
{(() => {
const badge = toStatusBadgeProps(user.status);
return <StatusBadge status={badge.status} label={badge.label} />;
})()}
</TableCell>
<TableCell>
<div className="text-sm text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{user.status === 'active' && (
<Button
onClick={() => 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
</Button>
)}
{user.status === 'suspended' && (
<Button
onClick={() => 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
</Button>
)}
{user.status !== 'deleted' && (
<Button
onClick={() => 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"
>
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</TableCell>
</Stack>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Email</TableHeader>
<TableHeader>Roles</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Last Login</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
))}
</TableBody>
</Table>
)}
</Card>
</TableHead>
<TableBody>
{viewData.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Icon icon={Shield} size={4} color="#3b82f6" />
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{user.displayName}</Text>
<Text size="xs" color="text-gray-500" block>ID: {user.id}</Text>
{user.primaryDriverId && (
<Text size="xs" color="text-gray-500" block>Driver: {user.primaryDriverId}</Text>
)}
</Box>
</Stack>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-300">{user.email}</Text>
</TableCell>
<TableCell>
<Stack direction="row" gap={1} wrap>
{user.roles.map((role, idx) => {
const style = getRoleBadgeStyle(role);
return (
<Surface
key={idx}
variant="muted"
rounded="full"
padding={1}
style={{
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
backgroundColor: style.backgroundColor,
color: style.color,
borderColor: style.border,
border: '1px solid'
}}
>
<Text size="xs" weight="medium">{role.charAt(0).toUpperCase() + role.slice(1)}</Text>
</Surface>
);
})}
</Stack>
</TableCell>
<TableCell>
<StatusBadge variant={getStatusBadgeVariant(user.status)}>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</StatusBadge>
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
{user.status === 'active' && (
<Button
onClick={() => onUpdateStatus(user.id, 'suspended')}
variant="secondary"
size="sm"
>
Suspend
</Button>
)}
{user.status === 'suspended' && (
<Button
onClick={() => onUpdateStatus(user.id, 'active')}
variant="secondary"
size="sm"
>
Activate
</Button>
)}
{user.status !== 'deleted' && (
<Button
onClick={() => onDeleteUser(user.id)}
disabled={deletingUser === user.id}
variant="secondary"
size="sm"
icon={<Icon icon={Trash2} size={3} />}
>
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</Button>
)}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Card>
{/* Stats Summary */}
{viewData.users.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">Total Users</Text>
<Text size="2xl" weight="bold" color="text-white">{viewData.total}</Text>
</div>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">Active</Text>
<Text size="2xl" weight="bold" color="text-white">
{viewData.activeUserCount}
</Text>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">Admins</Text>
<Text size="2xl" weight="bold" color="text-white">
{viewData.adminCount}
</Text>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)}
</div>
{/* Stats Summary */}
{viewData.users.length > 0 && (
<UserStatsSummary
total={viewData.total}
activeCount={viewData.activeUserCount}
adminCount={viewData.adminCount}
/>
)}
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<Box as="main">
<DashboardHero
currentDriver={currentDriver}
activeLeaguesCount={activeLeaguesCount}
/>
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Welcome Message */}
<div className="flex items-start gap-5">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<img
src={currentDriver.avatarUrl}
alt={currentDriver.name}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
</div>
<div>
<p className="text-gray-400 text-sm mb-1">Good morning,</p>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
{currentDriver.name}
<span className="ml-3 text-2xl">{currentDriver.country}</span>
</h1>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<span className="text-sm font-semibold text-primary-blue">{currentDriver.rating}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<span className="text-sm font-semibold text-yellow-400">#{currentDriver.rank}</span>
</div>
<span className="text-xs text-gray-500">{currentDriver.totalRaces} races completed</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<a href="/leagues" className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
<span>Flag</span>
Browse Leagues
</a>
<a href=routes.protected.profile className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
<span>Activity</span>
View Profile
</a>
</div>
</div>
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20 text-performance-green">
<span>Trophy</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{currentDriver.wins}</p>
<p className="text-xs text-gray-500">Wins</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/20 text-warning-amber">
<span>Medal</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{currentDriver.podiums}</p>
<p className="text-xs text-gray-500">Podiums</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 text-primary-blue">
<span>Target</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{currentDriver.consistency}</p>
<p className="text-xs text-gray-500">Consistency</p>
</div>
</div>
</div>
<div className="p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400">
<span>Users</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{activeLeaguesCount}</p>
<p className="text-xs text-gray-500">Active Leagues</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Main Content */}
<section className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Container size="lg" py={8}>
<Grid cols={12} gap={6}>
{/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Next Race Card */}
{nextRace && (
<div className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border border-primary-blue/30 rounded-xl p-6">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{nextRace.isMyLeague && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<span>Calendar</span>
{nextRace.formattedDate}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<span>Clock</span>
{nextRace.formattedTime}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{nextRace.timeUntil}</p>
</div>
<a href={`/races/${nextRace.id}`} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-white text-sm font-medium transition-colors flex items-center gap-2">
View Details
<span>ChevronRight</span>
</a>
</div>
</div>
</div>
</div>
)}
{/* League Standings Preview */}
{hasLeagueStandings && (
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<span>Award</span>
Your Championship Standings
</h2>
<a href=routes.protected.profileLeagues className="text-sm text-primary-blue hover:underline flex items-center gap-1">
View all <span>ChevronRight</span>
</a>
</div>
<div className="space-y-3">
{leagueStandings.map((summary) => (
<div key={summary.leagueId} className="flex items-center justify-between p-3 bg-deep-graphite rounded-lg">
<div>
<p className="text-white font-medium">{summary.leagueName}</p>
<p className="text-xs text-gray-500">Position {summary.position} {summary.points} points</p>
</div>
<span className="text-xs text-gray-400">{summary.totalDrivers} drivers</span>
</div>
))}
</div>
</div>
)}
{/* Activity Feed */}
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<span>Activity</span>
Recent Activity
</h2>
</div>
{hasFeedItems ? (
<div className="space-y-4">
{feedItems.slice(0, 5).map((item) => (
<div key={item.id} className="flex items-start gap-3 p-3 bg-deep-graphite rounded-lg">
<div className="flex-1">
<p className="text-white font-medium">{item.headline}</p>
{item.body && <p className="text-sm text-gray-400 mt-1">{item.body}</p>}
<p className="text-xs text-gray-500 mt-1">{item.formattedTime}</p>
</div>
{item.ctaHref && item.ctaLabel && (
<a href={item.ctaHref} className="text-xs text-primary-blue hover:underline">
{item.ctaLabel}
</a>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8">
<span className="text-4xl text-gray-600 mx-auto mb-3">Activity</span>
<p className="text-gray-400 mb-2">No activity yet</p>
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
</div>
)}
</div>
</div>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
{nextRace && <NextRaceCard nextRace={nextRace} />}
{hasLeagueStandings && <ChampionshipStandings standings={leagueStandings} />}
<ActivityFeed items={feedItems} hasItems={hasFeedItems} />
</Stack>
</GridItem>
{/* Right Column - Sidebar */}
<div className="space-y-6">
{/* Upcoming Races */}
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<span>Calendar</span>
Upcoming Races
</h3>
<a href="/races" className="text-xs text-primary-blue hover:underline">
View all
</a>
</div>
{hasUpcomingRaces ? (
<div className="space-y-3">
{upcomingRaces.slice(0, 5).map((race) => (
<div key={race.id} className="p-3 bg-deep-graphite rounded-lg">
<p className="text-white font-medium">{race.track}</p>
<p className="text-sm text-gray-400">{race.car}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{race.formattedDate}</span>
<span></span>
<span>{race.formattedTime}</span>
</div>
{race.isMyLeague && (
<span className="inline-block mt-1 px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
)}
</div>
{/* Friends */}
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<span>Users</span>
Friends
</h3>
<span className="text-xs text-gray-500">{friends.length} friends</span>
</div>
{hasFriends ? (
<div className="space-y-2">
{friends.slice(0, 6).map((friend) => (
<div key={friend.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite transition-colors">
<div className="w-9 h-9 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<img
src={friend.avatarUrl}
alt={friend.name}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">{friend.name}</p>
<p className="text-xs text-gray-500">{friend.country}</p>
</div>
</div>
))}
{friends.length > 6 && (
<a
href=routes.protected.profile
className="block text-center py-2 text-sm text-primary-blue hover:underline"
>
+{friends.length - 6} more
</a>
)}
</div>
) : (
<div className="text-center py-6">
<span className="text-3xl text-gray-600 mx-auto mb-2">UserPlus</span>
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
<a href="/drivers" className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs text-white transition-colors">
Find Drivers
</a>
</div>
)}
</div>
</div>
</div>
</section>
</main>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
<UpcomingRaces races={upcomingRaces} hasRaces={hasUpcomingRaces} />
<FriendsSidebar friends={friends} hasFriends={hasFriends} />
</Stack>
</GridItem>
</Grid>
</Container>
</Box>
);
}
}

View File

@@ -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 (
<div className="max-w-6xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading driver profile...</p>
</div>
</div>
</div>
<Container size="lg" py={12}>
<Stack align="center" justify="center" gap={4}>
<LoadingSpinner size={10} />
<Text color="text-gray-400">Loading driver profile...</Text>
</Stack>
</Container>
);
}
if (error || !driverProfile?.currentDriver) {
if (error || !viewData?.currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<div className="text-warning-amber mb-4">{error || 'Driver not found'}</div>
<Container size="md" py={12}>
<Stack align="center" gap={6}>
<Text color="text-warning-amber">{error || 'Driver not found'}</Text>
<Button variant="secondary" onClick={onBackClick}>
Back to Drivers
</Button>
</Card>
</div>
</Stack>
</Container>
);
}
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 (
<div className="max-w-6xl mx-auto px-4 pb-12 space-y-6">
{/* Back Navigation */}
<Button
variant="secondary"
onClick={onBackClick}
className="flex items-center gap-2 mb-4"
>
<ArrowLeft className="w-4 h-4" />
Back to Drivers
</Button>
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Back Navigation */}
<Box>
<Button
variant="secondary"
onClick={onBackClick}
icon={<ArrowLeft size={4} />}
>
Back to Drivers
</Button>
</Box>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: driver.name },
]}
/>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: currentDriver.name },
]}
/>
{/* Sponsor Insights Card */}
{isSponsorMode && sponsorInsights}
{/* Sponsor Insights Card */}
{isSponsorMode && sponsorInsights}
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
{/* Hero Header Section */}
<ProfileHero
driver={{
...currentDriver,
iracingId: currentDriver.iracingId || 0,
}}
stats={stats ? { rating: stats.rating || 0 } : null}
globalRank={currentDriver.globalRank || 0}
timezone={extendedProfile?.timezone || 'UTC'}
socialHandles={extendedProfile?.socialHandles || []}
onAddFriend={onAddFriend}
friendRequestSent={friendRequestSent}
/>
{/* Bio Section */}
{currentDriver.bio && <ProfileBio bio={currentDriver.bio} />}
{/* Team Memberships */}
{teamMemberships.length > 0 && (
<TeamMembershipGrid
memberships={teamMemberships.map((m) => ({
team: { id: m.teamId, name: m.teamName },
role: m.role,
joinedAt: new Date(m.joinedAt)
}))}
/>
</div>
)}
<div className="relative p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-start gap-6">
{/* Avatar */}
<div className="relative">
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
{/* Performance Overview */}
{stats && (
<PerformanceOverview
stats={{
wins: stats.wins,
podiums: stats.podiums,
totalRaces: stats.totalRaces,
consistency: stats.consistency,
dnfs: stats.dnfs,
bestFinish: stats.bestFinish || 0,
avgFinish: stats.avgFinish
}}
/>
)}
{/* Driver Info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-3xl md:text-4xl font-bold text-white">{driver.name}</h1>
<span className="text-4xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
{/* Tab Navigation */}
<ProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
{/* Rating and Rank */}
<div className="flex flex-wrap items-center gap-4 mb-4">
{stats && (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
<span className="text-xs text-gray-400">Rating</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="font-mono font-bold text-yellow-400">#{globalRank}</span>
<span className="text-xs text-gray-400">Global</span>
</div>
</>
)}
</div>
{/* Meta info */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
iRacing: {driver.iracingId}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
Joined{' '}
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{extendedProfile.timezone}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2">
<Button
variant="primary"
onClick={onAddFriend}
disabled={friendRequestSent}
className="flex items-center gap-2"
>
<UserPlus className="w-4 h-4" />
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</div>
</div>
{/* Social Handles */}
{extendedProfile.socialHandles.length > 0 && (
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span>
{extendedProfile.socialHandles.map((social: SocialHandle) => {
const Icon = getSocialIcon(social.platform);
return (
<a
key={social.platform}
href={social.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all ${getSocialColor(social.platform)}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{social.handle}</span>
<ExternalLink className="w-3 h-3 opacity-50" />
</a>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Bio Section */}
{driver.bio && (
<Card>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<User className="w-5 h-5 text-primary-blue" />
About
</h2>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
</Card>
)}
{/* Team Memberships */}
{allTeamMemberships.length > 0 && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-400" />
Team Memberships
<span className="text-sm text-gray-500 font-normal">({allTeamMemberships.length})</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allTeamMemberships.map((membership) => (
<Link
key={membership.team.id}
href={`/teams/${membership.team.id}`}
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
<Users className="w-6 h-6 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
{membership.team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">
{membership.role}
</span>
<span>
Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
</Link>
))}
</div>
</Card>
)}
{/* Performance Overview with Diagrams */}
{stats && (
<Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Activity className="w-5 h-5 text-neon-aqua" />
Performance Overview
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Circular Progress Charts */}
<div className="flex flex-col items-center">
<div className="flex gap-6 mb-4">
<CircularProgress
value={stats.wins}
max={stats.totalRaces}
label="Win Rate"
color="text-performance-green"
/>
<CircularProgress
value={stats.podiums}
max={stats.totalRaces}
label="Podium Rate"
color="text-warning-amber"
/>
</div>
<div className="flex gap-6">
<CircularProgress
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="text-primary-blue"
/>
<CircularProgress
value={stats.totalRaces - stats.dnfs}
max={stats.totalRaces}
label="Finish Rate"
color="text-neon-aqua"
/>
</div>
</div>
{/* Bar chart and key metrics */}
<div className="md:col-span-2">
<h3 className="text-sm font-medium text-gray-400 mb-4 flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
Results Breakdown
</h3>
<HorizontalBarChart
data={[
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
]}
maxValue={stats.totalRaces}
{/* Tab Content */}
{activeTab === 'overview' && (
<Stack gap={6}>
<CareerStats stats={stats || { totalRaces: 0, wins: 0, podiums: 0, consistency: 0 }} />
{extendedProfile && (
<RacingProfile
racingStyle={extendedProfile.racingStyle}
favoriteTrack={extendedProfile.favoriteTrack}
favoriteCar={extendedProfile.favoriteCar}
availableHours={extendedProfile.availableHours}
lookingForTeam={extendedProfile.lookingForTeam}
openToRequests={extendedProfile.openToRequests}
/>
)}
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500 uppercase">Best Finish</span>
</div>
<p className="text-2xl font-bold text-performance-green">P{stats.bestFinish}</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div>
<p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div>
</div>
</div>
</div>
</Card>
)}
{extendedProfile && extendedProfile.achievements.length > 0 && (
<AchievementGrid
achievements={extendedProfile.achievements.map((a) => ({
...a,
earnedAt: new Date(a.earnedAt)
}))}
/>
)}
{/* Tab Navigation */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit">
<button
type="button"
onClick={() => 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'
}`}
>
<User className="w-4 h-4" />
Overview
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<BarChart3 className="w-4 h-4" />
Detailed Stats
</button>
</div>
{socialSummary.friends.length > 0 && (
<FriendsPreview friends={socialSummary.friends} />
)}
</Stack>
)}
{/* Tab Content */}
{activeTab === 'overview' && (
<>
{/* Stats and Profile Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Career Stats */}
<Card className="lg:col-span-2">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-performance-green" />
Career Statistics
</h2>
{stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-white mb-1">{stats.totalRaces}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-performance-green mb-1">{stats.wins}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-warning-amber mb-1">{stats.podiums}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
</div>
</div>
) : (
<p className="text-gray-400 text-sm">No race statistics available yet.</p>
)}
</Card>
{/* Racing Preferences */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-neon-aqua" />
Racing Profile
</h2>
<div className="space-y-4">
<div>
<span className="text-xs text-gray-500 uppercase tracking-wider">Racing Style</span>
<p className="text-white font-medium">{extendedProfile.racingStyle}</p>
</div>
<div>
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Track</span>
<p className="text-white font-medium">{extendedProfile.favoriteTrack}</p>
</div>
<div>
<span className="text-xs text-gray-500 uppercase tracking-wider">Favorite Car</span>
<p className="text-white font-medium">{extendedProfile.favoriteCar}</p>
</div>
<div>
<span className="text-xs text-gray-500 uppercase tracking-wider">Available</span>
<p className="text-white font-medium">{extendedProfile.availableHours}</p>
</div>
{/* Status badges */}
<div className="pt-4 border-t border-charcoal-outline/50 space-y-2">
{extendedProfile.lookingForTeam && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-performance-green/10 border border-performance-green/30">
<Users className="w-4 h-4 text-performance-green" />
<span className="text-sm text-performance-green font-medium">Looking for Team</span>
</div>
)}
{extendedProfile.openToRequests && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<UserPlus className="w-4 h-4 text-primary-blue" />
<span className="text-sm text-primary-blue font-medium">Open to Friend Requests</span>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Achievements */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Award className="w-5 h-5 text-yellow-400" />
Achievements
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{extendedProfile.achievements.map((achievement: Achievement) => {
const Icon = getAchievementIcon(achievement.icon);
const rarityClasses = getRarityColor(achievement.rarity);
return (
<div
key={achievement.id}
className={`p-4 rounded-xl border ${rarityClasses} transition-all hover:scale-105`}
>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${rarityClasses.split(' ')[1]}`}>
<Icon className={`w-5 h-5 ${rarityClasses.split(' ')[0]}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">
{achievement.earnedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
</div>
);
})}
</div>
</Card>
{/* Friends Preview */}
{driverProfile.socialSummary.friends.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />
Friends
<span className="text-sm text-gray-500 font-normal">({driverProfile.socialSummary.friends.length})</span>
</h2>
</div>
<div className="flex flex-wrap gap-3">
{driverProfile.socialSummary.friends.slice(0, 8).map((friend) => (
<Link
key={friend.id}
href={`/drivers/${friend.id}`}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-iron-gray/50 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray transition-all"
>
<div className="w-8 h-8 rounded-full overflow-hidden bg-gradient-to-br from-primary-blue to-purple-600">
<Image
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={friend.name}
width={32}
height={32}
className="w-full h-full object-cover"
/>
</div>
<span className="text-sm text-white">{friend.name}</span>
<span className="text-lg">{getCountryFlag(friend.country)}</span>
</Link>
))}
{driverProfile.socialSummary.friends.length > 8 && (
<div className="flex items-center px-3 py-2 text-sm text-gray-400">+{driverProfile.socialSummary.friends.length - 8} more</div>
)}
</div>
</Card>
)}
</>
)}
{activeTab === 'stats' && stats && (
<div className="space-y-6">
{/* Detailed Performance Metrics */}
<Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-primary-blue" />
Detailed Performance Metrics
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Performance Bars */}
<div>
<h3 className="text-sm font-medium text-gray-400 mb-4">Results Breakdown</h3>
<HorizontalBarChart
data={[
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
]}
maxValue={stats.totalRaces}
/>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Percent className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500 uppercase">Win Rate</span>
</div>
<p className="text-2xl font-bold text-performance-green">
{((stats.wins / stats.totalRaces) * 100).toFixed(1)}%
</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Percent className="w-4 h-4 text-warning-amber" />
<span className="text-xs text-gray-500 uppercase">Podium Rate</span>
</div>
<p className="text-2xl font-bold text-warning-amber">
{((stats.podiums / stats.totalRaces) * 100).toFixed(1)}%
</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Consistency</span>
</div>
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-neon-aqua" />
<span className="text-xs text-gray-500 uppercase">Finish Rate</span>
</div>
<p className="text-2xl font-bold text-neon-aqua">
{(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}%
</p>
</div>
</div>
</div>
</Card>
{/* Position Statistics */}
<Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Flag className="w-5 h-5 text-red-400" />
Position Statistics
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-gradient-to-br from-performance-green/20 to-performance-green/5 border border-performance-green/30 text-center">
<div className="text-4xl font-bold text-performance-green mb-1">P{stats.bestFinish}</div>
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<div className="text-4xl font-bold text-primary-blue mb-1">
P{(stats.avgFinish ?? 0).toFixed(1)}
</div>
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
<div className="text-4xl font-bold text-warning-amber mb-1">P{stats.worstFinish}</div>
<div className="text-xs text-gray-400 uppercase">Worst Finish</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-br from-red-500/20 to-red-500/5 border border-red-500/30 text-center">
<div className="text-4xl font-bold text-red-400 mb-1">{stats.dnfs}</div>
<div className="text-xs text-gray-400 uppercase">DNFs</div>
</div>
</div>
</Card>
{/* Global Rankings */}
<Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-400" />
Global Rankings
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-6 rounded-xl bg-gradient-to-br from-yellow-400/20 to-yellow-600/5 border border-yellow-400/30 text-center">
<Trophy className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
<div className="text-3xl font-bold text-yellow-400 mb-1">#{globalRank}</div>
<div className="text-sm text-gray-400">Global Rank</div>
</div>
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
<div className="text-sm text-gray-400">Rating</div>
</div>
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
<TrendingUp className="w-8 h-8 text-purple-400 mx-auto mb-3" />
<div className="text-3xl font-bold text-purple-400 mb-1">Top {stats.percentile}%</div>
<div className="text-sm text-gray-400">Percentile</div>
</div>
</div>
</Card>
</div>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">This driver hasn't completed any races yet</p>
</Card>
)}
</div>
{activeTab === 'stats' && !stats && (
<Stack align="center" py={12} gap={4}>
<Text color="text-gray-400">No statistics available yet</Text>
<Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
</Stack>
)}
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
{onBackToLeaderboards && (
<Button
variant="secondary"
onClick={onBackToLeaderboards}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Leaderboards
</Button>
)}
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Trophy className="w-7 h-7 text-primary-blue" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Driver Leaderboard
</Heading>
<p className="text-gray-400">Full rankings of all drivers by performance metrics</p>
</div>
</div>
</div>
{/* Top 3 Podium */}
{viewData.podium.length > 0 && (
<div className="mb-10">
<div className="flex items-end justify-center gap-4 lg:gap-8">
{[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 (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick?.(driver.id)}
className="flex flex-col items-center group"
>
<div className="relative mb-4">
<div className={`relative ${position === 1 ? 'w-24 h-24 lg:w-28 lg:h-28' : 'w-20 h-20 lg:w-24 lg:h-24'} rounded-full overflow-hidden border-4 ${position === 1 ? 'border-yellow-400 shadow-[0_0_30px_rgba(250,204,21,0.3)]' : position === 2 ? 'border-gray-300' : 'border-amber-600'} group-hover:scale-105 transition-transform`}>
<Image
src={driver.avatarUrl}
alt={driver.name}
fill
className="object-cover"
/>
</div>
<div className={`absolute -bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold bg-gradient-to-br ${config.color} border-2 ${config.crown}`}>
{position}
</div>
</div>
<p className={`text-white font-semibold ${position === 1 ? 'text-lg' : 'text-base'} group-hover:text-primary-blue transition-colors mb-1`}>
{driver.name}
</p>
<p className={`font-mono font-bold ${position === 1 ? 'text-xl text-yellow-400' : 'text-lg text-primary-blue'}`}>
{driver.rating.toString()}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<span className="flex items-center gap-1">
<span className="w-3 h-3 text-performance-green">🏆</span>
{driver.wins}
</span>
<span></span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 text-warning-amber">🏅</span>
{driver.podiums}
</span>
</div>
<div className={`mt-4 w-28 lg:w-36 ${config.height} rounded-t-lg bg-gradient-to-t ${config.color} border-t border-x flex items-end justify-center pb-4`}>
<span className={`text-4xl lg:text-5xl font-black ${config.crown}`}>
{position}
</span>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-5 lg:col-span-4">Driver</div>
<div className="col-span-2 text-center hidden md:block">Races</div>
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
<div className="col-span-1 text-center hidden lg:block">Podiums</div>
<div className="col-span-2 text-center">Win Rate</div>
</div>
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{viewData.drivers.map((driver) => {
const position = driver.rank;
return (
<button
key={driver.id}
type="button"
onClick={() => 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"
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Box>
{onBackToLeaderboards && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBackToLeaderboards}
icon={<Icon icon={ArrowLeft} size={4} />}
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}>
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
</div>
</div>
Back to Leaderboards
</Button>
</Box>
)}
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05))', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
<Icon icon={Trophy} size={7} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Driver Leaderboard</Heading>
<Text color="text-gray-400" block mt={1}>Full rankings of all drivers by performance metrics</Text>
</Box>
</Stack>
</Box>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className="font-mono font-semibold text-white">
{driver.rating.toString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className="font-mono font-semibold text-performance-green">
{driver.wins}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className="font-mono font-semibold text-warning-amber">
{driver.podiums}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className="font-mono font-semibold text-white">
{driver.winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{viewData.drivers.length === 0 && (
<div className="py-16 text-center">
<span className="text-5xl mb-4 block">🔍</span>
<p className="text-gray-400 mb-2">No drivers found</p>
<p className="text-sm text-gray-500">There are no drivers in the system yet</p>
</div>
{/* Top 3 Podium */}
{viewData.podium.length > 0 && (
<RankingsPodium
podium={viewData.podium.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins),
podiums: Number(d.podiums)
}))}
onDriverClick={onDriverClick}
/>
)}
</div>
</div>
{/* Leaderboard Table */}
<RankingsTable
drivers={viewData.drivers.map(d => ({
...d,
rating: Number(d.rating),
wins: Number(d.wins)
}))}
onDriverClick={onDriverClick}
/>
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading drivers...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite border border-primary-blue/30 overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" />
<Container size="lg" py={8}>
<Stack gap={10}>
{/* Hero Section */}
<DriversHero
driverCount={drivers.length}
activeCount={activeCount}
totalWins={totalWins}
totalRaces={totalRaces}
onViewLeaderboard={onViewLeaderboard}
/>
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Users className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
Drivers
</Heading>
</div>
<p className="text-gray-400 text-lg leading-relaxed mb-6">
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
</p>
{/* Search */}
<DriversSearch query={searchQuery} onChange={onSearchChange} />
{/* Quick Stats */}
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-blue" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{drivers.length}</span> drivers
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{activeCount}</span> active
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-400" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{totalWins.toLocaleString()}</span> total wins
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-neon-aqua" />
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{totalRaces.toLocaleString()}</span> races
</span>
</div>
</div>
</div>
{/* Featured Drivers */}
{!searchQuery && (
<Box>
<Stack direction="row" align="center" gap={3} mb={4}>
<Surface variant="muted" rounded="xl" padding={2}>
<Icon icon={Crown} size={6} color="#f59e0b" />
</Surface>
<Box>
<Heading level={2}>Featured Drivers</Heading>
<Text size="xs" color="text-gray-500">Top performers on the grid</Text>
</Box>
</Stack>
{/* CTA */}
<div className="flex flex-col gap-4">
<Button
variant="primary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 px-6 py-3"
>
<Trophy className="w-5 h-5" />
View Leaderboard
</Button>
<p className="text-xs text-gray-500 text-center">See full driver rankings</p>
</div>
</div>
</div>
<Grid cols={4} gap={4}>
{featuredDrivers.map((driver, index) => (
<GridItem key={driver.id} colSpan={12} mdSpan={6} lgSpan={3}>
<FeaturedDriverCard
driver={driver}
position={index + 1}
onClick={() => onDriverClick(driver.id)}
/>
</GridItem>
))}
</Grid>
</Box>
)}
{/* Search */}
<div className="mb-8">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<Input
type="text"
placeholder="Search drivers by name or nationality..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-11"
{/* Active Drivers */}
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={onDriverClick} />}
{/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Category Distribution */}
{!searchQuery && <CategoryDistribution drivers={drivers} />}
{/* Leaderboard Preview */}
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={onDriverClick} />
{/* Empty State */}
{filteredDrivers.length === 0 && (
<EmptyState
icon={Search}
title="No drivers found"
description={`No drivers found matching "${searchQuery}"`}
action={{
label: 'Clear search',
onClick: () => onSearchChange(''),
variant: 'secondary'
}}
/>
</div>
</div>
{/* Featured Drivers */}
{!searchQuery && (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-yellow-400/10 border border-yellow-400/20">
<Crown className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Featured Drivers</h2>
<p className="text-xs text-gray-500">Top performers on the grid</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{featuredDrivers.map((driver, index) => (
<FeaturedDriverCard
key={driver.id}
driver={driver}
position={index + 1}
onClick={() => handleDriverClick(driver.id)}
/>
))}
</div>
</div>
)}
{/* Active Drivers */}
{!searchQuery && <RecentActivity drivers={drivers} onDriverClick={handleDriverClick} />}
{/* Skill Distribution */}
{!searchQuery && <SkillDistribution drivers={drivers} />}
{/* Category Distribution */}
{!searchQuery && <CategoryDistribution drivers={drivers} />}
{/* Leaderboard Preview */}
<LeaderboardPreview drivers={filteredDrivers} onDriverClick={handleDriverClick} />
{/* Empty State */}
{filteredDrivers.length === 0 && (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No drivers found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
)}
</div>
)}
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<main className="min-h-screen">
<Box as="main">
<Hero />
{/* Section 1: A Persistent Identity */}
@@ -35,55 +62,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<>
<p>
<Stack gap={4}>
<Text>
Your races, your seasons, your progress finally in one place.
</p>
<div className="space-y-3 mt-4">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Lifetime stats and season history across all your leagues
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Track your performance, consistency, and team contributions
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Your own rating that reflects real league competition
</span>
</div>
</div>
</div>
<p className="mt-4">
</Text>
<Stack gap={3}>
<FeatureItem text="Lifetime stats and season history across all your leagues" />
<FeatureItem text="Track your performance, consistency, and team contributions" />
<FeatureItem text="Your own rating that reflects real league competition" />
</Stack>
<Text>
iRacing gives you physics. GridPilot gives you a career.
</p>
</>
</Text>
</Stack>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
@@ -96,55 +87,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
<Stack gap={4}>
<Text size="sm">
Every race you run stays with you.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Your stats, your team, your story all connected
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
One race result updates your profile, team points, rating, and season history
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No more fragmented data across spreadsheets and forums
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
</Text>
<Stack gap={3}>
<ResultItem text="Your stats, your team, your story — all connected" color="#ef4444" />
<ResultItem text="One race result updates your profile, team points, rating, and season history" color="#ef4444" />
<ResultItem text="No more fragmented data across spreadsheets and forums" color="#ef4444" />
</Stack>
<Text size="sm">
Your racing career, finally in one place.
</p>
</>
</Text>
</Stack>
}
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right"
@@ -154,49 +109,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
<AlternatingSection
heading="Automatic Session Creation"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
<Stack gap={4}>
<Text size="sm">
Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">1</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Our companion app syncs with your league schedule
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">2</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
When it's race time, it creates the iRacing session automatically
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">3</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No clicking through wizards. No manual setup
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
</Text>
<Stack gap={3}>
<StepItem step={1} text="Our companion app syncs with your league schedule" />
<StepItem step={2} text="When it's race time, it creates the iRacing session automatically" />
<StepItem step={3} text="No clicking through wizards. No manual setup" />
</Stack>
<Text size="sm">
Automation instead of repetition.
</p>
</>
</Text>
</Stack>
}
mockup={<CompanionAutomationMockup />}
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={
<>
<p className="text-sm md:text-base leading-relaxed">
<Stack gap={4}>
<Text size="sm">
Right now, we're focused on making iRacing league racing better.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
</Text>
<Text size="sm">
But sims come and go. Your leagues, your teams, your rating — those stay.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
</Text>
<Text size="sm">
GridPilot is built to outlast any single platform.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
</Text>
<Text size="sm">
When the next sim arrives, your competitive identity moves with you.
</p>
</>
</Text>
</Stack>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
{/* Alpha-only discovery section */}
{data.isAlpha && (
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
<div className="flex items-baseline justify-between mb-8">
<div>
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
<p className="text-sm text-gray-400">
{viewData.isAlpha && (
<Container size="lg" py={12}>
<Stack gap={8}>
<Box>
<Heading level={2}>Discover the grid</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Explore leagues, teams, and races that make up the GridPilot ecosystem.
</p>
</div>
</div>
</Text>
</Box>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top leagues */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View all
</Button>
</div>
<ul className="space-y-3 text-sm">
{data.topLeagues.slice(0, 4).map((league: any) => (
<li key={league.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
{league.name
.split(' ')
.map((word: string) => word[0])
.join('')
.slice(0, 3)
.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{league.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{league.description}
</p>
</div>
</li>
))}
</ul>
</Card>
<Grid cols={3} gap={8}>
{/* Top leagues */}
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} style={{ fontSize: '0.875rem' }}>Featured leagues</Heading>
<Link href={routes.public.leagues}>
<Button variant="secondary" size="sm">
View all
</Button>
</Link>
</Stack>
<Stack gap={3}>
{viewData.topLeagues.slice(0, 4).map((league) => (
<Box key={league.id}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text size="xs" weight="bold" color="text-primary-blue">
{league.name.split(' ').map((word) => word[0]).join('').slice(0, 3).toUpperCase()}
</Text>
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text color="text-white" block truncate>{league.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} truncate>{league.description}</Text>
</Box>
</Stack>
</Box>
))}
</Stack>
</Stack>
</Card>
{/* Teams */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
<Button
as="a"
href="/teams"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
Browse teams
</Button>
</div>
<ul className="space-y-3 text-sm">
{data.teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{team.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Teams */}
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} style={{ fontSize: '0.875rem' }}>Teams on the grid</Heading>
<Link href={routes.public.teams}>
<Button variant="secondary" size="sm">
Browse teams
</Button>
</Link>
</Stack>
<Stack gap={3}>
{viewData.teams.slice(0, 4).map(team => (
<Box key={team.id}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text color="text-white" block truncate>{team.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} truncate>{team.description}</Text>
</Box>
</Stack>
</Box>
))}
</Stack>
</Stack>
</Card>
{/* Upcoming races */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View schedule
</Button>
</div>
{data.upcomingRaces.length === 0 ? (
<p className="text-xs text-gray-400">
No races scheduled in this demo snapshot.
</p>
) : (
<ul className="space-y-3 text-sm">
{data.upcomingRaces.map(race => (
<li key={race.id} className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-xs text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
{race.formattedDate}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</section>
{/* Upcoming races */}
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} style={{ fontSize: '0.875rem' }}>Upcoming races</Heading>
<Link href={routes.public.races}>
<Button variant="secondary" size="sm">
View schedule
</Button>
</Link>
</Stack>
{viewData.upcomingRaces.length === 0 ? (
<Text size="xs" color="text-gray-400">
No races scheduled in this demo snapshot.
</Text>
) : (
<Stack gap={3}>
{viewData.upcomingRaces.map(race => (
<Box key={race.id}>
<Stack direction="row" align="start" justify="between" gap={3}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text color="text-white" block truncate>{race.track}</Text>
<Text size="xs" color="text-gray-400" block mt={1} truncate>{race.car}</Text>
</Box>
<Text size="xs" color="text-gray-500" style={{ whiteSpace: 'nowrap' }}>
{race.formattedDate}
</Text>
</Stack>
</Box>
))}
</Stack>
)}
</Stack>
</Card>
</Grid>
</Stack>
</Container>
)}
<DiscordCTA />
<FAQ />
<Footer />
</main>
</Box>
);
}
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4 pb-12">
<div className="relative mb-10 py-10 px-8 rounded-2xl bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite border border-yellow-500/20 overflow-hidden">
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" />
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" />
<Container size="lg" py={8}>
<Box mb={10}>
<LeaderboardsHero
onNavigateToDrivers={onNavigateToDrivers}
onNavigateToTeams={onNavigateToTeams}
/>
</Box>
<div className="relative z-10">
<div className="flex items-center gap-4 mb-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-7 h-7 text-yellow-400" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Leaderboards
</Heading>
<p className="text-gray-400">Where champions rise and legends are made</p>
</div>
</div>
<p className="text-gray-400 text-lg leading-relaxed max-w-2xl mb-6">
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</p>
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={handleNavigateToDrivers}
className="flex items-center gap-2"
>
<Trophy className="w-4 h-4 text-primary-blue" />
Driver Rankings
</Button>
<Button
variant="secondary"
onClick={handleNavigateToTeams}
className="flex items-center gap-2"
>
<Users className="w-4 h-4 text-purple-400" />
Team Rankings
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<DriverLeaderboardPreview drivers={viewData.drivers} onDriverClick={handleDriverClick} onNavigateToDrivers={handleNavigateToDrivers} />
<TeamLeaderboardPreview teams={viewData.teams} onTeamClick={handleTeamClick} onNavigateToTeams={handleNavigateToTeams} />
</div>
</div>
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={6}>
<DriverLeaderboardPreview
drivers={viewData.drivers}
onDriverClick={onDriverClick}
onNavigateToDrivers={onNavigateToDrivers}
/>
</GridItem>
<GridItem colSpan={12} lgSpan={6}>
<TeamLeaderboardPreview
teams={viewData.teams}
onTeamClick={onTeamClick}
onNavigateToTeams={onNavigateToTeams}
/>
</GridItem>
</Grid>
</Container>
);
}
}

View File

@@ -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 (
<div className="space-y-6">
<Stack gap={6}>
<Card>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
</div>
<Stack gap={6}>
<Box>
<Heading level={1}>Schedule Admin</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Create, edit, and publish season races.</Text>
</Box>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-300" htmlFor="seasonId">
Season
</label>
{seasons.length > 0 ? (
<select
id="seasonId"
value={seasonId}
onChange={(e) => onSeasonChange(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
>
{seasons.map((s) => (
<option key={s.seasonId} value={s.seasonId}>
{s.name}
</option>
))}
</select>
) : (
<input
id="seasonId"
value={seasonId}
onChange={(e) => onSeasonChange(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="season-id"
/>
)}
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
</div>
<Box>
<Text size="sm" color="text-gray-300" block mb={2}>Season</Text>
<Select
value={seasonId}
onChange={(e) => onSeasonChange(e.target.value)}
options={seasons.map(s => ({ value: s.seasonId, label: s.name }))}
/>
<Text size="xs" color="text-gray-500" block mt={1}>Selected: {selectedSeasonLabel}</Text>
</Box>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-gray-300">
Status: <span className="font-medium text-white">{publishedLabel}</span>
</p>
<button
type="button"
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-300">
Status: <Text weight="medium" color="text-white">{publishedLabel}</Text>
</Text>
<Button
onClick={onPublishToggle}
disabled={!schedule || isPublishing}
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
disabled={isPublishing}
variant="primary"
size="sm"
>
{isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')}
</button>
</div>
{isPublishing ? 'Processing...' : (published ? 'Unpublish' : 'Publish')}
</Button>
</Stack>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<Box mb={4}>
<Heading level={2}>{isEditing ? 'Edit race' : 'Add race'}</Heading>
</Box>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="track" className="text-sm text-gray-300">
Track
</label>
<input
id="track"
<Grid cols={3} gap={4}>
<Box>
<Text size="sm" color="text-gray-300" block mb={2}>Track</Text>
<Input
value={track}
onChange={(e) => setTrack(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="Track name"
/>
</div>
</Box>
<div className="flex flex-col gap-1">
<label htmlFor="car" className="text-sm text-gray-300">
Car
</label>
<input
id="car"
<Box>
<Text size="sm" color="text-gray-300" block mb={2}>Car</Text>
<Input
value={car}
onChange={(e) => setCar(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="Car name"
/>
</div>
</Box>
<div className="flex flex-col gap-1">
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
Scheduled At (ISO)
</label>
<input
id="scheduledAtIso"
<Box>
<Text size="sm" color="text-gray-300" block mb={2}>Scheduled At (ISO)</Text>
<Input
value={scheduledAtIso}
onChange={(e) => setScheduledAtIso(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="2025-01-01T12:00:00.000Z"
/>
</div>
</div>
</Box>
</Grid>
<div className="flex items-center gap-2">
<button
type="button"
<Stack direction="row" gap={3} mt={6}>
<Button
onClick={onAddOrSave}
disabled={isSaving}
className="px-3 py-1.5 rounded bg-primary-blue text-white"
variant="primary"
>
{isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
</button>
</Button>
{isEditing && (
<button
type="button"
<Button
onClick={onCancelEdit}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
variant="secondary"
>
Cancel
</button>
</Button>
)}
</div>
</div>
</Stack>
</Box>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">Races</h2>
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<Box mb={4}>
<Heading level={2}>Races</Heading>
</Box>
{schedule?.races.length ? (
<div className="space-y-2">
{schedule.races.map((race) => (
<div
{races.length > 0 ? (
<Stack gap={3}>
{races.map((race) => (
<Surface
key={race.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
variant="muted"
rounded="lg"
border
padding={4}
>
<div className="min-w-0">
<p className="text-white font-medium truncate">{race.name}</p>
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
</div>
<Stack direction="row" align="center" justify="between">
<Box>
<Text weight="medium" color="text-white" block>{race.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{race.scheduledAt}</Text>
</Box>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onEdit(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Edit
</button>
<button
type="button"
onClick={() => 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'}
</button>
</div>
</div>
<Stack direction="row" gap={2}>
<Button
onClick={() => onEdit(race.id)}
variant="secondary"
size="sm"
>
Edit
</Button>
<Button
onClick={() => onDelete(race.id)}
disabled={isDeleting === race.id}
variant="secondary"
size="sm"
>
{isDeleting === race.id ? 'Deleting...' : 'Delete'}
</Button>
</Stack>
</Stack>
</Surface>
))}
</div>
</Stack>
) : (
<div className="py-4 text-sm text-gray-500">No races yet.</div>
<Box py={4}>
<Text size="sm" color="text-gray-500" block>No races yet.</Text>
</Box>
)}
</div>
</div>
</Box>
</Stack>
</Card>
</div>
</Stack>
);
}
}

View File

@@ -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 (
<Layout>
<Section>
<Container size="lg" py={6}>
<Stack gap={6}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' },
{ label: leagueName },
{ label: viewData.name },
]}
/>
<Section>
<Text size="3xl" weight="bold" className="text-white">
{leagueName}
<Box>
<Heading level={1}>{viewData.name}</Heading>
<Text color="text-gray-400" block mt={2}>
{viewData.description}
</Text>
<Text size="base" className="text-gray-400 mt-2">
{leagueDescription}
</Text>
</Section>
</Box>
<Section>
<div className="flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
className="pb-3 px-1 font-medium whitespace-nowrap transition-colors text-gray-400 hover:text-white"
>
{tab.label}
</Link>
))}
</div>
</Section>
<LeagueTabs tabs={tabs} />
<Section>
<Box>
{children}
</Section>
</Section>
</Layout>
</Box>
</Stack>
</Container>
);
}
}

View File

@@ -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<RulebookSection>('scoring');
if (loading) {
return (
<Card>
<div className="text-center py-12 text-gray-400">Loading rulebook...</div>
<Stack align="center" py={12}>
<Text color="text-gray-400">Loading rulebook...</Text>
</Stack>
</Card>
);
}
if (!viewModel || !viewModel.scoringConfig) {
if (!viewData || !viewData.scoringConfig) {
return (
<Card>
<div className="text-center py-12 text-gray-400">Unable to load rulebook</div>
<Stack align="center" py={12}>
<Text color="text-gray-400">Unable to load rulebook</Text>
</Stack>
</Card>
);
}
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 (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
<span className="text-sm font-medium text-primary-blue">{viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}</span>
</div>
</div>
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Rulebook</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</Box>
<Badge variant="primary">
{scoringConfig.scoringPresetName || 'Custom Rules'}
</Badge>
</Stack>
{/* Navigation Tabs */}
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline">
{sections.map((section) => (
<button
key={section.id}
onClick={() => 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}
</button>
))}
</div>
<RulebookTabs activeSection={activeSection} onSectionChange={onSectionChange} />
{/* Content Sections */}
{activeSection === 'scoring' && (
<div className="space-y-6">
<Stack gap={6}>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.gameName}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.championships.length}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
<p className="text-lg font-semibold text-white capitalize">
{primaryChampionship?.sessionTypes.join(', ') || 'Main'}
</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={viewModel.scoringConfig.dropPolicySummary}>
{viewModel.scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'}
</p>
</div>
</div>
<Grid cols={4} gap={4}>
<StatItem label="Platform" value={scoringConfig.gameName} />
<StatItem label="Championships" value={scoringConfig.championships.length} />
<StatItem label="Sessions Scored" value={primaryChampionship?.sessionTypes.join(', ') || 'Main'} />
<StatItem label="Drop Policy" value={scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} />
</Grid>
{/* Points Table */}
<PointsTable points={positionPoints} />
@@ -118,134 +87,137 @@ export function LeagueRulebookTemplate({
{/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
<div className="space-y-2">
{primaryChampionship.bonusSummary.map((bonus, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
>
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-sm font-bold">+</span>
</div>
<p className="text-sm text-gray-300">{bonus}</p>
</div>
))}
</div>
<Stack gap={4}>
<Heading level={2}>Bonus Points</Heading>
<Stack gap={2}>
{primaryChampionship.bonusSummary.map((bonus, idx) => (
<Surface
key={idx}
variant="muted"
rounded="lg"
border
padding={3}
>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2rem', height: '2rem', backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text color="text-performance-green" weight="bold">+</Text>
</Surface>
<Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
))}
</Stack>
</Stack>
</Card>
)}
{/* Drop Policy */}
{!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && (
{!scoringConfig.dropPolicySummary.includes('All results count') && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
<p className="text-sm text-gray-300">{viewModel.scoringConfig.dropPolicySummary}</p>
<p className="text-xs text-gray-500 mt-3">
Drop rules are applied automatically when calculating championship standings.
</p>
<Stack gap={4}>
<Heading level={2}>Drop Policy</Heading>
<Text size="sm" color="text-gray-300">{scoringConfig.dropPolicySummary}</Text>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
</Text>
</Box>
</Stack>
</Card>
)}
</div>
</Stack>
)}
{activeSection === 'conduct' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">1. Respect</h3>
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3>
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">3. Track Limits</h3>
<p>Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">4. Blue Flags</h3>
<p>Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">5. Communication</h3>
<p>Drivers are expected to communicate respectfully in voice and text chat during sessions.</p>
</div>
</div>
<Stack gap={4}>
<Heading level={2}>Driver Conduct</Heading>
<Stack gap={4}>
<ConductItem number={1} title="Respect" text="All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated." />
<ConductItem number={2} title="Clean Racing" text="Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly." />
<ConductItem number={3} title="Track Limits" text="Drivers must stay within track limits. Gaining a lasting advantage by exceeding track limits may result in penalties." />
<ConductItem number={4} title="Blue Flags" text="Lapped cars must yield to faster traffic within a reasonable time. Failure to do so may result in penalties." />
<ConductItem number={5} title="Communication" text="Drivers are expected to communicate respectfully in voice and text chat during sessions." />
</Stack>
</Stack>
</Card>
)}
{activeSection === 'protests' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2>
<div className="space-y-4 text-sm text-gray-300">
<div>
<h3 className="font-medium text-white mb-2">Filing a Protest</h3>
<p>Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Evidence</h3>
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Review Process</h3>
<p>League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented.</p>
</div>
<div>
<h3 className="font-medium text-white mb-2">Outcomes</h3>
<p>Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity.</p>
</div>
</div>
<Stack gap={4}>
<Heading level={2}>Protest Process</Heading>
<Stack gap={4}>
<ConductItem number={1} title="Filing a Protest" text="Protests can be filed within 48 hours of the race conclusion. Include the lap number, drivers involved, and a clear description of the incident." />
<ConductItem number={2} title="Evidence" text="Video evidence is highly recommended but not required. Stewards will review available replay data." />
<ConductItem number={3} title="Review Process" text="League stewards will review protests and make decisions within 72 hours. Decisions are final unless new evidence is presented." />
<ConductItem number={4} title="Outcomes" text="Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity." />
</Stack>
</Stack>
</Card>
)}
{activeSection === 'penalties' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2>
<div className="space-y-4 text-sm text-gray-300">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-2 font-medium text-gray-400">Infraction</th>
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th>
</tr>
</thead>
<tbody className="divide-y divide-charcoal-outline/50">
<tr>
<td className="py-3">Causing avoidable contact</td>
<td className="py-3 text-warning-amber">5-10 second time penalty</td>
</tr>
<tr>
<td className="py-3">Unsafe rejoin</td>
<td className="py-3 text-warning-amber">5 second time penalty</td>
</tr>
<tr>
<td className="py-3">Blocking</td>
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td>
</tr>
<tr>
<td className="py-3">Repeated track limit violations</td>
<td className="py-3 text-warning-amber">5 second penalty</td>
</tr>
<tr>
<td className="py-3">Intentional wrecking</td>
<td className="py-3 text-red-400">Disqualification</td>
</tr>
<tr>
<td className="py-3">Unsportsmanlike conduct</td>
<td className="py-3 text-red-400">Points deduction or ban</td>
</tr>
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4">
Penalties are applied at steward discretion based on incident severity and driver history.
</p>
</div>
<Stack gap={4}>
<Heading level={2}>Penalty Guidelines</Heading>
<Stack gap={4}>
<Table>
<TableHead>
<TableRow>
<TableHeader>Infraction</TableHeader>
<TableHeader>Typical Penalty</TableHeader>
</TableRow>
</TableHead>
<TableBody>
<PenaltyRow infraction="Causing avoidable contact" penalty="5-10 second time penalty" />
<PenaltyRow infraction="Unsafe rejoin" penalty="5 second time penalty" />
<PenaltyRow infraction="Blocking" penalty="Warning or 3 second penalty" />
<PenaltyRow infraction="Repeated track limit violations" penalty="5 second penalty" />
<PenaltyRow infraction="Intentional wrecking" penalty="Disqualification" color="#f87171" />
<PenaltyRow infraction="Unsportsmanlike conduct" penalty="Points deduction or ban" color="#f87171" />
</TableBody>
</Table>
<Box mt={4}>
<Text size="xs" color="text-gray-500" block>
Penalties are applied at steward discretion based on incident severity and driver history.
</Text>
</Box>
</Stack>
</Stack>
</Card>
)}
</div>
</Stack>
);
}
}
function StatItem({ label, value }: { label: string, value: string | number }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: '#262626', borderColor: '#262626' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
<Text weight="semibold" color="text-white" style={{ fontSize: '1.125rem' }}>{value}</Text>
</Surface>
);
}
function ConductItem({ number, title, text }: { number: number, title: string, text: string }) {
return (
<Box>
<Text weight="medium" color="text-white" block mb={2}>{number}. {title}</Text>
<Text size="sm" color="text-gray-300">{text}</Text>
</Box>
);
}
function PenaltyRow({ infraction, penalty, color }: { infraction: string, penalty: string, color?: string }) {
return (
<TableRow>
<TableCell>
<Text size="sm" color="text-gray-300">{infraction}</Text>
</TableCell>
<TableCell>
<Text size="sm" style={{ color: color || '#f59e0b' }}>{penalty}</Text>
</TableCell>
</TableRow>
);
}

View File

@@ -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 (
<Section>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Race Schedule</h2>
<p className="text-sm text-gray-400 mt-1">
Upcoming and completed races for this season
</p>
</div>
</div>
<Stack gap={6}>
<Box>
<Heading level={2}>Race Schedule</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Upcoming and completed races for this season
</Text>
</Box>
{viewData.races.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Calendar className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">No Races Scheduled</p>
<p className="text-sm text-gray-400">The race schedule will appear here once events are added.</p>
</div>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Icon icon={Calendar} size={8} color="#10b981" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="semibold" size="lg" color="text-white" block mb={2}>No Races Scheduled</Text>
<Text size="sm" color="text-gray-400">The race schedule will appear here once events are added.</Text>
</Box>
</Stack>
</Card>
) : (
<div className="space-y-4">
<Stack gap={4}>
{viewData.races.map((race) => (
<Card key={race.id}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-3">
<div className={`w-3 h-3 rounded-full ${race.isPast ? 'bg-performance-green' : 'bg-primary-blue'}`} />
<h3 className="font-semibold text-white text-lg">{race.name}</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
race.status === 'completed'
? 'bg-performance-green/20 text-performance-green'
: 'bg-primary-blue/20 text-primary-blue'
}`}>
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-300">
<Calendar className="w-4 h-4 text-gray-400" />
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-300">
<Clock className="w-4 h-4 text-gray-400" />
<span>{new Date(race.scheduledAt).toLocaleTimeString()}</span>
</div>
{race.track && (
<div className="flex items-center gap-2 text-sm text-gray-300">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="truncate">{race.track}</span>
</div>
)}
{race.car && (
<div className="flex items-center gap-2 text-sm text-gray-300">
<Car className="w-4 h-4 text-gray-400" />
<span className="truncate">{race.car}</span>
</div>
)}
</div>
{race.sessionType && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Trophy className="w-4 h-4" />
<span>{race.sessionType} Session</span>
</div>
)}
</div>
</div>
</Card>
<ScheduleRaceCard key={race.id} race={race} />
))}
</div>
</Stack>
)}
</Section>
</Stack>
);
}
}

View File

@@ -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 (
<Section>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">League Settings</h2>
<p className="text-sm text-gray-400 mt-1">
Manage your league configuration and preferences
</p>
</div>
</div>
<Stack gap={6}>
<Box>
<Heading level={2}>League Settings</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage your league configuration and preferences
</Text>
</Box>
<div className="space-y-6">
<Stack gap={6}>
{/* League Information */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Settings className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">League Information</h3>
<p className="text-sm text-gray-400">Basic league details</p>
</div>
</div>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Icon icon={Settings} size={5} color="#3b82f6" />
</Surface>
<Box>
<Heading level={3}>League Information</Heading>
<Text size="sm" color="text-gray-400">Basic league details</Text>
</Box>
</Stack>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">Name</label>
<p className="text-white">{viewData.league.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">Visibility</label>
<p className="text-white capitalize">{viewData.league.visibility}</p>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-400 mb-1">Description</label>
<p className="text-white">{viewData.league.description}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">Created</label>
<p className="text-white">{new Date(viewData.league.createdAt).toLocaleDateString()}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">Owner ID</label>
<p className="text-white font-mono text-sm">{viewData.league.ownerId}</p>
</div>
</div>
<Grid cols={2} gap={4}>
<InfoItem label="Name" value={viewData.league.name} />
<InfoItem label="Visibility" value={viewData.league.visibility} capitalize />
<GridItem colSpan={2}>
<InfoItem label="Description" value={viewData.league.description} />
</GridItem>
<InfoItem label="Created" value={new Date(viewData.league.createdAt).toLocaleDateString()} />
<InfoItem label="Owner ID" value={viewData.league.ownerId} mono />
</Grid>
</Stack>
</Card>
{/* Configuration */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<Trophy className="w-5 h-5 text-performance-green" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Configuration</h3>
<p className="text-sm text-gray-400">League rules and limits</p>
</div>
</div>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Icon icon={Trophy} size={5} color="#10b981" />
</Surface>
<Box>
<Heading level={3}>Configuration</Heading>
<Text size="sm" color="text-gray-400">League rules and limits</Text>
</Box>
</Stack>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-400">Max Drivers</p>
<p className="text-white">{viewData.config.maxDrivers}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-400">Require Approval</p>
<p className="text-white">{viewData.config.requireApproval ? 'Yes' : 'No'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-400">Allow Late Join</p>
<p className="text-white">{viewData.config.allowLateJoin ? 'Yes' : 'No'}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Trophy className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-400">Scoring Preset</p>
<p className="text-white font-mono text-sm">{viewData.config.scoringPresetId}</p>
</div>
</div>
</div>
<Grid cols={2} gap={4}>
<ConfigItem icon={Users} label="Max Drivers" value={viewData.config.maxDrivers} />
<ConfigItem icon={Shield} label="Require Approval" value={viewData.config.requireApproval ? 'Yes' : 'No'} />
<ConfigItem icon={Clock} label="Allow Late Join" value={viewData.config.allowLateJoin ? 'Yes' : 'No'} />
<ConfigItem icon={Trophy} label="Scoring Preset" value={viewData.config.scoringPresetId} mono />
</Grid>
</Stack>
</Card>
{/* Note about forms */}
<Card>
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-warning-amber/10 flex items-center justify-center">
<Settings className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Settings Management</h3>
<p className="text-sm text-gray-400">
Form-based editing and ownership transfer functionality will be implemented in future updates.
</p>
</div>
<Stack align="center" py={8} gap={4}>
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
<Icon icon={Settings} size={8} color="#f59e0b" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Heading level={3}>Settings Management</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Form-based editing and ownership transfer functionality will be implemented in future updates.
</Text>
</Box>
</Stack>
</Card>
</div>
</Section>
</Stack>
</Stack>
);
}
}
function InfoItem({ label, value, capitalize, mono }: { label: string, value: string, capitalize?: boolean, mono?: boolean }) {
return (
<Box>
<Text size="sm" weight="medium" color="text-gray-400" block mb={1}>{label}</Text>
<Text color="text-white" style={{ textTransform: capitalize ? 'capitalize' : 'none', fontFamily: mono ? 'monospace' : 'inherit' }}>{value}</Text>
</Box>
);
}
function ConfigItem({ icon, label, value, mono }: { icon: React.ElementType, label: string, value: string | number, mono?: boolean }) {
return (
<Stack direction="row" align="center" gap={3}>
<Icon icon={icon as any} size={5} color="#9ca3af" />
<Box>
<Text size="sm" weight="medium" color="text-gray-400" block>{label}</Text>
<Text color="text-white" style={{ fontFamily: mono ? 'monospace' : 'inherit' }}>{value}</Text>
</Box>
</Stack>
);
}

View File

@@ -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 (
<Section>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Sponsorships</h2>
<p className="text-sm text-gray-400 mt-1">
Manage sponsorship slots and review requests
</p>
</div>
</div>
<Stack gap={6}>
<Box>
<Heading level={2}>Sponsorships</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage sponsorship slots and review requests
</Text>
</Box>
<div className="space-y-6">
<Stack gap={6}>
{/* Sponsorship Slots */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Building className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Sponsorship Slots</h3>
<p className="text-sm text-gray-400">Available sponsorship opportunities</p>
</div>
</div>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Icon icon={Building} size={5} color="#3b82f6" />
</Surface>
<Box>
<Heading level={3}>Sponsorship Slots</Heading>
<Text size="sm" color="text-gray-400">Available sponsorship opportunities</Text>
</Box>
</Stack>
{viewData.sponsorshipSlots.length === 0 ? (
<div className="text-center py-8">
<Building className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-400">No sponsorship slots available</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{viewData.sponsorshipSlots.map((slot) => (
<div
key={slot.id}
className={`rounded-lg border p-4 ${
slot.isAvailable
? 'border-performance-green bg-performance-green/5'
: 'border-charcoal-outline bg-iron-gray/30'
}`}
>
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-white">{slot.name}</h4>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
slot.isAvailable
? 'bg-performance-green/20 text-performance-green'
: 'bg-gray-500/20 text-gray-400'
}`}>
{slot.isAvailable ? 'Available' : 'Taken'}
</span>
</div>
<p className="text-sm text-gray-300 mb-3">{slot.description}</p>
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-white font-semibold">
{slot.price} {slot.currency}
</span>
</div>
{!slot.isAvailable && slot.sponsoredBy && (
<div className="pt-3 border-t border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Sponsored by</p>
<p className="text-sm font-medium text-white">{slot.sponsoredBy.name}</p>
</div>
)}
</div>
))}
</div>
)}
{viewData.sponsorshipSlots.length === 0 ? (
<Stack align="center" py={8} gap={4}>
<Icon icon={Building} size={12} color="#525252" />
<Text color="text-gray-400">No sponsorship slots available</Text>
</Stack>
) : (
<Grid cols={3} gap={4}>
{viewData.sponsorshipSlots.map((slot) => (
<SponsorshipSlotCard key={slot.id} slot={slot} />
))}
</Grid>
)}
</Stack>
</Card>
{/* Sponsorship Requests */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
<Clock className="w-5 h-5 text-warning-amber" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Sponsorship Requests</h3>
<p className="text-sm text-gray-400">Pending and processed sponsorship applications</p>
</div>
</div>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
<Icon icon={Clock} size={5} color="#f59e0b" />
</Surface>
<Box>
<Heading level={3}>Sponsorship Requests</Heading>
<Text size="sm" color="text-gray-400">Pending and processed sponsorship applications</Text>
</Box>
</Stack>
{viewData.sponsorshipRequests.length === 0 ? (
<div className="text-center py-8">
<Clock className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-400">No sponsorship requests</p>
</div>
) : (
<div className="space-y-3">
{viewData.sponsorshipRequests.map((request) => {
const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId);
const statusIcon = {
pending: <AlertCircle className="w-5 h-5 text-warning-amber" />,
approved: <CheckCircle className="w-5 h-5 text-performance-green" />,
rejected: <XCircle className="w-5 h-5 text-red-400" />,
}[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 (
<div
key={request.id}
className={`rounded-lg border p-4 ${statusColor}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
{statusIcon}
<span className="font-semibold text-white">{request.sponsorName}</span>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
request.status === 'pending'
? 'bg-warning-amber/20 text-warning-amber'
: request.status === 'approved'
? 'bg-performance-green/20 text-performance-green'
: 'bg-red-400/20 text-red-400'
}`}>
{request.status}
</span>
</div>
<div className="text-sm text-gray-300 mb-2">
Requested: {slot?.name || 'Unknown slot'}
</div>
<div className="text-xs text-gray-400">
{new Date(request.requestedAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{viewData.sponsorshipRequests.length === 0 ? (
<Stack align="center" py={8} gap={4}>
<Icon icon={Clock} size={12} color="#525252" />
<Text color="text-gray-400">No sponsorship requests</Text>
</Stack>
) : (
<Stack gap={3}>
{viewData.sponsorshipRequests.map((request) => {
const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId);
return (
<SponsorshipRequestCard
key={request.id}
request={{
...request,
status: request.status as any,
slotName: slot?.name || 'Unknown slot'
}}
/>
);
})}
</Stack>
)}
</Stack>
</Card>
{/* Note about management */}
<Card>
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center">
<Building className="w-8 h-8 text-primary-blue" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Sponsorship Management</h3>
<p className="text-sm text-gray-400">
Interactive management features for approving requests and managing slots will be implemented in future updates.
</p>
</div>
<Stack align="center" py={8} gap={4}>
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Icon icon={Building} size={8} color="#3b82f6" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Heading level={3}>Sponsorship Management</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Interactive management features for approving requests and managing slots will be implemented in future updates.
</Text>
</Box>
</Stack>
</Card>
</div>
</Section>
</Stack>
</Stack>
);
}
}

View File

@@ -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 (
<div className="text-center text-gray-400">
Loading standings...
</div>
<Stack align="center" py={12}>
<Text color="text-gray-400">Loading standings...</Text>
</Stack>
);
}
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Championship Stats */}
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
<StandingsTable
standings={viewData.standings}
drivers={viewData.drivers}
memberships={viewData.memberships}
currentDriverId={viewData.currentDriverId ?? undefined}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
/>
<Stack gap={4}>
<Heading level={2}>Championship Standings</Heading>
<StandingsTable
standings={viewData.standings}
drivers={viewData.drivers}
memberships={viewData.memberships}
currentDriverId={viewData.currentDriverId ?? undefined}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
/>
</Stack>
</Card>
</div>
</Stack>
);
}
}

View File

@@ -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 <ArrowUpRight className="w-4 h-4 text-performance-green" />;
case 'withdrawal':
return <ArrowDownRight className="w-4 h-4 text-red-400" />;
case 'sponsorship':
return <DollarSign className="w-4 h-4 text-primary-blue" />;
case 'prize':
return <TrendingUp className="w-4 h-4 text-warning-amber" />;
default:
return <DollarSign className="w-4 h-4 text-gray-400" />;
}
};
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 (
<Section>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">League Wallet</h2>
<p className="text-sm text-gray-400 mt-1">
Financial overview and transaction history
</p>
</div>
</div>
<Stack gap={6}>
<Box>
<Heading level={2}>League Wallet</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Financial overview and transaction history
</Text>
</Box>
<div className="space-y-6">
<Stack gap={6}>
{/* Balance Card */}
<Card>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Wallet className="w-6 h-6 text-primary-blue" />
</div>
<div>
<p className="text-sm text-gray-400">Current Balance</p>
<p className="text-3xl font-bold text-white">
{formatCurrency(viewData.balance)}
</p>
</div>
</div>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Icon icon={Wallet} size={6} color="#3b82f6" />
</Surface>
<Box>
<Text size="sm" color="text-gray-400" block>Current Balance</Text>
<Text size="3xl" weight="bold" color="text-white">
{viewData.formattedBalance}
</Text>
</Box>
</Stack>
</Card>
{/* Transaction History */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<Calendar className="w-5 h-5 text-performance-green" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Transaction History</h3>
<p className="text-sm text-gray-400">Recent financial activity</p>
</div>
</div>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Icon icon={Calendar} size={5} color="#10b981" />
</Surface>
<Box>
<Heading level={3}>Transaction History</Heading>
<Text size="sm" color="text-gray-400">Recent financial activity</Text>
</Box>
</Stack>
{viewData.transactions.length === 0 ? (
<div className="text-center py-8">
<Wallet className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-400">No transactions yet</p>
</div>
) : (
<div className="space-y-3">
{viewData.transactions.map((transaction) => (
<div
key={transaction.id}
className="flex items-center justify-between p-4 rounded-lg border border-charcoal-outline bg-iron-gray/30"
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{getTransactionIcon(transaction.type)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">
{transaction.description}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{new Date(transaction.createdAt).toLocaleDateString()}</span>
<span></span>
<span className={`capitalize ${getTransactionColor(transaction.type)}`}>
{transaction.type}
</span>
<span></span>
<span className={`capitalize ${
transaction.status === 'completed'
? 'text-performance-green'
: transaction.status === 'pending'
? 'text-warning-amber'
: 'text-red-400'
}`}>
{transaction.status}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${
transaction.amount >= 0 ? 'text-performance-green' : 'text-red-400'
}`}>
{transaction.amount >= 0 ? '+' : '-'}{formatCurrency(transaction.amount)}
</p>
</div>
</div>
))}
</div>
)}
{viewData.transactions.length === 0 ? (
<Stack align="center" py={8} gap={4}>
<Icon icon={Wallet} size={12} color="#525252" />
<Text color="text-gray-400">No transactions yet</Text>
</Stack>
) : (
<Stack gap={3}>
{viewData.transactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</Stack>
)}
</Stack>
</Card>
{/* Note about features */}
<Card>
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center">
<Wallet className="w-8 h-8 text-primary-blue" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Wallet Management</h3>
<p className="text-sm text-gray-400">
Interactive withdrawal and export features will be implemented in future updates.
</p>
</div>
<Stack align="center" py={8} gap={4}>
<Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Icon icon={Wallet} size={8} color="#3b82f6" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Heading level={3}>Wallet Management</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Interactive withdrawal and export features will be implemented in future updates.
</Text>
</Box>
</Stack>
</Card>
</div>
</Section>
</Stack>
</Stack>
);
}
}

View File

@@ -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 (
<div className="max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1>
<p className="text-gray-400 text-sm">
View leagues you own and participate in, and jump into league admin tools.
</p>
</div>
<Container size="md" py={8}>
<Stack gap={8}>
<Box>
<Heading level={1}>Manage leagues</Heading>
<Text color="text-gray-400" size="sm" block mt={2}>
View leagues you own and participate in, and jump into league admin tools.
</Text>
</Box>
{/* Leagues You Own */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
{viewData.ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{/* Leagues You Own */}
<Surface variant="muted" rounded="lg" border padding={6}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>Leagues you own</Heading>
{viewData.ownedLeagues.length > 0 && (
<Text size="xs" color="text-gray-400">
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</Text>
)}
</Stack>
{viewData.ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You don't own any leagues yet in this session.
</p>
) : (
<div className="space-y-3">
{viewData.ownedLeagues.map((league: ProfileLeaguesViewData['ownedLeagues'][number]) => (
<div
key={league.leagueId}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
</div>
<div className="flex items-center gap-2">
<a
href={`/leagues/${league.leagueId}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</a>
<a href={`/leagues/${league.leagueId}?tab=admin`}>
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
Manage
</button>
</a>
</div>
</div>
))}
</div>
)}
</div>
{viewData.ownedLeagues.length === 0 ? (
<Text size="sm" color="text-gray-400">
You don't own any leagues yet in this session.
</Text>
) : (
<Stack gap={3}>
{viewData.ownedLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} isAdmin />
))}
</Stack>
)}
</Stack>
</Surface>
{/* Leagues You're In */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
{viewData.memberLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{/* Leagues You're In */}
<Surface variant="muted" rounded="lg" border padding={6}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>Leagues you're in</Heading>
{viewData.memberLeagues.length > 0 && (
<Text size="xs" color="text-gray-400">
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
</Text>
)}
</Stack>
{viewData.memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You're not a member of any other leagues yet.
</p>
) : (
<div className="space-y-3">
{viewData.memberLeagues.map((league: ProfileLeaguesViewData['memberLeagues'][number]) => (
<div
key={league.leagueId}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
<p className="text-xs text-gray-500 mt-1">
Your role:{' '}
{league.membershipRole.charAt(0).toUpperCase() + league.membershipRole.slice(1)}
</p>
</div>
<a
href={`/leagues/${league.leagueId}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</a>
</div>
))}
</div>
)}
</div>
</div>
{viewData.memberLeagues.length === 0 ? (
<Text size="sm" color="text-gray-400">
You're not a member of any other leagues yet.
</Text>
) : (
<Stack gap={3}>
{viewData.memberLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} />
))}
</Stack>
)}
</Stack>
</Surface>
</Stack>
</Container>
);
}

View File

@@ -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<void>;
}
function getAchievementIcon(icon: NonNullable<ProfileViewData['extendedProfile']>['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<ProfileTab>(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 (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<User className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
<p className="text-gray-400">Join the GridPilot community and start your racing journey</p>
</div>
<Container size="md">
<Stack align="center" gap={4} mb={8}>
<Surface variant="muted" rounded="xl" border padding={4}>
<Icon icon={User} size={8} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Create Your Driver Profile</Heading>
<Text color="text-gray-400">Join the GridPilot community and start your racing journey</Text>
</Box>
</Stack>
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
<p className="text-gray-400 text-sm">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p>
</div>
<CreateDriverForm />
</Card>
</div>
);
}
if (!viewData) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">Unable to load profile</p>
</Card>
</div>
<Box maxWidth="42rem" mx="auto">
<Card>
<Stack gap={6}>
<Box>
<Heading level={2}>Get Started</Heading>
<Text size="sm" color="text-gray-400">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</Text>
</Box>
<CreateDriverForm />
</Stack>
</Card>
</Box>
</Container>
);
}
if (editMode) {
return (
<div className="max-w-4xl mx-auto px-4 space-y-6">
<div className="flex items-center justify-between mb-4">
<Heading level={1}>Edit Profile</Heading>
<Button variant="secondary" onClick={() => setEditMode(false)}>
Cancel
</Button>
</div>
<Container size="md">
<Stack gap={6}>
<Stack direction="row" align="center" justify="between">
<Heading level={1}>Edit Profile</Heading>
<Button variant="secondary" onClick={() => onEditModeChange(false)}>
Cancel
</Button>
</Stack>
{/* ProfileSettings expects a DriverProfileDriverSummaryViewModel; keep existing component usage by passing a minimal compatible shape */}
<ProfileSettings
driver={{
id: viewData.driver.id,
name: viewData.driver.name,
country: viewData.driver.countryCode,
avatarUrl: viewData.driver.avatarUrl,
iracingId: viewData.driver.iracingId,
joinedAt: new Date().toISOString(),
rating: null,
globalRank: null,
consistency: null,
bio: viewData.driver.bio,
totalDrivers: null,
}}
onSave={async (updates) => {
await onSaveSettings(updates);
setEditMode(false);
}}
/>
</div>
<ProfileSettings
driver={{
id: viewData.driver.id,
name: viewData.driver.name,
country: viewData.driver.countryCode,
avatarUrl: viewData.driver.avatarUrl,
iracingId: viewData.driver.iracingId || '',
joinedAt: new Date().toISOString(),
rating: null,
globalRank: null,
consistency: null,
bio: viewData.driver.bio,
totalDrivers: null,
}}
onSave={async (updates) => {
await onSaveSettings(updates);
onEditModeChange(false);
}}
/>
</Stack>
</Container>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
{/* Hero */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
<div className="relative p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-start gap-6">
<div className="relative">
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={viewData.driver.avatarUrl}
alt={viewData.driver.name}
width={144}
height={144}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-performance-green border-4 border-iron-gray" />
</div>
<Container size="lg">
<Stack gap={6}>
{/* Back Navigation */}
<Box>
<Button
variant="secondary"
onClick={() => {}}
icon={<Icon icon={History} size={4} />}
>
Back to Drivers
</Button>
</Box>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-3xl md:text-4xl font-bold text-white">{viewData.driver.name}</h1>
<span className="text-4xl" aria-label={`Country: ${viewData.driver.countryCode}`}>{viewData.driver.countryFlag}</span>
{viewData.teamMemberships[0] && (
<span className="px-3 py-1 bg-purple-600/20 text-purple-400 rounded-full text-sm font-semibold border border-purple-600/30">
[{viewData.teamMemberships[0].teamTag || 'TEAM'}]
</span>
)}
</div>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' },
{ label: viewData.driver.name },
]}
/>
{viewData.stats && (
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{viewData.stats.ratingLabel}</span>
<span className="text-xs text-gray-400">Rating</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="font-mono font-bold text-yellow-400">{viewData.stats.globalRankLabel}</span>
<span className="text-xs text-gray-400">Global</span>
</div>
</div>
)}
<ProfileHero
driver={{
...viewData.driver,
country: viewData.driver.countryCode,
iracingId: Number(viewData.driver.iracingId) || 0,
joinedAt: new Date().toISOString(), // Placeholder
}}
stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
globalRank={Number(viewData.stats?.globalRankLabel) || 0}
timezone={viewData.extendedProfile?.timezone || 'UTC'}
socialHandles={viewData.extendedProfile?.socialHandles.map(s => ({ ...s, platform: s.platformLabel as any })) || []}
onAddFriend={onFriendRequestSend}
friendRequestSent={friendRequestSent}
/>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
iRacing: {viewData.driver.iracingId ?? '—'}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
Joined {viewData.driver.joinedAtLabel}
</span>
{viewData.extendedProfile && (
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{viewData.extendedProfile.timezone}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="primary" onClick={() => setEditMode(true)} className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
Edit Profile
</Button>
<Button
variant="secondary"
onClick={() => setFriendRequestSent(true)}
disabled={friendRequestSent}
className="w-full flex items-center gap-2"
>
<UserPlus className="w-4 h-4" />
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
<Link href=routes.protected.profileLeagues>
<Button variant="secondary" className="w-full flex items-center gap-2">
<Flag className="w-4 h-4" />
My Leagues
</Button>
</Link>
</div>
</div>
{viewData.extendedProfile && viewData.extendedProfile.socialHandles.length > 0 && (
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span>
{viewData.extendedProfile.socialHandles.map((social) => {
const Icon = getSocialIcon(social.platformLabel);
return (
<a
key={`${social.platformLabel}-${social.handle}`}
href={social.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all hover:text-white"
>
<Icon className="w-4 h-4" />
<span className="text-sm">{social.handle}</span>
<ExternalLink className="w-3 h-3 opacity-50" />
</a>
);
})}
</div>
</div>
)}
</div>
</div>
{viewData.driver.bio && (
<Card>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<User className="w-5 h-5 text-primary-blue" />
About
</h2>
<p className="text-gray-300 leading-relaxed">{viewData.driver.bio}</p>
</Card>
)}
{viewData.teamMemberships.length > 0 && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-400" />
Team Memberships
<span className="text-sm text-gray-500 font-normal">({viewData.teamMemberships.length})</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{viewData.teamMemberships.map((membership) => (
<Link
key={membership.teamId}
href={membership.href}
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline hover:border-purple-400/30 hover:bg-iron-gray/50 transition-all group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600/20 border border-purple-600/30">
<Users className="w-6 h-6 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">{membership.teamName}</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="px-2 py-0.5 rounded-full bg-purple-600/20 text-purple-400 capitalize">{membership.roleLabel}</span>
<span>Since {membership.joinedAtLabel}</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-purple-400 transition-colors" />
</Link>
))}
</div>
</Card>
)}
{/* Tabs */}
<div className="flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10">
<button
type="button"
onClick={() => 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'
}`}
>
<User className="w-4 h-4" />
Overview
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<History className="w-4 h-4" />
Race History
</button>
<button
type="button"
onClick={() => 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'
}`}
>
<BarChart3 className="w-4 h-4" />
Detailed Stats
</button>
</div>
{activeTab === 'history' && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<History className="w-5 h-5 text-red-400" />
Race History
</h2>
<ProfileRaceHistory driverId={viewData.driver.id} />
</Card>
)}
{activeTab === 'stats' && viewData.stats && (
<div className="space-y-6">
{viewData.driver.bio && (
<Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Activity className="w-5 h-5 text-neon-aqua" />
Performance Overview
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-white mb-1">{viewData.stats.totalRacesLabel}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-performance-green mb-1">{viewData.stats.winsLabel}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-warning-amber mb-1">{viewData.stats.podiumsLabel}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-primary-blue mb-1">{viewData.stats.consistencyLabel}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
</div>
</div>
<Stack gap={3}>
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
About
</Heading>
<Text color="text-gray-300" block>{viewData.driver.bio}</Text>
</Stack>
</Card>
</div>
)}
)}
{activeTab === 'overview' && viewData.extendedProfile && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Award className="w-5 h-5 text-yellow-400" />
Achievements
<span className="ml-auto text-sm text-gray-500">{viewData.extendedProfile.achievements.length} earned</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{viewData.extendedProfile.achievements.map((achievement) => {
const Icon = getAchievementIcon(achievement.icon);
return (
<div key={achievement.id} className="p-4 rounded-xl border border-charcoal-outline bg-iron-gray/30">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<Icon className="w-5 h-5 text-yellow-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">{achievement.earnedAtLabel}</p>
</div>
</div>
</div>
);
})}
</div>
</Card>
)}
</div>
{viewData.teamMemberships.length > 0 && (
<TeamMembershipGrid
memberships={viewData.teamMemberships.map(m => ({
team: { id: m.teamId, name: m.teamName },
role: m.roleLabel,
joinedAt: new Date() // Placeholder
}))}
/>
)}
<ProfileTabs activeTab={activeTab as any} onTabChange={onTabChange as any} />
{activeTab === 'history' && (
<Card>
<Stack gap={4}>
<Heading level={2} icon={<Icon icon={History} size={5} color="#f87171" />}>
Race History
</Heading>
<ProfileRaceHistory driverId={viewData.driver.id} />
</Stack>
</Card>
)}
{activeTab === 'stats' && viewData.stats && (
<Card>
<Stack gap={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview
</Heading>
<ProfileStatGrid
stats={[
{ label: 'Races', value: viewData.stats.totalRacesLabel },
{ label: 'Wins', value: viewData.stats.winsLabel, color: '#10b981' },
{ label: 'Podiums', value: viewData.stats.podiumsLabel, color: '#f59e0b' },
{ label: 'Consistency', value: viewData.stats.consistencyLabel, color: '#3b82f6' },
]}
/>
</Stack>
</Card>
)}
{activeTab === 'overview' && viewData.extendedProfile && (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements
</Heading>
<Text size="sm" color="text-gray-400" weight="normal">{viewData.extendedProfile.achievements.length} earned</Text>
</Stack>
<AchievementGrid
achievements={viewData.extendedProfile.achievements.map(a => ({
...a,
rarity: a.rarityLabel,
earnedAt: new Date() // Placeholder
}))}
/>
</Stack>
</Card>
)}
</Stack>
</Container>
);
}

View File

@@ -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<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
if (isLoading) {
return (
<Container size="lg" py={8}>
<Stack gap={6}>
<Skeleton width="8rem" height="1.5rem" />
<Skeleton width="100%" height="12rem" />
<Grid cols={3} gap={6}>
<GridItem colSpan={2}>
<Skeleton width="100%" height="16rem" />
</GridItem>
<Skeleton width="100%" height="16rem" />
</Grid>
</Stack>
</Container>
);
}
// 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 (
<Container size="md" py={8}>
<Stack gap={6}>
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
// Animate rating change when it changes
useEffect(() => {
if (ratingChange !== null) {
let start = 0;
const end = ratingChange;
const duration = 1000;
const startTime = performance.now();
<Card>
<Stack align="center" gap={4} py={12}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={AlertTriangle} size={8} color="#f59e0b" />
</Surface>
<Box>
<Text weight="medium" color="text-white" block mb={1}>{error instanceof Error ? error.message : error || 'Race not found'}</Text>
<Text size="sm" color="text-gray-500">
The race you&apos;re looking for doesn&apos;t exist or has been removed.
</Text>
</Box>
<Button
variant="secondary"
onClick={onBack}
>
Back to Races
</Button>
</Stack>
</Card>
</Stack>
</Container>
);
}
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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-iron-gray rounded w-1/4" />
<div className="h-48 bg-iron-gray rounded-xl" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 h-64 bg-iron-gray rounded-xl" />
<div className="h-64 bg-iron-gray rounded-xl" />
</div>
</div>
</div>
</div>
);
}
if (error || !viewModel || !viewModel.race) {
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
<Card className="text-center py-12 mt-6">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-warning-amber/10 rounded-full">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<div>
<p className="text-white font-medium mb-1">{error instanceof Error ? error.message : error || 'Race not found'}</p>
<p className="text-sm text-gray-500">
The race you're looking for doesn't exist or has been removed.
</p>
</div>
<Button
variant="secondary"
onClick={onBack}
className="mt-4"
>
Back to Races
</Button>
</div>
</Card>
</div>
</div>
);
}
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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto space-y-6">
{/* Navigation Row: Breadcrumbs left, Back button right */}
<div className="flex items-center justify-between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Navigation Row */}
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2 text-sm"
size="sm"
icon={<Icon icon={ArrowLeft} size={4} />}
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</div>
</Stack>
{/* User Result - Premium Achievement Card */}
{/* User Result */}
{userResult && (
<div
className={`
relative overflow-hidden rounded-2xl p-1
${
userResult.position === 1
? 'bg-gradient-to-r from-yellow-500 via-yellow-400 to-yellow-600'
: userResult.isPodium
? 'bg-gradient-to-r from-gray-400 via-gray-300 to-gray-500'
: 'bg-gradient-to-r from-primary-blue via-primary-blue/80 to-primary-blue'
}
`}
>
<div className="relative bg-deep-graphite rounded-xl p-6 sm:p-8">
{/* Decorative elements */}
<div className="absolute top-0 left-0 w-32 h-32 bg-gradient-to-br from-white/10 to-transparent rounded-full blur-2xl" />
<div className="absolute bottom-0 right-0 w-48 h-48 bg-gradient-to-tl from-white/5 to-transparent rounded-full blur-3xl" />
{/* Victory confetti effect for P1 */}
{userResult.position === 1 && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-4 left-[10%] w-2 h-2 bg-yellow-400 rounded-full animate-pulse" />
<div className="absolute top-8 left-[25%] w-1.5 h-1.5 bg-yellow-300 rounded-full animate-pulse delay-100" />
<div className="absolute top-6 right-[20%] w-2 h-2 bg-yellow-500 rounded-full animate-pulse delay-200" />
<div className="absolute top-10 right-[35%] w-1 h-1 bg-yellow-400 rounded-full animate-pulse delay-300" />
</div>
)}
<div className="relative z-10">
{/* Main content grid */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
{/* Left: Position and achievement */}
<div className="flex items-center gap-5">
{/* Giant position badge */}
<div
className={`
relative flex items-center justify-center w-24 h-24 sm:w-28 sm:h-28 rounded-3xl font-black text-4xl sm:text-5xl
${
userResult.position === 1
? 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-deep-graphite shadow-2xl shadow-yellow-500/30'
: userResult.position === 2
? 'bg-gradient-to-br from-gray-300 to-gray-500 text-deep-graphite shadow-xl shadow-gray-400/20'
: userResult.position === 3
? 'bg-gradient-to-br from-amber-600 to-amber-800 text-white shadow-xl shadow-amber-600/20'
: 'bg-gradient-to-br from-primary-blue to-primary-blue/70 text-white shadow-xl shadow-primary-blue/20'
}
`}
>
{userResult.position === 1 && (
<Trophy className="absolute -top-3 -right-2 w-8 h-8 text-yellow-300 drop-shadow-lg" />
)}
<span>P{userResult.position}</span>
</div>
{/* Achievement text */}
<div>
<p
className={`
text-2xl sm:text-3xl font-bold mb-1
${
userResult.position === 1
? 'text-yellow-400'
: userResult.isPodium
? 'text-gray-300'
: 'text-white'
}
`}
>
{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`}
</p>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span>Started P{userResult.startPosition}</span>
<span className="w-1 h-1 rounded-full bg-gray-600" />
<span className={userResult.isClean ? 'text-performance-green' : ''}>
{userResult.incidents}x incidents
{userResult.isClean && ' ✨'}
</span>
</div>
</div>
</div>
{/* Right: Stats cards */}
<div className="flex flex-wrap gap-3">
{/* Position change */}
{userResult.positionChange !== 0 && (
<div
className={`
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
${
userResult.positionChange > 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'
}
`}
>
<div
className={`
flex items-center gap-1 font-black text-2xl
${
userResult.positionChange > 0
? 'text-performance-green'
: 'text-red-400'
}
`}
>
{userResult.positionChange > 0 ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"
clipRule="evenodd"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clipRule="evenodd"
/>
</svg>
)}
{Math.abs(userResult.positionChange)}
</div>
<div className="text-xs text-gray-400 mt-0.5">
{userResult.positionChange > 0 ? 'Gained' : 'Lost'}
</div>
</div>
)}
{/* Rating change */}
{ratingChange !== null && (
<div
className={`
flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px]
${
ratingChange > 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'
}
`}
>
<div
className={`
font-mono font-black text-2xl
${ratingChange > 0 ? 'text-warning-amber' : 'text-red-400'}
`}
>
{animatedRatingChange > 0 ? '+' : ''}
{animatedRatingChange}
</div>
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
</div>
)}
{/* Clean race bonus */}
{userResult.isClean && (
<div className="flex flex-col items-center px-5 py-3 rounded-2xl min-w-[100px] bg-gradient-to-br from-performance-green/30 to-performance-green/10 border border-performance-green/40">
<div className="text-2xl"></div>
<div className="text-xs text-performance-green mt-0.5 font-medium">
Clean Race
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<RaceUserResult
{...userResult}
animatedRatingChange={animatedRatingChange}
/>
)}
{/* Hero Header */}
<div className={`relative overflow-hidden rounded-2xl ${config.bg} border ${config.border} p-6 sm:p-8`}>
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<RaceHero
track={race.track}
scheduledAt={race.scheduledAt}
car={race.car}
status={race.status}
statusConfig={config}
/>
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl" />
<Grid cols={12} gap={6}>
<GridItem lgSpan={8} colSpan={12}>
<Stack gap={6}>
<RaceDetailCard
track={race.track}
car={race.car}
sessionType={race.sessionType}
statusLabel={config.label}
statusColor={config.variant === 'success' ? '#10b981' : config.variant === 'primary' ? '#3b82f6' : '#9ca3af'}
/>
<div className="relative z-10">
{/* Status Badge */}
<div className="flex items-center gap-3 mb-4">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}>
{race.status === 'running' && (
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
)}
<StatusIcon className={`w-4 h-4 ${config.color}`} />
<span className={`text-sm font-semibold ${config.color}`}>{config.label}</span>
</div>
{timeUntil && (
<span className="text-sm text-gray-400">
Starts in <span className="text-white font-medium">{timeUntil}</span>
</span>
)}
</div>
<RaceEntryList
entries={entryList}
onDriverClick={onDriverClick}
/>
</Stack>
</GridItem>
{/* Title */}
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2">
{race.track}
</Heading>
<GridItem lgSpan={4} colSpan={12}>
<Stack gap={6}>
{league && <LeagueSummaryCard league={league} />}
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(new Date(race.scheduledAt))}
</span>
<span className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatTime(new Date(race.scheduledAt))}
</span>
<span className="flex items-center gap-2">
<Car className="w-4 h-4" />
{race.car}
</span>
</div>
</div>
{/* Prominent SOF Badge - Electric Design */}
{raceSOF != null && (
<div className="absolute top-6 right-6 sm:top-8 sm:right-8">
<div className="relative group">
{/* Glow effect */}
<div className="absolute inset-0 bg-warning-amber/40 rounded-2xl blur-xl group-hover:blur-2xl transition-all duration-300" />
<div className="relative flex items-center gap-4 px-6 py-4 rounded-2xl bg-gradient-to-br from-warning-amber/30 via-warning-amber/20 to-orange-500/20 border border-warning-amber/50 shadow-2xl backdrop-blur-sm">
{/* Electric bolt with animation */}
<div className="relative">
<Zap className="w-8 h-8 text-warning-amber drop-shadow-lg" />
<Zap className="absolute inset-0 w-8 h-8 text-warning-amber animate-pulse opacity-50" />
</div>
<div>
<div className="text-[10px] text-warning-amber/90 uppercase tracking-widest font-bold mb-0.5">
Strength of Field
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-black text-warning-amber font-mono tracking-tight drop-shadow-lg">
{raceSOF}
</span>
<span className="text-sm text-warning-amber/70 font-medium">SOF</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Race Details */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-primary-blue" />
Race Details
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Track</p>
<p className="text-white font-medium">{race.track}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Car</p>
<p className="text-white font-medium">{race.car}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Session Type</p>
<p className="text-white font-medium capitalize">{race.sessionType}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<p className={`font-medium ${config.color}`}>{config.label}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Strength of Field</p>
<p className="text-warning-amber font-medium flex items-center gap-1.5">
<Zap className="w-4 h-4" />
{raceSOF ?? '—'}
</p>
</div>
</div>
</Card>
{/* Entry List */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-primary-blue" />
Entry List
</h2>
<span className="text-sm text-gray-400">
{entryList.length} driver{entryList.length !== 1 ? 's' : ''}
</span>
</div>
{entryList.length === 0 ? (
<div className="text-center py-8">
<div className="p-4 bg-iron-gray rounded-full inline-block mb-3">
<Users className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400">No drivers registered yet</p>
<p className="text-sm text-gray-500">Be the first to sign up!</p>
</div>
) : (
<div className="space-y-1">
{entryList.map((driver, index) => {
const isCurrentUser = driver.isCurrentUser;
const countryFlag = getCountryFlag(driver.country);
return (
<div
key={driver.id}
onClick={() => 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 */}
<div
className={`
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${
race.status === 'completed' && index === 0
? 'bg-yellow-500/20 text-yellow-400'
: race.status === 'completed' && index === 1
? 'bg-gray-400/20 text-gray-300'
: race.status === 'completed' && index === 2
? 'bg-amber-600/20 text-amber-500'
: 'bg-iron-gray text-gray-500'
}
`}
>
{index + 1}
</div>
{/* Avatar with nation flag */}
<div className="relative flex-shrink-0">
<img
src={driver.avatarUrl}
alt={driver.name}
className={`
w-10 h-10 rounded-full object-cover
${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}
`}
/>
{/* Nation flag */}
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
{countryFlag}
</div>
</div>
{/* Driver info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={`text-sm font-semibold truncate ${
isCurrentUser ? 'text-primary-blue' : 'text-white'
}`}
>
{driver.name}
</p>
{isCurrentUser && (
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
You
</span>
)}
</div>
<p className="text-xs text-gray-500">{driver.country}</p>
</div>
{/* Rating badge */}
{driver.rating != null && (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
<Zap className="w-3 h-3 text-warning-amber" />
<span className="text-xs font-bold text-warning-amber font-mono">
{driver.rating}
</span>
</div>
)}
</div>
);
})}
</div>
)}
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* League Card - Premium Design */}
{league && (
<Card className="overflow-hidden">
<div className="flex items-center gap-4 mb-4">
<div className="w-14 h-14 rounded-xl overflow-hidden bg-iron-gray flex-shrink-0">
<img
src={`league-logo-${league.id}`}
alt={league.name}
className="w-full h-full object-cover"
{/* Actions Card */}
<Card>
<Stack gap={4}>
<Text size="xl" weight="bold" color="text-white">Actions</Text>
<Stack gap={3}>
<RaceJoinButton
raceStatus={race.status}
isUserRegistered={viewData.registration.isUserRegistered}
canRegister={viewData.registration.canRegister}
onRegister={onRegister}
onWithdraw={onWithdraw}
onCancel={onCancel}
onReopen={onReopen}
onEndRace={onEndRace}
canReopenRace={viewData.canReopenRace}
isOwnerOrAdmin={isOwnerOrAdmin}
isLoading={mutationLoading}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-0.5">League</p>
<h3 className="text-white font-semibold truncate">{league.name}</h3>
</div>
</div>
{league.description && (
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
)}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-deep-graphite">
<p className="text-xs text-gray-500 mb-1">Max Drivers</p>
<p className="text-white font-medium">{league.settings.maxDrivers ?? 32}</p>
</div>
<div className="p-3 rounded-lg bg-deep-graphite">
<p className="text-xs text-gray-500 mb-1">Format</p>
<p className="text-white font-medium capitalize">
{league.settings.qualifyingFormat ?? 'Open'}
</p>
</div>
</div>
<Link
href={`/leagues/${league.id}`}
className="flex items-center justify-center gap-2 w-full py-2.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30 text-primary-blue text-sm font-medium hover:bg-primary-blue/20 transition-colors"
>
View League
<ArrowRight className="w-4 h-4" />
</Link>
</Card>
)}
{/* Quick Actions Card */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Actions</h2>
<div className="space-y-3">
{/* Registration Actions */}
<RaceJoinButton
raceStatus={race.status}
isUserRegistered={viewModel.registration.isUserRegistered}
canRegister={viewModel.registration.canRegister}
onRegister={onRegister}
onWithdraw={onWithdraw}
onCancel={onCancel}
onReopen={onReopen}
onEndRace={onEndRace}
canReopenRace={viewModel.canReopenRace}
isOwnerOrAdmin={isOwnerOrAdmin}
isLoading={mutationLoading}
/>
{/* Results and Stewarding for completed races */}
{race.status === 'completed' && (
<>
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={onResultsClick}
>
<Trophy className="w-4 h-4" />
View Results
</Button>
{userResult && (
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onFileProtest}
>
<Scale className="w-4 h-4" />
File Protest
</Button>
{race.status === 'completed' && (
<>
<Button variant="primary" fullWidth onClick={onResultsClick} icon={<Icon icon={Trophy} size={4} />}>
View Results
</Button>
{userResult && (
<Button variant="secondary" fullWidth onClick={onFileProtest} icon={<Icon icon={Scale} size={4} />}>
File Protest
</Button>
)}
<Button variant="secondary" fullWidth onClick={onStewardingClick} icon={<Icon icon={Scale} size={4} />}>
Stewarding
</Button>
</>
)}
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onStewardingClick}
>
<Scale className="w-4 h-4" />
Stewarding
</Button>
</>
)}
</div>
</Card>
</Stack>
</Stack>
</Card>
{/* Status Info */}
<Card className={`${config.bg} border ${config.border}`}>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${config.bg}`}>
<StatusIcon className={`w-5 h-5 ${config.color}`} />
</div>
<div>
<p className={`font-medium ${config.color}`}>{config.label}</p>
<p className="text-sm text-gray-400 mt-1">{config.description}</p>
</div>
</div>
</Card>
</div>
</div>
</div>
{/* Modals would be rendered by parent */}
</div>
{/* Status Info */}
<InfoBox
icon={config.icon}
title={config.label}
description={config.description}
variant={config.variant}
/>
</Stack>
</GridItem>
</Grid>
</Stack>
</Container>
);
}
}

View File

@@ -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<string, number>;
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 (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading results...</div>
</div>
</div>
<Container size="lg" py={12}>
<Stack align="center">
<Text color="text-gray-400">Loading results...</Text>
</Stack>
</Container>
);
}
if (error && !raceTrack) {
if (error && !viewData.raceTrack) {
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<Card className="text-center py-12">
<div className="text-warning-amber mb-4">
{error?.message || 'Race not found'}
</div>
<Container size="md" py={12}>
<Card>
<Stack align="center" py={12} gap={4}>
<Text color="text-warning-amber">{error?.message || 'Race not found'}</Text>
<Button
variant="secondary"
onClick={onBack}
>
Back to Races
</Button>
</Card>
</div>
</div>
</Stack>
</Card>
</Container>
);
}
const hasResults = results.length > 0;
const hasResults = viewData.results.length > 0;
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
<Container size="lg" py={8}>
<Stack gap={6}>
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2 text-sm"
icon={<Icon icon={ArrowLeft} size={4} />}
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</div>
</Stack>
{/* Header */}
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
<Trophy className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Race Results</h1>
<p className="text-sm text-gray-400">
{raceTrack} {raceScheduledAt ? formatDate(raceScheduledAt) : ''}
</p>
</div>
</div>
<Surface variant="muted" rounded="xl" border padding={6} style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.5), rgba(38, 38, 38, 0.3))', borderColor: '#262626' }}>
<Stack direction="row" align="center" gap={4} mb={6}>
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Icon icon={Trophy} size={6} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Race Results</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{viewData.raceTrack} {viewData.raceScheduledAt ? formatDate(viewData.raceScheduledAt) : ''}
</Text>
</Box>
</Stack>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-deep-graphite/60 rounded-lg">
<p className="text-xs text-gray-400 mb-1">Drivers</p>
<p className="text-lg font-bold text-white">{totalDrivers ?? 0}</p>
</div>
<div className="p-3 bg-deep-graphite/60 rounded-lg">
<p className="text-xs text-gray-400 mb-1">League</p>
<p className="text-sm font-medium text-white truncate">{leagueName ?? '—'}</p>
</div>
<div className="p-3 bg-deep-graphite/60 rounded-lg">
<p className="text-xs text-gray-400 mb-1">SOF</p>
<p className="text-lg font-bold text-warning-amber flex items-center gap-1">
<Zap className="w-4 h-4" />
{raceSOF ?? '—'}
</p>
</div>
<div className="p-3 bg-deep-graphite/60 rounded-lg">
<p className="text-xs text-gray-400 mb-1">Fastest Lap</p>
<p className="text-lg font-bold text-performance-green">
{fastestLapTime ? formatTime(fastestLapTime) : '—'}
</p>
</div>
</div>
</Card>
<Grid cols={4} gap={4}>
<StatItem label="Drivers" value={viewData.totalDrivers ?? 0} />
<StatItem label="League" value={viewData.leagueName ?? '—'} />
<StatItem label="SOF" value={viewData.raceSOF ?? '—'} icon={Zap} color="#f59e0b" />
<StatItem label="Fastest Lap" value={viewData.fastestLapTime ? formatTime(viewData.fastestLapTime) : '—'} color="#10b981" />
</Grid>
</Surface>
{importSuccess && (
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green">
<strong>Success!</strong> Results imported and standings updated.
</div>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
<Text color="text-performance-green" weight="bold">Success!</Text>
<Text color="text-performance-green" size="sm" block mt={1}>Results imported and standings updated.</Text>
</Surface>
)}
{importError && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
<strong>Error:</strong> {importError}
</div>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<Text color="text-error-red" weight="bold">Error:</Text>
<Text color="text-error-red" size="sm" block mt={1}>{importError}</Text>
</Surface>
)}
<Card>
{hasResults ? (
<div className="space-y-4">
<Stack gap={6}>
{/* Results Table */}
<div className="space-y-2">
{results.map((result) => {
const isCurrentUser = result.driverId === currentDriverId;
const countryFlag = getCountryFlag(result.country);
const points = pointsSystem[result.position.toString()] ?? 0;
return (
<div
key={result.driverId}
className={`
flex items-center gap-3 p-3 rounded-xl
${isCurrentUser ? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40' : 'bg-deep-graphite'}
`}
>
{/* Position */}
<div className={`
flex items-center justify-center w-10 h-10 rounded-lg font-bold
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
'bg-iron-gray text-gray-500'}
`}>
{result.position}
</div>
{/* Avatar */}
<div className="relative flex-shrink-0">
<img
src={result.driverAvatar}
alt={result.driverName}
className={`w-10 h-10 rounded-full object-cover ${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}`}
/>
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
{countryFlag}
</div>
</div>
{/* Driver Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-semibold truncate ${isCurrentUser ? 'text-primary-blue' : 'text-white'}`}>
{result.driverName}
</p>
{isCurrentUser && (
<span className="px-2 py-0.5 text-[10px] font-bold bg-primary-blue text-white rounded-full uppercase tracking-wide">
You
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-400 mt-0.5">
<span>{result.car}</span>
<span></span>
<span>Laps: {result.laps}</span>
<span></span>
<span>Incidents: {result.incidents}</span>
</div>
</div>
{/* Times */}
<div className="text-right min-w-[100px]">
<p className="text-sm font-mono text-white">{result.time}</p>
<p className="text-xs text-performance-green">FL: {result.fastestLap}</p>
</div>
{/* Points */}
<div className="flex-shrink-0">
<div className="flex flex-col items-center px-3 py-1 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
<span className="text-xs text-gray-400">PTS</span>
<span className="text-sm font-bold text-warning-amber">{points}</span>
</div>
</div>
</div>
);
})}
</div>
<Stack gap={2}>
{viewData.results.map((result) => (
<RaceResultRow
key={result.driverId}
result={result as any}
points={viewData.pointsSystem[result.position.toString()] ?? 0}
/>
))}
</Stack>
{/* Penalties Section */}
{penalties.length > 0 && (
<div className="mt-6 pt-6 border-t border-charcoal-outline">
<h3 className="text-lg font-semibold text-white mb-4">Penalties</h3>
<div className="space-y-2">
{penalties.map((penalty, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-deep-graphite rounded-lg">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<span className="text-red-400 font-bold text-sm">!</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-white">{penalty.driverName}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
{penalty.notes && (
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
)}
</div>
<div className="text-right">
<span className="text-2xl font-bold text-red-400">
{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`}
</span>
</div>
</div>
{viewData.penalties.length > 0 && (
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<Box mb={4}>
<Heading level={2}>Penalties</Heading>
</Box>
<Stack gap={2}>
{viewData.penalties.map((penalty, index) => (
<RacePenaltyRow key={index} penalty={penalty as any} />
))}
</div>
</div>
</Stack>
</Box>
)}
</div>
</Stack>
) : (
<>
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2>
<p className="text-gray-400 text-sm mb-6">
No results imported. Upload CSV to test the standings system.
</p>
<Stack gap={6}>
<Box>
<Heading level={2}>Import Results</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
No results imported. Upload CSV to test the standings system.
</Text>
</Box>
{importing ? (
<div className="text-center py-8 text-gray-400">
Importing results and updating standings...
</div>
<Stack align="center" py={8}>
<Text color="text-gray-400">Importing results and updating standings...</Text>
</Stack>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-400">
<Stack gap={4}>
<Text size="sm" color="text-gray-400">
This is a placeholder for the import form. In the actual implementation,
this would render the ImportResultsForm component.
</p>
<Button
variant="primary"
onClick={() => {
// Mock import for demo
onImportResults([]);
}}
disabled={importing}
>
Import Results (Demo)
</Button>
</div>
</Text>
<Box>
<Button
variant="primary"
onClick={() => onImportResults([])}
disabled={importing}
>
Import Results (Demo)
</Button>
</Box>
</Stack>
)}
</>
</Stack>
)}
</Card>
</div>
</div>
</Stack>
</Container>
);
}
}
function StatItem({ label, value, icon, color = 'text-white' }: { label: string, value: string | number, icon?: any, color?: string }) {
return (
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(15, 17, 21, 0.6)' }}>
<Text size="xs" color="text-gray-500" block mb={1}>{label}</Text>
<Stack direction="row" align="center" gap={1.5}>
{icon && <Icon icon={icon} size={4} color={color === 'text-white' ? '#9ca3af' : color} />}
<Text weight="bold" color={color as any} style={{ fontSize: '1.125rem' }}>{value}</Text>
</Stack>
</Surface>
);
}

View File

@@ -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<string, Driver>;
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 (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
Pending
</span>
);
case 'upheld':
return (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
Upheld
</span>
);
case 'dismissed':
return (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
Dismissed
</span>
);
case 'withdrawn':
return (
<span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">
Withdrawn
</span>
);
default:
return null;
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-iron-gray rounded w-1/4" />
<div className="h-48 bg-iron-gray rounded-xl" />
</div>
</div>
</div>
<Container size="lg" py={12}>
<Stack align="center">
<Text color="text-gray-400">Loading stewarding data...</Text>
</Stack>
</Container>
);
}
if (!stewardingData?.race) {
if (!viewData?.race) {
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-warning-amber/10 rounded-full">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<div>
<p className="text-white font-medium mb-1">Race not found</p>
<p className="text-sm text-gray-500">
The race you're looking for doesn't exist.
</p>
</div>
<Button variant="secondary" onClick={onBack}>
Back to Races
</Button>
</div>
</Card>
</div>
</div>
<Container size="md" py={12}>
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={AlertTriangle} size={8} color="#f59e0b" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="medium" color="text-white" block mb={1}>Race not found</Text>
<Text size="sm" color="text-gray-500">The race you're looking for doesn't exist.</Text>
</Box>
<Button variant="secondary" onClick={onBack}>
Back to Races
</Button>
</Stack>
</Card>
</Container>
);
}
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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto space-y-6">
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Navigation */}
<div className="flex items-center justify-between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" />
<Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} />
<Button
variant="secondary"
onClick={() => onBack()}
className="flex items-center gap-2 text-sm"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
<ArrowLeft className="w-4 h-4" />
Back to Race
</Button>
</div>
</Stack>
{/* Header */}
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center">
<Scale className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Stewarding</h1>
<p className="text-sm text-gray-400">
{stewardingData.race.track} {stewardingData.race.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''}
</p>
</div>
</div>
<Surface variant="muted" rounded="xl" border padding={6} style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.5), rgba(38, 38, 38, 0.3))', borderColor: '#262626' }}>
<Stack direction="row" align="center" gap={4} mb={6}>
<Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Icon icon={Scale} size={6} color="#3b82f6" />
</Surface>
<Box>
<Heading level={1}>Stewarding</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{viewData.race.track} {formatDate(viewData.race.scheduledAt)}
</Text>
</Box>
</Stack>
{/* Stats */}
<RaceStewardingStats
pendingCount={stewardingData.pendingCount ?? 0}
resolvedCount={stewardingData.resolvedCount ?? 0}
penaltiesCount={stewardingData.penaltiesCount ?? 0}
pendingCount={viewData.pendingCount}
resolvedCount={viewData.resolvedCount}
penaltiesCount={viewData.penaltiesCount}
/>
</Card>
</Surface>
{/* Tab Navigation */}
<StewardingTabs
activeTab={activeTab}
onTabChange={setActiveTab}
pendingCount={pendingProtests.length}
pendingCount={viewData.pendingProtests.length}
/>
{/* Content */}
{activeTab === 'pending' && (
<div className="space-y-4">
{pendingProtests.length === 0 ? (
<Card className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
<p className="text-sm text-gray-400">No pending protests to review</p>
<Stack gap={4}>
{viewData.pendingProtests.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Flag} size={8} color="#10b981" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
<Text size="sm" color="text-gray-400">No pending protests to review</Text>
</Box>
</Stack>
</Card>
) : (
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 (
<Card
key={protest.id}
className={`${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<Link
href={`/drivers/${protest.protestingDriverId}`}
className="font-medium text-white hover:text-primary-blue transition-colors"
>
{protester?.name || 'Unknown'}
</Link>
<span className="text-gray-400">vs</span>
<Link
href={`/drivers/${protest.accusedDriverId}`}
className="font-medium text-white hover:text-primary-blue transition-colors"
>
{accused?.name || 'Unknown'}
</Link>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {formatDate(protest.filedAt)}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary-blue hover:underline"
>
<Video className="w-3 h-3" />
Video Evidence
</a>
</>
)}
</div>
<p className="text-sm text-gray-300">{protest.incident.description}</p>
</div>
{isAdmin && stewardingData?.league && (
<Button
variant="primary"
onClick={() => onReviewProtest(protest.id)}
>
Review
</Button>
)}
</div>
</Card>
);
})
viewData.pendingProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest as any}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</div>
</Stack>
)}
{activeTab === 'resolved' && (
<div className="space-y-4">
{resolvedProtests.length === 0 ? (
<Card className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-gray-500" />
</div>
<p className="font-semibold text-lg text-white mb-2">No Resolved Protests</p>
<p className="text-sm text-gray-400">
Resolved protests will appear here
</p>
<Stack gap={4}>
{viewData.resolvedProtests.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={CheckCircle} size={8} color="#525252" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Resolved Protests</Text>
<Text size="sm" color="text-gray-400">Resolved protests will appear here</Text>
</Box>
</Stack>
</Card>
) : (
resolvedProtests.map((protest) => {
const protester = stewardingData.driverMap[protest.protestingDriverId];
const accused = stewardingData.driverMap[protest.accusedDriverId];
return (
<Card key={protest.id}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
<Link
href={`/drivers/${protest.protestingDriverId}`}
className="font-medium text-white hover:text-primary-blue transition-colors"
>
{protester?.name || 'Unknown'}
</Link>
<span className="text-gray-400">vs</span>
<Link
href={`/drivers/${protest.accusedDriverId}`}
className="font-medium text-white hover:text-primary-blue transition-colors"
>
{accused?.name || 'Unknown'}
</Link>
{getStatusBadge(protest.status)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {formatDate(protest.filedAt)}</span>
</div>
<p className="text-sm text-gray-300 mb-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-3 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
Steward Decision
</p>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
)}
</div>
</div>
</Card>
);
})
viewData.resolvedProtests.map((protest) => (
<ProtestCard
key={protest.id}
protest={protest as any}
protester={viewData.driverMap[protest.protestingDriverId]}
accused={viewData.driverMap[protest.accusedDriverId]}
isAdmin={isAdmin}
onReview={onReviewProtest}
formatDate={formatDate}
/>
))
)}
</div>
</Stack>
)}
{activeTab === 'penalties' && (
<div className="space-y-4">
{stewardingData?.penalties.length === 0 ? (
<Card className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center">
<Gavel className="w-8 h-8 text-gray-500" />
</div>
<p className="font-semibold text-lg text-white mb-2">No Penalties</p>
<p className="text-sm text-gray-400">
Penalties issued for this race will appear here
</p>
<Stack gap={4}>
{viewData.penalties.length === 0 ? (
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Gavel} size={8} color="#525252" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>No Penalties</Text>
<Text size="sm" color="text-gray-400">Penalties issued for this race will appear here</Text>
</Box>
</Stack>
</Card>
) : (
stewardingData?.penalties.map((penalty) => {
const driver = stewardingData.driverMap[penalty.driverId];
return (
<Card key={penalty.id}>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-6 h-6 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Link
href={`/drivers/${penalty.driverId}`}
className="font-medium text-white hover:text-primary-blue transition-colors"
>
{driver?.name || 'Unknown'}
</Link>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
{penalty.notes && (
<p className="text-sm text-gray-500 mt-1 italic">{penalty.notes}</p>
)}
</div>
<div className="text-right">
<span className="text-2xl font-bold text-red-400">
{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`}
</span>
</div>
</div>
</Card>
);
})
viewData.penalties.map((penalty) => (
<RacePenaltyRow key={penalty.id} penalty={penalty as any} />
))
)}
</div>
</Stack>
)}
</div>
</div>
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-iron-gray rounded w-1/4" />
<div className="h-10 bg-iron-gray rounded w-1/3" />
<div className="space-y-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
))}
</div>
</div>
</div>
</div>
<Container size="lg" py={8}>
<Stack gap={6}>
<Skeleton width="8rem" height="1.5rem" />
<Skeleton width="12rem" height="2.5rem" />
<Stack gap={4}>
{[1, 2, 3, 4, 5].map(i => (
<Skeleton key={i} width="100%" height="6rem" />
))}
</Stack>
</Stack>
</Container>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto space-y-6">
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbItems} />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3">
<Flag className="w-6 h-6 text-primary-blue" />
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={1} icon={<Icon icon={Flag} size={6} color="#3b82f6" />}>
All Races
</Heading>
<p className="text-gray-400 text-sm mt-1">
<Text size="sm" color="text-gray-400" block mt={1}>
{filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
</p>
</div>
</Text>
</Box>
<Button
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
icon={<Icon icon={SlidersHorizontal} size={4} />}
>
<SlidersHorizontal className="w-4 h-4" />
Filters
</Button>
</div>
</Stack>
{/* Search & Filters */}
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}>
<div className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
{/* Filter Row */}
<div className="flex flex-wrap gap-4">
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => 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"
>
<option value="all">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="running">Live</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
{/* League Filter */}
<select
value={leagueFilter}
onChange={(e) => 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"
>
<option value="all">All Leagues</option>
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
const race = races.find(r => r.leagueId === leagueId);
return race ? (
<option key={leagueId} value={leagueId}>
{race.leagueName}
</option>
) : null;
})}
</select>
{/* Clear Filters */}
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
<button
onClick={() => {
setStatusFilter('all');
setLeagueFilter('all');
setSearchQuery('');
}}
className="px-4 py-2 text-sm text-primary-blue hover:underline"
>
Clear filters
</button>
)}
</div>
</div>
</Card>
{/* Search & Filters (Simplified for template) */}
{showFilters && (
<Card>
<Stack gap={4}>
<Text size="sm" color="text-gray-400">
Use the filter button to open advanced search and filtering options.
</Text>
<Box>
<Button variant="primary" onClick={() => setShowFilterModal(true)}>
Open Filters
</Button>
</Box>
</Stack>
</Card>
)}
{/* Race List */}
{paginatedRaces.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-iron-gray rounded-full">
<Calendar className="w-8 h-8 text-gray-500" />
</div>
<div>
<p className="text-white font-medium mb-1">No races found</p>
<p className="text-sm text-gray-500">
<Card>
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Calendar} size={8} color="#525252" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
<Text size="sm" color="text-gray-500">
{races.length === 0
? 'No races have been scheduled yet'
: 'Try adjusting your search or filters'}
</p>
</div>
</div>
</Text>
</Box>
</Stack>
</Card>
) : (
<div className="space-y-3">
{paginatedRaces.map(race => {
const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
return (
<div
key={race.id}
onClick={() => 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' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<div className="flex items-center gap-4">
{/* Date Column */}
<div className="hidden sm:flex flex-col items-center min-w-[80px] text-center">
<p className="text-xs text-gray-500 uppercase">
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
</p>
<p className="text-2xl font-bold text-white">
{new Date(race.scheduledAt).getDate()}
</p>
<p className="text-xs text-gray-500">
{formatTime(race.scheduledAt)}
</p>
</div>
{/* Divider */}
<div className="hidden sm:block w-px h-16 bg-charcoal-outline" />
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1.5 text-sm text-warning-amber">
<Zap className="w-3.5 h-3.5" />
SOF {race.strengthOfField}
</span>
)}
<span className="sm:hidden text-sm text-gray-500">
{formatDate(race.scheduledAt)}
</span>
</div>
<Link
href={`/leagues/${race.leagueId}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{race.leagueName}
</Link>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border flex-shrink-0`}>
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);
})}
</div>
<Stack gap={3}>
{paginatedRaces.map(race => (
<RaceListItem key={race.id} race={race as any} onClick={onRaceClick} />
))}
</Stack>
)}
{/* 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}
/>
</div>
</div>
</Stack>
</Container>
);
}
}

View File

@@ -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<string, typeof filteredRaces[0][]>();
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 (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="animate-pulse space-y-6">
<div className="h-10 bg-iron-gray rounded w-1/4" />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
))}
</div>
<div className="h-64 bg-iron-gray rounded-lg" />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border border-charcoal-outline p-8">
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Flag className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl font-bold text-white">
Race Calendar
</Heading>
</div>
<p className="text-gray-400 max-w-2xl">
Track upcoming races, view live events, and explore results across all your leagues.
</p>
</div>
<Box as="main">
<Container size="lg" py={8}>
<Stack gap={8}>
<RacePageHeader
totalCount={viewData.totalCount}
scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
completedCount={viewData.completedCount}
/>
{/* Quick Stats */}
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<CalendarDays className="w-4 h-4" />
<span>Total</span>
</div>
<p className="text-2xl font-bold text-white">{stats.total}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
<Clock className="w-4 h-4" />
<span>Scheduled</span>
</div>
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
<Zap className="w-4 h-4" />
<span>Live Now</span>
</div>
<p className="text-2xl font-bold text-white">{stats.running}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<Trophy className="w-4 h-4" />
<span>Completed</span>
</div>
<p className="text-2xl font-bold text-white">{stats.completed}</p>
</div>
</div>
</div>
<LiveRacesBanner
liveRaces={viewData.liveRaces}
onRaceClick={onRaceClick}
/>
{/* Live Races Banner */}
{liveRaces.length > 0 && (
<div className="relative overflow-hidden rounded-xl bg-gradient-to-r from-performance-green/20 via-performance-green/10 to-transparent border border-performance-green/30 p-6">
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
</div>
</div>
<div className="space-y-3">
{liveRaces.map((race) => (
<div
key={race.id}
onClick={() => 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"
>
<div className="flex items-center gap-4">
<div className="p-2 bg-performance-green/20 rounded-lg">
<PlayCircle className="w-5 h-5 text-performance-green" />
</div>
<div>
<h3 className="font-semibold text-white">{race.track}</h3>
<p className="text-sm text-gray-400">{race.leagueName}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</div>
))}
</div>
</div>
</div>
)}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
<RaceFilterBar
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
leagues={viewData.leagues}
onShowMoreFilters={() => setShowFilterModal(true)}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Race List */}
<div className="lg:col-span-2 space-y-6">
{/* Filters */}
<Card className="!p-4">
<div className="flex flex-wrap gap-4">
{/* Time Filter Tabs */}
<div className="flex items-center gap-1 p-1 bg-deep-graphite rounded-lg">
{(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => (
<button
key={filter}
onClick={() => 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' && <span className="inline-block w-2 h-2 bg-performance-green rounded-full mr-2 animate-pulse" />}
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
<RaceList
racesByDate={viewData.racesByDate}
totalCount={viewData.totalCount}
onRaceClick={onRaceClick}
/>
</Stack>
</GridItem>
{/* League Filter */}
<select
value={leagueFilter}
onChange={(e) => 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"
>
<option value="all">All Leagues</option>
{races && [...new Set(races.map(r => r.leagueId))].filter(Boolean).map(leagueId => {
const item = races.find(r => r.leagueId === leagueId);
return item ? (
<option key={leagueId} value={leagueId}>
{item.leagueName}
</option>
) : null;
})}
</select>
<GridItem colSpan={12} lgSpan={4}>
<RaceSidebar
upcomingRaces={viewData.upcomingRaces}
recentResults={viewData.recentResults}
onRaceClick={onRaceClick}
/>
</GridItem>
</Grid>
{/* Filter Button */}
<button
onClick={() => 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
</button>
</div>
</Card>
{/* Race List by Date */}
{filteredRaces.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-iron-gray rounded-full">
<Calendar className="w-8 h-8 text-gray-500" />
</div>
<div>
<p className="text-white font-medium mb-1">No races found</p>
<p className="text-sm text-gray-500">
{totalCount === 0
? 'No races have been scheduled yet'
: 'Try adjusting your filters'}
</p>
</div>
</div>
</Card>
) : (
<div className="space-y-4">
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
<div key={dateKey} className="space-y-3">
{/* Date Header */}
<div className="flex items-center gap-3 px-2">
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Calendar className="w-4 h-4 text-primary-blue" />
</div>
<span className="text-sm font-semibold text-white">
{formatFullDate(new Date(dateKey))}
</span>
<span className="text-xs text-gray-500">
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
</span>
</div>
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map((race) => {
const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon;
return (
<div
key={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`}
onClick={() => onRaceClick(race.id)}
>
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<div className="flex items-start gap-4">
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">
{formatTime(race.scheduledAt)}
</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running'
? 'LIVE'
: getRelativeTime(race.scheduledAt)}
</p>
</div>
{/* Divider */}
<div className={`w-px self-stretch ${config.bg}`} />
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1 text-sm text-gray-400">
<Zap className="w-3.5 h-3.5 text-warning-amber" />
SOF {race.strengthOfField}
</span>
)}
</div>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
{/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${race.leagueId ?? ''}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{race.leagueName}
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* View All Link */}
{filteredRaces.length > 0 && (
<div className="text-center">
<Link
href="/races"
className="inline-flex items-center gap-2 px-6 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:border-primary-blue transition-colors"
>
View All Races
<ArrowRight className="w-4 h-4" />
</Link>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Upcoming This Week */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white flex items-center gap-2">
<Clock className="w-4 h-4 text-primary-blue" />
Next Up
</h3>
<span className="text-xs text-gray-500">This week</span>
</div>
{upcomingRaces.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
No races scheduled this week
</p>
) : (
<div className="space-y-3">
{upcomingRaces.map((race) => {
if (!race.scheduledAt) {
return null;
}
const scheduledAtDate = new Date(race.scheduledAt);
return (
<div
key={race.id}
onClick={() => onRaceClick(race.id)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-blue">
{scheduledAtDate.getDate()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
);
})}
</div>
)}
</Card>
{/* Recent Results */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white flex items-center gap-2">
<Trophy className="w-4 h-4 text-warning-amber" />
Recent Results
</h3>
</div>
{recentResults.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
No completed races yet
</p>
) : (
<div className="space-y-3">
{recentResults.map((race) => (
<div
key={race.id}
onClick={() => onRaceClick(race.id)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
>
<div className="flex-shrink-0 w-10 h-10 bg-gray-500/10 rounded-lg flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-gray-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatDate(new Date(race.scheduledAt))}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
))}
</div>
)}
</Card>
{/* Quick Actions */}
<Card>
<h3 className="font-semibold text-white mb-4">Quick Actions</h3>
<div className="space-y-2">
<Link
href="/leagues"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
>
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Users className="w-4 h-4 text-primary-blue" />
</div>
<span className="text-sm text-white">Browse Leagues</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
<Link
href="/leaderboards"
className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite hover:bg-charcoal-outline/50 transition-colors"
>
<div className="p-2 bg-warning-amber/10 rounded-lg">
<Trophy className="w-4 h-4 text-warning-amber" />
</div>
<span className="text-sm text-white">View Leaderboards</span>
<ChevronRight className="w-4 h-4 text-gray-500 ml-auto" />
</Link>
</div>
</Card>
</div>
</div>
{/* Filter Modal */}
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => 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}
/>
</div>
</div>
<RaceFilterModal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
searchQuery=""
setSearchQuery={() => {}}
leagues={viewData.leagues}
showSearch={false}
showTimeFilter={false}
/>
</Stack>
</Container>
</Box>
);
}
}

View File

@@ -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<void>;
@@ -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 (
<Section>
<Stack gap={6}>
<Card>
<Section>
<Section>
<Text size="2xl" weight="bold" className="text-white">
Roster Admin
</Text>
<Text size="sm" className="text-gray-400">
<Stack gap={6}>
<Box>
<Heading level={1}>Roster Admin</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Manage join requests and member roles.
</Text>
</Section>
</Box>
<Section>
<div className="flex items-center justify-between gap-3">
<Text size="lg" weight="semibold" className="text-white">
Pending join requests
</Text>
<Text size="xs" className="text-gray-500">
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={2}>Pending join requests</Heading>
<Text size="xs" color="text-gray-500">
{pendingCountLabel}
</Text>
</div>
</Stack>
{loading ? (
<Text size="sm" className="text-gray-400">
Loading
</Text>
) : joinRequests.length ? (
<div className="space-y-2">
<Text size="sm" color="text-gray-400">Loading</Text>
) : joinRequests.length > 0 ? (
<Stack gap={3}>
{joinRequests.map((req) => (
<div
<Surface
key={req.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
variant="muted"
rounded="lg"
border
padding={3}
>
<div className="min-w-0">
<Text weight="medium" className="text-white truncate">
{(req.driver as any)?.name || 'Unknown'}
</Text>
<Text size="xs" className="text-gray-400 truncate">
{req.requestedAt}
</Text>
{req.message && (
<Text size="xs" className="text-gray-500 truncate">
{req.message}
</Text>
)}
</div>
<Stack direction="row" align="center" justify="between">
<Box>
<Text weight="medium" color="text-white" block>{req.driver.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{req.requestedAt}</Text>
{req.message && (
<Text size="xs" color="text-gray-500" block mt={1} truncate>{req.message}</Text>
)}
</Box>
<div className="flex items-center gap-2">
<Button
data-testid={`join-request-${req.id}-approve`}
onClick={() => onApprove(req.id)}
className="bg-primary-blue text-white"
>
Approve
</Button>
<Button
data-testid={`join-request-${req.id}-reject`}
onClick={() => onReject(req.id)}
className="bg-iron-gray text-gray-200"
>
Reject
</Button>
</div>
</div>
<Stack direction="row" gap={2}>
<Button
onClick={() => onApprove(req.id)}
variant="primary"
size="sm"
>
Approve
</Button>
<Button
onClick={() => onReject(req.id)}
variant="secondary"
size="sm"
>
Reject
</Button>
</Stack>
</Stack>
</Surface>
))}
</div>
</Stack>
) : (
<Text size="sm" className="text-gray-500">
No pending join requests.
</Text>
<Text size="sm" color="text-gray-500">No pending join requests.</Text>
)}
</Section>
</Box>
<Section>
<Text size="lg" weight="semibold" className="text-white">
Members
</Text>
<Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<Box mb={4}>
<Heading level={2}>Members</Heading>
</Box>
{loading ? (
<Text size="sm" className="text-gray-400">
Loading
</Text>
) : members.length ? (
<div className="space-y-2">
<Text size="sm" color="text-gray-400">Loading</Text>
) : members.length > 0 ? (
<Stack gap={3}>
{members.map((member) => (
<div
<Surface
key={member.driverId}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
variant="muted"
rounded="lg"
border
padding={3}
>
<div className="min-w-0">
<Text weight="medium" className="text-white truncate">
{(member.driver as any)?.name || 'Unknown'}
</Text>
<Text size="xs" className="text-gray-400 truncate">
{member.joinedAt}
</Text>
</div>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Text weight="medium" color="text-white" block>{member.driver.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{member.joinedAt}</Text>
</Box>
<div className="flex flex-col md:flex-row md:items-center gap-2">
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}>
Role for {(member.driver as any)?.name || 'Unknown'}
</label>
<Select
id={`role-${member.driverId}`}
aria-label={`Role for ${(member.driver as any)?.name || 'Unknown'}`}
value={member.role}
onChange={(e) => 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"
/>
<Button
data-testid={`member-${member.driverId}-remove`}
onClick={() => onRemove(member.driverId)}
className="bg-iron-gray text-gray-200"
>
Remove
</Button>
</div>
</div>
<Stack direction="row" align="center" gap={3}>
<Box>
<Select
value={member.role}
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
options={roleOptions.map((role) => ({ value: role, label: role }))}
/>
</Box>
<Button
onClick={() => onRemove(member.driverId)}
variant="secondary"
size="sm"
>
Remove
</Button>
</Stack>
</Stack>
</Surface>
))}
</div>
</Stack>
) : (
<Text size="sm" className="text-gray-500">
No members found.
</Text>
<Text size="sm" color="text-gray-500">No members found.</Text>
)}
</Section>
</Section>
</Box>
</Stack>
</Card>
</Section>
</Stack>
);
}
}

View File

@@ -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 (
<Section>
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Rulebook</h1>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20">
<span className="text-sm font-medium text-primary-blue">{viewData.scoringPresetName || 'Custom Rules'}</span>
</div>
</div>
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1}>Rulebook</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</Box>
<Badge variant="primary">
{viewData.scoringPresetName || 'Custom Rules'}
</Badge>
</Stack>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p>
<p className="text-lg font-semibold text-white">{viewData.gameName}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Championships</p>
<p className="text-lg font-semibold text-white">{viewData.championshipsCount}</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Sessions Scored</p>
<p className="text-lg font-semibold text-white capitalize">
{viewData.sessionTypes}
</p>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Drop Policy</p>
<p className="text-lg font-semibold text-white truncate" title={viewData.dropPolicySummary}>
{viewData.hasActiveDropPolicy ? 'Active' : 'None'}
</p>
</div>
</div>
<Grid cols={4} gap={4}>
<StatItem label="Platform" value={viewData.gameName} />
<StatItem label="Championships" value={viewData.championshipsCount} />
<StatItem label="Sessions Scored" value={viewData.sessionTypes} capitalize />
<StatItem label="Drop Policy" value={viewData.hasActiveDropPolicy ? 'Active' : 'None'} />
</Grid>
{/* Points Table */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Points System</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-2 font-medium text-gray-400">Position</th>
<th className="text-left py-2 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{viewData.positionPoints.map((point) => (
<tr key={point.position} className="border-b border-charcoal-outline/50">
<td className="py-3 text-white">{point.position}</td>
<td className="py-3 text-white">{point.points}</td>
</tr>
))}
</tbody>
</table>
</div>
<Box mb={4}>
<Heading level={2}>Points System</Heading>
</Box>
<Table>
<TableHead>
<TableRow>
<TableHeader>Position</TableHeader>
<TableHeader>Points</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{viewData.positionPoints.map((point) => (
<TableRow key={point.position}>
<TableCell>
<Text color="text-white">{point.position}</Text>
</TableCell>
<TableCell>
<Text color="text-white">{point.points}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Bonus Points */}
{viewData.hasBonusPoints && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2>
<div className="space-y-2">
<Box mb={4}>
<Heading level={2}>Bonus Points</Heading>
</Box>
<Stack gap={2}>
{viewData.bonusPoints.map((bonus, idx) => (
<div
<Surface
key={idx}
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline"
variant="muted"
rounded="lg"
border
padding={3}
>
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0">
<span className="text-performance-green text-sm font-bold">+</span>
</div>
<p className="text-sm text-gray-300">{bonus}</p>
</div>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2rem', height: '2rem', backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text color="text-performance-green" weight="bold">+</Text>
</Surface>
<Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
))}
</div>
</Stack>
</Card>
)}
{/* Drop Policy */}
{viewData.hasActiveDropPolicy && (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2>
<p className="text-sm text-gray-300">{viewData.dropPolicySummary}</p>
<p className="text-xs text-gray-500 mt-3">
Drop rules are applied automatically when calculating championship standings.
</p>
<Box mb={4}>
<Heading level={2}>Drop Policy</Heading>
</Box>
<Text size="sm" color="text-gray-300">{viewData.dropPolicySummary}</Text>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
</Text>
</Box>
</Card>
)}
</Section>
</Stack>
);
}
}
function StatItem({ label, value, capitalize }: { label: string, value: string | number, capitalize?: boolean }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: '#262626', borderColor: '#262626' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
<Text weight="semibold" color="text-white" style={{ fontSize: '1.125rem', textTransform: capitalize ? 'capitalize' : 'none' }}>{value}</Text>
</Surface>
);
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Sponsor Dashboard</h2>
<p className="text-gray-400">Welcome back, {viewData.sponsorName}</p>
</div>
<div className="flex items-center gap-3">
{/* Time Range Selector */}
<div className="flex items-center bg-iron-gray/50 rounded-lg p-1">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<button
key={range}
onClick={() => {}}
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}
</button>
))}
</div>
{/* Quick Actions */}
<Button variant="secondary" className="hidden sm:flex">
<RefreshCw className="w-4 h-4" />
</Button>
<Link href=routes.sponsor.settings>
<Button variant="secondary" className="hidden sm:flex">
<Settings className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<MetricCard
title="Total Impressions"
value={viewData.totalImpressions}
change={viewData.metrics.impressionsChange}
icon={Eye}
delay={0}
/>
<MetricCard
title="Unique Viewers"
value="12.5k" // Mock
change={viewData.metrics.viewersChange}
icon={Users}
delay={0.1}
/>
<MetricCard
title="Engagement Rate"
value="4.2%" // Mock
change={viewData.metrics.exposureChange}
icon={TrendingUp}
suffix="%"
delay={0.2}
/>
<MetricCard
title="Total Investment"
value={viewData.totalInvestment}
icon={DollarSign}
prefix="$"
delay={0.3}
/>
</div>
{/* Sponsorship Categories */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Your Sponsorships</h3>
<Link href=routes.sponsor.campaigns>
<Button variant="secondary" className="text-sm">
View All
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
<SponsorshipCategoryCard
icon={Trophy}
title="Leagues"
count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions}
color="text-primary-blue"
href="/sponsor/campaigns?type=leagues"
/>
<SponsorshipCategoryCard
icon={Users}
title="Teams"
count={categoryData.teams.count}
impressions={categoryData.teams.impressions}
color="text-purple-400"
href="/sponsor/campaigns?type=teams"
/>
<SponsorshipCategoryCard
icon={Car}
title="Drivers"
count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions}
color="text-performance-green"
href="/sponsor/campaigns?type=drivers"
/>
<SponsorshipCategoryCard
icon={Flag}
title="Races"
count={categoryData.races.count}
impressions={categoryData.races.impressions}
color="text-warning-amber"
href="/sponsor/campaigns?type=races"
/>
<SponsorshipCategoryCard
icon={Megaphone}
title="Platform Ads"
count={categoryData.platform.count}
impressions={categoryData.platform.impressions}
color="text-racing-red"
href="/sponsor/campaigns?type=platform"
/>
</div>
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Sponsored Entities */}
<div className="lg:col-span-2 space-y-6">
{/* Top Performing Sponsorships */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Top Performing</h3>
<Link href="/leagues">
<Button variant="secondary" className="text-sm">
<Plus className="w-4 h-4 mr-1" />
Find More
</Button>
</Link>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Mock data for now */}
<div className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="px-2 py-1 rounded text-xs font-medium bg-primary-blue/20 text-primary-blue border border-primary-blue/30">
Main
</div>
<div>
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-gray-500" />
<span className="font-medium text-white">Sample League</span>
</div>
<div className="text-sm text-gray-500">Sample details</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="font-semibold text-white">1.2k</div>
<div className="text-xs text-gray-500">impressions</div>
</div>
<Button variant="secondary" className="text-xs">
<ExternalLink className="w-3 h-3" />
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={2}>Sponsor Dashboard</Heading>
<Text color="text-gray-400" block mt={1}>Welcome back, {viewData.sponsorName}</Text>
</Box>
<Stack direction="row" align="center" gap={3}>
{/* Time Range Selector */}
<Surface variant="muted" rounded="lg" padding={1}>
<Stack direction="row" align="center">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<Button
key={range}
variant="ghost"
size="sm"
>
{range === 'all' ? 'All' : range}
</Button>
</div>
</div>
</div>
</Card>
{/* Upcoming Events */}
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-warning-amber" />
Upcoming Sponsored Events
</h3>
</div>
<div className="p-4">
<div className="text-center py-8 text-gray-500">
<Calendar className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No upcoming sponsored events</p>
</div>
</div>
</Card>
</div>
{/* Right Column - Activity & Quick Actions */}
<div className="space-y-6">
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="space-y-2">
<Link href="/leagues" className="block">
<Button variant="secondary" className="w-full justify-start">
<Target className="w-4 h-4 mr-2" />
Find Leagues to Sponsor
</Button>
</Link>
<Link href="/teams" className="block">
<Button variant="secondary" className="w-full justify-start">
<Users className="w-4 h-4 mr-2" />
Browse Teams
</Button>
</Link>
<Link href="/drivers" className="block">
<Button variant="secondary" className="w-full justify-start">
<Car className="w-4 h-4 mr-2" />
Discover Drivers
</Button>
</Link>
<Link href=routes.sponsor.billing className="block">
<Button variant="secondary" className="w-full justify-start">
<CreditCard className="w-4 h-4 mr-2" />
Manage Billing
</Button>
</Link>
<Link href=routes.sponsor.campaigns className="block">
<Button variant="secondary" className="w-full justify-start">
<BarChart3 className="w-4 h-4 mr-2" />
View Analytics
</Button>
</Link>
</div>
</Card>
{/* Renewal Alerts */}
{viewData.upcomingRenewals.length > 0 && (
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Bell className="w-5 h-5 text-warning-amber" />
Upcoming Renewals
</h3>
<div className="space-y-3">
{viewData.upcomingRenewals.map((renewal: any) => (
<RenewalAlert key={renewal.id} renewal={renewal} />
))}
</div>
</Card>
)}
</Stack>
</Surface>
{/* Recent Activity */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
<div>
{viewData.recentActivity.map((activity: any) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
</Card>
<Button variant="secondary">
<Icon icon={RefreshCw} size={4} />
</Button>
<Box>
<Link href={routes.sponsor.settings} variant="ghost">
<Button variant="secondary">
<Icon icon={Settings} size={4} />
</Button>
</Link>
</Box>
</Stack>
</Stack>
{/* Investment Summary */}
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary-blue" />
Investment Summary
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-gray-400">Active Sponsorships</span>
<span className="font-medium text-white">{viewData.activeSponsorships}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Total Investment</span>
<span className="font-medium text-white">{viewData.formattedTotalInvestment}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Cost per 1K Views</span>
<span className="font-medium text-performance-green">
{viewData.costPerThousandViews}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-400">Next Invoice</span>
<span className="font-medium text-white">Jan 1, 2026</span>
</div>
<div className="pt-3 border-t border-charcoal-outline">
<Link href=routes.sponsor.billing>
<Button variant="secondary" className="w-full text-sm">
<CreditCard className="w-4 h-4 mr-2" />
View Billing Details
</Button>
</Link>
</div>
</div>
</Card>
</div>
</div>
</div>
{/* Key Metrics */}
<Grid cols={4} gap={4}>
<MetricCard
title="Total Impressions"
value={viewData.totalImpressions}
change={viewData.metrics.impressionsChange}
icon={Eye}
delay={0}
/>
<MetricCard
title="Unique Viewers"
value="12.5k"
change={viewData.metrics.viewersChange}
icon={Users}
delay={0.1}
/>
<MetricCard
title="Engagement Rate"
value="4.2%"
change={viewData.metrics.exposureChange}
icon={TrendingUp}
suffix="%"
delay={0.2}
/>
<MetricCard
title="Total Investment"
value={viewData.totalInvestment}
icon={DollarSign}
prefix="$"
delay={0.3}
/>
</Grid>
{/* Sponsorship Categories */}
<Box>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3}>Your Sponsorships</Heading>
<Box>
<Link href={routes.sponsor.campaigns} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={ChevronRight} size={4} />}>
View All
</Button>
</Link>
</Box>
</Stack>
<Grid cols={5} gap={4}>
<SponsorshipCategoryCard
icon={Trophy}
title="Leagues"
count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions}
color="#3b82f6"
href="/sponsor/campaigns?type=leagues"
/>
<SponsorshipCategoryCard
icon={Users}
title="Teams"
count={categoryData.teams.count}
impressions={categoryData.teams.impressions}
color="#a855f7"
href="/sponsor/campaigns?type=teams"
/>
<SponsorshipCategoryCard
icon={Car}
title="Drivers"
count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions}
color="#10b981"
href="/sponsor/campaigns?type=drivers"
/>
<SponsorshipCategoryCard
icon={Flag}
title="Races"
count={categoryData.races.count}
impressions={categoryData.races.impressions}
color="#f59e0b"
href="/sponsor/campaigns?type=races"
/>
<SponsorshipCategoryCard
icon={Megaphone}
title="Platform Ads"
count={categoryData.platform.count}
impressions={categoryData.platform.impressions}
color="#ef4444"
href="/sponsor/campaigns?type=platform"
/>
</Grid>
</Box>
{/* Main Content Grid */}
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Stack gap={6}>
{/* Top Performing Sponsorships */}
<Card p={0}>
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" align="center" justify="between">
<Heading level={3}>Top Performing</Heading>
<Box>
<Link href={routes.public.leagues} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Find More
</Button>
</Link>
</Box>
</Stack>
</Box>
<Box p={4}>
<Surface variant="muted" rounded="lg" padding={4}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Badge variant="primary">Main</Badge>
<Box>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={4} color="#737373" />
<Text weight="medium" color="text-white">Sample League</Text>
</Stack>
<Text size="sm" color="text-gray-500" block mt={1}>Sample details</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={4}>
<Box style={{ textAlign: 'right' }}>
<Text weight="semibold" color="text-white" block>1.2k</Text>
<Text size="xs" color="text-gray-500">impressions</Text>
</Box>
<Button variant="secondary" size="sm">
<Icon icon={ExternalLink} size={3} />
</Button>
</Stack>
</Stack>
</Surface>
</Box>
</Card>
{/* Upcoming Events */}
<Card p={0}>
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<Heading level={3} icon={<Icon icon={Calendar} size={5} color="#f59e0b" />}>
Upcoming Sponsored Events
</Heading>
</Box>
<Box p={4}>
<Stack align="center" gap={2} py={8}>
<Icon icon={Calendar} size={8} color="#737373" />
<Text color="text-gray-400">No upcoming sponsored events</Text>
</Stack>
</Box>
</Card>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Stack gap={6}>
{/* Quick Actions */}
<Card>
<Stack gap={4}>
<Heading level={3}>Quick Actions</Heading>
<Stack gap={2}>
<Box>
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Target} size={4} />}>
Find Leagues to Sponsor
</Button>
</Link>
</Box>
<Box>
<Link href={routes.public.teams} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Users} size={4} />}>
Browse Teams
</Button>
</Link>
</Box>
<Box>
<Link href={routes.public.drivers} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Car} size={4} />}>
Discover Drivers
</Button>
</Link>
</Box>
<Box>
<Link href={routes.sponsor.billing} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={CreditCard} size={4} />}>
Manage Billing
</Button>
</Link>
</Box>
<Box>
<Link href={routes.sponsor.campaigns} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={BarChart3} size={4} />}>
View Analytics
</Button>
</Link>
</Box>
</Stack>
</Stack>
</Card>
{/* Renewal Alerts */}
{viewData.upcomingRenewals.length > 0 && (
<Card>
<Stack gap={4}>
<Heading level={3} icon={<Icon icon={Bell} size={5} color="#f59e0b" />}>
Upcoming Renewals
</Heading>
<Stack gap={3}>
{viewData.upcomingRenewals.map((renewal) => (
<RenewalAlert key={renewal.id} renewal={renewal} />
))}
</Stack>
</Stack>
</Card>
)}
{/* Recent Activity */}
<Card>
<Stack gap={4}>
<Heading level={3}>Recent Activity</Heading>
<Box>
{viewData.recentActivity.map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</Box>
</Stack>
</Card>
{/* Investment Summary */}
<Card>
<Stack gap={4}>
<Heading level={3} icon={<Icon icon={FileText} size={5} color="#3b82f6" />}>
Investment Summary
</Heading>
<Stack gap={3}>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Active Sponsorships</Text>
<Text weight="medium" color="text-white">{viewData.activeSponsorships}</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Total Investment</Text>
<Text weight="medium" color="text-white">{viewData.formattedTotalInvestment}</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Cost per 1K Views</Text>
<Text weight="medium" color="text-performance-green">
{viewData.costPerThousandViews}
</Text>
</Stack>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">Next Invoice</Text>
<Text weight="medium" color="text-white">Jan 1, 2026</Text>
</Stack>
<Box pt={3} style={{ borderTop: '1px solid #262626' }}>
<Box>
<Link href={routes.sponsor.billing} variant="ghost">
<Button variant="secondary" fullWidth size="sm" icon={<Icon icon={CreditCard} size={4} />}>
View Billing Details
</Button>
</Link>
</Box>
</Box>
</Stack>
</Stack>
</Card>
</Stack>
</GridItem>
</Grid>
</Stack>
</Container>
);
}
}

View File

@@ -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<TabType>('overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
const league = data.league;
const config = league.tierConfig;
const league = viewData.league;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<Link href=routes.sponsor.leagues className="hover:text-white transition-colors">Leagues</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{league.name}</span>
</div>
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Breadcrumb */}
<Box>
<Stack direction="row" align="center" gap={2}>
<Link href={routes.sponsor.dashboard}>
<Text size="sm" color="text-gray-400">Dashboard</Text>
</Link>
<Text size="sm" color="text-gray-500">/</Text>
<Link href={routes.sponsor.leagues}>
<Text size="sm" color="text-gray-400">Leagues</Text>
</Link>
<Text size="sm" color="text-gray-500">/</Text>
<Text size="sm" color="text-white">{league.name}</Text>
</Stack>
</Box>
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
{league.tier}
</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
Active Season
</span>
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">{league.name}</h1>
<p className="text-gray-400 mb-4">{league.game} {league.season} {league.completedRaces}/{league.races} races completed</p>
<p className="text-gray-400 max-w-2xl">{league.description}</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Link href={`/leagues/${league.id}`}>
<Button variant="secondary">
<ExternalLink className="w-4 h-4 mr-2" />
View League
</Button>
</Link>
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
<Button variant="primary" onClick={() => setActiveTab('sponsor')}>
<Megaphone className="w-4 h-4 mr-2" />
Become a Sponsor
</Button>
)}
</div>
</div>
{/* Header */}
<Stack direction="row" align="start" justify="between" wrap gap={6}>
<Box style={{ flex: 1 }}>
<Stack direction="row" align="center" gap={3} mb={2}>
<Badge variant="primary"> {league.tier}</Badge>
<Badge variant="success">Active Season</Badge>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Star} size={3.5} color="#facc15" />
<Text size="sm" weight="medium" color="text-white">{league.rating}</Text>
</Stack>
</Surface>
</Stack>
<Heading level={1}>{league.name}</Heading>
<Text color="text-gray-400" block mt={2}>
{league.game} {league.season} {league.completedRaces}/{league.races} races completed
</Text>
<Text color="text-gray-400" block mt={4} style={{ maxWidth: '42rem' }}>
{league.description}
</Text>
</Box>
<Stack direction="row" gap={3}>
<Link href={`/leagues/${league.id}`}>
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
View League
</Button>
</Link>
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
<Button variant="primary" onClick={() => setActiveTab('sponsor')} icon={<Icon icon={Megaphone} size={4} />}>
Become a Sponsor
</Button>
)}
</Stack>
</Stack>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Eye className="w-5 h-5 text-primary-blue" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.formattedTotalImpressions}</div>
<div className="text-xs text-gray-400">Total Views</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<TrendingUp className="w-5 h-5 text-performance-green" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.formattedAvgViewsPerRace}</div>
<div className="text-xs text-gray-400">Avg/Race</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.drivers}</div>
<div className="text-xs text-gray-400">Drivers</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
<BarChart3 className="w-5 h-5 text-warning-amber" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.engagement}%</div>
<div className="text-xs text-gray-400">Engagement</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-racing-red/10">
<Calendar className="w-5 h-5 text-racing-red" />
</div>
<div>
<div className="text-xl font-bold text-white">{league.racesLeft}</div>
<div className="text-xs text-gray-400">Races Left</div>
</div>
</div>
</Card>
</motion.div>
</div>
{/* Quick Stats */}
<Grid cols={5} gap={4}>
<StatCard icon={Eye} label="Total Views" value={league.formattedTotalImpressions} color="#3b82f6" />
<StatCard icon={TrendingUp} label="Avg/Race" value={league.formattedAvgViewsPerRace} color="#10b981" />
<StatCard icon={Users} label="Drivers" value={league.drivers} color="#a855f7" />
<StatCard icon={BarChart3} label="Engagement" value={`${league.engagement}%`} color="#f59e0b" />
<StatCard icon={Calendar} label="Races Left" value={league.racesLeft} color="#ef4444" />
</Grid>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto">
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<button
key={tab}
onClick={() => 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}
</button>
))}
</div>
{/* Tabs */}
<Box style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" gap={6}>
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<Box
key={tab}
onClick={() => setActiveTab(tab)}
pb={3}
style={{
cursor: 'pointer',
borderBottom: activeTab === tab ? '2px solid #3b82f6' : '2px solid transparent',
color: activeTab === tab ? '#3b82f6' : '#9ca3af'
}}
>
<Text size="sm" weight="medium" style={{ textTransform: 'capitalize' }}>
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</Text>
</Box>
))}
</Stack>
</Box>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trophy className="w-5 h-5 text-primary-blue" />
League Information
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Platform</span>
<span className="text-white font-medium">{league.game}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Season</span>
<span className="text-white font-medium">{league.season}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Duration</span>
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Drivers</span>
<span className="text-white font-medium">{league.drivers}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Races</span>
<span className="text-white font-medium">{league.races}</span>
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<Grid cols={2} gap={6}>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Trophy} size={5} color="#3b82f6" />}>
League Information
</Heading>
</Box>
<Stack gap={3}>
<InfoRow label="Platform" value={league.game} />
<InfoRow label="Season" value={league.season} />
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
<InfoRow label="Drivers" value={league.drivers} />
<InfoRow label="Races" value={league.races} last />
</Stack>
</Card>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Sponsorship Value
</Heading>
</Box>
<Stack gap={3}>
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
</Stack>
</Card>
{league.nextRace && (
<GridItem colSpan={2}>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#f59e0b" />}>
Next Race
</Heading>
</Box>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.05)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
<Icon icon={Flag} size={6} color="#f59e0b" />
</Surface>
<Box>
<Text size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</Text>
</Box>
</Stack>
<Button variant="secondary">
View Schedule
</Button>
</Stack>
</Surface>
</Card>
</GridItem>
)}
</Grid>
)}
{activeTab === 'drivers' && (
<Card p={0}>
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<Heading level={2}>Championship Standings</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</Text>
</Box>
<Stack gap={0}>
{viewData.drivers.map((driver, index) => (
<Box key={driver.id} p={4} style={{ borderBottom: index < viewData.drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#262626' }}>
<Text weight="bold" color="text-white">{driver.position}</Text>
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{driver.name}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{driver.team} {driver.country}</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={8}>
<Box style={{ textAlign: 'right' }}>
<Text weight="medium" color="text-white" block>{driver.races}</Text>
<Text size="xs" color="text-gray-500">races</Text>
</Box>
<Box style={{ textAlign: 'right' }}>
<Text weight="semibold" color="text-white" block>{driver.formattedImpressions}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
</Stack>
</Stack>
</Box>
))}
</Stack>
</Card>
)}
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-performance-green" />
Sponsorship Value
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Total Season Views</span>
<span className="text-white font-medium">{league.formattedTotalImpressions}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Projected Total</span>
<span className="text-white font-medium">{league.formattedProjectedTotal}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Main Sponsor CPM</span>
<span className="text-performance-green font-medium">
{league.formattedMainSponsorCpm}
</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Engagement Rate</span>
<span className="text-white font-medium">{league.engagement}%</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">League Rating</span>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{league.rating}/5.0</span>
</div>
</div>
</div>
{activeTab === 'races' && (
<Card p={0}>
<Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<Heading level={2}>Race Calendar</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
</Box>
<Stack gap={0}>
{viewData.races.map((race, index) => (
<Box key={race.id} p={4} style={{ borderBottom: index < viewData.races.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4}>
<Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.status === 'completed' ? '#10b981' : '#f59e0b' }} />
<Box>
<Text weight="medium" color="text-white" block>{race.name}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
</Box>
</Stack>
<Box>
{race.status === 'completed' ? (
<Box style={{ textAlign: 'right' }}>
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
) : (
<Badge variant="warning">Upcoming</Badge>
)}
</Box>
</Stack>
</Box>
))}
</Stack>
</Card>
)}
{/* Next Race */}
{league.nextRace && (
<Card className="p-5 lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-warning-amber" />
Next Race
</h3>
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center">
<Flag className="w-6 h-6 text-warning-amber" />
</div>
<div>
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p>
<p className="text-sm text-gray-400">{league.nextRace.date}</p>
</div>
</div>
<Button variant="secondary">
View Schedule
{activeTab === 'sponsor' && (
<Stack gap={6}>
<Grid cols={2} gap={6}>
<SponsorTierCard
type="main"
available={league.sponsorSlots.main.available}
price={league.sponsorSlots.main.price}
benefits={league.sponsorSlots.main.benefits}
isSelected={selectedTier === 'main'}
onClick={() => setSelectedTier('main')}
/>
<SponsorTierCard
type="secondary"
available={league.sponsorSlots.secondary.available > 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')}
/>
</Grid>
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
Sponsorship Summary
</Heading>
</Box>
<Stack gap={3} mb={6}>
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
<Box pt={4} style={{ borderTop: '1px solid #262626' }}>
<Stack direction="row" align="center" justify="between">
<Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
<Text size="xl" weight="bold" color="text-white">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</Text>
</Stack>
</Box>
</Stack>
<Text size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice}
</Text>
<Stack direction="row" gap={3}>
<Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
Request Sponsorship
</Button>
</div>
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
Download Info Pack
</Button>
</Stack>
</Card>
)}
</div>
)}
{activeTab === 'drivers' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Championship Standings</h3>
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.drivers.map((driver) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
{driver.position}
</div>
<div>
<div className="font-medium text-white">{driver.name}</div>
<div className="text-sm text-gray-500">{driver.team} {driver.country}</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-medium text-white">{driver.races}</div>
<div className="text-xs text-gray-500">races</div>
</div>
<div className="text-right">
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
<div className="text-xs text-gray-500">views</div>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'races' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Race Calendar</h3>
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.races.map((race) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
}`} />
<div>
<div className="font-medium text-white">{race.name}</div>
<div className="text-sm text-gray-500">{race.formattedDate}</div>
</div>
</div>
<div className="flex items-center gap-4">
{race.status === 'completed' ? (
<div className="text-right">
<div className="font-semibold text-white">{race.views.toLocaleString()}</div>
<div className="text-xs text-gray-500">views</div>
</div>
) : (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber">
Upcoming
</span>
)}
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'sponsor' && (
<div className="space-y-6">
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Main Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'main'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-5 h-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Primary branding position</p>
</div>
{league.sponsorSlots.main.available ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Filled
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.main.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.main.benefits.map((benefit: string, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'main' && league.sponsorSlots.main.available && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
{/* Secondary Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'secondary'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${league.sponsorSlots.secondary.available === 0 ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Star className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-semibold text-white">Secondary Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Supporting branding position</p>
</div>
{league.sponsorSlots.secondary.available > 0 ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
{league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Full
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.secondary.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.secondary.benefits.map((benefit: string, i: number) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
</div>
{/* Checkout Summary */}
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary-blue" />
Sponsorship Summary
</h3>
<div className="space-y-3 mb-6">
<div className="flex justify-between py-2">
<span className="text-gray-400">Selected Tier</span>
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Season Price</span>
<span className="text-white font-medium">
${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Platform Fee ({siteConfig.fees.platformFeePercent}%)</span>
<span className="text-white font-medium">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
</span>
</div>
<div className="flex justify-between py-2 border-t border-charcoal-outline pt-4">
<span className="text-white font-semibold">Total (excl. VAT)</span>
<span className="text-white font-bold text-xl">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</span>
</div>
</div>
<p className="text-xs text-gray-500 mb-4">
{siteConfig.vat.notice}
</p>
<div className="flex gap-3">
<Button variant="primary" className="flex-1">
<Megaphone className="w-4 h-4 mr-2" />
Request Sponsorship
</Button>
<Button variant="secondary">
<FileText className="w-4 h-4 mr-2" />
Download Info Pack
</Button>
</div>
</Card>
</div>
)}
</div>
</Stack>
)}
</Stack>
</Container>
);
}
}
function StatCard({ icon, label, value, color }: { icon: any, label: string, value: string | number, color: string }) {
return (
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}1A` }}>
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Text size="xl" weight="bold" color="text-white" block>{value}</Text>
<Text size="xs" color="text-gray-500" block>{label}</Text>
</Box>
</Stack>
</Card>
);
}
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
return (
<Box py={2} style={{ borderBottom: last ? 'none' : '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">{label}</Text>
<Text weight="medium" color={color as any}>{value}</Text>
</Stack>
</Box>
);
}

View File

@@ -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 (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Card className={`overflow-hidden border ${config.border} ${config.bg} hover:border-primary-blue/50 transition-all duration-300 h-full`}>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs font-medium capitalize border ${config.badge}`}>
{config.icon} {league.tier}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${status.bg} ${status.color}`}>
{status.label}
</span>
</div>
<h3 className="font-semibold text-white text-lg">{league.name}</h3>
<p className="text-sm text-gray-500">{league.game}</p>
</div>
<div className="flex items-center gap-1 bg-iron-gray/50 px-2 py-1 rounded">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
{/* Description */}
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p>
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-white">{league.drivers}</div>
<div className="text-xs text-gray-500">Drivers</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-white">{league.formattedAvgViews}</div>
<div className="text-xs text-gray-500">Avg Views</div>
</div>
<div className="text-center p-2 bg-iron-gray/50 rounded-lg">
<div className="text-lg font-bold text-performance-green">{league.formattedCpm}</div>
<div className="text-xs text-gray-500">CPM</div>
</div>
</div>
{/* Next Race */}
{league.nextRace && (
<div className="flex items-center gap-2 mb-4 text-sm">
<Clock className="w-4 h-4 text-gray-500" />
<span className="text-gray-400">Next:</span>
<span className="text-white">{league.nextRace}</span>
</div>
)}
{/* Sponsorship Slots */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${league.mainSponsorSlot.available ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Main Sponsor</span>
</div>
<div className="text-sm">
{league.mainSponsorSlot.available ? (
<span className="text-white font-semibold">${league.mainSponsorSlot.price}/season</span>
) : (
<span className="text-gray-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Filled
</span>
)}
</div>
</div>
<div className="flex items-center justify-between p-2.5 bg-iron-gray/30 rounded-lg">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${league.secondarySlots.available > 0 ? 'bg-performance-green' : 'bg-racing-red'}`} />
<span className="text-sm text-gray-300">Secondary Slots</span>
</div>
<div className="text-sm">
{league.secondarySlots.available > 0 ? (
<span className="text-white font-semibold">
{league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price}
</span>
) : (
<span className="text-gray-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Full
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Link href={`/sponsor/leagues/${league.id}`} className="flex-1">
<Button variant="secondary" className="w-full text-sm">
View Details
</Button>
</Link>
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} className="flex-1">
<Button variant="primary" className="w-full text-sm">
Sponsor
</Button>
</Link>
)}
</div>
</div>
</Card>
</motion.div>
);
}
export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
const shouldReduceMotion = useReducedMotion();
export function SponsorLeaguesTemplate({ viewData }: SponsorLeaguesTemplateProps) {
const [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('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 (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">Browse Leagues</span>
</div>
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Breadcrumb */}
<Box>
<Stack direction="row" align="center" gap={2}>
<Link href={routes.sponsor.dashboard}>
<Text size="sm" color="text-gray-400">Dashboard</Text>
</Link>
<Text size="sm" color="text-gray-500">/</Text>
<Text size="sm" color="text-white">Browse Leagues</Text>
</Stack>
</Box>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-3">
<Trophy className="w-7 h-7 text-primary-blue" />
League Sponsorship Marketplace
</h1>
<p className="text-gray-400">
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
</p>
</div>
{/* Header */}
<Box>
<Heading level={1} icon={<Icon icon={Trophy} size={7} color="#3b82f6" />}>
League Sponsorship Marketplace
</Heading>
<Text color="text-gray-400" block mt={2}>
Discover racing leagues looking for sponsors. All prices shown exclude VAT.
</Text>
</Box>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-gray-400">Leagues</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-performance-green">{stats.mainAvailable}</div>
<div className="text-sm text-gray-400">Main Slots</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-primary-blue">{stats.secondaryAvailable}</div>
<div className="text-sm text-gray-400">Secondary Slots</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.totalDrivers}</div>
<div className="text-sm text-gray-400">Total Drivers</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="p-4 text-center">
<div className="text-2xl font-bold text-warning-amber">${stats.avgCpm}</div>
<div className="text-sm text-gray-400">Avg CPM</div>
</Card>
</motion.div>
</div>
{/* Stats Overview */}
<Grid cols={5} gap={4}>
<StatCard label="Leagues" value={stats.total} />
<StatCard label="Main Slots" value={stats.mainAvailable} color="text-performance-green" />
<StatCard label="Secondary Slots" value={stats.secondaryAvailable} color="text-primary-blue" />
<StatCard label="Total Drivers" value={stats.totalDrivers} />
<StatCard label="Avg CPM" value={`$${stats.avgCpm}`} color="text-warning-amber" />
</Grid>
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4 mb-6">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search leagues..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
{/* Tier Filter */}
<select
value={tierFilter}
onChange={(e) => 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"
>
<option value="all">All Tiers</option>
<option value="premium"> Premium</option>
<option value="standard">🏆 Standard</option>
<option value="starter">🚀 Starter</option>
</select>
{/* Availability Filter */}
<select
value={availabilityFilter}
onChange={(e) => 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"
>
<option value="all">All Slots</option>
<option value="main">Main Available</option>
<option value="secondary">Secondary Available</option>
</select>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => 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"
>
<option value="rating">Sort by Rating</option>
<option value="drivers">Sort by Drivers</option>
<option value="views">Sort by Views</option>
<option value="price">Sort by Price</option>
</select>
</div>
{/* Results Count */}
<div className="flex items-center justify-between mb-6">
<p className="text-sm text-gray-400">
Showing {filteredLeagues.length} of {data.leagues.length} leagues
</p>
<div className="flex items-center gap-2">
<Link href="/teams">
<Button variant="secondary" className="text-sm">
<Users className="w-4 h-4 mr-2" />
Browse Teams
</Button>
</Link>
<Link href="/drivers">
<Button variant="secondary" className="text-sm">
<Car className="w-4 h-4 mr-2" />
Browse Drivers
</Button>
</Link>
</div>
</div>
{/* League Grid */}
{filteredLeagues.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLeagues.map((league: any, index: number) => (
<LeagueCard key={league.id} league={league} index={index} />
))}
</div>
) : (
<Card className="text-center py-16">
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3>
<p className="text-gray-400 mb-6">Try adjusting your filters to see more results</p>
<Button variant="secondary" onClick={() => {
setSearchQuery('');
setTierFilter('all');
setAvailabilityFilter('all');
}}>
Clear Filters
</Button>
{/* Filters (Simplified for template) */}
<Card>
<Stack gap={4}>
<Text size="sm" color="text-gray-400">
Use the search and filter options to find the perfect league for your brand.
</Text>
<Grid cols={4} gap={4}>
<Box>
<input
type="text"
placeholder="Search leagues..."
value={searchQuery}
onChange={(e) => 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"
/>
</Box>
{/* Selects would go here, using standard Select UI if available */}
</Grid>
</Stack>
</Card>
)}
{/* Platform Fee Notice */}
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="flex items-start gap-3">
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p>
<p className="text-xs text-gray-500">
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
</p>
</div>
</div>
</div>
</div>
{/* Results Count */}
<Stack direction="row" align="center" justify="between">
<Text size="sm" color="text-gray-400">
Showing {filteredLeagues.length} of {viewData.leagues.length} leagues
</Text>
<Stack direction="row" align="center" gap={3}>
<Link href="/teams">
<Button variant="secondary" size="sm" icon={<Icon icon={Users} size={4} />}>
Browse Teams
</Button>
</Link>
<Link href="/drivers">
<Button variant="secondary" size="sm" icon={<Icon icon={Car} size={4} />}>
Browse Drivers
</Button>
</Link>
</Stack>
</Stack>
{/* League Grid */}
{filteredLeagues.length > 0 ? (
<Grid cols={3} gap={6}>
{filteredLeagues.map((league) => (
<GridItem key={league.id} colSpan={12} mdSpan={6} lgSpan={4}>
<AvailableLeagueCard league={league as any} />
</GridItem>
))}
</Grid>
) : (
<Card>
<Stack align="center" py={16} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Trophy} size={12} color="#525252" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Heading level={3}>No leagues found</Heading>
<Text color="text-gray-400" block mt={2}>Try adjusting your filters to see more results</Text>
</Box>
<Button variant="secondary" onClick={() => {
setSearchQuery('');
setTierFilter('all');
setAvailabilityFilter('all');
}}>
Clear Filters
</Button>
</Stack>
</Card>
)}
{/* Platform Fee Notice */}
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={Megaphone} size={5} color="#3b82f6" />
<Box>
<Text size="sm" color="text-gray-300" weight="medium" block mb={1}>Platform Fee</Text>
<Text size="xs" color="text-gray-500">
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
</Text>
</Box>
</Stack>
</Surface>
</Stack>
</Container>
);
}
}
function StatCard({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
return (
<Card>
<Box style={{ textAlign: 'center' }}>
<Text size="2xl" weight="bold" color={color as any} block mb={1}>{value}</Text>
<Text size="sm" color="text-gray-400">{label}</Text>
</Box>
</Card>
);
}

View File

@@ -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 (
<Container size="md" className="space-y-8">
<div>
<Heading level={1} className="text-white mb-2">
Sponsorship Requests
</Heading>
<p className="text-gray-400 text-sm">
Manage pending sponsorship requests for your profile.
</p>
</div>
<Container size="md" py={8}>
<Stack gap={8}>
<Box>
<Heading level={1}>Sponsorship Requests</Heading>
<Text size="sm" color="text-gray-400" block mt={2}>
Manage pending sponsorship requests for your profile.
</Text>
</Box>
{viewData.sections.map((section) => (
<Card key={`${section.entityType}-${section.entityId}`}>
<div className="flex items-center justify-between mb-4">
<Heading level={2} className="text-white">
{section.entityName}
</Heading>
<span className="text-xs text-gray-400">
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
</span>
</div>
{viewData.sections.map((section) => (
<Card key={`${section.entityType}-${section.entityId}`}>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2}>{section.entityName}</Heading>
<Text size="xs" color="text-gray-400">
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
</Text>
</Stack>
{section.requests.length === 0 ? (
<p className="text-sm text-gray-400">No pending requests.</p>
) : (
<div className="space-y-3">
{section.requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
>
<div className="flex-1">
<p className="text-white font-medium">{request.sponsorName}</p>
{request.message && (
<p className="text-xs text-gray-400 mt-1">{request.message}</p>
)}
<p className="text-xs text-gray-500 mt-1">
{new Date(request.createdAtIso).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => onAccept(request.id)}
{section.requests.length === 0 ? (
<Text size="sm" color="text-gray-400">No pending requests.</Text>
) : (
<Stack gap={3}>
{section.requests.map((request) => (
<Surface
key={request.id}
variant="muted"
rounded="lg"
border
padding={4}
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => onReject(request.id)}
>
Reject
</Button>
</div>
</div>
))}
</div>
)}
</Card>
))}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="medium" color="text-white" block>{request.sponsorName}</Text>
{request.message && (
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
)}
<Text size="xs" color="text-gray-500" block mt={2}>
{new Date(request.createdAtIso).toLocaleDateString()}
</Text>
</Box>
<Stack direction="row" gap={2}>
<Button
variant="primary"
onClick={() => onAccept(request.id)}
size="sm"
>
Accept
</Button>
<Button
variant="secondary"
onClick={() => onReject(request.id)}
size="sm"
>
Reject
</Button>
</Stack>
</Stack>
</Surface>
))}
</Stack>
)}
</Stack>
</Card>
))}
</Stack>
</Container>
);
}

View File

@@ -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 (
<Section>
<Stack gap={6}>
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
<Stack gap={6}>
<Box>
<Heading level={1}>Stewarding</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
Quick overview of protests and penalties across all races
</p>
</div>
</div>
</Text>
</Box>
{/* Stats summary */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
<div className="text-2xl font-bold text-warning-amber">{viewData.totalPending}</div>
<div className="text-sm text-gray-400">Pending</div>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
<div className="text-2xl font-bold text-performance-green">{viewData.totalResolved}</div>
<div className="text-sm text-gray-400">Resolved</div>
</div>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center">
<div className="text-2xl font-bold text-red-400">{viewData.totalPenalties}</div>
<div className="text-sm text-gray-400">Penalties</div>
</div>
</div>
{/* Stats summary */}
<Grid cols={3} gap={4}>
<StatItem label="Pending" value={viewData.totalPending} color="#f59e0b" />
<StatItem label="Resolved" value={viewData.totalResolved} color="#10b981" />
<StatItem label="Penalties" value={viewData.totalPenalties} color="#ef4444" />
</Grid>
{/* Content */}
{viewData.races.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">All Clear!</p>
<p className="text-sm text-gray-400">No protests or penalties to review.</p>
</div>
) : (
<div className="space-y-4">
{viewData.races.map((race) => (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<div className="px-4 py-3 bg-iron-gray/30">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span>
</div>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{race.pendingProtests.length} pending
</span>
</div>
</div>
{/* Content */}
{viewData.races.length === 0 ? (
<Stack align="center" py={12} gap={4}>
<Surface variant="muted" rounded="full" padding={4}>
<Icon icon={Flag} size={8} color="#10b981" />
</Surface>
<Box style={{ textAlign: 'center' }}>
<Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
<Text size="sm" color="text-gray-400">No protests or penalties to review.</Text>
</Box>
</Stack>
) : (
<Stack gap={4}>
{viewData.races.map((race) => (
<Surface
key={race.id}
variant="muted"
rounded="lg"
border
style={{ overflow: 'hidden', borderColor: '#262626' }}
>
{/* Race Header */}
<Box p={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626' }}>
<Stack direction="row" align="center" gap={4} wrap>
<Stack direction="row" align="center" gap={2}>
<Icon icon={MapPin} size={4} color="#9ca3af" />
<Text weight="medium" color="text-white">{race.track}</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-400">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
</Stack>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium" color="text-warning-amber">{race.pendingProtests.length} pending</Text>
</Surface>
</Stack>
</Box>
{/* Race Content */}
<div className="p-4 space-y-3 bg-deep-graphite/50">
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{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 */}
<Box p={4}>
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? (
<Box py={4}>
<Text size="sm" color="text-gray-400" block style={{ textAlign: 'center' }}>No items to display</Text>
</Box>
) : (
<Stack gap={3}>
{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 (
<div
key={protest.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
</div>
<div className="text-sm text-gray-400">
Review needed
</div>
</div>
</div>
);
})}
return (
<Surface
key={protest.id}
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
>
<Stack direction="row" align="start" justify="between" gap={4}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={2} mb={2} wrap>
<Icon icon={AlertCircle} size={4} color="#f59e0b" />
<Text weight="medium" color="text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</Text>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium" color="text-warning-amber">Pending</Text>
</Surface>
</Stack>
<Stack direction="row" align="center" gap={4} mb={2}>
<Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
<Text size="sm" color="text-gray-400"></Text>
<Text size="sm" color="text-gray-400">Filed {new Date(protest.filedAt).toLocaleDateString()}</Text>
</Stack>
<Text size="sm" color="text-gray-300" block truncate>{protest.incident.description}</Text>
</Box>
<Text size="sm" color="text-gray-500">Review needed</Text>
</Stack>
</Surface>
);
})}
{race.penalties.map((penalty) => {
const driver = viewData.drivers.find(d => d.id === penalty.driverId);
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{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`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
</div>
))}
</div>
)}
{race.penalties.map((penalty) => {
const driver = viewData.drivers.find(d => d.id === penalty.driverId);
return (
<Surface
key={penalty.id}
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
>
<Stack direction="row" align="center" justify="between" gap={4}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)' }}>
<Icon icon={Gavel} size={4} color="#ef4444" />
</Surface>
<Box>
<Stack direction="row" align="center" gap={2}>
<Text weight="medium" color="text-white">{driver?.name || 'Unknown'}</Text>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium" color="text-error-red" style={{ textTransform: 'capitalize' }}>
{penalty.type.replace('_', ' ')}
</Text>
</Surface>
</Stack>
<Text size="sm" color="text-gray-400" block mt={1}>{penalty.reason}</Text>
</Box>
</Stack>
<Box style={{ textAlign: 'right' }}>
<Text weight="bold" color="text-error-red" style={{ fontSize: '1.125rem' }}>
{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`}
</Text>
</Box>
</Stack>
</Surface>
);
})}
</Stack>
)}
</Box>
</Surface>
))}
</Stack>
)}
</Stack>
</Card>
</Section>
</Stack>
);
}
}
function StatItem({ label, value, color }: { label: string, value: string | number, color: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626', textAlign: 'center' }}>
<Text size="2xl" weight="bold" style={{ color }}>{value}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{label}</Text>
</Surface>
);
}

View File

@@ -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 (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading team...</div>
</div>
<Container size="lg" py={12}>
<Stack align="center">
<Text color="text-gray-400">Loading team...</Text>
</Stack>
</Container>
);
}
// Show not found state
if (!viewData.team) {
return (
<div className="max-w-6xl mx-auto">
<Container size="md" py={12}>
<Card>
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2>
<p className="text-gray-400 mb-6">
The team you're looking for doesn't exist or has been disbanded.
</p>
<Stack align="center" py={12} gap={6}>
<Box style={{ textAlign: 'center' }}>
<Heading level={1}>Team Not Found</Heading>
<Text color="text-gray-400" block mt={2}>
The team you're looking for doesn't exist or has been disbanded.
</Text>
</Box>
<Button variant="primary" onClick={onGoBack}>
Go Back
</Button>
</div>
</Stack>
</Card>
</div>
</Container>
);
}
@@ -90,164 +89,128 @@ export function TeamDetailTemplate({
const visibleTabs = tabs.filter(tab => tab.visible);
return (
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: viewData.team.name }
]}
/>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && viewData.team && (
<SponsorInsightsCard
entityType="team"
entityId={viewData.team.id}
entityName={viewData.team.name}
tier="standard"
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
<Container size="lg" py={8}>
<Stack gap={6}>
{/* Breadcrumb */}
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: viewData.team.name }
]}
/>
)}
<Card className="mb-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={getMediaUrl('team-logo', viewData.team.id)}
alt={viewData.team.name}
width={96}
height={96}
className="w-full h-full object-cover"
/>
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{viewData.team.name}</h1>
{viewData.team.tag && (
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
[{viewData.team.tag}]
</span>
)}
</div>
<p className="text-gray-300 mb-4 max-w-2xl">{viewData.team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{viewData.memberships.length} {viewData.memberships.length === 1 ? 'member' : 'members'}</span>
{viewData.team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
{viewData.team.category}
</span>
)}
{viewData.team.createdAt && (
<span>
Founded {new Date(viewData.team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
)}
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<span>
Active in {viewData.team.leagues.length} {viewData.team.leagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
</div>
</div>
<JoinTeamButton teamId={viewData.team.id} onUpdate={onUpdate} />
</div>
</Card>
<div className="mb-6">
<div className="flex items-center gap-2 border-b border-charcoal-outline">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => 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 && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
)}
</button>
))}
</div>
</div>
<div>
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{viewData.team.description}</p>
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{viewData.team.category && (
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
)}
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
)}
{viewData.team.createdAt && (
<StatItem
label="Founded"
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
color="text-gray-300"
/>
)}
</div>
</Card>
</div>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3>
<div className="text-center py-8 text-gray-400">
No recent activity to display
</div>
</Card>
</div>
)}
{activeTab === 'roster' && (
<TeamRoster
teamId={viewData.team.id}
memberships={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onChangeRole={onChangeRole}
{/* Sponsor Insights Card */}
{isSponsorMode && viewData.team && (
<SponsorInsightsCard
entityType="team"
entityId={viewData.team.id}
entityName={viewData.team.name}
tier="standard"
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
)}
<TeamHero
team={viewData.team}
memberCount={viewData.memberships.length}
onUpdate={onUpdate}
/>
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
)}
</div>
</div>
{/* Tabs */}
<Box style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" gap={6}>
{visibleTabs.map((tab) => (
<Box
key={tab.id}
onClick={() => onTabChange(tab.id)}
pb={3}
style={{
cursor: 'pointer',
borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent',
color: activeTab === tab.id ? '#3b82f6' : '#9ca3af'
}}
>
<Text weight="medium">{tab.label}</Text>
</Box>
))}
</Stack>
</Box>
<Box>
{activeTab === 'overview' && (
<Stack gap={6}>
<Grid cols={12} gap={6}>
<GridItem colSpan={12} lgSpan={8}>
<Card>
<Box mb={4}>
<Heading level={2}>About</Heading>
</Box>
<Text color="text-gray-300" style={{ lineHeight: 1.625 }}>{viewData.team.description}</Text>
</Card>
</GridItem>
<GridItem colSpan={12} lgSpan={4}>
<Card>
<Box mb={4}>
<Heading level={2}>Quick Stats</Heading>
</Box>
<Stack gap={3}>
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{viewData.team.category && (
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
)}
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
)}
{viewData.team.createdAt && (
<StatItem
label="Founded"
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
color="text-gray-300"
/>
)}
</Stack>
</Card>
</GridItem>
</Grid>
<Card>
<Box mb={4}>
<Heading level={2}>Recent Activity</Heading>
</Box>
<Box py={8}>
<Text color="text-gray-400" block style={{ textAlign: 'center' }}>No recent activity to display</Text>
</Box>
</Card>
</Stack>
)}
{activeTab === 'roster' && (
<TeamRoster
teamId={viewData.team.id}
memberships={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onChangeRole={onChangeRole}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
)}
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
)}
</Box>
</Stack>
</Container>
);
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={onBackToTeams}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
Back to Teams
</Button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-7 h-7 text-yellow-400" />
</div>
<div>
<Heading level={1} className="text-3xl lg:text-4xl">
Team Leaderboard
</Heading>
<p className="text-gray-400">Rankings of all teams by performance metrics</p>
</div>
</div>
</div>
{/* Filters and Search */}
<TeamRankingsFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
filterLevel={filterLevel}
onFilterLevelChange={onFilterLevelChange}
sortBy={sortBy}
onSortChange={onSortChange}
/>
{/* Podium for Top 3 - only show when viewing by rating without filters */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
)}
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-purple-400" />
<span className="text-xs text-gray-500">Total Teams</span>
</div>
<p className="text-2xl font-bold text-white">{filteredAndSortedTeams.length}</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Crown className="w-4 h-4 text-yellow-400" />
<span className="text-xs text-gray-500">Pro Teams</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.filter((t) => t.performanceLevel === 'pro').length}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500">Total Wins</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalWins(t),
0,
)}
</p>
</div>
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-neon-aqua" />
<span className="text-xs text-gray-500">Total Races</span>
</div>
<p className="text-2xl font-bold text-white">
{filteredAndSortedTeams.reduce<number>(
(sum, t) => sum + getSafeTotalRaces(t),
0,
)}
</p>
</div>
</div>
{/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-1 text-center">Rank</div>
<div className="col-span-4 lg:col-span-5">Team</div>
<div className="col-span-2 text-center hidden lg:block">Members</div>
<div className="col-span-2 lg:col-span-1 text-center">Rating</div>
<div className="col-span-2 lg:col-span-1 text-center">Wins</div>
<div className="col-span-2 text-center">Win Rate</div>
</div>
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{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 (
<button
key={team.id}
type="button"
onClick={() => 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 */}
<div className="col-span-1 flex items-center justify-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(index)} ${getMedalColor(index)}`}
>
{index < 3 ? (
<Medal className="w-4 h-4" />
) : (
index + 1
)}
</div>
</div>
{/* Team Info */}
<div className="col-span-4 lg:col-span-5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-semibold truncate group-hover:text-purple-400 transition-colors">
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
<span className={`${team.performanceLevel === 'pro' ? 'text-yellow-400' : team.performanceLevel === 'advanced' ? 'text-purple-400' : team.performanceLevel === 'intermediate' ? 'text-primary-blue' : 'text-green-400'}`}>
{team.performanceLevel}
</span>
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
)}
{team.region && (
<span className="flex items-center gap-1 text-gray-400">
<Globe className="w-3 h-3 text-neon-aqua" />
{team.region}
</span>
)}
{team.languages && team.languages.length > 0 && (
<span className="flex items-center gap-1 text-gray-400">
<Languages className="w-3 h-3 text-purple-400" />
{team.languages.slice(0, 2).join(', ')}
{team.languages.length > 2 && ` +${team.languages.length - 2}`}
</span>
)}
{team.isRecruiting && (
<span className="flex items-center gap-1 text-performance-green">
<div className="w-1.5 h-1.5 rounded-full bg-performance-green animate-pulse" />
Recruiting
</span>
)}
</div>
</div>
</div>
{/* Members */}
<div className="col-span-2 items-center justify-center hidden lg:flex">
<span className="flex items-center gap-1 text-gray-400">
<Users className="w-4 h-4" />
{team.memberCount}
</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span
className={`font-mono font-semibold ${
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
}`}
>
{getSafeRating(team).toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
{getSafeTotalWins(team)}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-purple-400' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{filteredAndSortedTeams.length === 0 && (
<div className="py-16 text-center">
<Trophy className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No teams found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Box>
<Box mb={6}>
<Button
variant="secondary"
onClick={() => {
onSearchChange('');
onFilterLevelChange('all');
}}
className="mt-4"
onClick={onBackToTeams}
icon={<Icon icon={ArrowLeft} size={4} />}
>
Clear Filters
Back to Teams
</Button>
</div>
</Box>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))', border: '1px solid rgba(250, 204, 21, 0.3)' }}>
<Icon icon={Award} size={7} color="#facc15" />
</Surface>
<Box>
<Heading level={1}>Team Leaderboard</Heading>
<Text color="text-gray-400" block mt={1}>Rankings of all teams by performance metrics</Text>
</Box>
</Stack>
</Box>
{/* Filters and Search */}
<TeamRankingsFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
filterLevel={filterLevel}
onFilterLevelChange={onFilterLevelChange}
sortBy={sortBy}
onSortChange={onSortChange}
/>
{/* Podium for Top 3 */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} />
)}
</div>
</div>
{/* Leaderboard Table */}
<TeamRankingsTable
teams={filteredAndSortedTeams}
sortBy={sortBy}
onTeamClick={onTeamClick}
/>
</Stack>
</Container>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-7xl mx-auto px-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1>
<p className="text-gray-400">Browse and manage your racing teams</p>
</div>
<Link href=routes.team.detail('create')>
<Button variant="primary">Create Team</Button>
</Link>
</div>
<Box as="main">
<Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */}
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Heading level={1}>Teams</Heading>
<Text color="text-gray-400">Browse and manage your racing teams</Text>
</Box>
<Box>
<Button variant="primary" onClick={onCreateTeam}>Create Team</Button>
</Box>
</Stack>
{/* Teams Grid */}
{teams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{teams.map((team: TeamSummaryData) => (
<Card key={team.teamId} className="hover:border-primary-blue/50 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{team.logoUrl ? (
<img
src={team.logoUrl}
alt={team.teamName}
className="w-12 h-12 rounded-lg object-cover bg-iron-gray"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-iron-gray flex items-center justify-center">
<Users className="w-6 h-6 text-gray-500" />
</div>
)}
<div>
<h3 className="font-semibold text-white">{team.teamName}</h3>
<p className="text-sm text-gray-400">{team.leagueName}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-4">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{team.memberCount} members
</span>
</div>
{/* Teams Grid */}
{teams.length > 0 ? (
<Grid cols={3} gap={6}>
{teams.map((team: TeamSummaryData) => (
<TeamCard
key={team.teamId}
id={team.teamId}
name={team.teamName}
logo={team.logoUrl}
memberCount={team.memberCount}
leagues={[team.leagueName]}
onClick={() => onTeamClick?.(team.teamId)}
/>
))}
</Grid>
) : (
<EmptyState
icon={Users}
title="No teams yet"
description="Get started by creating your first racing team"
action={{
label: 'Create Team',
onClick: onCreateTeam,
variant: 'primary'
}}
/>
)}
<div className="flex gap-2">
<Link href={`/teams/${team.teamId}`} className="flex-1">
<Button variant="secondary" className="w-full text-sm">
View Team
</Button>
</Link>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-16">
<Users className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">No teams yet</h3>
<p className="text-gray-400 mb-4">Get started by creating your first racing team</p>
<Link href=routes.team.detail('create')>
<Button variant="primary">Create Team</Button>
</Link>
</div>
)}
{/* Team Leaderboard Preview */}
<div className="mt-12">
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-2">
<Trophy className="w-6 h-6 text-yellow-400" />
Top Teams
</h2>
<TeamLeaderboardPreview topTeams={[]} onTeamClick={() => {}} />
</div>
</div>
</main>
{/* Team Leaderboard Preview */}
<Box mt={12}>
<TeamLeaderboardPreview
topTeams={[]}
onTeamClick={(id) => onTeamClick?.(id)}
onViewFullLeaderboard={onViewFullLeaderboard}
/>
</Box>
</Stack>
</Container>
</Box>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
<Box as="main" style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative w-full max-w-md">
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
<Box style={{ position: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
{/* Header */}
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Reset Password</Heading>
<p className="text-gray-400">
<Box style={{ textAlign: 'center' }} mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Reset Password</Heading>
<Text color="text-gray-400" block mt={2}>
Enter your email and we will send you a reset link
</p>
</div>
</Text>
</Box>
<Card className="relative overflow-hidden">
<Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
{!viewData.showSuccess ? (
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={mutationState.isPending}
className="pl-10"
autoComplete="email"
/>
</div>
</div>
<form onSubmit={formActions.handleSubmit}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem' }}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Error Message */}
{mutationState.error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{mutationState.error}</p>
</motion.div>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
className="w-full flex items-center justify-center gap-2"
>
{mutationState.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Sending...
</>
) : (
<>
<Shield className="w-4 h-4" />
Send Reset Link
</>
{/* Error Message */}
{mutationState.error && (
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#ef4444" />
<Text size="sm" color="text-error-red">{mutationState.error}</Text>
</Stack>
</Surface>
)}
</Button>
{/* Back to Login */}
<div className="text-center">
<Link
href="/auth/login"
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1"
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
>
<ArrowLeft className="w-4 h-4" />
Back to Login
</Link>
</div>
{mutationState.isPending ? 'Sending...' : 'Send Reset Link'}
</Button>
{/* Back to Login */}
<Box style={{ textAlign: 'center' }}>
<Link href="/auth/login">
<Stack direction="row" align="center" justify="center" gap={1}>
<Icon icon={ArrowLeft} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue">Back to Login</Text>
</Stack>
</Link>
</Box>
</Stack>
</form>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="relative space-y-4"
>
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30">
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
{viewData.magicLink && (
<div className="mt-2">
<p className="text-xs text-gray-400 mb-1">Development Mode - Magic Link:</p>
<div className="bg-iron-gray p-2 rounded border border-charcoal-outline">
<code className="text-xs text-primary-blue break-all">
{viewData.magicLink}
</code>
</div>
<p className="text-[10px] text-gray-500 mt-1">
In production, this would be sent via email
</p>
</div>
)}
</div>
</div>
<Stack gap={4} style={{ position: 'relative' }}>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={6} color="#10b981" />
<Box>
<Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
{viewData.magicLink && (
<Box mt={2}>
<Text size="xs" color="text-gray-400" block mb={1}>Development Mode - Magic Link:</Text>
<Surface variant="muted" rounded="md" border padding={2} style={{ backgroundColor: '#262626' }}>
<Text size="xs" color="text-primary-blue" style={{ wordBreak: 'break-all' }}>{viewData.magicLink}</Text>
</Surface>
<Text size="xs" color="text-gray-500" block mt={1}>
In production, this would be sent via email
</Text>
</Box>
)}
</Box>
</Stack>
</Surface>
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
className="w-full"
fullWidth
>
Return to Login
</Button>
</motion.div>
</Stack>
)}
</Card>
{/* Trust Indicators */}
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Secure reset process</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
<span>15 minute expiration</span>
</div>
</div>
<Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure reset process</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CheckCircle2} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">15 minute expiration</Text>
</Stack>
</Stack>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
Need help?{' '}
<Link href="/support" className="text-gray-400 hover:underline">
Contact support
</Link>
</p>
</div>
</main>
<Box mt={6} style={{ textAlign: 'center' }}>
<Text size="xs" color="text-gray-500">
Need help?{' '}
<Link href="/support">
<Text color="text-gray-400">Contact support</Text>
</Link>
</Text>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite flex">
<Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
{/* Left Side - Info Panel (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12">
<div className="max-w-lg">
<Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<Box style={{ maxWidth: '32rem' }}>
{/* Logo */}
<div className="flex items-center gap-3 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30">
<Flag className="w-6 h-6 text-primary-blue" />
</div>
<span className="text-2xl font-bold text-white">GridPilot</span>
</div>
<Stack direction="row" align="center" gap={3} mb={8}>
<Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Icon icon={Flag} size={6} color="#3b82f6" />
</Surface>
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
</Stack>
<Heading level={2} className="text-white mb-4">
Your Sim Racing Infrastructure
</Heading>
<Box mb={4}>
<Heading level={2}>
Your Sim Racing Infrastructure
</Heading>
</Box>
<p className="text-gray-400 text-lg mb-8">
<Text size="lg" color="text-gray-400" block mb={8}>
Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles.
</p>
</Text>
{/* Role Cards */}
<UserRolesPreview variant="full" />
{/* Workflow Mockup */}
<AuthWorkflowMockup />
<Box mt={8}>
<AuthWorkflowMockup />
</Box>
{/* Trust Indicators */}
<div className="mt-8 flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Secure login</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">iRacing verified</span>
</div>
</div>
</div>
</div>
<Stack direction="row" align="center" gap={6} mt={8}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure login</Text>
</Stack>
<Text size="sm" color="text-gray-500">iRacing verified</Text>
</Stack>
</Box>
</Box>
{/* Right Side - Login Form */}
<div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="relative w-full max-w-md">
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative' }}>
<Box style={{ width: '100%', maxWidth: '28rem' }}>
{/* Mobile Logo/Header */}
<div className="text-center mb-8 lg:hidden">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Welcome Back</Heading>
<p className="text-gray-400">
<Box className="lg:hidden" style={{ textAlign: 'center' }} mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Welcome Back</Heading>
<Text color="text-gray-400" block mt={2}>
Sign in to continue to GridPilot
</p>
</div>
</Text>
</Box>
{/* Desktop Header */}
<div className="hidden lg:block text-center mb-8">
<Heading level={2} className="mb-2">Welcome Back</Heading>
<p className="text-gray-400">
<Box className="hidden lg:block" style={{ textAlign: 'center' }} mb={8}>
<Heading level={2}>Welcome Back</Heading>
<Text color="text-gray-400" block mt={2}>
Sign in to access your racing dashboard
</p>
</div>
</Text>
</Box>
<Card className="relative overflow-hidden">
<Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value as string}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="pl-10"
autoComplete="email"
/>
</div>
</div>
<form onSubmit={formActions.handleSubmit}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value as string}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
style={{ paddingLeft: '2.5rem' }}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */}
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="password" className="block text-sm font-medium text-gray-300">
Password
</label>
<Link href="/auth/forgot-password" className="text-xs text-primary-blue hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="password"
name="password"
type={viewData.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value as string}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.password.error}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="pl-10 pr-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => formActions.setShowPassword(!viewData.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{viewData.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Password */}
<Box>
<Stack direction="row" align="center" justify="between" mb={2}>
<Text size="sm" weight="medium" color="text-gray-300">
Password
</Text>
<Link href="/auth/forgot-password">
<Text size="xs" color="text-primary-blue">Forgot password?</Text>
</Link>
</Stack>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="password"
name="password"
type={viewData.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value as string}
onChange={formActions.handleChange}
variant={viewData.formState.fields.password.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="current-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!viewData.showPassword)}
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Icon icon={viewData.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.password.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.password.error}
</Text>
)}
</Box>
{/* Remember Me */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
{/* Remember Me */}
<Stack direction="row" align="center" gap={2}>
<input
id="rememberMe"
name="rememberMe"
@@ -191,34 +199,25 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="w-4 h-4 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/>
<span className="text-sm text-gray-300">Keep me signed in</span>
</label>
</div>
<Text size="sm" color="text-gray-300">Keep me signed in</Text>
</Stack>
{/* Insufficient Permissions Message */}
<AnimatePresence>
{/* Insufficient Permissions Message */}
{viewData.hasInsufficientPermissions && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30"
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" />
<div className="text-sm text-gray-300">
<strong className="text-warning-amber">Insufficient Permissions</strong>
<p className="mt-1">
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#f59e0b" />
<Box>
<Text weight="bold" color="text-warning-amber" block>Insufficient Permissions</Text>
<Text size="sm" color="text-gray-300" block mt={1}>
You don't have permission to access that page. Please log in with an account that has the required role.
</p>
</div>
</div>
</motion.div>
</Text>
</Box>
</Stack>
</Surface>
)}
</AnimatePresence>
{/* Enhanced Error Display */}
<AnimatePresence>
{/* Enhanced Error Display */}
{viewData.submitError && (
<EnhancedFormError
error={new Error(viewData.submitError)}
@@ -228,73 +227,77 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
showDeveloperDetails={viewData.showErrorDetails}
/>
)}
</AnimatePresence>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="w-full flex items-center justify-center gap-2"
>
{mutationState.isPending || viewData.formState.isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Signing in...
</>
) : (
<>
<LogIn className="w-4 h-4" />
Sign In
</>
)}
</Button>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={viewData.formState.isSubmitting || mutationState.isPending}
fullWidth
icon={mutationState.isPending || viewData.formState.isSubmitting ? <LoadingSpinner size={4} color="white" /> : <Icon icon={LogIn} size={4} />}
>
{mutationState.isPending || viewData.formState.isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</Stack>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-charcoal-outline" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
</div>
</div>
<Box style={{ position: 'relative' }} my={6}>
<Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
<Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
</Box>
<Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
<Box px={4} style={{ backgroundColor: '#171717' }}>
<Text size="xs" color="text-gray-500">or continue with</Text>
</Box>
</Box>
</Box>
{/* Sign Up Link */}
<p className="mt-6 text-center text-sm text-gray-400">
Don't have an account?{''}
<Link
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
className="text-primary-blue hover:underline font-medium"
>
Create one
</Link>
</p>
<Box style={{ textAlign: 'center' }} mt={6}>
<Text size="sm" color="text-gray-400">
Don't have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
>
<Text color="text-primary-blue" weight="medium">Create one</Text>
</Link>
</Text>
</Box>
</Card>
{/* Name Immutability Notice */}
<div className="mt-6 p-4 rounded-lg bg-iron-gray/30 border border-charcoal-outline">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-gray-400">
<strong>Note:</strong> Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
</div>
</div>
</div>
<Box mt={6}>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#737373" />
<Text size="xs" color="text-gray-400">
<Text weight="bold">Note:</Text> Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
</Text>
</Stack>
</Surface>
</Box>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
By signing in, you agree to our{''}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
{''}and{''}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
</p>
<Box mt={6} style={{ textAlign: 'center' }}>
<Text size="xs" color="text-gray-500">
By signing in, you agree to our{' '}
<Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */}
<UserRolesPreview variant="compact" />
</div>
</div>
</main>
<Box mt={8} className="lg:hidden">
<UserRolesPreview variant="compact" />
</Box>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
<Box as="main" style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative w-full max-w-md">
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
<Box style={{ position: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
{/* Header */}
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Reset Password</Heading>
<p className="text-gray-400">
<Box style={{ textAlign: 'center' }} mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Reset Password</Heading>
<Text color="text-gray-400" block mt={2}>
Create a new secure password for your account
</p>
</div>
</Text>
</Box>
<Card className="relative overflow-hidden">
<Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
{!viewData.showSuccess ? (
<form onSubmit={formActions.handleSubmit} className="relative space-y-5">
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-300 mb-2">
New Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="newPassword"
name="newPassword"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.newPassword.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.newPassword.error}
errorMessage={viewData.formState.fields.newPassword.error}
placeholder="••••••••"
disabled={mutationState.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<form onSubmit={formActions.handleSubmit}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* New Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
New Password
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="newPassword"
name="newPassword"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.newPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.newPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.newPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.newPassword.error}
</Text>
)}
</Box>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.confirmPassword.error}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••"
disabled={mutationState.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Confirm Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.confirmPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.confirmPassword.error}
</Text>
)}
</Box>
{/* Error Message */}
{mutationState.error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{mutationState.error}</p>
</motion.div>
)}
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
className="w-full flex items-center justify-center gap-2"
>
{mutationState.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Resetting...
</>
) : (
<>
<Shield className="w-4 h-4" />
Reset Password
</>
{/* Error Message */}
{mutationState.error && (
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#ef4444" />
<Text size="sm" color="text-error-red">{mutationState.error}</Text>
</Stack>
</Surface>
)}
</Button>
{/* Back to Login */}
<div className="text-center">
<Link
href="/auth/login"
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1"
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
>
<ArrowLeft className="w-4 h-4" />
Back to Login
</Link>
</div>
{mutationState.isPending ? 'Resetting...' : 'Reset Password'}
</Button>
{/* Back to Login */}
<Box style={{ textAlign: 'center' }}>
<Link href="/auth/login">
<Stack direction="row" align="center" justify="center" gap={1}>
<Icon icon={ArrowLeft} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue">Back to Login</Text>
</Stack>
</Link>
</Box>
</Stack>
</form>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="relative space-y-4"
>
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30">
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
<p className="text-xs text-gray-400 mt-1">
Your password has been successfully reset
</p>
</div>
</div>
<Stack gap={4} style={{ position: 'relative' }}>
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={CheckCircle2} size={6} color="#10b981" />
<Box>
<Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>
Your password has been successfully reset
</Text>
</Box>
</Stack>
</Surface>
<Button
type="button"
variant="secondary"
onClick={() => window.location.href = '/auth/login'}
className="w-full"
fullWidth
>
Return to Login
</Button>
</motion.div>
</Stack>
)}
</Card>
{/* Trust Indicators */}
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Secure password reset</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
<span>Encrypted transmission</span>
</div>
</div>
<Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure password reset</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CheckCircle2} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Encrypted transmission</Text>
</Stack>
</Stack>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
Need help?{' '}
<Link href="/support" className="text-gray-400 hover:underline">
Contact support
</Link>
</p>
</div>
</main>
<Box mt={6} style={{ textAlign: 'center' }}>
<Text size="xs" color="text-gray-500">
Need help?{' '}
<Link href="/support">
<Text color="text-gray-400">Contact support</Text>
</Link>
</Text>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite flex">
<Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.05), transparent, rgba(147, 51, 234, 0.05))' }} />
{/* Left Side - Info Panel (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12">
<div className="max-w-lg">
<Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<Box style={{ maxWidth: '32rem' }}>
{/* Logo */}
<div className="flex items-center gap-3 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30">
<Flag className="w-6 h-6 text-primary-blue" />
</div>
<span className="text-2xl font-bold text-white">GridPilot</span>
</div>
<Stack direction="row" align="center" gap={3} mb={8}>
<Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Icon icon={Flag} size={6} color="#3b82f6" />
</Surface>
<Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
</Stack>
<Heading level={2} className="text-white mb-4">
Start Your Racing Journey
</Heading>
<Box mb={4}>
<Heading level={2}>Start Your Racing Journey</Heading>
</Box>
<p className="text-gray-400 text-lg mb-8">
<Text size="lg" color="text-gray-400" block mb={8}>
Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams.
</p>
</Text>
{/* Role Cards */}
<div className="space-y-3 mb-8">
{USER_ROLES.map((role, index) => (
<motion.div
<Stack gap={3} mb={8}>
{USER_ROLES.map((role) => (
<Surface
key={role.title}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline"
variant="muted"
rounded="xl"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
>
<div className={`w-10 h-10 rounded-lg bg-${role.color}/20 flex items-center justify-center`}>
<role.icon className={`w-5 h-5 text-${role.color}`} />
</div>
<div>
<h4 className="text-white font-medium">{role.title}</h4>
<p className="text-sm text-gray-500">{role.description}</p>
</div>
</motion.div>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
<Icon icon={role.icon} size={5} color={role.color} />
</Surface>
<Box>
<Text weight="medium" color="text-white" block>{role.title}</Text>
<Text size="sm" color="text-gray-500" block mt={1}>{role.description}</Text>
</Box>
</Stack>
</Surface>
))}
</div>
</Stack>
{/* Features List */}
<div className="bg-iron-gray/30 rounded-xl border border-charcoal-outline p-5 mb-8">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-medium text-white">What you'll get</span>
</div>
<ul className="space-y-2">
{FEATURES.map((feature, index) => (
<li
key={index}
className="flex items-center gap-2 text-sm text-gray-400"
>
<Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" />
{feature}
</li>
))}
</ul>
</div>
<Box mb={8}>
<Surface variant="muted" rounded="xl" border padding={5} style={{ backgroundColor: 'rgba(38, 38, 38, 0.2)', borderColor: '#262626' }}>
<Stack direction="row" align="center" gap={2} mb={4}>
<Icon icon={Sparkles} size={4} color="#3b82f6" />
<Text size="sm" weight="medium" color="text-white">What you&apos;ll get</Text>
</Stack>
<Stack gap={2}>
{FEATURES.map((feature, index) => (
<Stack key={index} direction="row" align="center" gap={2}>
<Icon icon={Check} size={3.5} color="#10b981" />
<Text size="sm" color="text-gray-400">{feature}</Text>
</Stack>
))}
</Stack>
</Surface>
</Box>
{/* Trust Indicators */}
<div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Secure signup</span>
</div>
<div className="flex items-center gap-2">
<span>iRacing integration</span>
</div>
</div>
</div>
</div>
<Stack direction="row" align="center" gap={6}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="#737373" />
<Text size="sm" color="text-gray-500">Secure signup</Text>
</Stack>
<Text size="sm" color="text-gray-500">iRacing integration</Text>
</Stack>
</Box>
</Box>
{/* Right Side - Signup Form */}
<div className="flex-1 flex items-center justify-center px-4 py-12 overflow-y-auto">
<div className="relative w-full max-w-md">
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative', overflowY: 'auto' }}>
<Box style={{ width: '100%', maxWidth: '28rem' }}>
{/* Mobile Logo/Header */}
<div className="text-center mb-8 lg:hidden">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<Flag className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Join GridPilot</Heading>
<p className="text-gray-400">
<Box className="lg:hidden" style={{ textAlign: 'center' }} mb={8}>
<Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Icon icon={Flag} size={8} color="#3b82f6" />
</Surface>
<Heading level={1}>Join GridPilot</Heading>
<Text color="text-gray-400" block mt={2}>
Create your account and start racing
</p>
</div>
</Text>
</Box>
{/* Desktop Header */}
<div className="hidden lg:block text-center mb-8">
<Heading level={2} className="mb-2">Create Account</Heading>
<p className="text-gray-400">
<Box className="hidden lg:block" style={{ textAlign: 'center' }} mb={8}>
<Heading level={2}>Create Account</Heading>
<Text color="text-gray-400" block mt={2}>
Get started with your free account
</p>
</div>
</Text>
</Box>
<Card className="relative overflow-hidden">
<Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */}
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.1), transparent)', borderBottomLeftRadius: '9999px' }} />
<form onSubmit={formActions.handleSubmit} className="relative space-y-4">
{/* First Name */}
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
First Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="firstName"
name="firstName"
type="text"
value={viewData.formState.fields.firstName.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.firstName.error}
errorMessage={viewData.formState.fields.firstName.error}
placeholder="John"
disabled={mutationState.isPending}
className="pl-10"
autoComplete="given-name"
/>
</div>
</div>
<form onSubmit={formActions.handleSubmit}>
<Stack gap={4} style={{ position: 'relative' }}>
{/* First Name */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
First Name
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input
id="firstName"
name="firstName"
type="text"
value={viewData.formState.fields.firstName.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.firstName.error ? 'error' : 'default'}
placeholder="John"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem' }}
autoComplete="given-name"
/>
</Box>
{viewData.formState.fields.firstName.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.firstName.error}
</Text>
)}
</Box>
{/* Last Name */}
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
Last Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="lastName"
name="lastName"
type="text"
value={viewData.formState.fields.lastName.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.lastName.error}
errorMessage={viewData.formState.fields.lastName.error}
placeholder="Smith"
disabled={mutationState.isPending}
className="pl-10"
autoComplete="family-name"
/>
</div>
<p className="mt-1 text-xs text-gray-500">Your name will be used as-is and cannot be changed later</p>
</div>
{/* Last Name */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Last Name
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input
id="lastName"
name="lastName"
type="text"
value={viewData.formState.fields.lastName.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.lastName.error ? 'error' : 'default'}
placeholder="Smith"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem' }}
autoComplete="family-name"
/>
</Box>
{viewData.formState.fields.lastName.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.lastName.error}
</Text>
)}
<Text size="xs" color="text-gray-500" block mt={1}>Your name will be used as-is and cannot be changed later</Text>
</Box>
{/* Name Immutability Warning */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<AlertCircle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" />
<div className="text-sm text-warning-amber">
<strong>Important:</strong> Your name cannot be changed after signup. Please ensure it's correct.
</div>
</div>
{/* Name Immutability Warning */}
<Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<Stack direction="row" align="start" gap={3}>
<Icon icon={AlertCircle} size={5} color="#f59e0b" />
<Text size="sm" color="text-warning-amber">
<Text weight="bold">Important:</Text> Your name cannot be changed after signup. Please ensure it's correct.
</Text>
</Stack>
</Surface>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com"
disabled={mutationState.isPending}
className="pl-10"
autoComplete="email"
/>
</div>
</div>
{/* Email */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input
id="email"
name="email"
type="email"
value={viewData.formState.fields.email.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.email.error ? 'error' : 'default'}
placeholder="you@example.com"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem' }}
autoComplete="email"
/>
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="password"
name="password"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.password.error}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••"
disabled={mutationState.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{/* Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Password
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="password"
name="password"
type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.password.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)}
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.password.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.password.error}
</Text>
)}
{/* Password Strength */}
{viewData.formState.fields.password.value && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<motion.div
className={`h-full ${passwordStrength.color}`}
initial={{ width: 0 }}
animate={{ width: `${(passwordStrength.score / 5) * 100}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className={`text-xs font-medium ${
passwordStrength.score <= 1 ? 'text-red-400' :
passwordStrength.score <= 2 ? 'text-warning-amber' :
passwordStrength.score <= 3 ? 'text-primary-blue' :
'text-performance-green'
}`}>
{passwordStrength.label}
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{passwordRequirements.map((req, index) => (
<div key={index} className="flex items-center gap-1.5 text-xs">
{req.met ? (
<Check className="w-3 h-3 text-performance-green" />
) : (
<X className="w-3 h-3 text-gray-500" />
)}
<span className={req.met ? 'text-gray-300' : 'text-gray-500'}>
{req.label}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Password Strength */}
{viewData.formState.fields.password.value && (
<Box mt={3}>
<Stack direction="row" align="center" gap={2} mb={2}>
<Box style={{ flex: 1, height: '0.375rem', borderRadius: '9999px', backgroundColor: '#262626', overflow: 'hidden' }}>
<Box style={{ height: '100%', width: `${(passwordStrength.score / 5) * 100}%`, backgroundColor: passwordStrength.color === 'bg-red-500' ? '#ef4444' : passwordStrength.color === 'bg-yellow-500' ? '#f59e0b' : passwordStrength.color === 'bg-blue-500' ? '#3b82f6' : '#10b981' }} />
</Box>
<Text size="xs" weight="medium" style={{ color: passwordStrength.color === 'bg-red-500' ? '#f87171' : passwordStrength.color === 'bg-yellow-500' ? '#fbbf24' : passwordStrength.color === 'bg-blue-500' ? '#60a5fa' : '#34d399' }}>
{passwordStrength.label}
</Text>
</Stack>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '0.25rem' }}>
{passwordRequirements.map((req, index) => (
<Stack key={index} direction="row" align="center" gap={1.5}>
<Icon icon={req.met ? Check : X} size={3} color={req.met ? '#10b981' : '#525252'} />
<Text size="xs" color={req.met ? 'text-gray-300' : 'text-gray-500'}>
{req.label}
</Text>
</Stack>
))}
</Box>
</Box>
)}
</Box>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
error={!!viewData.formState.fields.confirmPassword.error}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••"
disabled={mutationState.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
<button
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && (
<p className="mt-1 text-xs text-performance-green flex items-center gap-1">
<Check className="w-3 h-3" /> Passwords match
</p>
)}
</div>
{/* Confirm Password */}
<Box>
<Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password
</Text>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input
id="confirmPassword"
name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange}
variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
placeholder="••••••••"
disabled={mutationState.isPending}
style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password"
/>
<Box
as="button"
type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</Box>
</Box>
{viewData.formState.fields.confirmPassword.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.confirmPassword.error}
</Text>
)}
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && (
<Stack direction="row" align="center" gap={1} mt={1}>
<Icon icon={Check} size={3} color="#10b981" />
<Text size="xs" color="text-performance-green">Passwords match</Text>
</Stack>
)}
</Box>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
className="w-full flex items-center justify-center gap-2"
>
{mutationState.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating account...
</>
) : (
<>
<UserPlus className="w-4 h-4" />
Create Account
</>
)}
</Button>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={UserPlus} size={4} />}
>
{mutationState.isPending ? 'Creating account...' : 'Create Account'}
</Button>
</Stack>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-charcoal-outline" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span>
</div>
</div>
<Box style={{ position: 'relative' }} my={6}>
<Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
<Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
</Box>
<Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
<Box px={4} style={{ backgroundColor: '#171717' }}>
<Text size="xs" color="text-gray-500">or continue with</Text>
</Box>
</Box>
</Box>
{/* Login Link */}
<p className="mt-6 text-center text-sm text-gray-400">
Already have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
className="text-primary-blue hover:underline font-medium"
>
Sign in
</Link>
</p>
<Box style={{ textAlign: 'center' }} mt={6}>
<Text size="sm" color="text-gray-400">
Already have an account?{' '}
<Link
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
>
<Text color="text-primary-blue" weight="medium">Sign in</Text>
</Link>
</Text>
</Box>
</Card>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
By creating an account, you agree to our{' '}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link>
{' '}and{' '}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link>
</p>
<Box mt={6} style={{ textAlign: 'center' }}>
<Text size="xs" color="text-gray-500">
By creating an account, you agree to our{' '}
<Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */}
<div className="mt-8 lg:hidden">
<p className="text-center text-xs text-gray-500 mb-4">One account for all roles</p>
<div className="flex justify-center gap-6">
<Box mt={8} className="lg:hidden">
<Text size="xs" color="text-gray-500" block mb={4} style={{ textAlign: 'center' }}>One account for all roles</Text>
<Stack direction="row" align="center" justify="center" gap={6}>
{USER_ROLES.map((role) => (
<div key={role.title} className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-lg bg-${role.color}/20 flex items-center justify-center mb-1`}>
<role.icon className={`w-4 h-4 text-${role.color}`} />
</div>
<span className="text-xs text-gray-500">{role.title}</span>
</div>
<Stack key={role.title} align="center" gap={1}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
<Icon icon={role.icon} size={4} color={role.color} />
</Surface>
<Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack>
))}
</div>
</div>
</div>
</div>
</main>
</Stack>
</Box>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -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 (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', backgroundColor: '#0f1115' }}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import React from 'react';
import { ErrorBanner } from './ErrorBanner';
interface AuthErrorProps {
action: string;
}
export function AuthError({ action }: AuthErrorProps) {
return (
<ErrorBanner
message={`Failed to load ${action} page`}
title="Error"
variant="error"
/>
);
}

View File

@@ -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 (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#0f1115' }}>
<Stack align="center" gap={4}>
<LoadingSpinner size={12} />
<Text color="text-gray-400">{message}</Text>
</Stack>
</Box>
);
}

25
apps/website/ui/Badge.tsx Normal file
View File

@@ -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 <Box className={classes}>{children}</Box>;
}

93
apps/website/ui/Box.tsx Normal file
View File

@@ -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<T extends ElementType> {
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(<T extends ElementType = 'div'>(
{
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<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<any>
) => {
const Tag = (as as any) || 'div';
const spacingMap: Record<string | number, string> = {
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 (
<Tag ref={ref} className={classes} {...props} style={style}>
{children}
</Tag>
);
});
Box.displayName = 'Box';

View File

@@ -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<HTMLButtonElement> {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
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 ? (
<Stack direction="row" align="center" gap={2} center={fullWidth}>
{icon}
{children}
</Stack>
) : children;
if (as === 'a') {
return (
<a
href={href}
className={classes}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{content}
</a>
);
}
return (
<button
type={type}
className={classes}
onClick={onClick}
disabled={disabled}
{...props}
>
{children}
{content}
</button>
);
}
}

View File

@@ -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<HTMLDivElement> {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
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<number, string> = {
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 (
<div className={classes} onClick={onClick}>
<div className={classes} onClick={onClick} {...props}>
{children}
</div>
);
}
}

View File

@@ -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<HTMLElement> {
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<number, string> = {
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 (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -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<string, string> = {
'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 (
<span
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
onMouseEnter={() => setShowTooltipState(true)}
onMouseLeave={() => setShowTooltipState(false)}
title={showTooltip ? countryName : undefined}
>
<span className="select-none">{flag}</span>
{showTooltip && showTooltipState && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs font-medium text-white bg-deep-graphite border border-charcoal-outline rounded shadow-lg whitespace-nowrap z-50">
{countryName}
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-charcoal-outline"></span>
</span>
)}
</span>
);
}

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className="relative">
{/* Trigger Button */}
<button
type="button"
onClick={() => !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'}`}
>
<div className="flex items-center gap-3">
<Globe className="w-4 h-4 text-gray-500" />
{selectedCountry ? (
<span className="flex items-center gap-2">
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
<span>{selectedCountry.name}</span>
</span>
) : (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
{/* Search Input */}
<div className="p-2 border-b border-charcoal-outline">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
</div>
{/* Country List */}
<div className="overflow-y-auto max-h-60">
{filteredCountries.length > 0 ? (
filteredCountries.map((country) => (
<button
key={country.code}
type="button"
onClick={() => 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'
}`}
>
<span className="flex items-center gap-3">
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
<span>{country.name}</span>
</span>
{value === country.code && (
<Check className="w-4 h-4 text-primary-blue" />
)}
</button>
))
) : (
<div className="px-4 py-6 text-center text-gray-500 text-sm">
No countries found
</div>
)}
</div>
</div>
)}
{/* Error Message */}
{error && errorMessage && (
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
)}
</div>
);
}

View File

@@ -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 (
<Box
className={`${colorClasses[color]} ${sizeClasses[size]} ${positionClasses[position]} rounded-full pointer-events-none`}
style={opacityStyle}
/>
);
}

View File

@@ -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 (
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-300">
{label}
{required && <span className="text-warning-amber ml-1">*</span>}
</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
type="number"
value={value === '' ? '' : String(value)}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
min={1}
className="pr-16"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -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 (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
>
<Box style={{ flex: 1 }}>
{title && <Text weight="medium" style={{ color: colors.text }} block mb={1}>{title}</Text>}
<Text size="sm" style={{ color: colors.text, opacity: 0.9 }} block>{message}</Text>
</Box>
</Surface>
);
}

View File

@@ -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 (
<Stack gap={2}>
<label className="block text-sm font-medium text-gray-300">
<Stack direction="row" align="center" gap={2}>
{icon && <Icon icon={icon} size={4} color="#6b7280" />}
<Text size="sm" weight="medium" color="text-gray-300">{label}</Text>
{required && <Text color="text-error-red">*</Text>}
</Stack>
</label>
{children}
{error && (
<Text size="xs" color="text-error-red" block mt={1}>{error}</Text>
)}
{hint && !error && (
<Text size="xs" color="text-gray-500" block mt={1}>{hint}</Text>
)}
</Stack>
);
}

52
apps/website/ui/Grid.tsx Normal file
View File

@@ -0,0 +1,52 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
interface GridProps extends HTMLAttributes<HTMLElement> {
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<number, string> = {
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<number, string> = {
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 (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -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 (
<Box className={spanClasses}>
{children}
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Container from '@/components/ui/Container';
import Container from '@/ui/Container';
interface HeaderProps {
children: React.ReactNode;

View File

@@ -0,0 +1,34 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Stack } from './Stack';
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
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 ? (
<Stack direction="row" align="center" gap={2}>
{icon}
{children}
</Stack>
) : children;
return <Tag className={classes} style={style} {...props}>{content}</Tag>;
}

30
apps/website/ui/Hero.tsx Normal file
View File

@@ -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 (
<Box className={classes}>
<Box className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
<Box className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
<Box className="relative z-10">
{children}
</Box>
</Box>
);
}

37
apps/website/ui/Icon.tsx Normal file
View File

@@ -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<string | number, string> = {
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 (
<LucideIcon
className={`${sizeClass} ${className}`}
style={combinedStyle}
{...props}
/>
);
}

23
apps/website/ui/Image.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { ImgHTMLAttributes } from 'react';
interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
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
<img
src={src}
alt={alt}
width={width}
height={height}
className={className}
{...props}
/>
);
}

View File

@@ -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<BannerType, {
icon: LucideIcon;
bg: string;
border: string;
titleColor: string;
iconColor: string;
}> = {
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 (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: config.bg, borderColor: config.border }}
>
<Stack direction="row" align="start" gap={3}>
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
<Box style={{ flex: 1 }}>
{title && (
<Text weight="medium" color={config.titleColor as any} block mb={1}>{title}</Text>
)}
<Text size="sm" color="text-gray-400" block>{children}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -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 (
<Surface
variant="muted"
rounded="xl"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}>
<Icon icon={icon} size={5} color={colors.icon} />
</Surface>
<Box>
<Text weight="medium" style={{ color: colors.text }} block>{title}</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{description}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -1,16 +1,28 @@
import { forwardRef } from 'react';
import { Text } from './Text';
import { Box } from './Box';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
errorMessage?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ 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 <input ref={ref} className={classes} {...props} />;
return (
<Box fullWidth>
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-error-red" block mt={1}>
{errorMessage}
</Text>
)}
</Box>
);
}
);

View File

@@ -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<HTMLAnchorElement> {
href: string;
children: ReactNode;
className?: string;
variant?: 'primary' | 'secondary' | 'ghost';
target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => 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 (
<NextLink
<a
href={href}
className={classes}
target={target}
rel={rel}
onClick={onClick}
style={style}
{...props}
>
{children}
</NextLink>
</a>
);
}
}

View File

@@ -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 (
<div
className={`animate-spin ${className}`}
style={style}
role="status"
aria-label="Loading"
/>
);
}

View File

@@ -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 (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
right: '-8px',
bottom: '-8px',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
opacity: 0.5,
}}
/>
<div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
opacity: 0.7,
}}
/>
<div
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
>
{children}
</div>
</div>
);
}
// Desktop: render with animations
return (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<motion.div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
right: '-8px',
bottom: '-8px',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 0.5, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
<motion.div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 0.7, scale: 1 }}
transition={{ duration: 0.3, delay: 0.15 }}
/>
<motion.div
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
whileHover={
shouldReduceMotion
? {}
: {
scale: 1.02,
rotateY: 3,
rotateX: -2,
y: -12,
transition: {
type: 'spring',
stiffness: 200,
damping: 20,
},
}
}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<motion.div
className="absolute inset-0 pointer-events-none rounded-lg"
whileHover={
shouldReduceMotion
? {}
: {
boxShadow: '0 0 40px rgba(25, 140, 255, 0.4)',
transition: { duration: 0.2 },
}
}
/>
{children}
</motion.div>
</div>
);
}

185
apps/website/ui/Modal.tsx Normal file
View File

@@ -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<void>;
onSecondaryAction?: () => void;
onOpenChange?: (open: boolean) => void;
isOpen: boolean;
}
export function Modal({
title,
description,
children,
primaryActionLabel,
secondaryActionLabel,
onPrimaryAction,
onSecondaryAction,
onOpenChange,
isOpen,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<Element | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (event.target === event.currentTarget && onOpenChange) {
onOpenChange(false);
}
};
if (!isOpen) {
return null;
}
return (
<Box
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: '0 1rem' }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-description' : undefined}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
>
<Box
ref={dialogRef}
style={{ width: '100%', maxWidth: '28rem', borderRadius: '1rem', backgroundColor: '#0f1115', border: '1px solid #262626', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', outline: 'none' }}
tabIndex={-1}
>
<Box p={6} style={{ borderBottom: '1px solid rgba(38, 38, 38, 0.8)' }}>
<Heading level={2} id="modal-title">{title}</Heading>
{description && (
<Text
id="modal-description"
size="sm"
color="text-gray-400"
block
mt={2}
>
{description}
</Text>
)}
</Box>
<Box p={6}>
<Text size="sm" color="text-gray-100">{children}</Text>
</Box>
{(primaryActionLabel || secondaryActionLabel) && (
<Box p={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.8)', display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
{secondaryActionLabel && (
<Button
type="button"
onClick={() => {
onSecondaryAction?.();
onOpenChange?.(false);
}}
variant="secondary"
size="sm"
>
{secondaryActionLabel}
</Button>
)}
{primaryActionLabel && (
<Button
type="button"
onClick={async () => {
if (onPrimaryAction) {
await onPrimaryAction();
}
}}
variant="primary"
size="sm"
>
{primaryActionLabel}
</Button>
)}
</Box>
)}
</Box>
</Box>
);
}
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<HTMLElement>(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;
}

View File

@@ -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 (
<Box mb={8}>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" border padding={3} className={`bg-gradient-to-br ${iconGradient} ${iconBorder}`}>
<Icon icon={icon} size={7} color="#d1d5db" />
</Surface>
<Box>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1}>{description}</Text>
)}
</Box>
</Stack>
</Box>
{action && <Box>{action}</Box>}
</Stack>
</Box>
);
}

View File

@@ -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 (
<Box
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<Icon icon={User} size={6} color="#9ca3af" />
</Box>
);
}

View File

@@ -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<HTMLButtonElement | HTMLDivElement> = (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 = (
<div className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle && (
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
)}
</div>
<div className="flex flex-col items-end gap-1">
{primaryTag && (
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{primaryTag}
</span>
)}
{selected && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
Selected
</span>
)}
</div>
</div>
{description && (
<p className="text-xs text-gray-300">{description}</p>
)}
{children}
{stats && stats.length > 0 && (
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="space-y-0.5">
<dt className="font-medium text-gray-500">{stat.label}</dt>
<dd className="text-xs text-gray-200">{stat.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
if (isInteractive) {
return (
<button
type="button"
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
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}`}
>
<div className="p-4">
{content}
</div>
</button>
);
}
return (
<Card
className={commonClasses}
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
>
{content}
</Card>
);
}

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Text } from './Text';
interface QuickActionLinkProps {
href: string;

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
<div
ref={sliderRef}
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
{/* Track fill */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
style={{ width: `${rangePercent}%` }}
/>
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
bg-white border-2 border-primary-blue shadow-md
transition-transform duration-75
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
<span className="text-[10px] text-gray-500">{unitLabel}</span>
</div>
</div>
</div>
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">{label}</label>
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
</div>
{showLargeValue && (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
<span className="text-sm text-gray-400">{unitLabel}</span>
</div>
)}
{/* Custom slider */}
<div
ref={sliderRef}
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
{/* Track fill with gradient */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
style={{ width: `${rangePercent}%` }}
/>
{/* Tick marks */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className={`w-0.5 h-1 rounded-full transition-colors ${
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
}`}
/>
))}
</div>
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
transition-all duration-75
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
{/* Value input and quick presets */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
disabled={disabled}
className={`
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
bg-iron-gray border border-charcoal-outline text-white
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
transition-colors
${error ? 'border-warning-amber' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
/>
<span className="text-xs text-gray-400">{unitLabel}</span>
</div>
{quickPresets.length > 0 && (
<div className="flex gap-1">
{quickPresets.slice(0, 3).map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
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}
</button>
))}
</div>
)}
</div>
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
{error && <p className="text-xs text-warning-amber">{error}</p>}
</div>
);
}

View File

@@ -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 (
<section className={classes}>
{title && (
<h2 className="text-xl font-semibold text-white">{title}</h2>
)}
{description && (
<p className="text-sm text-gray-400">{description}</p>
)}
{children}
</section>
<Box as="section" id={id} className={classes} py={py as 0} px={4}>
<Box className="mx-auto max-w-7xl">
{(title || description) && (
<Box mb={8}>
{title && <Heading level={2}>{title}</Heading>}
{description && <Text color="text-gray-400" block mt={2}>{description}</Text>}
</Box>
)}
{children}
</Box>
</Box>
);
}
}

View File

@@ -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 (
<Box p={5} style={{ borderBottom: '1px solid #262626', background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)' }}>
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Heading level={2}>{title}</Heading>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
)}
</Box>
</Stack>
</Box>
{action && <Box>{action}</Box>}
</Stack>
</Box>
);
}

View File

@@ -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 (
<Box style={{ display: 'inline-flex', width: '100%', flexWrap: 'wrap', gap: '0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(38, 38, 38, 0.6)', padding: '0.25rem' }}>
{options.map((option) => {
const isSelected = option.value === value;
return (
<Box
key={option.value}
as="button"
type="button"
onClick={() => 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'
}}
>
<Stack gap={0.5}>
<Text size="xs" weight="medium" color="inherit">{option.label}</Text>
{option.description && (
<Text size="xs" color={isSelected ? 'text-white' : 'text-gray-400'} style={{ fontSize: '10px', opacity: isSelected ? 0.8 : 1 }}>
{option.description}
</Text>
)}
</Stack>
</Box>
);
})}
</Box>
);
}

View File

@@ -5,13 +5,14 @@ interface SelectOption {
label: string;
}
interface SelectProps {
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
id?: string;
'aria-label'?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => 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) => (
<option key={option.value} value={option.value}>
@@ -40,4 +45,4 @@ export function Select({
))}
</select>
);
}
}

View File

@@ -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 (
<div
className={`animate-pulse ${className}`}
style={style}
role="status"
aria-label="Loading..."
/>
);
}

View File

@@ -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 (
<Image
src={`/media/sponsors/${sponsorId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
}}
/>
);
}

95
apps/website/ui/Stack.tsx Normal file
View File

@@ -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<HTMLElement> {
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<number, string> = {
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<number, string> = {
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 <Box className={classes} {...props}>{children}</Box>;
}

View File

@@ -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 (
<Card className={`${variantClasses[variant]} ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">
<Text size="sm" color="text-gray-400" className="mb-1" block>
{label}
</Text>
<Text size="3xl" weight="bold" color="text-white">
<Text size="3xl" weight="bold" color="text-white" block>
{value}
</Text>
{subValue && (
<Text size="xs" color="text-gray-500" className="mt-1" block>
{subValue}
</Text>
)}
</div>
<div className="flex flex-col items-end gap-2">
{icon && (
<Icon icon={icon} size={8} color={iconColorClasses[variant]} />
)}
{trend && (
<Text size="sm" color={trend.isPositive ? 'text-performance-green' : 'text-error-red'}>
{trend.isPositive ? '↑' : '↓'}{Math.abs(trend.value)}%
</Text>
)}
</div>
{icon && (
<div className={iconColorClasses[variant]}>
{icon}
</div>
)}
</div>
</Card>
);

View File

@@ -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 (
<Text size="xs" className={classes}>
const content = icon ? (
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={icon} size={3} />
{children}
</Text>
</Stack>
) : children;
return (
<span className={classes}>
{content}
</span>
);
}

View File

@@ -0,0 +1,70 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
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<number, string> = {
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 (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
export function TabContent({ children, className = '' }: { children: React.ReactNode, className?: string }) {
return (
<div className={className}>
{children}
</div>
);
}

View File

@@ -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 (
<div className={`flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10 ${className}`}>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => 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 && <Icon className="w-4 h-4" />}
{tab.label}
</button>
);
})}
</div>
);
}

View File

@@ -1,88 +1,88 @@
import { ReactNode } from 'react';
import React, { ReactNode, HTMLAttributes } from 'react';
interface TableProps {
interface TableProps extends HTMLAttributes<HTMLTableElement> {
children: ReactNode;
className?: string;
}
export function Table({ children, className = '' }: TableProps) {
export function Table({ children, className = '', ...props }: TableProps) {
return (
<div className={`overflow-x-auto ${className}`}>
<table className="w-full">
<div style={{ overflowX: 'auto' }}>
<table className={`w-full ${className}`} {...props}>
{children}
</table>
</div>
);
}
interface TableHeadProps {
interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableHead({ children }: TableHeadProps) {
export function TableHead({ children, ...props }: TableHeadProps) {
return (
<thead>
<thead {...props}>
{children}
</thead>
);
}
interface TableBodyProps {
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableBody({ children }: TableBodyProps) {
export function TableBody({ children, ...props }: TableBodyProps) {
return (
<tbody>
<tbody {...props}>
{children}
</tbody>
);
}
interface TableRowProps {
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
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 (
<tr className={classes}>
<tr className={classes} {...props}>
{children}
</tr>
);
}
interface TableHeaderProps {
interface TableHeaderProps extends HTMLAttributes<HTMLTableCellElement> {
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 (
<th className={classes}>
<th className={classes} {...props}>
{children}
</th>
);
}
interface TableCellProps {
interface TableCellProps extends HTMLAttributes<HTMLTableCellElement> {
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 (
<td className={classes}>
<td className={classes} {...props}>
{children}
</td>
);
}
}

View File

@@ -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<HTMLSpanElement> {
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<number, string> = {
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 <span className={classes}>{children}</span>;
}
return <span className={classes} style={style} {...props}>{children}</span>;
}

View File

@@ -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 className={`flex items-start justify-between cursor-pointer py-3 border-b border-charcoal-outline/50 last:border-b-0 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<Box style={{ flex: 1, paddingRight: '1rem' }}>
<Text weight="medium" color="text-gray-200" block>{label}</Text>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
)}
</Box>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => !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 && (
<motion.div
className="absolute inset-0 rounded-full bg-primary-blue"
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
animate={{ boxShadow: '0 0 12px rgba(25, 140, 255, 0.4)' }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
/>
)}
{/* Knob */}
<motion.span
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
initial={false}
animate={{
x: checked ? 24 : 2,
scale: 1,
}}
whileTap={{ scale: disabled ? 1 : 0.9 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
duration: shouldReduceMotion ? 0 : undefined,
}}
/>
</button>
</label>
);
}

View File

@@ -1,5 +0,0 @@
export function OnboardingCardAccent() {
return (
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
);
}

View File

@@ -1,11 +0,0 @@
interface OnboardingContainerProps {
children: React.ReactNode;
}
export function OnboardingContainer({ children }: OnboardingContainerProps) {
return (
<div className="max-w-3xl mx-auto px-4 py-10">
{children}
</div>
);
}

View File

@@ -1,12 +0,0 @@
interface OnboardingErrorProps {
message: string;
}
export function OnboardingError({ message }: OnboardingErrorProps) {
return (
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<span className="text-red-400 flex-shrink-0 mt-0.5"></span>
<p className="text-sm text-red-400">{message}</p>
</div>
);
}

View File

@@ -1,12 +0,0 @@
interface OnboardingFormProps {
children: React.ReactNode;
onSubmit: (e: React.FormEvent) => void | Promise<void>;
}
export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) {
return (
<form onSubmit={onSubmit} className="relative">
{children}
</form>
);
}

View File

@@ -1,17 +0,0 @@
interface OnboardingHeaderProps {
title: string;
subtitle: string;
emoji: string;
}
export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) {
return (
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<span className="text-2xl">{emoji}</span>
</div>
<h1 className="text-4xl font-bold mb-2">{title}</h1>
<p className="text-gray-400">{subtitle}</p>
</div>
);
}

View File

@@ -1,7 +0,0 @@
export function OnboardingHelpText() {
return (
<p className="text-center text-xs text-gray-500 mt-6">
Your avatar will be AI-generated based on your photo and chosen suit color
</p>
);
}

View File

@@ -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 (
<div className="mt-8 flex items-center justify-between">
<Button
type="button"
variant="secondary"
onClick={onBack}
disabled={loading}
className="flex items-center gap-2"
>
<span></span>
Back
</Button>
{!isLastStep ? (
<Button
type="button"
variant="primary"
onClick={onNext}
disabled={loading}
className="flex items-center gap-2"
>
Continue
<span></span>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading || !canSubmit}
className="flex items-center gap-2"
>
{loading ? (
<>
<span className="animate-spin"></span>
Creating Profile...
</>
) : (
<>
<span></span>
Complete Setup
</>
)}
</Button>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<User className="w-5 h-5 text-primary-blue" />
Personal Information
</Heading>
<p className="text-sm text-gray-400">
Tell us a bit about yourself
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
First Name *
</label>
<Input
id="firstName"
type="text"
value={personalInfo.firstName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, firstName: e.target.value })
}
error={!!errors.firstName}
errorMessage={errors.firstName}
placeholder="John"
disabled={loading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
Last Name *
</label>
<Input
id="lastName"
type="text"
value={personalInfo.lastName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, lastName: e.target.value })
}
error={!!errors.lastName}
errorMessage={errors.lastName}
placeholder="Racer"
disabled={loading}
/>
</div>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-300 mb-2">
Display Name * <span className="text-gray-500 font-normal">(shown publicly)</span>
</label>
<Input
id="displayName"
type="text"
value={personalInfo.displayName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, displayName: e.target.value })
}
error={!!errors.displayName}
errorMessage={errors.displayName}
placeholder="SpeedyRacer42"
disabled={loading}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country *
</label>
<CountrySelect
value={personalInfo.country}
onChange={(value) =>
setPersonalInfo({ ...personalInfo, country: value })
}
error={!!errors.country}
errorMessage={errors.country ?? ''}
disabled={loading}
/>
</div>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-300 mb-2">
Timezone
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
<select
id="timezone"
value={personalInfo.timezone}
onChange={(e) =>
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}
>
<option value="">Select timezone</option>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 rotate-90" />
</div>
</div>
</div>
</div>
);
}