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

View File

@@ -1,29 +1,30 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card'; 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 { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Text } from '@/ui/Text'; 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 { import {
Search,
Filter,
RefreshCw, RefreshCw,
Users,
Shield, Shield,
Trash2, Trash2,
AlertTriangle Users
} from 'lucide-react'; } from 'lucide-react';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { UserFilters } from '@/components/admin/UserFilters';
import { UserStatsSummary } from '@/components/admin/UserStatsSummary';
import { Surface } from '@/ui/Surface';
/** interface AdminUsersTemplateProps {
* AdminUsersTemplate viewData: AdminUsersViewData;
*
* Pure template for admin users page.
* Accepts ViewData only, no business logic.
*/
export function AdminUsersTemplate(props: {
adminUsersViewData: AdminUsersViewData;
onRefresh: () => void; onRefresh: () => void;
onSearch: (search: string) => void; onSearch: (search: string) => void;
onFilterRole: (role: string) => void; onFilterRole: (role: string) => void;
@@ -37,309 +38,216 @@ export function AdminUsersTemplate(props: {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
deletingUser: string | null; deletingUser: string | null;
}) { }
const {
adminUsersViewData: viewData,
onRefresh,
onSearch,
onFilterRole,
onFilterStatus,
onClearFilters,
onUpdateStatus,
onDeleteUser,
search,
roleFilter,
statusFilter,
loading,
error,
deletingUser
} = props;
const toStatusBadgeProps = ( export function AdminUsersTemplate({
status: string, viewData,
): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => { onRefresh,
onSearch,
onFilterRole,
onFilterStatus,
onClearFilters,
onUpdateStatus,
onDeleteUser,
search,
roleFilter,
statusFilter,
loading,
error,
deletingUser
}: AdminUsersTemplateProps) {
const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => {
switch (status) { switch (status) {
case 'active': case 'active': return 'success';
return { status: 'success', label: 'Active' }; case 'suspended': return 'warning';
case 'suspended': case 'deleted': return 'error';
return { status: 'warning', label: 'Suspended' }; default: return 'info';
case 'deleted':
return { status: 'error', label: 'Deleted' };
default:
return { status: 'neutral', label: status };
} }
}; };
const getRoleBadgeClass = (role: string) => { const getRoleBadgeStyle = (role: string) => {
switch (role) { switch (role) {
case 'owner': case 'owner': return { backgroundColor: 'rgba(168, 85, 247, 0.2)', color: '#d8b4fe', border: '1px solid rgba(168, 85, 247, 0.3)' };
return 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; case 'admin': return { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#93c5fd', border: '1px solid rgba(59, 130, 246, 0.3)' };
case 'admin': default: return { backgroundColor: 'rgba(115, 115, 115, 0.2)', color: '#d1d5db', border: '1px solid rgba(115, 115, 115, 0.3)' };
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;
} }
}; };
return ( return (
<div className="container mx-auto p-6 space-y-6"> <Container size="lg" py={6}>
{/* Header */} <Stack gap={6}>
<div className="flex items-center justify-between"> {/* Header */}
<div> <Stack direction="row" align="center" justify="between">
<Text size="2xl" weight="bold" color="text-white">User Management</Text> <Box>
<Text size="sm" color="text-gray-400" className="mt-1">Manage and monitor all system users</Text> <Heading level={1}>User Management</Heading>
</div> <Text size="sm" color="text-gray-400" block mt={1}>Manage and monitor all system users</Text>
<Button </Box>
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>
<Button <Button
onClick={() => {}} onClick={onRefresh}
disabled={loading}
variant="secondary" variant="secondary"
className="text-racing-red hover:opacity-70 p-0" icon={<Icon icon={RefreshCw} size={4} className={loading ? 'animate-spin' : ''} />}
> >
× Refresh
</Button> </Button>
</div> </Stack>
)}
{/* Filters Card */} {/* Error Banner */}
<Card> {error && (
<div className="space-y-4"> <InfoBox
<div className="flex items-center justify-between"> icon={Users}
<div className="flex items-center gap-2"> title="Error"
<Filter className="w-4 h-4 text-gray-400" /> description={error}
<Text weight="medium" color="text-white">Filters</Text> variant="warning"
</div> />
{(search || roleFilter || statusFilter) && ( )}
{/* 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 <Button
onClick={onClearFilters} onClick={onClearFilters}
variant="secondary" variant="ghost"
className="text-xs p-0" size="sm"
> >
Clear all Clear filters
</Button> </Button>
)} </Stack>
</div> ) : (
<Table>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <TableHead>
<div className="relative"> <TableRow>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <TableHeader>User</TableHeader>
<Input <TableHeader>Email</TableHeader>
type="text" <TableHeader>Roles</TableHeader>
placeholder="Search by email or name..." <TableHeader>Status</TableHeader>
value={search} <TableHeader>Last Login</TableHeader>
onChange={(e) => onSearch(e.target.value)} <TableHeader>Actions</TableHeader>
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>
</TableRow> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {viewData.users.map((user) => (
)} <TableRow key={user.id}>
</Card> <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 */} {/* Stats Summary */}
{viewData.users.length > 0 && ( {viewData.users.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <UserStatsSummary
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10"> total={viewData.total}
<div className="flex items-center justify-between"> activeCount={viewData.activeUserCount}
<div> adminCount={viewData.adminCount}
<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> </Stack>
<Users className="w-6 h-6 text-blue-400" /> </Container>
</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>
); );
} }

View File

@@ -1,18 +1,18 @@
'use client';
import React from 'react';
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData'; import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
import { import { Box } from '@/ui/Box';
Trophy, import { Container } from '@/ui/Container';
Medal, import { Grid } from '@/ui/Grid';
Target, import { GridItem } from '@/ui/GridItem';
Users, import { DashboardHero } from '@/components/dashboard/DashboardHero';
ChevronRight, import { NextRaceCard } from '@/components/dashboard/NextRaceCard';
Calendar, import { ChampionshipStandings } from '@/components/dashboard/ChampionshipStandings';
Clock, import { ActivityFeed } from '@/components/dashboard/ActivityFeed';
Activity, import { UpcomingRaces } from '@/components/dashboard/UpcomingRaces';
Award, import { FriendsSidebar } from '@/components/dashboard/FriendsSidebar';
UserPlus, import { Stack } from '@/ui/Stack';
Flag,
User,
} from 'lucide-react';
interface DashboardTemplateProps { interface DashboardTemplateProps {
viewData: DashboardViewData; viewData: DashboardViewData;
@@ -27,7 +27,6 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) {
feedItems, feedItems,
friends, friends,
activeLeaguesCount, activeLeaguesCount,
friendCount,
hasUpcomingRaces, hasUpcomingRaces,
hasLeagueStandings, hasLeagueStandings,
hasFeedItems, hasFeedItems,
@@ -35,312 +34,32 @@ export function DashboardTemplate({ viewData }: DashboardTemplateProps) {
} = viewData; } = viewData;
return ( return (
<main className="min-h-screen bg-deep-graphite"> <Box as="main">
{/* Hero Section */} <DashboardHero
<section className="relative overflow-hidden"> currentDriver={currentDriver}
{/* Background Pattern */} activeLeaguesCount={activeLeaguesCount}
<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>
<div className="relative max-w-7xl mx-auto px-6 py-10"> <Container size="lg" py={8}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8"> <Grid cols={12} gap={6}>
{/* 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">
{/* Left Column - Main Content */} {/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6"> <GridItem colSpan={12} lgSpan={8}>
{/* Next Race Card */} <Stack gap={6}>
{nextRace && ( {nextRace && <NextRaceCard nextRace={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"> {hasLeagueStandings && <ChampionshipStandings standings={leagueStandings} />}
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" /> <ActivityFeed items={feedItems} hasItems={hasFeedItems} />
<div className="relative"> </Stack>
<div className="flex items-center gap-2 mb-4"> </GridItem>
<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>
{/* Right Column - Sidebar */} {/* Right Column - Sidebar */}
<div className="space-y-6"> <GridItem colSpan={12} lgSpan={4}>
{/* Upcoming Races */} <Stack gap={6}>
<div className="bg-iron-gray/30 border border-charcoal-outline rounded-xl p-6"> <UpcomingRaces races={upcomingRaces} hasRaces={hasUpcomingRaces} />
<div className="flex items-center justify-between mb-4"> <FriendsSidebar friends={friends} hasFriends={hasFriends} />
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> </Stack>
<span>Calendar</span> </GridItem>
Upcoming Races </Grid>
</h3> </Container>
<a href="/races" className="text-xs text-primary-blue hover:underline"> </Box>
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>
); );
} }

View File

@@ -1,816 +1,190 @@
'use client'; 'use client';
import { useState } from 'react'; import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { import {
User,
Trophy,
Star,
Calendar,
Users,
Flag,
Award,
TrendingUp,
UserPlus,
ExternalLink,
Target,
Zap,
Clock,
Medal,
Crown,
ChevronRight,
Globe,
Twitter,
Youtube,
Twitch,
MessageCircle,
ArrowLeft, ArrowLeft,
BarChart3,
Shield,
Percent,
Activity,
} from 'lucide-react'; } from 'lucide-react';
import Button from '@/components/ui/Button'; import { Box } from '@/ui/Box';
import Card from '@/components/ui/Card'; 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 Breadcrumbs from '@/components/layout/Breadcrumbs';
import { CircularProgress } from '@/components/drivers/CircularProgress'; import { ProfileHero } from '@/components/profile/ProfileHero';
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart'; import { ProfileBio } from '@/components/profile/ProfileBio';
import { mediaConfig } from '@/lib/config/mediaConfig'; import { TeamMembershipGrid } from '@/components/profile/TeamMembershipGrid';
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; 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'; 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 { interface DriverProfileTemplateProps {
driverProfile: DriverProfileViewModel; viewData: DriverProfileViewData;
allTeamMemberships: TeamMembershipInfo[];
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
onBackClick: () => void; onBackClick: () => void;
onAddFriend: () => void; onAddFriend: () => void;
friendRequestSent: boolean; friendRequestSent: boolean;
activeTab: ProfileTab; activeTab: ProfileTab;
setActiveTab: (tab: ProfileTab) => void; onTabChange: (tab: ProfileTab) => void;
isSponsorMode?: boolean; isSponsorMode?: boolean;
sponsorInsights?: React.ReactNode; 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({ export function DriverProfileTemplate({
driverProfile, viewData,
allTeamMemberships,
isLoading = false, isLoading = false,
error = null, error = null,
onBackClick, onBackClick,
onAddFriend, onAddFriend,
friendRequestSent, friendRequestSent,
activeTab, activeTab,
setActiveTab, onTabChange,
isSponsorMode = false, isSponsorMode = false,
sponsorInsights = null, sponsorInsights = null,
}: DriverProfileTemplateProps) { }: DriverProfileTemplateProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-6xl mx-auto px-4"> <Container size="lg" py={12}>
<div className="flex items-center justify-center min-h-[400px]"> <Stack align="center" justify="center" gap={4}>
<div className="flex flex-col items-center gap-4"> <LoadingSpinner size={10} />
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" /> <Text color="text-gray-400">Loading driver profile...</Text>
<p className="text-gray-400">Loading driver profile...</p> </Stack>
</div> </Container>
</div>
</div>
); );
} }
if (error || !driverProfile?.currentDriver) { if (error || !viewData?.currentDriver) {
return ( return (
<div className="max-w-4xl mx-auto px-4"> <Container size="md" py={12}>
<Card className="text-center py-12"> <Stack align="center" gap={6}>
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" /> <Text color="text-warning-amber">{error || 'Driver not found'}</Text>
<div className="text-warning-amber mb-4">{error || 'Driver not found'}</div>
<Button variant="secondary" onClick={onBackClick}> <Button variant="secondary" onClick={onBackClick}>
Back to Drivers Back to Drivers
</Button> </Button>
</Card> </Stack>
</div> </Container>
); );
} }
const extendedProfile: DriverExtendedProfile = driverProfile.extendedProfile ? { const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData;
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;
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-12 space-y-6"> <Container size="lg" py={8}>
{/* Back Navigation */} <Stack gap={6}>
<Button {/* Back Navigation */}
variant="secondary" <Box>
onClick={onBackClick} <Button
className="flex items-center gap-2 mb-4" variant="secondary"
> onClick={onBackClick}
<ArrowLeft className="w-4 h-4" /> icon={<ArrowLeft size={4} />}
Back to Drivers >
</Button> Back to Drivers
</Button>
</Box>
{/* Breadcrumb */} {/* Breadcrumb */}
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: 'Home', href: '/' }, { label: 'Home', href: '/' },
{ label: 'Drivers', href: '/drivers' }, { label: 'Drivers', href: '/drivers' },
{ label: driver.name }, { label: currentDriver.name },
]} ]}
/> />
{/* Sponsor Insights Card */} {/* Sponsor Insights Card */}
{isSponsorMode && sponsorInsights} {isSponsorMode && sponsorInsights}
{/* Hero Header Section */} {/* 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"> <ProfileHero
{/* Background Pattern */} driver={{
<div className="absolute inset-0 opacity-5"> ...currentDriver,
<div iracingId: currentDriver.iracingId || 0,
className="absolute inset-0" }}
style={{ stats={stats ? { rating: stats.rating || 0 } : null}
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")`, 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)
}))}
/>
)}
{/* 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
}} }}
/> />
</div> )}
<div className="relative p-6 md:p-8"> {/* Tab Navigation */}
<div className="flex flex-col md:flex-row md:items-start gap-6"> <ProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
{/* 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>
{/* Driver Info */} {/* Tab Content */}
<div className="flex-1 min-w-0"> {activeTab === 'overview' && (
<div className="flex flex-wrap items-center gap-3 mb-2"> <Stack gap={6}>
<h1 className="text-3xl md:text-4xl font-bold text-white">{driver.name}</h1> <CareerStats stats={stats || { totalRaces: 0, wins: 0, podiums: 0, consistency: 0 }} />
<span className="text-4xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
{/* Rating and Rank */} {extendedProfile && (
<div className="flex flex-wrap items-center gap-4 mb-4"> <RacingProfile
{stats && ( racingStyle={extendedProfile.racingStyle}
<> favoriteTrack={extendedProfile.favoriteTrack}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30"> favoriteCar={extendedProfile.favoriteCar}
<Star className="w-4 h-4 text-primary-blue" /> availableHours={extendedProfile.availableHours}
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span> lookingForTeam={extendedProfile.lookingForTeam}
<span className="text-xs text-gray-400">Rating</span> openToRequests={extendedProfile.openToRequests}
</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}
/> />
)}
<div className="mt-6 grid grid-cols-2 gap-4"> {extendedProfile && extendedProfile.achievements.length > 0 && (
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline"> <AchievementGrid
<div className="flex items-center gap-2 mb-2"> achievements={extendedProfile.achievements.map((a) => ({
<TrendingUp className="w-4 h-4 text-performance-green" /> ...a,
<span className="text-xs text-gray-500 uppercase">Best Finish</span> earnedAt: new Date(a.earnedAt)
</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>
)}
{/* Tab Navigation */} {socialSummary.friends.length > 0 && (
<div className="flex items-center gap-1 p-1 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit"> <FriendsPreview friends={socialSummary.friends} />
<button )}
type="button" </Stack>
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>
{/* Tab Content */} {activeTab === 'stats' && !stats && (
{activeTab === 'overview' && ( <Stack align="center" py={12} gap={4}>
<> <Text color="text-gray-400">No statistics available yet</Text>
{/* Stats and Profile Grid */} <Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> </Stack>
{/* Career Stats */} )}
<Card className="lg:col-span-2"> </Stack>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> </Container>
<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>
); );
} }

View File

@@ -1,15 +1,18 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Trophy, ArrowLeft, Medal } from 'lucide-react'; import { Trophy, ArrowLeft } from 'lucide-react';
import Button from '@/components/ui/Button'; import { Button } from '@/ui/Button';
import Heading from '@/components/ui/Heading'; import { Heading } from '@/ui/Heading';
import Image from 'next/image'; 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'; import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
import { RankingsPodium } from '@/components/drivers/RankingsPodium';
// ============================================================================ import { RankingsTable } from '@/components/drivers/RankingsTable';
// TYPES
// ============================================================================
interface DriverRankingsTemplateProps { interface DriverRankingsTemplateProps {
viewData: DriverRankingsViewData; viewData: DriverRankingsViewData;
@@ -17,209 +20,62 @@ interface DriverRankingsTemplateProps {
onBackToLeaderboards?: () => void; onBackToLeaderboards?: () => void;
} }
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function DriverRankingsTemplate({ export function DriverRankingsTemplate({
viewData, viewData,
onDriverClick, onDriverClick,
onBackToLeaderboards, onBackToLeaderboards,
}: DriverRankingsTemplateProps): React.ReactElement { }: DriverRankingsTemplateProps): React.ReactElement {
return ( return (
<div className="max-w-7xl mx-auto px-4 pb-12"> <Container size="lg" py={8}>
{/* Header */} <Stack gap={8}>
<div className="mb-8"> {/* Header */}
{onBackToLeaderboards && ( <Box>
<Button {onBackToLeaderboards && (
variant="secondary" <Box mb={6}>
onClick={onBackToLeaderboards} <Button
className="flex items-center gap-2 mb-6" variant="secondary"
> onClick={onBackToLeaderboards}
<ArrowLeft className="w-4 h-4" /> icon={<Icon icon={ArrowLeft} size={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"
> >
{/* Position */} Back to Leaderboards
<div className="col-span-1 flex items-center justify-center"> </Button>
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}> </Box>
{position <= 3 ? <Medal className="w-4 h-4" /> : position} )}
</div>
</div>
{/* Driver Info */} <Stack direction="row" align="center" gap={4}>
<div className="col-span-5 lg:col-span-4 flex items-center gap-3"> <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)' }}>
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline"> <Icon icon={Trophy} size={7} color="#3b82f6" />
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" /> </Surface>
</div> <Box>
<div className="min-w-0"> <Heading level={1}>Driver Leaderboard</Heading>
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors"> <Text color="text-gray-400" block mt={1}>Full rankings of all drivers by performance metrics</Text>
{driver.name} </Box>
</p> </Stack>
<div className="flex items-center gap-2 text-xs text-gray-500"> </Box>
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Races */} {/* Top 3 Podium */}
<div className="col-span-2 items-center justify-center hidden md:flex"> {viewData.podium.length > 0 && (
<span className="text-gray-400">{driver.racesCompleted}</span> <RankingsPodium
</div> podium={viewData.podium.map(d => ({
...d,
{/* Rating */} rating: Number(d.rating),
<div className="col-span-2 lg:col-span-1 flex items-center justify-center"> wins: Number(d.wins),
<span className="font-mono font-semibold text-white"> podiums: Number(d.podiums)
{driver.rating.toString()} }))}
</span> onDriverClick={onDriverClick}
</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>
)} )}
</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'; 'use client';
import { useRouter } from 'next/navigation'; import React from 'react';
import { import {
Trophy,
Users,
Search, Search,
Crown, Crown,
} from 'lucide-react'; } from 'lucide-react';
import Button from '@/components/ui/Button'; import { Heading } from '@/ui/Heading';
import Input from '@/components/ui/Input'; import { Box } from '@/ui/Box';
import Card from '@/components/ui/Card'; import { Stack } from '@/ui/Stack';
import Heading from '@/components/ui/Heading'; 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 { FeaturedDriverCard } from '@/components/drivers/FeaturedDriverCard';
import { SkillDistribution } from '@/components/drivers/SkillDistribution'; import { SkillDistribution } from '@/components/drivers/SkillDistribution';
import { CategoryDistribution } from '@/components/drivers/CategoryDistribution'; import { CategoryDistribution } from '@/components/drivers/CategoryDistribution';
import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview'; import { LeaderboardPreview } from '@/components/drivers/LeaderboardPreview';
import { RecentActivity } from '@/components/drivers/RecentActivity'; import { RecentActivity } from '@/components/drivers/RecentActivity';
import { useDriverSearch } from '@/lib/hooks/useDriverSearch'; import { DriversHero } from '@/components/drivers/DriversHero';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; import { DriversSearch } from '@/components/drivers/DriversSearch';
import { EmptyState } from '@/components/shared/state/EmptyState';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
interface DriversTemplateProps { 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) { export function DriversTemplate({
const drivers = data?.drivers || []; viewData,
const totalRaces = data?.totalRaces || 0; searchQuery,
const totalWins = data?.totalWins || 0; onSearchChange,
const activeCount = data?.activeCount || 0; filteredDrivers,
const isLoading = false; onDriverClick,
onViewLeaderboard
const router = useRouter(); }: DriversTemplateProps) {
const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers); const drivers = viewData?.drivers || [];
const totalRaces = viewData?.totalRaces || 0;
const handleDriverClick = (driverId: string) => { const totalWins = viewData?.totalWins || 0;
router.push(`/drivers/${driverId}`); const activeCount = viewData?.activeCount || 0;
};
// Featured drivers (top 4) // Featured drivers (top 4)
const featuredDrivers = filteredDrivers.slice(0, 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 ( return (
<div className="max-w-7xl mx-auto px-4 pb-12"> <Container size="lg" py={8}>
{/* Hero Section */} <Stack gap={10}>
<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"> {/* Hero Section */}
{/* Background decoration */} <DriversHero
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/10 rounded-full blur-3xl" /> driverCount={drivers.length}
<div className="absolute bottom-0 left-0 w-64 h-64 bg-yellow-400/5 rounded-full blur-3xl" /> activeCount={activeCount}
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-performance-green/5 rounded-full blur-2xl" /> 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"> {/* Search */}
<div className="max-w-2xl"> <DriversSearch query={searchQuery} onChange={onSearchChange} />
<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>
{/* Quick Stats */} {/* Featured Drivers */}
<div className="flex flex-wrap gap-6"> {!searchQuery && (
<div className="flex items-center gap-2"> <Box>
<div className="w-2 h-2 rounded-full bg-primary-blue" /> <Stack direction="row" align="center" gap={3} mb={4}>
<span className="text-sm text-gray-400"> <Surface variant="muted" rounded="xl" padding={2}>
<span className="text-white font-semibold">{drivers.length}</span> drivers <Icon icon={Crown} size={6} color="#f59e0b" />
</span> </Surface>
</div> <Box>
<div className="flex items-center gap-2"> <Heading level={2}>Featured Drivers</Heading>
<div className="w-2 h-2 rounded-full bg-performance-green animate-pulse" /> <Text size="xs" color="text-gray-500">Top performers on the grid</Text>
<span className="text-sm text-gray-400"> </Box>
<span className="text-white font-semibold">{activeCount}</span> active </Stack>
</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>
{/* CTA */} <Grid cols={4} gap={4}>
<div className="flex flex-col gap-4"> {featuredDrivers.map((driver, index) => (
<Button <GridItem key={driver.id} colSpan={12} mdSpan={6} lgSpan={3}>
variant="primary" <FeaturedDriverCard
onClick={() => router.push('/leaderboards/drivers')} driver={driver}
className="flex items-center gap-2 px-6 py-3" position={index + 1}
> onClick={() => onDriverClick(driver.id)}
<Trophy className="w-5 h-5" /> />
View Leaderboard </GridItem>
</Button> ))}
<p className="text-xs text-gray-500 text-center">See full driver rankings</p> </Grid>
</div> </Box>
</div> )}
</div>
{/* Search */} {/* Active Drivers */}
<div className="mb-8"> {!searchQuery && <RecentActivity drivers={drivers} onDriverClick={onDriverClick} />}
<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" /> {/* Skill Distribution */}
<Input {!searchQuery && <SkillDistribution drivers={drivers} />}
type="text"
placeholder="Search drivers by name or nationality..." {/* Category Distribution */}
value={searchQuery} {!searchQuery && <CategoryDistribution drivers={drivers} />}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-11" {/* 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> </Stack>
</Container>
{/* 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>
); );
} }

View File

@@ -1,4 +1,6 @@
import Image from 'next/image'; 'use client';
import React from 'react';
import Hero from '@/components/landing/Hero'; import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection'; import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid'; import FeatureGrid from '@/components/landing/FeatureGrid';
@@ -9,25 +11,50 @@ import CareerProgressionMockup from '@/components/mockups/CareerProgressionMocku
import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup'; import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup'; import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup'; import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack'; import MockupStack from '@/ui/MockupStack';
import Card from '@/components/ui/Card'; import { Card } from '@/ui/Card';
import Button from '@/components/ui/Button'; 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 { 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; isAlpha: boolean;
upcomingRaces: any[]; upcomingRaces: Array<{
topLeagues: any[]; id: string;
teams: any[]; 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 { interface HomeTemplateProps {
data: HomeTemplateData; viewData: HomeViewData;
} }
export default function HomeTemplate({ data }: HomeTemplateProps) { export function HomeTemplate({ viewData }: HomeTemplateProps) {
return ( return (
<main className="min-h-screen"> <Box as="main">
<Hero /> <Hero />
{/* Section 1: A Persistent Identity */} {/* Section 1: A Persistent Identity */}
@@ -35,55 +62,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
heading="A Persistent Identity" heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4" backgroundVideo="/gameplay.mp4"
description={ description={
<> <Stack gap={4}>
<p> <Text>
Your races, your seasons, your progress finally in one place. Your races, your seasons, your progress finally in one place.
</p> </Text>
<div className="space-y-3 mt-4"> <Stack gap={3}>
<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)]"> <FeatureItem text="Lifetime stats and season history across all your leagues" />
<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" /> <FeatureItem text="Track your performance, consistency, and team contributions" />
<div className="flex items-start gap-3"> <FeatureItem text="Your own rating that reflects real league competition" />
<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"> </Stack>
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Text>
<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">
iRacing gives you physics. GridPilot gives you a career. iRacing gives you physics. GridPilot gives you a career.
</p> </Text>
</> </Stack>
} }
mockup={<CareerProgressionMockup />} mockup={<CareerProgressionMockup />}
layout="text-left" layout="text-left"
@@ -96,55 +87,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
heading="Results That Actually Stay" heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg" backgroundImage="/images/ff1600.jpeg"
description={ description={
<> <Stack gap={4}>
<p className="text-sm md:text-base leading-relaxed"> <Text size="sm">
Every race you run stays with you. Every race you run stays with you.
</p> </Text>
<div className="space-y-3 mt-4 md:mt-6"> <Stack gap={3}>
<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)]"> <ResultItem text="Your stats, your team, your story — all connected" color="#ef4444" />
<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" /> <ResultItem text="One race result updates your profile, team points, rating, and season history" color="#ef4444" />
<div className="flex items-start gap-2.5 md:gap-3"> <ResultItem text="No more fragmented data across spreadsheets and forums" color="#ef4444" />
<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"> </Stack>
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Text size="sm">
<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">
Your racing career, finally in one place. Your racing career, finally in one place.
</p> </Text>
</> </Stack>
} }
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>} mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right" layout="text-right"
@@ -154,49 +109,19 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
<AlternatingSection <AlternatingSection
heading="Automatic Session Creation" heading="Automatic Session Creation"
description={ description={
<> <Stack gap={4}>
<p className="text-sm md:text-base leading-relaxed"> <Text size="sm">
Setting up league races used to mean clicking through iRacing's wizard 20 times. Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p> </Text>
<div className="space-y-3 mt-4 md:mt-6"> <Stack gap={3}>
<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)]"> <StepItem step={1} text="Our companion app syncs with your league schedule" />
<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" /> <StepItem step={2} text="When it's race time, it creates the iRacing session automatically" />
<div className="flex items-start gap-2.5 md:gap-3 relative"> <StepItem step={3} text="No clicking through wizards. No manual setup" />
<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"> </Stack>
<span className="text-primary-blue font-bold text-sm">1</span> <Text size="sm">
</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">
Automation instead of repetition. Automation instead of repetition.
</p> </Text>
</> </Stack>
} }
mockup={<CompanionAutomationMockup />} mockup={<CompanionAutomationMockup />}
layout="text-left" layout="text-left"
@@ -207,149 +132,145 @@ export default function HomeTemplate({ data }: HomeTemplateProps) {
heading="Built for iRacing. Ready for the future." heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg" backgroundImage="/images/lmp3.jpeg"
description={ description={
<> <Stack gap={4}>
<p className="text-sm md:text-base leading-relaxed"> <Text size="sm">
Right now, we're focused on making iRacing league racing better. Right now, we're focused on making iRacing league racing better.
</p> </Text>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed"> <Text size="sm">
But sims come and go. Your leagues, your teams, your rating — those stay. But sims come and go. Your leagues, your teams, your rating — those stay.
</p> </Text>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed"> <Text size="sm">
GridPilot is built to outlast any single platform. GridPilot is built to outlast any single platform.
</p> </Text>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed"> <Text size="sm">
When the next sim arrives, your competitive identity moves with you. When the next sim arrives, your competitive identity moves with you.
</p> </Text>
</> </Stack>
} }
mockup={<SimPlatformMockup />} mockup={<SimPlatformMockup />}
layout="text-right" layout="text-right"
/> />
{/* Alpha-only discovery section */} {/* Alpha-only discovery section */}
{data.isAlpha && ( {viewData.isAlpha && (
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6"> <Container size="lg" py={12}>
<div className="flex items-baseline justify-between mb-8"> <Stack gap={8}>
<div> <Box>
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2> <Heading level={2}>Discover the grid</Heading>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block mt={2}>
Explore leagues, teams, and races that make up the GridPilot ecosystem. Explore leagues, teams, and races that make up the GridPilot ecosystem.
</p> </Text>
</div> </Box>
</div>
<div className="grid gap-8 lg:grid-cols-3"> <Grid cols={3} gap={8}>
{/* Top leagues */} {/* Top leagues */}
<Card className="bg-iron-gray/80"> <Card>
<div className="flex items-baseline justify-between mb-4"> <Stack gap={4}>
<h3 className="text-sm font-semibold text-white">Featured leagues</h3> <Stack direction="row" align="center" justify="between">
<Button <Heading level={3} style={{ fontSize: '0.875rem' }}>Featured leagues</Heading>
as="a" <Link href={routes.public.leagues}>
href="/leagues" <Button variant="secondary" size="sm">
variant="secondary" View all
className="text-[11px] px-3 py-1.5" </Button>
> </Link>
View all </Stack>
</Button> <Stack gap={3}>
</div> {viewData.topLeagues.slice(0, 4).map((league) => (
<ul className="space-y-3 text-sm"> <Box key={league.id}>
{data.topLeagues.slice(0, 4).map((league: any) => ( <Stack direction="row" align="start" gap={3}>
<li key={league.id} className="flex items-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' }}>
<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"> <Text size="xs" weight="bold" color="text-primary-blue">
{league.name {league.name.split(' ').map((word) => word[0]).join('').slice(0, 3).toUpperCase()}
.split(' ') </Text>
.map((word: string) => word[0]) </Surface>
.join('') <Box style={{ flex: 1, minWidth: 0 }}>
.slice(0, 3) <Text color="text-white" block truncate>{league.name}</Text>
.toUpperCase()} <Text size="xs" color="text-gray-400" block mt={1} truncate>{league.description}</Text>
</div> </Box>
<div className="flex-1 min-w-0"> </Stack>
<p className="text-white truncate">{league.name}</p> </Box>
<p className="text-xs text-gray-400 line-clamp-2"> ))}
{league.description} </Stack>
</p> </Stack>
</div> </Card>
</li>
))}
</ul>
</Card>
{/* Teams */} {/* Teams */}
<Card className="bg-iron-gray/80"> <Card>
<div className="flex items-baseline justify-between mb-4"> <Stack gap={4}>
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3> <Stack direction="row" align="center" justify="between">
<Button <Heading level={3} style={{ fontSize: '0.875rem' }}>Teams on the grid</Heading>
as="a" <Link href={routes.public.teams}>
href="/teams" <Button variant="secondary" size="sm">
variant="secondary" Browse teams
className="text-[11px] px-3 py-1.5" </Button>
> </Link>
Browse teams </Stack>
</Button> <Stack gap={3}>
</div> {viewData.teams.slice(0, 4).map(team => (
<ul className="space-y-3 text-sm"> <Box key={team.id}>
{data.teams.slice(0, 4).map(team => ( <Stack direction="row" align="start" gap={3}>
<li key={team.id} className="flex items-start gap-3"> <Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline"> <Image
<Image src={team.logoUrl || getMediaUrl('team-logo', team.id)}
src={team.logoUrl || getMediaUrl('team-logo', team.id)} alt={team.name}
alt={team.name} width={40}
width={40} height={40}
height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="w-full h-full object-cover" />
/> </Surface>
</div> <Box style={{ flex: 1, minWidth: 0 }}>
<div className="flex-1 min-w-0"> <Text color="text-white" block truncate>{team.name}</Text>
<p className="text-white truncate">{team.name}</p> <Text size="xs" color="text-gray-400" block mt={1} truncate>{team.description}</Text>
<p className="text-xs text-gray-400 line-clamp-2"> </Box>
{team.description} </Stack>
</p> </Box>
</div> ))}
</li> </Stack>
))} </Stack>
</ul> </Card>
</Card>
{/* Upcoming races */} {/* Upcoming races */}
<Card className="bg-iron-gray/80"> <Card>
<div className="flex items-baseline justify-between mb-4"> <Stack gap={4}>
<h3 className="text-sm font-semibold text-white">Upcoming races</h3> <Stack direction="row" align="center" justify="between">
<Button <Heading level={3} style={{ fontSize: '0.875rem' }}>Upcoming races</Heading>
as="a" <Link href={routes.public.races}>
href="/races" <Button variant="secondary" size="sm">
variant="secondary" View schedule
className="text-[11px] px-3 py-1.5" </Button>
> </Link>
View schedule </Stack>
</Button> {viewData.upcomingRaces.length === 0 ? (
</div> <Text size="xs" color="text-gray-400">
{data.upcomingRaces.length === 0 ? ( No races scheduled in this demo snapshot.
<p className="text-xs text-gray-400"> </Text>
No races scheduled in this demo snapshot. ) : (
</p> <Stack gap={3}>
) : ( {viewData.upcomingRaces.map(race => (
<ul className="space-y-3 text-sm"> <Box key={race.id}>
{data.upcomingRaces.map(race => ( <Stack direction="row" align="start" justify="between" gap={3}>
<li key={race.id} className="flex items-start justify-between gap-3"> <Box style={{ flex: 1, minWidth: 0 }}>
<div className="flex-1 min-w-0"> <Text color="text-white" block truncate>{race.track}</Text>
<p className="text-white truncate">{race.track}</p> <Text size="xs" color="text-gray-400" block mt={1} truncate>{race.car}</Text>
<p className="text-xs text-gray-400 truncate">{race.car}</p> </Box>
</div> <Text size="xs" color="text-gray-500" style={{ whiteSpace: 'nowrap' }}>
<div className="text-right text-xs text-gray-500 whitespace-nowrap"> {race.formattedDate}
{race.formattedDate} </Text>
</div> </Stack>
</li> </Box>
))} ))}
</ul> </Stack>
)} )}
</Card> </Stack>
</div> </Card>
</section> </Grid>
</Stack>
</Container>
)} )}
<DiscordCTA /> <DiscordCTA />
<FAQ /> <FAQ />
<Footer /> <Footer />
</main> </Box>
); );
} }

View File

@@ -1,95 +1,55 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useRouter } from 'next/navigation'; import { Box } from '@/ui/Box';
import { Trophy, Users, Award } from 'lucide-react'; import { Container } from '@/ui/Container';
import Button from '@/components/ui/Button'; import { Grid } from '@/ui/Grid';
import Heading from '@/components/ui/Heading'; import { GridItem } from '@/ui/GridItem';
import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview'; import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview';
import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview';
import { LeaderboardsHero } from '@/components/leaderboards/LeaderboardsHero';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { routes } from '@/lib/routing/RouteConfig';
// ============================================================================
// TYPES
// ============================================================================
interface LeaderboardsTemplateProps { interface LeaderboardsTemplateProps {
viewData: LeaderboardsViewData; viewData: LeaderboardsViewData;
onDriverClick: (id: string) => void;
onTeamClick: (id: string) => void;
onNavigateToDrivers: () => void;
onNavigateToTeams: () => void;
} }
// ============================================================================ export function LeaderboardsTemplate({
// MAIN TEMPLATE COMPONENT viewData,
// ============================================================================ onDriverClick,
onTeamClick,
export function LeaderboardsTemplate({ viewData }: LeaderboardsTemplateProps) { onNavigateToDrivers,
const router = useRouter(); onNavigateToTeams
}: LeaderboardsTemplateProps) {
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);
};
return ( return (
<div className="max-w-7xl mx-auto px-4 pb-12"> <Container size="lg" py={8}>
<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"> <Box mb={10}>
<div className="absolute top-0 right-0 w-96 h-96 bg-yellow-400/10 rounded-full blur-3xl" /> <LeaderboardsHero
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600/5 rounded-full blur-3xl" /> onNavigateToDrivers={onNavigateToDrivers}
<div className="absolute top-1/2 right-1/4 w-48 h-48 bg-purple-500/5 rounded-full blur-2xl" /> onNavigateToTeams={onNavigateToTeams}
/>
</Box>
<div className="relative z-10"> <Grid cols={12} gap={6}>
<div className="flex items-center gap-4 mb-4"> <GridItem colSpan={12} lgSpan={6}>
<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"> <DriverLeaderboardPreview
<Award className="w-7 h-7 text-yellow-400" /> drivers={viewData.drivers}
</div> onDriverClick={onDriverClick}
<div> onNavigateToDrivers={onNavigateToDrivers}
<Heading level={1} className="text-3xl lg:text-4xl"> />
Leaderboards </GridItem>
</Heading> <GridItem colSpan={12} lgSpan={6}>
<p className="text-gray-400">Where champions rise and legends are made</p> <TeamLeaderboardPreview
</div> teams={viewData.teams}
</div> onTeamClick={onTeamClick}
onNavigateToTeams={onNavigateToTeams}
<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? </GridItem>
</p> </Grid>
</Container>
<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>
); );
} }

View File

@@ -1,20 +1,21 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel'; import { Card } from '@/ui/Card';
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel'; import { Box } from '@/ui/Box';
import Card from '@/components/ui/Card'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
// ============================================================================ import { Heading } from '@/ui/Heading';
// TYPES 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 { interface LeagueAdminScheduleTemplateProps {
data: { viewData: LeagueAdminScheduleViewData;
schedule: LeagueAdminScheduleViewModel;
seasons: LeagueSeasonSummaryViewModel[];
seasonId: string;
};
onSeasonChange: (seasonId: string) => void; onSeasonChange: (seasonId: string) => void;
onPublishToggle: () => void; onPublishToggle: () => void;
onAddOrSave: () => void; onAddOrSave: () => void;
@@ -39,12 +40,8 @@ interface LeagueAdminScheduleTemplateProps {
setScheduledAtIso: (value: string) => void; setScheduledAtIso: (value: string) => void;
} }
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeagueAdminScheduleTemplate({ export function LeagueAdminScheduleTemplate({
data, viewData,
onSeasonChange, onSeasonChange,
onPublishToggle, onPublishToggle,
onAddOrSave, onAddOrSave,
@@ -62,10 +59,10 @@ export function LeagueAdminScheduleTemplate({
setCar, setCar,
setScheduledAtIso, setScheduledAtIso,
}: LeagueAdminScheduleTemplateProps) { }: LeagueAdminScheduleTemplateProps) {
const { schedule, seasons, seasonId } = data; const { races, seasons, seasonId, published } = viewData;
const isEditing = editingRaceId !== null; const isEditing = editingRaceId !== null;
const publishedLabel = schedule.published ? 'Published' : 'Unpublished'; const publishedLabel = published ? 'Published' : 'Unpublished';
const selectedSeasonLabel = useMemo(() => { const selectedSeasonLabel = useMemo(() => {
const selected = seasons.find((s) => s.seasonId === seasonId); const selected = seasons.find((s) => s.seasonId === seasonId);
@@ -73,162 +70,142 @@ export function LeagueAdminScheduleTemplate({
}, [seasons, seasonId]); }, [seasons, seasonId]);
return ( return (
<div className="space-y-6"> <Stack gap={6}>
<Card> <Card>
<div className="space-y-4"> <Stack gap={6}>
<div> <Box>
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1> <Heading level={1}>Schedule Admin</Heading>
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p> <Text size="sm" color="text-gray-400" block mt={1}>Create, edit, and publish season races.</Text>
</div> </Box>
<div className="flex flex-col gap-2"> <Box>
<label className="text-sm text-gray-300" htmlFor="seasonId"> <Text size="sm" color="text-gray-300" block mb={2}>Season</Text>
Season <Select
</label> value={seasonId}
{seasons.length > 0 ? ( onChange={(e) => onSeasonChange(e.target.value)}
<select options={seasons.map(s => ({ value: s.seasonId, label: s.name }))}
id="seasonId" />
value={seasonId} <Text size="xs" color="text-gray-500" block mt={1}>Selected: {selectedSeasonLabel}</Text>
onChange={(e) => onSeasonChange(e.target.value)} </Box>
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>
<div className="flex items-center justify-between gap-3"> <Stack direction="row" align="center" justify="between">
<p className="text-sm text-gray-300"> <Text size="sm" color="text-gray-300">
Status: <span className="font-medium text-white">{publishedLabel}</span> Status: <Text weight="medium" color="text-white">{publishedLabel}</Text>
</p> </Text>
<button <Button
type="button"
onClick={onPublishToggle} onClick={onPublishToggle}
disabled={!schedule || isPublishing} disabled={isPublishing}
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50" variant="primary"
size="sm"
> >
{isPublishing ? 'Processing...' : (schedule?.published ? 'Unpublish' : 'Publish')} {isPublishing ? 'Processing...' : (published ? 'Unpublish' : 'Publish')}
</button> </Button>
</div> </Stack>
<div className="border-t border-charcoal-outline pt-4 space-y-3"> <Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2> <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"> <Grid cols={3} gap={4}>
<div className="flex flex-col gap-1"> <Box>
<label htmlFor="track" className="text-sm text-gray-300"> <Text size="sm" color="text-gray-300" block mb={2}>Track</Text>
Track <Input
</label>
<input
id="track"
value={track} value={track}
onChange={(e) => setTrack(e.target.value)} 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"> <Box>
<label htmlFor="car" className="text-sm text-gray-300"> <Text size="sm" color="text-gray-300" block mb={2}>Car</Text>
Car <Input
</label>
<input
id="car"
value={car} value={car}
onChange={(e) => setCar(e.target.value)} 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"> <Box>
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300"> <Text size="sm" color="text-gray-300" block mb={2}>Scheduled At (ISO)</Text>
Scheduled At (ISO) <Input
</label>
<input
id="scheduledAtIso"
value={scheduledAtIso} value={scheduledAtIso}
onChange={(e) => setScheduledAtIso(e.target.value)} onChange={(e) => setScheduledAtIso(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="2025-01-01T12:00:00.000Z" placeholder="2025-01-01T12:00:00.000Z"
/> />
</div> </Box>
</div> </Grid>
<div className="flex items-center gap-2"> <Stack direction="row" gap={3} mt={6}>
<button <Button
type="button"
onClick={onAddOrSave} onClick={onAddOrSave}
disabled={isSaving} disabled={isSaving}
className="px-3 py-1.5 rounded bg-primary-blue text-white" variant="primary"
> >
{isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')} {isSaving ? 'Processing...' : (isEditing ? 'Save' : 'Add race')}
</button> </Button>
{isEditing && ( {isEditing && (
<button <Button
type="button"
onClick={onCancelEdit} onClick={onCancelEdit}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200" variant="secondary"
> >
Cancel Cancel
</button> </Button>
)} )}
</div> </Stack>
</div> </Box>
<div className="border-t border-charcoal-outline pt-4 space-y-3"> <Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<h2 className="text-lg font-semibold text-white">Races</h2> <Box mb={4}>
<Heading level={2}>Races</Heading>
</Box>
{schedule?.races.length ? ( {races.length > 0 ? (
<div className="space-y-2"> <Stack gap={3}>
{schedule.races.map((race) => ( {races.map((race) => (
<div <Surface
key={race.id} 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"> <Stack direction="row" align="center" justify="between">
<p className="text-white font-medium truncate">{race.name}</p> <Box>
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p> <Text weight="medium" color="text-white" block>{race.name}</Text>
</div> <Text size="xs" color="text-gray-400" block mt={1}>{race.scheduledAt}</Text>
</Box>
<div className="flex items-center gap-2"> <Stack direction="row" gap={2}>
<button <Button
type="button" onClick={() => onEdit(race.id)}
onClick={() => onEdit(race.id)} variant="secondary"
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200" size="sm"
> >
Edit Edit
</button> </Button>
<button <Button
type="button" onClick={() => onDelete(race.id)}
onClick={() => onDelete(race.id)} disabled={isDeleting === race.id}
disabled={isDeleting === race.id} variant="secondary"
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200" size="sm"
> >
{isDeleting === race.id ? 'Deleting...' : 'Delete'} {isDeleting === race.id ? 'Deleting...' : 'Delete'}
</button> </Button>
</div> </Stack>
</div> </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> </Box>
</div> </Stack>
</Card> </Card>
</div> </Stack>
); );
} }

View File

@@ -1,8 +1,14 @@
import { Section } from '@/ui/Section'; 'use client';
import { Layout } from '@/ui/Layout';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; 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 Breadcrumbs from '@/components/layout/Breadcrumbs';
import { LeagueTabs } from '@/components/leagues/LeagueTabs';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
interface Tab { interface Tab {
label: string; label: string;
@@ -11,58 +17,40 @@ interface Tab {
} }
interface LeagueDetailTemplateProps { interface LeagueDetailTemplateProps {
leagueId: string; viewData: LeagueDetailViewData;
leagueName: string;
leagueDescription: string;
tabs: Tab[]; tabs: Tab[];
children: React.ReactNode; children: React.ReactNode;
} }
export function LeagueDetailTemplate({ export function LeagueDetailTemplate({
leagueId, viewData,
leagueName,
leagueDescription,
tabs, tabs,
children, children,
}: LeagueDetailTemplateProps) { }: LeagueDetailTemplateProps) {
return ( return (
<Layout> <Container size="lg" py={6}>
<Section> <Stack gap={6}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: 'Home', href: '/' }, { label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' }, { label: 'Leagues', href: '/leagues' },
{ label: leagueName }, { label: viewData.name },
]} ]}
/> />
<Section> <Box>
<Text size="3xl" weight="bold" className="text-white"> <Heading level={1}>{viewData.name}</Heading>
{leagueName} <Text color="text-gray-400" block mt={2}>
{viewData.description}
</Text> </Text>
<Text size="base" className="text-gray-400 mt-2"> </Box>
{leagueDescription}
</Text>
</Section>
<Section> <LeagueTabs tabs={tabs} />
<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>
<Section> <Box>
{children} {children}
</Section> </Box>
</Section> </Stack>
</Layout> </Container>
); );
} }

View File

@@ -1,116 +1,85 @@
'use client'; 'use client';
import { useState } from 'react'; import React from 'react';
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 { 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 PointsTable from '@/components/leagues/PointsTable';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; import { RulebookTabs, type RulebookSection } from '@/components/leagues/RulebookTabs';
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
// ============================================================================ import { Surface } from '@/ui/Surface';
// TYPES
// ============================================================================
type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
interface LeagueRulebookTemplateProps { interface LeagueRulebookTemplateProps {
viewModel: LeagueDetailPageViewModel; viewData: LeagueRulebookViewData;
activeSection: RulebookSection;
onSectionChange: (section: RulebookSection) => void;
loading?: boolean; loading?: boolean;
} }
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeagueRulebookTemplate({ export function LeagueRulebookTemplate({
viewModel, viewData,
activeSection,
onSectionChange,
loading = false, loading = false,
}: LeagueRulebookTemplateProps) { }: LeagueRulebookTemplateProps) {
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
if (loading) { if (loading) {
return ( return (
<Card> <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> </Card>
); );
} }
if (!viewModel || !viewModel.scoringConfig) { if (!viewData || !viewData.scoringConfig) {
return ( return (
<Card> <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> </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 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 })) .map(p => ({ position: p.position, points: p.points }))
.sort((a, b) => a.position - b.position) || []; .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 ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<div> <Box>
<h1 className="text-2xl font-bold text-white">Rulebook</h1> <Heading level={1}>Rulebook</Heading>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p> <Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</div> </Box>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20"> <Badge variant="primary">
<span className="text-sm font-medium text-primary-blue">{viewModel.scoringConfig.scoringPresetName || 'Custom Rules'}</span> {scoringConfig.scoringPresetName || 'Custom Rules'}
</div> </Badge>
</div> </Stack>
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex gap-1 p-1 bg-deep-graphite rounded-lg border border-charcoal-outline"> <RulebookTabs activeSection={activeSection} onSectionChange={onSectionChange} />
{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>
{/* Content Sections */} {/* Content Sections */}
{activeSection === 'scoring' && ( {activeSection === 'scoring' && (
<div className="space-y-6"> <Stack gap={6}>
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <Grid cols={4} gap={4}>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline"> <StatItem label="Platform" value={scoringConfig.gameName} />
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p> <StatItem label="Championships" value={scoringConfig.championships.length} />
<p className="text-lg font-semibold text-white">{viewModel.scoringConfig.gameName}</p> <StatItem label="Sessions Scored" value={primaryChampionship?.sessionTypes.join(', ') || 'Main'} />
</div> <StatItem label="Drop Policy" value={scoringConfig.dropPolicySummary.includes('All') ? 'None' : 'Active'} />
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline"> </Grid>
<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>
{/* Points Table */} {/* Points Table */}
<PointsTable points={positionPoints} /> <PointsTable points={positionPoints} />
@@ -118,134 +87,137 @@ export function LeagueRulebookTemplate({
{/* Bonus Points */} {/* Bonus Points */}
{primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && ( {primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2> <Stack gap={4}>
<div className="space-y-2"> <Heading level={2}>Bonus Points</Heading>
{primaryChampionship.bonusSummary.map((bonus, idx) => ( <Stack gap={2}>
<div {primaryChampionship.bonusSummary.map((bonus, idx) => (
key={idx} <Surface
className="flex items-center gap-4 p-3 bg-deep-graphite rounded-lg border border-charcoal-outline" key={idx}
> variant="muted"
<div className="w-8 h-8 rounded-full bg-performance-green/10 border border-performance-green/20 flex items-center justify-center shrink-0"> rounded="lg"
<span className="text-performance-green text-sm font-bold">+</span> border
</div> padding={3}
<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' }}>
</div> <Text color="text-performance-green" weight="bold">+</Text>
</Surface>
<Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
))}
</Stack>
</Stack>
</Card> </Card>
)} )}
{/* Drop Policy */} {/* Drop Policy */}
{!viewModel.scoringConfig.dropPolicySummary.includes('All results count') && ( {!scoringConfig.dropPolicySummary.includes('All results count') && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2> <Stack gap={4}>
<p className="text-sm text-gray-300">{viewModel.scoringConfig.dropPolicySummary}</p> <Heading level={2}>Drop Policy</Heading>
<p className="text-xs text-gray-500 mt-3"> <Text size="sm" color="text-gray-300">{scoringConfig.dropPolicySummary}</Text>
Drop rules are applied automatically when calculating championship standings. <Box mt={3}>
</p> <Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
</Text>
</Box>
</Stack>
</Card> </Card>
)} )}
</div> </Stack>
)} )}
{activeSection === 'conduct' && ( {activeSection === 'conduct' && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Driver Conduct</h2> <Stack gap={4}>
<div className="space-y-4 text-sm text-gray-300"> <Heading level={2}>Driver Conduct</Heading>
<div> <Stack gap={4}>
<h3 className="font-medium text-white mb-2">1. Respect</h3> <ConductItem number={1} title="Respect" text="All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated." />
<p>All drivers must treat each other with respect. Abusive language, harassment, or unsportsmanlike behavior will not be tolerated.</p> <ConductItem number={2} title="Clean Racing" text="Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly." />
</div> <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." />
<div> <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." />
<h3 className="font-medium text-white mb-2">2. Clean Racing</h3> <ConductItem number={5} title="Communication" text="Drivers are expected to communicate respectfully in voice and text chat during sessions." />
<p>Intentional wrecking, blocking, or dangerous driving is prohibited. Leave space for other drivers and race cleanly.</p> </Stack>
</div> </Stack>
<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>
</Card> </Card>
)} )}
{activeSection === 'protests' && ( {activeSection === 'protests' && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Protest Process</h2> <Stack gap={4}>
<div className="space-y-4 text-sm text-gray-300"> <Heading level={2}>Protest Process</Heading>
<div> <Stack gap={4}>
<h3 className="font-medium text-white mb-2">Filing a Protest</h3> <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." />
<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> <ConductItem number={2} title="Evidence" text="Video evidence is highly recommended but not required. Stewards will review available replay data." />
</div> <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." />
<div> <ConductItem number={4} title="Outcomes" text="Protests may result in no action, warnings, time penalties, position penalties, or points deductions depending on severity." />
<h3 className="font-medium text-white mb-2">Evidence</h3> </Stack>
<p>Video evidence is highly recommended but not required. Stewards will review available replay data.</p> </Stack>
</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>
</Card> </Card>
)} )}
{activeSection === 'penalties' && ( {activeSection === 'penalties' && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Penalty Guidelines</h2> <Stack gap={4}>
<div className="space-y-4 text-sm text-gray-300"> <Heading level={2}>Penalty Guidelines</Heading>
<div className="overflow-x-auto"> <Stack gap={4}>
<table className="w-full"> <Table>
<thead> <TableHead>
<tr className="border-b border-charcoal-outline"> <TableRow>
<th className="text-left py-2 font-medium text-gray-400">Infraction</th> <TableHeader>Infraction</TableHeader>
<th className="text-left py-2 font-medium text-gray-400">Typical Penalty</th> <TableHeader>Typical Penalty</TableHeader>
</tr> </TableRow>
</thead> </TableHead>
<tbody className="divide-y divide-charcoal-outline/50"> <TableBody>
<tr> <PenaltyRow infraction="Causing avoidable contact" penalty="5-10 second time penalty" />
<td className="py-3">Causing avoidable contact</td> <PenaltyRow infraction="Unsafe rejoin" penalty="5 second time penalty" />
<td className="py-3 text-warning-amber">5-10 second time penalty</td> <PenaltyRow infraction="Blocking" penalty="Warning or 3 second penalty" />
</tr> <PenaltyRow infraction="Repeated track limit violations" penalty="5 second penalty" />
<tr> <PenaltyRow infraction="Intentional wrecking" penalty="Disqualification" color="#f87171" />
<td className="py-3">Unsafe rejoin</td> <PenaltyRow infraction="Unsportsmanlike conduct" penalty="Points deduction or ban" color="#f87171" />
<td className="py-3 text-warning-amber">5 second time penalty</td> </TableBody>
</tr> </Table>
<tr> <Box mt={4}>
<td className="py-3">Blocking</td> <Text size="xs" color="text-gray-500" block>
<td className="py-3 text-warning-amber">Warning or 3 second penalty</td> Penalties are applied at steward discretion based on incident severity and driver history.
</tr> </Text>
<tr> </Box>
<td className="py-3">Repeated track limit violations</td> </Stack>
<td className="py-3 text-warning-amber">5 second penalty</td> </Stack>
</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>
</Card> </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 { Card } from '@/ui/Card';
import { Section } from '@/ui/Section'; import { Box } from '@/ui/Box';
import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react'; 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 { interface LeagueScheduleTemplateProps {
viewData: LeagueScheduleViewData; viewData: LeagueScheduleViewData;
@@ -9,82 +18,33 @@ interface LeagueScheduleTemplateProps {
export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) { export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) {
return ( return (
<Section> <Stack gap={6}>
<div className="flex items-center justify-between mb-6"> <Box>
<div> <Heading level={2}>Race Schedule</Heading>
<h2 className="text-xl font-semibold text-white">Race Schedule</h2> <Text size="sm" color="text-gray-400" block mt={1}>
<p className="text-sm text-gray-400 mt-1"> Upcoming and completed races for this season
Upcoming and completed races for this season </Text>
</p> </Box>
</div>
</div>
{viewData.races.length === 0 ? ( {viewData.races.length === 0 ? (
<Card> <Card>
<div className="text-center py-12"> <Stack align="center" py={12} gap={4}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Calendar className="w-8 h-8 text-performance-green" /> <Icon icon={Calendar} size={8} color="#10b981" />
</div> </Surface>
<p className="font-semibold text-lg text-white mb-2">No Races Scheduled</p> <Box style={{ textAlign: 'center' }}>
<p className="text-sm text-gray-400">The race schedule will appear here once events are added.</p> <Text weight="semibold" size="lg" color="text-white" block mb={2}>No Races Scheduled</Text>
</div> <Text size="sm" color="text-gray-400">The race schedule will appear here once events are added.</Text>
</Box>
</Stack>
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <Stack gap={4}>
{viewData.races.map((race) => ( {viewData.races.map((race) => (
<Card key={race.id}> <ScheduleRaceCard key={race.id} race={race} />
<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>
))} ))}
</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 { 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 { Settings, Users, Trophy, Shield, Clock } from 'lucide-react';
import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
interface LeagueSettingsTemplateProps { interface LeagueSettingsTemplateProps {
viewData: LeagueSettingsViewData; viewData: LeagueSettingsViewData;
@@ -9,113 +19,98 @@ interface LeagueSettingsTemplateProps {
export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) { export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) {
return ( return (
<Section> <Stack gap={6}>
<div className="flex items-center justify-between mb-6"> <Box>
<div> <Heading level={2}>League Settings</Heading>
<h2 className="text-xl font-semibold text-white">League Settings</h2> <Text size="sm" color="text-gray-400" block mt={1}>
<p className="text-sm text-gray-400 mt-1"> Manage your league configuration and preferences
Manage your league configuration and preferences </Text>
</p> </Box>
</div>
</div>
<div className="space-y-6"> <Stack gap={6}>
{/* League Information */} {/* League Information */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <Stack gap={4}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10"> <Stack direction="row" align="center" gap={3}>
<Settings className="w-5 h-5 text-primary-blue" /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
</div> <Icon icon={Settings} size={5} color="#3b82f6" />
<div> </Surface>
<h3 className="text-lg font-semibold text-white">League Information</h3> <Box>
<p className="text-sm text-gray-400">Basic league details</p> <Heading level={3}>League Information</Heading>
</div> <Text size="sm" color="text-gray-400">Basic league details</Text>
</div> </Box>
</Stack>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Grid cols={2} gap={4}>
<div> <InfoItem label="Name" value={viewData.league.name} />
<label className="block text-sm font-medium text-gray-400 mb-1">Name</label> <InfoItem label="Visibility" value={viewData.league.visibility} capitalize />
<p className="text-white">{viewData.league.name}</p> <GridItem colSpan={2}>
</div> <InfoItem label="Description" value={viewData.league.description} />
<div> </GridItem>
<label className="block text-sm font-medium text-gray-400 mb-1">Visibility</label> <InfoItem label="Created" value={new Date(viewData.league.createdAt).toLocaleDateString()} />
<p className="text-white capitalize">{viewData.league.visibility}</p> <InfoItem label="Owner ID" value={viewData.league.ownerId} mono />
</div> </Grid>
<div className="md:col-span-2"> </Stack>
<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>
</Card> </Card>
{/* Configuration */} {/* Configuration */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <Stack gap={4}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10"> <Stack direction="row" align="center" gap={3}>
<Trophy className="w-5 h-5 text-performance-green" /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
</div> <Icon icon={Trophy} size={5} color="#10b981" />
<div> </Surface>
<h3 className="text-lg font-semibold text-white">Configuration</h3> <Box>
<p className="text-sm text-gray-400">League rules and limits</p> <Heading level={3}>Configuration</Heading>
</div> <Text size="sm" color="text-gray-400">League rules and limits</Text>
</div> </Box>
</Stack>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Grid cols={2} gap={4}>
<div className="flex items-center gap-3"> <ConfigItem icon={Users} label="Max Drivers" value={viewData.config.maxDrivers} />
<Users className="w-5 h-5 text-gray-400" /> <ConfigItem icon={Shield} label="Require Approval" value={viewData.config.requireApproval ? 'Yes' : 'No'} />
<div> <ConfigItem icon={Clock} label="Allow Late Join" value={viewData.config.allowLateJoin ? 'Yes' : 'No'} />
<p className="text-sm font-medium text-gray-400">Max Drivers</p> <ConfigItem icon={Trophy} label="Scoring Preset" value={viewData.config.scoringPresetId} mono />
<p className="text-white">{viewData.config.maxDrivers}</p> </Grid>
</div> </Stack>
</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>
</Card> </Card>
{/* Note about forms */} {/* Note about forms */}
<Card> <Card>
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-warning-amber/10 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
<Settings className="w-8 h-8 text-warning-amber" /> <Icon icon={Settings} size={8} color="#f59e0b" />
</div> </Surface>
<h3 className="text-lg font-medium text-white mb-2">Settings Management</h3> <Box style={{ textAlign: 'center' }}>
<p className="text-sm text-gray-400"> <Heading level={3}>Settings Management</Heading>
Form-based editing and ownership transfer functionality will be implemented in future updates. <Text size="sm" color="text-gray-400" block mt={2}>
</p> Form-based editing and ownership transfer functionality will be implemented in future updates.
</div> </Text>
</Box>
</Stack>
</Card> </Card>
</div> </Stack>
</Section> </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 { Card } from '@/ui/Card';
import { Section } from '@/ui/Section'; import { Box } from '@/ui/Box';
import { Building, DollarSign, Clock, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; 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 { interface LeagueSponsorshipsTemplateProps {
viewData: LeagueSponsorshipsViewData; viewData: LeagueSponsorshipsViewData;
@@ -9,160 +20,96 @@ interface LeagueSponsorshipsTemplateProps {
export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTemplateProps) { export function LeagueSponsorshipsTemplate({ viewData }: LeagueSponsorshipsTemplateProps) {
return ( return (
<Section> <Stack gap={6}>
<div className="flex items-center justify-between mb-6"> <Box>
<div> <Heading level={2}>Sponsorships</Heading>
<h2 className="text-xl font-semibold text-white">Sponsorships</h2> <Text size="sm" color="text-gray-400" block mt={1}>
<p className="text-sm text-gray-400 mt-1"> Manage sponsorship slots and review requests
Manage sponsorship slots and review requests </Text>
</p> </Box>
</div>
</div>
<div className="space-y-6"> <Stack gap={6}>
{/* Sponsorship Slots */} {/* Sponsorship Slots */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <Stack gap={4}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10"> <Stack direction="row" align="center" gap={3}>
<Building className="w-5 h-5 text-primary-blue" /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
</div> <Icon icon={Building} size={5} color="#3b82f6" />
<div> </Surface>
<h3 className="text-lg font-semibold text-white">Sponsorship Slots</h3> <Box>
<p className="text-sm text-gray-400">Available sponsorship opportunities</p> <Heading level={3}>Sponsorship Slots</Heading>
</div> <Text size="sm" color="text-gray-400">Available sponsorship opportunities</Text>
</div> </Box>
</Stack>
{viewData.sponsorshipSlots.length === 0 ? ( {viewData.sponsorshipSlots.length === 0 ? (
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<Building className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <Icon icon={Building} size={12} color="#525252" />
<p className="text-gray-400">No sponsorship slots available</p> <Text color="text-gray-400">No sponsorship slots available</Text>
</div> </Stack>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <Grid cols={3} gap={4}>
{viewData.sponsorshipSlots.map((slot) => ( {viewData.sponsorshipSlots.map((slot) => (
<div <SponsorshipSlotCard key={slot.id} slot={slot} />
key={slot.id} ))}
className={`rounded-lg border p-4 ${ </Grid>
slot.isAvailable )}
? 'border-performance-green bg-performance-green/5' </Stack>
: '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>
)}
</Card> </Card>
{/* Sponsorship Requests */} {/* Sponsorship Requests */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <Stack gap={4}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10"> <Stack direction="row" align="center" gap={3}>
<Clock className="w-5 h-5 text-warning-amber" /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
</div> <Icon icon={Clock} size={5} color="#f59e0b" />
<div> </Surface>
<h3 className="text-lg font-semibold text-white">Sponsorship Requests</h3> <Box>
<p className="text-sm text-gray-400">Pending and processed sponsorship applications</p> <Heading level={3}>Sponsorship Requests</Heading>
</div> <Text size="sm" color="text-gray-400">Pending and processed sponsorship applications</Text>
</div> </Box>
</Stack>
{viewData.sponsorshipRequests.length === 0 ? ( {viewData.sponsorshipRequests.length === 0 ? (
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<Clock className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <Icon icon={Clock} size={12} color="#525252" />
<p className="text-gray-400">No sponsorship requests</p> <Text color="text-gray-400">No sponsorship requests</Text>
</div> </Stack>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{viewData.sponsorshipRequests.map((request) => { {viewData.sponsorshipRequests.map((request) => {
const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId); const slot = viewData.sponsorshipSlots.find(s => s.id === request.slotId);
const statusIcon = { return (
pending: <AlertCircle className="w-5 h-5 text-warning-amber" />, <SponsorshipRequestCard
approved: <CheckCircle className="w-5 h-5 text-performance-green" />, key={request.id}
rejected: <XCircle className="w-5 h-5 text-red-400" />, request={{
}[request.status]; ...request,
status: request.status as any,
const statusColor = { slotName: slot?.name || 'Unknown slot'
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]; })}
</Stack>
return ( )}
<div </Stack>
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>
)}
</Card> </Card>
{/* Note about management */} {/* Note about management */}
<Card> <Card>
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Building className="w-8 h-8 text-primary-blue" /> <Icon icon={Building} size={8} color="#3b82f6" />
</div> </Surface>
<h3 className="text-lg font-medium text-white mb-2">Sponsorship Management</h3> <Box style={{ textAlign: 'center' }}>
<p className="text-sm text-gray-400"> <Heading level={3}>Sponsorship Management</Heading>
Interactive management features for approving requests and managing slots will be implemented in future updates. <Text size="sm" color="text-gray-400" block mt={2}>
</p> Interactive management features for approving requests and managing slots will be implemented in future updates.
</div> </Text>
</Box>
</Stack>
</Card> </Card>
</div> </Stack>
</Section> </Stack>
); );
} }

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import React from 'react';
import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats'; import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats';
import { StandingsTable } from '@/components/leagues/StandingsTable'; 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'; import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
// ============================================================================
// TYPES
// ============================================================================
interface LeagueStandingsTemplateProps { interface LeagueStandingsTemplateProps {
viewData: LeagueStandingsViewData; viewData: LeagueStandingsViewData;
onRemoveMember: (driverId: string) => void; onRemoveMember: (driverId: string) => void;
@@ -16,10 +17,6 @@ interface LeagueStandingsTemplateProps {
loading?: boolean; loading?: boolean;
} }
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeagueStandingsTemplate({ export function LeagueStandingsTemplate({
viewData, viewData,
onRemoveMember, onRemoveMember,
@@ -28,29 +25,31 @@ export function LeagueStandingsTemplate({
}: LeagueStandingsTemplateProps) { }: LeagueStandingsTemplateProps) {
if (loading) { if (loading) {
return ( return (
<div className="text-center text-gray-400"> <Stack align="center" py={12}>
Loading standings... <Text color="text-gray-400">Loading standings...</Text>
</div> </Stack>
); );
} }
return ( return (
<div className="space-y-6"> <Stack gap={6}>
{/* Championship Stats */} {/* Championship Stats */}
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} /> <LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
<Card> <Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2> <Stack gap={4}>
<StandingsTable <Heading level={2}>Championship Standings</Heading>
standings={viewData.standings} <StandingsTable
drivers={viewData.drivers} standings={viewData.standings}
memberships={viewData.memberships} drivers={viewData.drivers}
currentDriverId={viewData.currentDriverId ?? undefined} memberships={viewData.memberships}
isAdmin={viewData.isAdmin} currentDriverId={viewData.currentDriverId ?? undefined}
onRemoveMember={onRemoveMember} isAdmin={viewData.isAdmin}
onUpdateRole={onUpdateRole} onRemoveMember={onRemoveMember}
/> onUpdateRole={onUpdateRole}
/>
</Stack>
</Card> </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 { Card } from '@/ui/Card';
import { Section } from '@/ui/Section'; import { Box } from '@/ui/Box';
import { Wallet, TrendingUp, TrendingDown, DollarSign, Calendar, ArrowUpRight, ArrowDownRight } from 'lucide-react'; 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 { interface LeagueWalletTemplateProps {
viewData: LeagueWalletViewData; viewData: LeagueWalletViewData;
} }
export function LeagueWalletTemplate({ viewData }: LeagueWalletTemplateProps) { 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 ( return (
<Section> <Stack gap={6}>
<div className="flex items-center justify-between mb-6"> <Box>
<div> <Heading level={2}>League Wallet</Heading>
<h2 className="text-xl font-semibold text-white">League Wallet</h2> <Text size="sm" color="text-gray-400" block mt={1}>
<p className="text-sm text-gray-400 mt-1"> Financial overview and transaction history
Financial overview and transaction history </Text>
</p> </Box>
</div>
</div>
<div className="space-y-6"> <Stack gap={6}>
{/* Balance Card */} {/* Balance Card */}
<Card> <Card>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10"> <Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Wallet className="w-6 h-6 text-primary-blue" /> <Icon icon={Wallet} size={6} color="#3b82f6" />
</div> </Surface>
<div> <Box>
<p className="text-sm text-gray-400">Current Balance</p> <Text size="sm" color="text-gray-400" block>Current Balance</Text>
<p className="text-3xl font-bold text-white"> <Text size="3xl" weight="bold" color="text-white">
{formatCurrency(viewData.balance)} {viewData.formattedBalance}
</p> </Text>
</div> </Box>
</div> </Stack>
</Card> </Card>
{/* Transaction History */} {/* Transaction History */}
<Card> <Card>
<div className="flex items-center gap-3 mb-4"> <Stack gap={4}>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10"> <Stack direction="row" align="center" gap={3}>
<Calendar className="w-5 h-5 text-performance-green" /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
</div> <Icon icon={Calendar} size={5} color="#10b981" />
<div> </Surface>
<h3 className="text-lg font-semibold text-white">Transaction History</h3> <Box>
<p className="text-sm text-gray-400">Recent financial activity</p> <Heading level={3}>Transaction History</Heading>
</div> <Text size="sm" color="text-gray-400">Recent financial activity</Text>
</div> </Box>
</Stack>
{viewData.transactions.length === 0 ? ( {viewData.transactions.length === 0 ? (
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<Wallet className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <Icon icon={Wallet} size={12} color="#525252" />
<p className="text-gray-400">No transactions yet</p> <Text color="text-gray-400">No transactions yet</Text>
</div> </Stack>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{viewData.transactions.map((transaction) => ( {viewData.transactions.map((transaction) => (
<div <TransactionRow key={transaction.id} transaction={transaction} />
key={transaction.id} ))}
className="flex items-center justify-between p-4 rounded-lg border border-charcoal-outline bg-iron-gray/30" </Stack>
> )}
<div className="flex items-center gap-3"> </Stack>
<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>
)}
</Card> </Card>
{/* Note about features */} {/* Note about features */}
<Card> <Card>
<div className="text-center py-8"> <Stack align="center" py={8} gap={4}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-primary-blue/10 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={4} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Wallet className="w-8 h-8 text-primary-blue" /> <Icon icon={Wallet} size={8} color="#3b82f6" />
</div> </Surface>
<h3 className="text-lg font-medium text-white mb-2">Wallet Management</h3> <Box style={{ textAlign: 'center' }}>
<p className="text-sm text-gray-400"> <Heading level={3}>Wallet Management</Heading>
Interactive withdrawal and export features will be implemented in future updates. <Text size="sm" color="text-gray-400" block mt={2}>
</p> Interactive withdrawal and export features will be implemented in future updates.
</div> </Text>
</Box>
</Stack>
</Card> </Card>
</div> </Stack>
</Section> </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 type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData';
import { LeagueListItem } from '@/components/profile/LeagueListItem';
interface ProfileLeaguesTemplateProps { interface ProfileLeaguesTemplateProps {
viewData: ProfileLeaguesViewData; viewData: ProfileLeaguesViewData;
@@ -6,104 +16,67 @@ interface ProfileLeaguesTemplateProps {
export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) { export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) {
return ( return (
<div className="max-w-6xl mx-auto space-y-8"> <Container size="md" py={8}>
<div> <Stack gap={8}>
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1> <Box>
<p className="text-gray-400 text-sm"> <Heading level={1}>Manage leagues</Heading>
View leagues you own and participate in, and jump into league admin tools. <Text color="text-gray-400" size="sm" block mt={2}>
</p> View leagues you own and participate in, and jump into league admin tools.
</div> </Text>
</Box>
{/* Leagues You Own */} {/* Leagues You Own */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6"> <Surface variant="muted" rounded="lg" border padding={6}>
<div className="flex items-center justify-between mb-4"> <Stack gap={4}>
<h2 className="text-xl font-semibold text-white">Leagues you own</h2> <Stack direction="row" align="center" justify="between">
{viewData.ownedLeagues.length > 0 && ( <Heading level={2}>Leagues you own</Heading>
<span className="text-xs text-gray-400"> {viewData.ownedLeagues.length > 0 && (
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} <Text size="xs" color="text-gray-400">
</span> {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
)} </Text>
</div> )}
</Stack>
{viewData.ownedLeagues.length === 0 ? ( {viewData.ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
You don't own any leagues yet in this session. You don't own any leagues yet in this session.
</p> </Text>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{viewData.ownedLeagues.map((league: ProfileLeaguesViewData['ownedLeagues'][number]) => ( {viewData.ownedLeagues.map((league) => (
<div <LeagueListItem key={league.leagueId} league={league} isAdmin />
key={league.leagueId} ))}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline" </Stack>
> )}
<div> </Stack>
<h3 className="text-white font-medium">{league.name}</h3> </Surface>
<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>
{/* Leagues You're In */} {/* Leagues You're In */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6"> <Surface variant="muted" rounded="lg" border padding={6}>
<div className="flex items-center justify-between mb-4"> <Stack gap={4}>
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2> <Stack direction="row" align="center" justify="between">
{viewData.memberLeagues.length > 0 && ( <Heading level={2}>Leagues you're in</Heading>
<span className="text-xs text-gray-400"> {viewData.memberLeagues.length > 0 && (
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} <Text size="xs" color="text-gray-400">
</span> {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
)} </Text>
</div> )}
</Stack>
{viewData.memberLeagues.length === 0 ? ( {viewData.memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
You're not a member of any other leagues yet. You're not a member of any other leagues yet.
</p> </Text>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{viewData.memberLeagues.map((league: ProfileLeaguesViewData['memberLeagues'][number]) => ( {viewData.memberLeagues.map((league) => (
<div <LeagueListItem key={league.leagueId} league={league} />
key={league.leagueId} ))}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline" </Stack>
> )}
<div> </Stack>
<h3 className="text-white font-medium">{league.name}</h3> </Surface>
<p className="text-xs text-gray-400 mt-1 line-clamp-2"> </Stack>
{league.description} </Container>
</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>
); );
} }

View File

@@ -1,447 +1,232 @@
'use client'; 'use client';
import Card from '@/components/ui/Card'; import React from 'react';
import Button from '@/components/ui/Button'; import CreateDriverForm from '@/components/drivers/CreateDriverForm';
import Heading from '@/components/ui/Heading'; import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory';
import Image from 'next/image'; import ProfileSettings from '@/components/drivers/ProfileSettings';
import Link from 'next/link'; 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 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 { import {
Activity, Activity,
Award, Award,
BarChart3,
Calendar,
ChevronRight,
Clock,
Edit3,
ExternalLink,
Flag,
Globe,
History, History,
MessageCircle,
Percent,
Settings,
Shield,
Star,
Target,
TrendingUp,
Trophy,
Twitch,
Twitter,
User, User,
UserPlus,
Users,
Youtube,
Zap,
Medal,
Crown,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { useRouter, useSearchParams } from 'next/navigation';
import ProfileSettings from '@/components/drivers/ProfileSettings';
import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory';
import CreateDriverForm from '@/components/drivers/CreateDriverForm';
type ProfileTab = 'overview' | 'history' | 'stats'; export type ProfileTab = 'overview' | 'history' | 'stats';
interface ProfileTemplateProps { interface ProfileTemplateProps {
viewData: ProfileViewData | null; viewData: ProfileViewData;
mode: 'profile-exists' | 'needs-profile'; 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>; onSaveSettings: (updates: { bio?: string; country?: string }) => Promise<void>;
} }
function getAchievementIcon(icon: NonNullable<ProfileViewData['extendedProfile']>['achievements'][number]['icon']) { export function ProfileTemplate({
switch (icon) { viewData,
case 'trophy': mode,
return Trophy; activeTab,
case 'medal': onTabChange,
return Medal; editMode,
case 'star': onEditModeChange,
return Star; friendRequestSent,
case 'crown': onFriendRequestSend,
return Crown; onSaveSettings,
case 'target': }: ProfileTemplateProps) {
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]);
if (mode === 'needs-profile') { if (mode === 'needs-profile') {
return ( return (
<div className="max-w-4xl mx-auto px-4"> <Container size="md">
<div className="text-center mb-8"> <Stack align="center" gap={4} 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"> <Surface variant="muted" rounded="xl" border padding={4}>
<User className="w-8 h-8 text-primary-blue" /> <Icon icon={User} size={8} color="#3b82f6" />
</div> </Surface>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading> <Box>
<p className="text-gray-400">Join the GridPilot community and start your racing journey</p> <Heading level={1}>Create Your Driver Profile</Heading>
</div> <Text color="text-gray-400">Join the GridPilot community and start your racing journey</Text>
</Box>
</Stack>
<Card className="max-w-2xl mx-auto"> <Box maxWidth="42rem" mx="auto">
<div className="mb-6"> <Card>
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2> <Stack gap={6}>
<p className="text-gray-400 text-sm"> <Box>
Create your driver profile to join leagues, compete in races, and connect with other drivers. <Heading level={2}>Get Started</Heading>
</p> <Text size="sm" color="text-gray-400">
</div> Create your driver profile to join leagues, compete in races, and connect with other drivers.
<CreateDriverForm /> </Text>
</Card> </Box>
</div> <CreateDriverForm />
); </Stack>
} </Card>
</Box>
if (!viewData) { </Container>
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>
); );
} }
if (editMode) { if (editMode) {
return ( return (
<div className="max-w-4xl mx-auto px-4 space-y-6"> <Container size="md">
<div className="flex items-center justify-between mb-4"> <Stack gap={6}>
<Heading level={1}>Edit Profile</Heading> <Stack direction="row" align="center" justify="between">
<Button variant="secondary" onClick={() => setEditMode(false)}> <Heading level={1}>Edit Profile</Heading>
Cancel <Button variant="secondary" onClick={() => onEditModeChange(false)}>
</Button> Cancel
</div> </Button>
</Stack>
{/* ProfileSettings expects a DriverProfileDriverSummaryViewModel; keep existing component usage by passing a minimal compatible shape */} <ProfileSettings
<ProfileSettings driver={{
driver={{ id: viewData.driver.id,
id: viewData.driver.id, name: viewData.driver.name,
name: viewData.driver.name, country: viewData.driver.countryCode,
country: viewData.driver.countryCode, avatarUrl: viewData.driver.avatarUrl,
avatarUrl: viewData.driver.avatarUrl, iracingId: viewData.driver.iracingId || '',
iracingId: viewData.driver.iracingId, joinedAt: new Date().toISOString(),
joinedAt: new Date().toISOString(), rating: null,
rating: null, globalRank: null,
globalRank: null, consistency: null,
consistency: null, bio: viewData.driver.bio,
bio: viewData.driver.bio, totalDrivers: null,
totalDrivers: null, }}
}} onSave={async (updates) => {
onSave={async (updates) => { await onSaveSettings(updates);
await onSaveSettings(updates); onEditModeChange(false);
setEditMode(false); }}
}} />
/> </Stack>
</div> </Container>
); );
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6"> <Container size="lg">
{/* Hero */} <Stack gap={6}>
<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"> {/* Back Navigation */}
<div className="relative p-6 md:p-8"> <Box>
<div className="flex flex-col md:flex-row md:items-start gap-6"> <Button
<div className="relative"> variant="secondary"
<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"> onClick={() => {}}
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray"> icon={<Icon icon={History} size={4} />}
<Image >
src={viewData.driver.avatarUrl} Back to Drivers
alt={viewData.driver.name} </Button>
width={144} </Box>
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>
<div className="flex-1 min-w-0"> {/* Breadcrumb */}
<div className="flex flex-wrap items-center gap-3 mb-2"> <Breadcrumbs
<h1 className="text-3xl md:text-4xl font-bold text-white">{viewData.driver.name}</h1> items={[
<span className="text-4xl" aria-label={`Country: ${viewData.driver.countryCode}`}>{viewData.driver.countryFlag}</span> { label: 'Home', href: '/' },
{viewData.teamMemberships[0] && ( { label: 'Drivers', href: '/drivers' },
<span className="px-3 py-1 bg-purple-600/20 text-purple-400 rounded-full text-sm font-semibold border border-purple-600/30"> { label: viewData.driver.name },
[{viewData.teamMemberships[0].teamTag || 'TEAM'}] ]}
</span> />
)}
</div>
{viewData.stats && ( <ProfileHero
<div className="flex flex-wrap items-center gap-4 mb-4"> driver={{
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30"> ...viewData.driver,
<Star className="w-4 h-4 text-primary-blue" /> country: viewData.driver.countryCode,
<span className="font-mono font-bold text-primary-blue">{viewData.stats.ratingLabel}</span> iracingId: Number(viewData.driver.iracingId) || 0,
<span className="text-xs text-gray-400">Rating</span> joinedAt: new Date().toISOString(), // Placeholder
</div> }}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30"> stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
<Trophy className="w-4 h-4 text-yellow-400" /> globalRank={Number(viewData.stats?.globalRankLabel) || 0}
<span className="font-mono font-bold text-yellow-400">{viewData.stats.globalRankLabel}</span> timezone={viewData.extendedProfile?.timezone || 'UTC'}
<span className="text-xs text-gray-400">Global</span> socialHandles={viewData.extendedProfile?.socialHandles.map(s => ({ ...s, platform: s.platformLabel as any })) || []}
</div> onAddFriend={onFriendRequestSend}
</div> friendRequestSent={friendRequestSent}
)} />
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400"> {viewData.driver.bio && (
<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">
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2"> <Stack gap={3}>
<Activity className="w-5 h-5 text-neon-aqua" /> <Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
Performance Overview About
</h2> </Heading>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <Text color="text-gray-300" block>{viewData.driver.bio}</Text>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center"> </Stack>
<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>
</Card> </Card>
</div> )}
)}
{activeTab === 'overview' && viewData.extendedProfile && ( {viewData.teamMemberships.length > 0 && (
<Card> <TeamMembershipGrid
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> memberships={viewData.teamMemberships.map(m => ({
<Award className="w-5 h-5 text-yellow-400" /> team: { id: m.teamId, name: m.teamName },
Achievements role: m.roleLabel,
<span className="ml-auto text-sm text-gray-500">{viewData.extendedProfile.achievements.length} earned</span> joinedAt: new Date() // Placeholder
</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 ( <ProfileTabs activeTab={activeTab as any} onTabChange={onTabChange as any} />
<div key={achievement.id} className="p-4 rounded-xl border border-charcoal-outline bg-iron-gray/30">
<div className="flex items-start gap-3"> {activeTab === 'history' && (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50 border border-charcoal-outline"> <Card>
<Icon className="w-5 h-5 text-yellow-400" /> <Stack gap={4}>
</div> <Heading level={2} icon={<Icon icon={History} size={5} color="#f87171" />}>
<div className="flex-1 min-w-0"> Race History
<p className="text-white font-semibold text-sm">{achievement.title}</p> </Heading>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p> <ProfileRaceHistory driverId={viewData.driver.id} />
<p className="text-gray-500 text-xs mt-1">{achievement.earnedAtLabel}</p> </Stack>
</div> </Card>
</div> )}
</div>
); {activeTab === 'stats' && viewData.stats && (
})} <Card>
</div> <Stack gap={6}>
</Card> <Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
)} Performance Overview
</div> </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'; 'use client';
import { useEffect, useState } from 'react'; import React from 'react';
import Link from 'next/link';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Box } from '@/ui/Box';
import Heading from '@/components/ui/Heading'; 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 { 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 { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
ArrowRight,
Calendar,
Car,
CheckCircle2, CheckCircle2,
Clock, Clock,
Flag,
PlayCircle, PlayCircle,
Scale,
Trophy, Trophy,
UserMinus,
UserPlus,
Users,
XCircle, XCircle,
Zap, Scale,
} from 'lucide-react'; } from 'lucide-react';
import { Surface } from '@/ui/Surface';
import { Card } from '@/ui/Card';
export interface RaceDetailEntryViewModel { export interface RaceDetailEntryViewModel {
id: string; id: string;
@@ -69,7 +74,7 @@ export interface RaceDetailRegistration {
canRegister: boolean; canRegister: boolean;
} }
export interface RaceDetailViewModel { export interface RaceDetailViewData {
race: RaceDetailRace; race: RaceDetailRace;
league?: RaceDetailLeague; league?: RaceDetailLeague;
entryList: RaceDetailEntryViewModel[]; entryList: RaceDetailEntryViewModel[];
@@ -79,7 +84,7 @@ export interface RaceDetailViewModel {
} }
export interface RaceDetailTemplateProps { export interface RaceDetailTemplateProps {
viewModel?: RaceDetailViewModel; viewData?: RaceDetailViewData;
isLoading: boolean; isLoading: boolean;
error?: Error | null; error?: Error | null;
// Actions // Actions
@@ -98,10 +103,7 @@ export interface RaceDetailTemplateProps {
currentDriverId?: string; currentDriverId?: string;
isOwnerOrAdmin?: boolean; isOwnerOrAdmin?: boolean;
// UI State // UI State
showProtestModal: boolean; animatedRatingChange: number;
setShowProtestModal: (show: boolean) => void;
showEndRaceModal: boolean;
setShowEndRaceModal: (show: boolean) => void;
// Loading states // Loading states
mutationLoading?: { mutationLoading?: {
register?: boolean; register?: boolean;
@@ -113,7 +115,7 @@ export interface RaceDetailTemplateProps {
} }
export function RaceDetailTemplate({ export function RaceDetailTemplate({
viewModel, viewData,
isLoading, isLoading,
error, error,
onBack, onBack,
@@ -125,183 +127,88 @@ export function RaceDetailTemplate({
onFileProtest, onFileProtest,
onResultsClick, onResultsClick,
onStewardingClick, onStewardingClick,
onLeagueClick,
onDriverClick, onDriverClick,
currentDriverId,
isOwnerOrAdmin = false, isOwnerOrAdmin = false,
showProtestModal, animatedRatingChange,
setShowProtestModal,
showEndRaceModal,
setShowEndRaceModal,
mutationLoading = {}, mutationLoading = {},
}: RaceDetailTemplateProps) { }: RaceDetailTemplateProps) {
const [ratingChange, setRatingChange] = useState<number | null>(null); if (isLoading) {
const [animatedRatingChange, setAnimatedRatingChange] = useState(0); 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 if (error || !viewData || !viewData.race) {
useEffect(() => { return (
if (viewModel?.userResult?.ratingChange !== undefined) { <Container size="md" py={8}>
setRatingChange(viewModel.userResult.ratingChange); <Stack gap={6}>
} <Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
}, [viewModel?.userResult?.ratingChange]);
// Animate rating change when it changes <Card>
useEffect(() => { <Stack align="center" gap={4} py={12}>
if (ratingChange !== null) { <Surface variant="muted" rounded="full" padding={4}>
let start = 0; <Icon icon={AlertTriangle} size={8} color="#f59e0b" />
const end = ratingChange; </Surface>
const duration = 1000; <Box>
const startTime = performance.now(); <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 { race, league, entryList, userResult } = viewData;
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 statusConfig = { const statusConfig = {
scheduled: { scheduled: {
icon: Clock, icon: Clock,
color: 'text-primary-blue', variant: 'primary' as const,
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled', label: 'Scheduled',
description: 'This race is scheduled and waiting to start', description: 'This race is scheduled and waiting to start',
}, },
running: { running: {
icon: PlayCircle, icon: PlayCircle,
color: 'text-performance-green', variant: 'success' as const,
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE NOW', label: 'LIVE NOW',
description: 'This race is currently in progress', description: 'This race is currently in progress',
}, },
completed: { completed: {
icon: CheckCircle2, icon: CheckCircle2,
color: 'text-gray-400', variant: 'default' as const,
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed', label: 'Completed',
description: 'This race has finished', description: 'This race has finished',
}, },
cancelled: { cancelled: {
icon: XCircle, icon: XCircle,
color: 'text-warning-amber', variant: 'warning' as const,
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled', label: 'Cancelled',
description: 'This race has been 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) { const config = statusConfig[race.status] || statusConfig.scheduled;
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 breadcrumbItems = [ const breadcrumbItems = [
{ label: 'Races', href: '/races' }, { label: 'Races', href: '/races' },
@@ -310,544 +217,109 @@ export function RaceDetailTemplate({
]; ];
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={8}>
<div className="max-w-7xl mx-auto space-y-6"> <Stack gap={6}>
{/* Navigation Row: Breadcrumbs left, Back button right */} {/* Navigation Row */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" /> <Breadcrumbs items={breadcrumbItems} />
<Button <Button
variant="secondary" variant="secondary"
onClick={onBack} 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 Back
</Button> </Button>
</div> </Stack>
{/* User Result - Premium Achievement Card */} {/* User Result */}
{userResult && ( {userResult && (
<div <RaceUserResult
className={` {...userResult}
relative overflow-hidden rounded-2xl p-1 animatedRatingChange={animatedRatingChange}
${ />
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>
)} )}
{/* Hero Header */} {/* Hero Header */}
<div className={`relative overflow-hidden rounded-2xl ${config.bg} border ${config.border} p-6 sm:p-8`}> <RaceHero
{/* Live indicator */} track={race.track}
{race.status === 'running' && ( scheduledAt={race.scheduledAt}
<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" /> 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"> <RaceEntryList
{/* Status Badge */} entries={entryList}
<div className="flex items-center gap-3 mb-4"> onDriverClick={onDriverClick}
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}> />
{race.status === 'running' && ( </Stack>
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" /> </GridItem>
)}
<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>
{/* Title */} <GridItem lgSpan={4} colSpan={12}>
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2"> <Stack gap={6}>
{race.track} {league && <LeagueSummaryCard league={league} />}
</Heading>
{/* Meta */} {/* Actions Card */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400"> <Card>
<span className="flex items-center gap-2"> <Stack gap={4}>
<Calendar className="w-4 h-4" /> <Text size="xl" weight="bold" color="text-white">Actions</Text>
{formatDate(new Date(race.scheduledAt))} <Stack gap={3}>
</span> <RaceJoinButton
<span className="flex items-center gap-2"> raceStatus={race.status}
<Clock className="w-4 h-4" /> isUserRegistered={viewData.registration.isUserRegistered}
{formatTime(new Date(race.scheduledAt))} canRegister={viewData.registration.canRegister}
</span> onRegister={onRegister}
<span className="flex items-center gap-2"> onWithdraw={onWithdraw}
<Car className="w-4 h-4" /> onCancel={onCancel}
{race.car} onReopen={onReopen}
</span> onEndRace={onEndRace}
</div> canReopenRace={viewData.canReopenRace}
</div> isOwnerOrAdmin={isOwnerOrAdmin}
{/* Prominent SOF Badge - Electric Design */} isLoading={mutationLoading}
{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"
/> />
</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 && ( {race.status === 'completed' && (
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p> <>
)} <Button variant="primary" fullWidth onClick={onResultsClick} icon={<Icon icon={Trophy} size={4} />}>
View Results
<div className="grid grid-cols-2 gap-3 mb-4"> </Button>
<div className="p-3 rounded-lg bg-deep-graphite"> {userResult && (
<p className="text-xs text-gray-500 mb-1">Max Drivers</p> <Button variant="secondary" fullWidth onClick={onFileProtest} icon={<Icon icon={Scale} size={4} />}>
<p className="text-white font-medium">{league.settings.maxDrivers ?? 32}</p> File Protest
</div> </Button>
<div className="p-3 rounded-lg bg-deep-graphite"> )}
<p className="text-xs text-gray-500 mb-1">Format</p> <Button variant="secondary" fullWidth onClick={onStewardingClick} icon={<Icon icon={Scale} size={4} />}>
<p className="text-white font-medium capitalize"> Stewarding
{league.settings.qualifyingFormat ?? 'Open'} </Button>
</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>
)} )}
<Button </Stack>
variant="secondary" </Stack>
className="w-full flex items-center justify-center gap-2" </Card>
onClick={onStewardingClick}
>
<Scale className="w-4 h-4" />
Stewarding
</Button>
</>
)}
</div>
</Card>
{/* Status Info */} {/* Status Info */}
<Card className={`${config.bg} border ${config.border}`}> <InfoBox
<div className="flex items-start gap-3"> icon={config.icon}
<div className={`p-2 rounded-lg ${config.bg}`}> title={config.label}
<StatusIcon className={`w-5 h-5 ${config.color}`} /> description={config.description}
</div> variant={config.variant}
<div> />
<p className={`font-medium ${config.color}`}>{config.label}</p> </Stack>
<p className="text-sm text-gray-400 mt-1">{config.description}</p> </GridItem>
</div> </Grid>
</div> </Stack>
</Card> </Container>
</div>
</div>
</div>
{/* Modals would be rendered by parent */}
</div>
); );
} }

View File

@@ -1,44 +1,24 @@
'use client'; 'use client';
import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import Button from '@/components/ui/Button'; import { Button } from '@/ui/Button';
import Card from '@/components/ui/Card'; import { Card } from '@/ui/Card';
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
export interface ResultEntry { import { Text } from '@/ui/Text';
position: number; import { Heading } from '@/ui/Heading';
driverId: string; import { Container } from '@/ui/Container';
driverName: string; import { Grid } from '@/ui/Grid';
driverAvatar: string; import { Icon } from '@/ui/Icon';
country: string; import { Surface } from '@/ui/Surface';
car: string; import { ArrowLeft, Trophy, Zap } from 'lucide-react';
laps: number; import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
time: string; import { RaceResultRow } from '@/components/races/RaceResultRow';
fastestLap: string; import { RacePenaltyRow } from '@/components/races/RacePenaltyRow';
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;
}
export interface RaceResultsTemplateProps { export interface RaceResultsTemplateProps {
raceTrack?: string; viewData: RaceResultsViewData;
raceScheduledAt?: string;
totalDrivers?: number;
leagueName?: string;
raceSOF?: number | null;
results: ResultEntry[];
penalties: PenaltyEntry[];
pointsSystem: Record<string, number>;
fastestLapTime: number;
currentDriverId: string; currentDriverId: string;
isAdmin: boolean; isAdmin: boolean;
isLoading: boolean; isLoading: boolean;
@@ -56,27 +36,15 @@ export interface RaceResultsTemplateProps {
} }
export function RaceResultsTemplate({ export function RaceResultsTemplate({
raceTrack, viewData,
raceScheduledAt,
totalDrivers,
leagueName,
raceSOF,
results,
penalties,
pointsSystem,
fastestLapTime,
currentDriverId, currentDriverId,
isAdmin,
isLoading, isLoading,
error, error,
onBack, onBack,
onImportResults, onImportResults,
onPenaltyClick,
importing, importing,
importSuccess, importSuccess,
importError, importError,
showImportForm,
setShowImportForm,
}: RaceResultsTemplateProps) { }: RaceResultsTemplateProps) {
const formatDate = (date: string) => { const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', { 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')}`; 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 = [ const breadcrumbItems = [
{ label: 'Races', href: '/races' }, { label: 'Races', href: '/races' },
...(leagueName ? [{ label: leagueName, href: `/leagues/${leagueName}` }] : []), ...(viewData.leagueName ? [{ label: viewData.leagueName, href: `/leagues/${viewData.leagueName}` }] : []),
...(raceTrack ? [{ label: raceTrack, href: `/races/${raceTrack}` }] : []), ...(viewData.raceTrack ? [{ label: viewData.raceTrack, href: `/races/${viewData.raceTrack}` }] : []),
{ label: 'Results' }, { label: 'Results' },
]; ];
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={12}>
<div className="max-w-6xl mx-auto"> <Stack align="center">
<div className="text-center text-gray-400">Loading results...</div> <Text color="text-gray-400">Loading results...</Text>
</div> </Stack>
</div> </Container>
); );
} }
if (error && !raceTrack) { if (error && !viewData.raceTrack) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8"> <Container size="md" py={12}>
<div className="max-w-6xl mx-auto"> <Card>
<Card className="text-center py-12"> <Stack align="center" py={12} gap={4}>
<div className="text-warning-amber mb-4"> <Text color="text-warning-amber">{error?.message || 'Race not found'}</Text>
{error?.message || 'Race not found'}
</div>
<Button <Button
variant="secondary" variant="secondary"
onClick={onBack} onClick={onBack}
> >
Back to Races Back to Races
</Button> </Button>
</Card> </Stack>
</div> </Card>
</div> </Container>
); );
} }
const hasResults = results.length > 0; const hasResults = viewData.results.length > 0;
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={8}>
<div className="max-w-6xl mx-auto space-y-6"> <Stack gap={6}>
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" /> <Breadcrumbs items={breadcrumbItems} />
<Button <Button
variant="secondary" variant="secondary"
onClick={onBack} onClick={onBack}
className="flex items-center gap-2 text-sm" icon={<Icon icon={ArrowLeft} size={4} />}
> >
<ArrowLeft className="w-4 h-4" />
Back Back
</Button> </Button>
</div> </Stack>
{/* Header */} {/* Header */}
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30"> <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' }}>
<div className="flex items-center gap-4 mb-4"> <Stack direction="row" align="center" gap={4} mb={6}>
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center"> <Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Trophy className="w-6 h-6 text-primary-blue" /> <Icon icon={Trophy} size={6} color="#3b82f6" />
</div> </Surface>
<div> <Box>
<h1 className="text-2xl font-bold text-white">Race Results</h1> <Heading level={1}>Race Results</Heading>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block mt={1}>
{raceTrack} {raceScheduledAt ? formatDate(raceScheduledAt) : ''} {viewData.raceTrack} {viewData.raceScheduledAt ? formatDate(viewData.raceScheduledAt) : ''}
</p> </Text>
</div> </Box>
</div> </Stack>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <Grid cols={4} gap={4}>
<div className="p-3 bg-deep-graphite/60 rounded-lg"> <StatItem label="Drivers" value={viewData.totalDrivers ?? 0} />
<p className="text-xs text-gray-400 mb-1">Drivers</p> <StatItem label="League" value={viewData.leagueName ?? '—'} />
<p className="text-lg font-bold text-white">{totalDrivers ?? 0}</p> <StatItem label="SOF" value={viewData.raceSOF ?? '—'} icon={Zap} color="#f59e0b" />
</div> <StatItem label="Fastest Lap" value={viewData.fastestLapTime ? formatTime(viewData.fastestLapTime) : '—'} color="#10b981" />
<div className="p-3 bg-deep-graphite/60 rounded-lg"> </Grid>
<p className="text-xs text-gray-400 mb-1">League</p> </Surface>
<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>
{importSuccess && ( {importSuccess && (
<div className="p-4 bg-performance-green/10 border border-performance-green/30 rounded-lg text-performance-green"> <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
<strong>Success!</strong> Results imported and standings updated. <Text color="text-performance-green" weight="bold">Success!</Text>
</div> <Text color="text-performance-green" size="sm" block mt={1}>Results imported and standings updated.</Text>
</Surface>
)} )}
{importError && ( {importError && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber"> <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<strong>Error:</strong> {importError} <Text color="text-error-red" weight="bold">Error:</Text>
</div> <Text color="text-error-red" size="sm" block mt={1}>{importError}</Text>
</Surface>
)} )}
<Card> <Card>
{hasResults ? ( {hasResults ? (
<div className="space-y-4"> <Stack gap={6}>
{/* Results Table */} {/* Results Table */}
<div className="space-y-2"> <Stack gap={2}>
{results.map((result) => { {viewData.results.map((result) => (
const isCurrentUser = result.driverId === currentDriverId; <RaceResultRow
const countryFlag = getCountryFlag(result.country); key={result.driverId}
const points = pointsSystem[result.position.toString()] ?? 0; result={result as any}
points={viewData.pointsSystem[result.position.toString()] ?? 0}
return ( />
<div ))}
key={result.driverId} </Stack>
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>
{/* Penalties Section */} {/* Penalties Section */}
{penalties.length > 0 && ( {viewData.penalties.length > 0 && (
<div className="mt-6 pt-6 border-t border-charcoal-outline"> <Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<h3 className="text-lg font-semibold text-white mb-4">Penalties</h3> <Box mb={4}>
<div className="space-y-2"> <Heading level={2}>Penalties</Heading>
{penalties.map((penalty, index) => ( </Box>
<div key={index} className="flex items-center gap-3 p-3 bg-deep-graphite rounded-lg"> <Stack gap={2}>
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0"> {viewData.penalties.map((penalty, index) => (
<span className="text-red-400 font-bold text-sm">!</span> <RacePenaltyRow key={index} penalty={penalty as any} />
</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>
))} ))}
</div> </Stack>
</div> </Box>
)} )}
</div> </Stack>
) : ( ) : (
<> <Stack gap={6}>
<h2 className="text-xl font-semibold text-white mb-6">Import Results</h2> <Box>
<p className="text-gray-400 text-sm mb-6"> <Heading level={2}>Import Results</Heading>
No results imported. Upload CSV to test the standings system. <Text size="sm" color="text-gray-400" block mt={2}>
</p> No results imported. Upload CSV to test the standings system.
</Text>
</Box>
{importing ? ( {importing ? (
<div className="text-center py-8 text-gray-400"> <Stack align="center" py={8}>
Importing results and updating standings... <Text color="text-gray-400">Importing results and updating standings...</Text>
</div> </Stack>
) : ( ) : (
<div className="space-y-4"> <Stack gap={4}>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
This is a placeholder for the import form. In the actual implementation, This is a placeholder for the import form. In the actual implementation,
this would render the ImportResultsForm component. this would render the ImportResultsForm component.
</p> </Text>
<Button <Box>
variant="primary" <Button
onClick={() => { variant="primary"
// Mock import for demo onClick={() => onImportResults([])}
onImportResults([]); disabled={importing}
}} >
disabled={importing} Import Results (Demo)
> </Button>
Import Results (Demo) </Box>
</Button> </Stack>
</div>
)} )}
</> </Stack>
)} )}
</Card> </Card>
</div> </Stack>
</div> </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'; 'use client';
import { useState } from 'react'; import React from 'react';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import RaceStewardingStats from '@/components/races/RaceStewardingStats'; 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 { 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 { import {
AlertCircle,
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
CheckCircle, CheckCircle,
Clock,
Flag, Flag,
Gavel, Gavel,
Scale, Scale,
Video
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
export type StewardingTab = 'pending' | 'resolved' | 'penalties'; export type StewardingTab = 'pending' | 'resolved' | 'penalties';
export interface Protest { interface RaceStewardingTemplateProps {
id: string; viewData: RaceStewardingViewData;
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;
isLoading: boolean; isLoading: boolean;
error?: Error | null; error?: Error | null;
// Actions // Actions
@@ -82,7 +42,7 @@ export interface RaceStewardingTemplateProps {
} }
export function RaceStewardingTemplate({ export function RaceStewardingTemplate({
stewardingData, viewData,
isLoading, isLoading,
error, error,
onBack, onBack,
@@ -91,345 +51,178 @@ export function RaceStewardingTemplate({
activeTab, activeTab,
setActiveTab, setActiveTab,
}: RaceStewardingTemplateProps) { }: RaceStewardingTemplateProps) {
const formatDate = (date: Date | string) => { const formatDate = (date: string) => {
const d = typeof date === 'string' ? new Date(date) : date; return new Date(date).toLocaleDateString('en-US', {
return d.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: '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) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={12}>
<div className="max-w-4xl mx-auto"> <Stack align="center">
<div className="animate-pulse space-y-6"> <Text color="text-gray-400">Loading stewarding data...</Text>
<div className="h-6 bg-iron-gray rounded w-1/4" /> </Stack>
<div className="h-48 bg-iron-gray rounded-xl" /> </Container>
</div>
</div>
</div>
); );
} }
if (!stewardingData?.race) { if (!viewData?.race) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="md" py={12}>
<div className="max-w-4xl mx-auto"> <Card>
<Card className="text-center py-12"> <Stack align="center" py={12} gap={4}>
<div className="flex flex-col items-center gap-4"> <Surface variant="muted" rounded="full" padding={4}>
<div className="p-4 bg-warning-amber/10 rounded-full"> <Icon icon={AlertTriangle} size={8} color="#f59e0b" />
<AlertTriangle className="w-8 h-8 text-warning-amber" /> </Surface>
</div> <Box style={{ textAlign: 'center' }}>
<div> <Text weight="medium" color="text-white" block mb={1}>Race not found</Text>
<p className="text-white font-medium mb-1">Race not found</p> <Text size="sm" color="text-gray-500">The race you're looking for doesn't exist.</Text>
<p className="text-sm text-gray-500"> </Box>
The race you're looking for doesn't exist. <Button variant="secondary" onClick={onBack}>
</p> Back to Races
</div> </Button>
<Button variant="secondary" onClick={onBack}> </Stack>
Back to Races </Card>
</Button> </Container>
</div>
</Card>
</div>
</div>
); );
} }
const breadcrumbItems = [ const breadcrumbItems = [
{ label: 'Races', href: '/races' }, { label: 'Races', href: '/races' },
{ label: stewardingData.race.track, href: `/races/${stewardingData.race.id}` }, { label: viewData.race.track, href: `/races/${viewData.race.id}` },
{ label: 'Stewarding' }, { label: 'Stewarding' },
]; ];
const pendingProtests = stewardingData.pendingProtests ?? [];
const resolvedProtests = stewardingData.resolvedProtests ?? [];
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={8}>
<div className="max-w-4xl mx-auto space-y-6"> <Stack gap={6}>
{/* Navigation */} {/* Navigation */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<Breadcrumbs items={breadcrumbItems} className="text-sm text-gray-400" /> <Breadcrumbs items={breadcrumbItems} />
<Button <Button
variant="secondary" variant="secondary"
onClick={() => onBack()} onClick={onBack}
className="flex items-center gap-2 text-sm" icon={<Icon icon={ArrowLeft} size={4} />}
> >
<ArrowLeft className="w-4 h-4" />
Back to Race Back to Race
</Button> </Button>
</div> </Stack>
{/* Header */} {/* Header */}
<Card className="bg-gradient-to-r from-iron-gray/50 to-iron-gray/30"> <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' }}>
<div className="flex items-center gap-4 mb-4"> <Stack direction="row" align="center" gap={4} mb={6}>
<div className="w-12 h-12 rounded-xl bg-primary-blue/20 flex items-center justify-center"> <Surface variant="muted" rounded="xl" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Scale className="w-6 h-6 text-primary-blue" /> <Icon icon={Scale} size={6} color="#3b82f6" />
</div> </Surface>
<div> <Box>
<h1 className="text-2xl font-bold text-white">Stewarding</h1> <Heading level={1}>Stewarding</Heading>
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400" block mt={1}>
{stewardingData.race.track} {stewardingData.race.scheduledAt ? formatDate(stewardingData.race.scheduledAt) : ''} {viewData.race.track} {formatDate(viewData.race.scheduledAt)}
</p> </Text>
</div> </Box>
</div> </Stack>
{/* Stats */} {/* Stats */}
<RaceStewardingStats <RaceStewardingStats
pendingCount={stewardingData.pendingCount ?? 0} pendingCount={viewData.pendingCount}
resolvedCount={stewardingData.resolvedCount ?? 0} resolvedCount={viewData.resolvedCount}
penaltiesCount={stewardingData.penaltiesCount ?? 0} penaltiesCount={viewData.penaltiesCount}
/> />
</Card> </Surface>
{/* Tab Navigation */} {/* Tab Navigation */}
<StewardingTabs <StewardingTabs
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
pendingCount={pendingProtests.length} pendingCount={viewData.pendingProtests.length}
/> />
{/* Content */} {/* Content */}
{activeTab === 'pending' && ( {activeTab === 'pending' && (
<div className="space-y-4"> <Stack gap={4}>
{pendingProtests.length === 0 ? ( {viewData.pendingProtests.length === 0 ? (
<Card className="text-center py-12"> <Card>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center"> <Stack align="center" py={12} gap={4}>
<Flag className="w-8 h-8 text-performance-green" /> <Surface variant="muted" rounded="full" padding={4}>
</div> <Icon icon={Flag} size={8} color="#10b981" />
<p className="font-semibold text-lg text-white mb-2">All Clear!</p> </Surface>
<p className="text-sm text-gray-400">No pending protests to review</p> <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> </Card>
) : ( ) : (
pendingProtests.map((protest) => { viewData.pendingProtests.map((protest) => (
const protester = stewardingData.driverMap[protest.protestingDriverId]; <ProtestCard
const accused = stewardingData.driverMap[protest.accusedDriverId]; key={protest.id}
const daysSinceFiled = Math.floor( protest={protest as any}
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24) protester={viewData.driverMap[protest.protestingDriverId]}
); accused={viewData.driverMap[protest.accusedDriverId]}
const isUrgent = daysSinceFiled > 2; isAdmin={isAdmin}
onReview={onReviewProtest}
return ( formatDate={formatDate}
<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>
);
})
)} )}
</div> </Stack>
)} )}
{activeTab === 'resolved' && ( {activeTab === 'resolved' && (
<div className="space-y-4"> <Stack gap={4}>
{resolvedProtests.length === 0 ? ( {viewData.resolvedProtests.length === 0 ? (
<Card className="text-center py-12"> <Card>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center"> <Stack align="center" py={12} gap={4}>
<CheckCircle className="w-8 h-8 text-gray-500" /> <Surface variant="muted" rounded="full" padding={4}>
</div> <Icon icon={CheckCircle} size={8} color="#525252" />
<p className="font-semibold text-lg text-white mb-2">No Resolved Protests</p> </Surface>
<p className="text-sm text-gray-400"> <Box style={{ textAlign: 'center' }}>
Resolved protests will appear here <Text weight="semibold" size="lg" color="text-white" block mb={1}>No Resolved Protests</Text>
</p> <Text size="sm" color="text-gray-400">Resolved protests will appear here</Text>
</Box>
</Stack>
</Card> </Card>
) : ( ) : (
resolvedProtests.map((protest) => { viewData.resolvedProtests.map((protest) => (
const protester = stewardingData.driverMap[protest.protestingDriverId]; <ProtestCard
const accused = stewardingData.driverMap[protest.accusedDriverId]; key={protest.id}
protest={protest as any}
return ( protester={viewData.driverMap[protest.protestingDriverId]}
<Card key={protest.id}> accused={viewData.driverMap[protest.accusedDriverId]}
<div className="flex items-start justify-between gap-4"> isAdmin={isAdmin}
<div className="flex-1 min-w-0"> onReview={onReviewProtest}
<div className="flex items-center gap-2 mb-2"> formatDate={formatDate}
<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>
);
})
)} )}
</div> </Stack>
)} )}
{activeTab === 'penalties' && ( {activeTab === 'penalties' && (
<div className="space-y-4"> <Stack gap={4}>
{stewardingData?.penalties.length === 0 ? ( {viewData.penalties.length === 0 ? (
<Card className="text-center py-12"> <Card>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-500/10 flex items-center justify-center"> <Stack align="center" py={12} gap={4}>
<Gavel className="w-8 h-8 text-gray-500" /> <Surface variant="muted" rounded="full" padding={4}>
</div> <Icon icon={Gavel} size={8} color="#525252" />
<p className="font-semibold text-lg text-white mb-2">No Penalties</p> </Surface>
<p className="text-sm text-gray-400"> <Box style={{ textAlign: 'center' }}>
Penalties issued for this race will appear here <Text weight="semibold" size="lg" color="text-white" block mb={1}>No Penalties</Text>
</p> <Text size="sm" color="text-gray-400">Penalties issued for this race will appear here</Text>
</Box>
</Stack>
</Card> </Card>
) : ( ) : (
stewardingData?.penalties.map((penalty) => { viewData.penalties.map((penalty) => (
const driver = stewardingData.driverMap[penalty.driverId]; <RacePenaltyRow key={penalty.id} penalty={penalty as any} />
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>
);
})
)} )}
</div> </Stack>
)} )}
</div> </Stack>
</div> </Container>
); );
} }

View File

@@ -1,45 +1,31 @@
'use client'; 'use client';
import { useMemo, useEffect } from 'react'; import React, { useMemo, useEffect } from 'react';
import Link from 'next/link'; import { Card } from '@/ui/Card';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { import {
Calendar,
Clock,
Flag, Flag,
ChevronRight,
ChevronLeft,
Car,
Trophy,
Zap,
PlayCircle,
CheckCircle2,
XCircle,
Search,
SlidersHorizontal, SlidersHorizontal,
Calendar,
} from 'lucide-react'; } from 'lucide-react';
import { RaceFilterModal } from '@/components/races/RaceFilterModal'; import { RaceFilterModal } from '@/components/races/RaceFilterModal';
import { RacePagination } from '@/components/races/RacePagination'; 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 type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
export interface Race { interface RacesAllTemplateProps {
id: string; viewData: RacesViewData;
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[];
isLoading: boolean; isLoading: boolean;
// Pagination // Pagination
currentPage: number; currentPage: number;
@@ -64,7 +50,7 @@ export interface RacesAllTemplateProps {
} }
export function RacesAllTemplate({ export function RacesAllTemplate({
races, viewData,
isLoading, isLoading,
currentPage, currentPage,
totalPages, totalPages,
@@ -81,8 +67,9 @@ export function RacesAllTemplate({
showFilterModal, showFilterModal,
setShowFilterModal, setShowFilterModal,
onRaceClick, onRaceClick,
onLeagueClick,
}: RacesAllTemplateProps) { }: RacesAllTemplateProps) {
const { races } = viewData;
// Filter races // Filter races
const filteredRaces = useMemo(() => { const filteredRaces = useMemo(() => {
return races.filter(race => { return races.filter(race => {
@@ -119,55 +106,6 @@ export function RacesAllTemplate({
onPageChange(1); onPageChange(1);
}, [statusFilter, leagueFilter, searchQuery]); }, [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 = [ const breadcrumbItems = [
{ label: 'Races', href: '/races' }, { label: 'Races', href: '/races' },
{ label: 'All Races' }, { label: 'All Races' },
@@ -175,214 +113,85 @@ export function RacesAllTemplate({
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={8}>
<div className="max-w-5xl mx-auto"> <Stack gap={6}>
<div className="animate-pulse space-y-6"> <Skeleton width="8rem" height="1.5rem" />
<div className="h-6 bg-iron-gray rounded w-1/4" /> <Skeleton width="12rem" height="2.5rem" />
<div className="h-10 bg-iron-gray rounded w-1/3" /> <Stack gap={4}>
<div className="space-y-4"> {[1, 2, 3, 4, 5].map(i => (
{[1, 2, 3, 4, 5].map(i => ( <Skeleton key={i} width="100%" height="6rem" />
<div key={i} className="h-24 bg-iron-gray rounded-lg" /> ))}
))} </Stack>
</div> </Stack>
</div> </Container>
</div>
</div>
); );
} }
return ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Container size="lg" py={8}>
<div className="max-w-5xl mx-auto space-y-6"> <Stack gap={6}>
{/* Breadcrumbs */} {/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbItems} /> <Breadcrumbs items={breadcrumbItems} />
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<div> <Box>
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3"> <Heading level={1} icon={<Icon icon={Flag} size={6} color="#3b82f6" />}>
<Flag className="w-6 h-6 text-primary-blue" />
All Races All Races
</Heading> </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 {filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
</p> </Text>
</div> </Box>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2" icon={<Icon icon={SlidersHorizontal} size={4} />}
> >
<SlidersHorizontal className="w-4 h-4" />
Filters Filters
</Button> </Button>
</div> </Stack>
{/* Search & Filters */} {/* Search & Filters (Simplified for template) */}
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}> {showFilters && (
<div className="space-y-4"> <Card>
{/* Search */} <Stack gap={4}>
<div className="relative"> <Text size="sm" color="text-gray-400">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> Use the filter button to open advanced search and filtering options.
<input </Text>
type="text" <Box>
value={searchQuery} <Button variant="primary" onClick={() => setShowFilterModal(true)}>
onChange={(e) => setSearchQuery(e.target.value)} Open Filters
placeholder="Search by track, car, or league..." </Button>
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" </Box>
/> </Stack>
</div> </Card>
)}
{/* 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>
{/* Race List */} {/* Race List */}
{paginatedRaces.length === 0 ? ( {paginatedRaces.length === 0 ? (
<Card className="text-center py-12"> <Card>
<div className="flex flex-col items-center gap-4"> <Stack align="center" py={12} gap={4}>
<div className="p-4 bg-iron-gray rounded-full"> <Surface variant="muted" rounded="full" padding={4}>
<Calendar className="w-8 h-8 text-gray-500" /> <Icon icon={Calendar} size={8} color="#525252" />
</div> </Surface>
<div> <Box style={{ textAlign: 'center' }}>
<p className="text-white font-medium mb-1">No races found</p> <Text weight="medium" color="text-white" block mb={1}>No races found</Text>
<p className="text-sm text-gray-500"> <Text size="sm" color="text-gray-500">
{races.length === 0 {races.length === 0
? 'No races have been scheduled yet' ? 'No races have been scheduled yet'
: 'Try adjusting your search or filters'} : 'Try adjusting your search or filters'}
</p> </Text>
</div> </Box>
</div> </Stack>
</Card> </Card>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{paginatedRaces.map(race => { {paginatedRaces.map(race => (
const config = statusConfig[race.status as keyof typeof statusConfig]; <RaceListItem key={race.id} race={race as any} onClick={onRaceClick} />
const StatusIcon = config.icon; ))}
</Stack>
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>
)} )}
{/* Pagination */} {/* Pagination */}
@@ -406,11 +215,11 @@ export function RacesAllTemplate({
setTimeFilter={() => {}} setTimeFilter={() => {}}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]} leagues={viewData.leagues}
showSearch={true} showSearch={true}
showTimeFilter={false} showTimeFilter={false}
/> />
</div> </Stack>
</div> </Container>
); );
} }

View File

@@ -1,53 +1,24 @@
'use client'; 'use client';
import { useMemo } from 'react'; import React from 'react';
import Link from 'next/link'; import { Box } from '@/ui/Box';
import Card from '@/components/ui/Card'; import { Stack } from '@/ui/Stack';
import Heading from '@/components/ui/Heading'; import { Container } from '@/ui/Container';
import {
Calendar,
Clock,
Flag,
ChevronRight,
MapPin,
Car,
Trophy,
Users,
Zap,
PlayCircle,
CheckCircle2,
XCircle,
CalendarDays,
ArrowRight,
} from 'lucide-react';
import { RaceFilterModal } from '@/components/races/RaceFilterModal'; 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 TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; 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 { export interface RacesTemplateProps {
races: Race[]; viewData: RacesViewData;
totalCount: number;
scheduledRaces: Race[];
runningRaces: Race[];
completedRaces: Race[];
isLoading: boolean;
// Filters // Filters
statusFilter: RaceStatusFilter; statusFilter: RaceStatusFilter;
setStatusFilter: (filter: RaceStatusFilter) => void; setStatusFilter: (filter: RaceStatusFilter) => void;
@@ -58,24 +29,15 @@ export interface RacesTemplateProps {
// Actions // Actions
onRaceClick: (raceId: string) => void; onRaceClick: (raceId: string) => void;
onLeagueClick: (leagueId: string) => void; onLeagueClick: (leagueId: string) => void;
onRegister: (raceId: string, leagueId: string) => void;
onWithdraw: (raceId: string) => void; onWithdraw: (raceId: string) => void;
onCancel: (raceId: string) => void; onCancel: (raceId: string) => void;
// UI State // UI State
showFilterModal: boolean; showFilterModal: boolean;
setShowFilterModal: (show: boolean) => void; setShowFilterModal: (show: boolean) => void;
// User state
currentDriverId?: string;
userMemberships?: Array<{ leagueId: string; role: string }>;
} }
export function RacesTemplate({ export function RacesTemplate({
races, viewData,
totalCount,
scheduledRaces,
runningRaces,
completedRaces,
isLoading,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
leagueFilter, leagueFilter,
@@ -83,581 +45,71 @@ export function RacesTemplate({
timeFilter, timeFilter,
setTimeFilter, setTimeFilter,
onRaceClick, onRaceClick,
onLeagueClick,
onRegister,
onWithdraw,
onCancel,
showFilterModal, showFilterModal,
setShowFilterModal, setShowFilterModal,
currentDriverId,
userMemberships,
}: RacesTemplateProps) { }: 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 ( return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> <Box as="main">
<div className="max-w-7xl mx-auto space-y-8"> <Container size="lg" py={8}>
{/* Hero Header */} <Stack gap={8}>
<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"> <RacePageHeader
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" /> totalCount={viewData.totalCount}
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" /> scheduledCount={viewData.scheduledCount}
runningCount={viewData.runningCount}
completedCount={viewData.completedCount}
/>
<div className="relative z-10"> <LiveRacesBanner
<div className="flex items-center gap-3 mb-2"> liveRaces={viewData.liveRaces}
<div className="p-2 bg-primary-blue/10 rounded-lg"> onRaceClick={onRaceClick}
<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>
{/* Quick Stats */} <Grid cols={12} gap={6}>
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6"> <GridItem colSpan={12} lgSpan={8}>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50"> <Stack gap={6}>
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1"> <RaceFilterBar
<CalendarDays className="w-4 h-4" /> timeFilter={timeFilter}
<span>Total</span> setTimeFilter={setTimeFilter}
</div> leagueFilter={leagueFilter}
<p className="text-2xl font-bold text-white">{stats.total}</p> setLeagueFilter={setLeagueFilter}
</div> leagues={viewData.leagues}
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50"> onShowMoreFilters={() => setShowFilterModal(true)}
<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>
{/* Live Races Banner */} <RaceList
{liveRaces.length > 0 && ( racesByDate={viewData.racesByDate}
<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"> totalCount={viewData.totalCount}
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" /> onRaceClick={onRaceClick}
/>
</Stack>
</GridItem>
<div className="relative z-10"> <GridItem colSpan={12} lgSpan={4}>
<div className="flex items-center gap-2 mb-4"> <RaceSidebar
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full"> upcomingRaces={viewData.upcomingRaces}
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" /> recentResults={viewData.recentResults}
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span> onRaceClick={onRaceClick}
</div> />
</div> </GridItem>
</Grid>
<div className="space-y-3"> <RaceFilterModal
{liveRaces.map((race) => ( isOpen={showFilterModal}
<div onClose={() => setShowFilterModal(false)}
key={race.id} statusFilter={statusFilter}
onClick={() => onRaceClick(race.id)} setStatusFilter={setStatusFilter}
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" leagueFilter={leagueFilter}
> setLeagueFilter={setLeagueFilter}
<div className="flex items-center gap-4"> timeFilter={timeFilter}
<div className="p-2 bg-performance-green/20 rounded-lg"> setTimeFilter={setTimeFilter}
<PlayCircle className="w-5 h-5 text-performance-green" /> searchQuery=""
</div> setSearchQuery={() => {}}
<div> leagues={viewData.leagues}
<h3 className="font-semibold text-white">{race.track}</h3> showSearch={false}
<p className="text-sm text-gray-400">{race.leagueName}</p> showTimeFilter={false}
</div> />
</div> </Stack>
<ChevronRight className="w-5 h-5 text-gray-400" /> </Container>
</div> </Box>
))}
</div>
</div>
</div>
)}
<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>
{/* 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>
{/* 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>
); );
} }

View File

@@ -1,15 +1,19 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Section } from '@/ui/Section';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Select } from '@/ui/Select'; 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 { MembershipRole } from '@/lib/types/MembershipRole';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO'; import type { LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
interface RosterAdminTemplateProps { interface RosterAdminTemplateProps {
joinRequests: LeagueRosterJoinRequestDTO[]; viewData: LeagueRosterAdminViewData;
members: LeagueRosterMemberDTO[];
loading: boolean; loading: boolean;
pendingCountLabel: string; pendingCountLabel: string;
onApprove: (requestId: string) => Promise<void>; onApprove: (requestId: string) => Promise<void>;
@@ -20,8 +24,7 @@ interface RosterAdminTemplateProps {
} }
export function RosterAdminTemplate({ export function RosterAdminTemplate({
joinRequests, viewData,
members,
loading, loading,
pendingCountLabel, pendingCountLabel,
onApprove, onApprove,
@@ -30,136 +33,122 @@ export function RosterAdminTemplate({
onRemove, onRemove,
roleOptions, roleOptions,
}: RosterAdminTemplateProps) { }: RosterAdminTemplateProps) {
const { joinRequests, members } = viewData;
return ( return (
<Section> <Stack gap={6}>
<Card> <Card>
<Section> <Stack gap={6}>
<Section> <Box>
<Text size="2xl" weight="bold" className="text-white"> <Heading level={1}>Roster Admin</Heading>
Roster Admin <Text size="sm" color="text-gray-400" block mt={1}>
</Text>
<Text size="sm" className="text-gray-400">
Manage join requests and member roles. Manage join requests and member roles.
</Text> </Text>
</Section> </Box>
<Section> <Box>
<div className="flex items-center justify-between gap-3"> <Stack direction="row" align="center" justify="between" mb={4}>
<Text size="lg" weight="semibold" className="text-white"> <Heading level={2}>Pending join requests</Heading>
Pending join requests <Text size="xs" color="text-gray-500">
</Text>
<Text size="xs" className="text-gray-500">
{pendingCountLabel} {pendingCountLabel}
</Text> </Text>
</div> </Stack>
{loading ? ( {loading ? (
<Text size="sm" className="text-gray-400"> <Text size="sm" color="text-gray-400">Loading</Text>
Loading ) : joinRequests.length > 0 ? (
</Text> <Stack gap={3}>
) : joinRequests.length ? (
<div className="space-y-2">
{joinRequests.map((req) => ( {joinRequests.map((req) => (
<div <Surface
key={req.id} 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"> <Stack direction="row" align="center" justify="between">
<Text weight="medium" className="text-white truncate"> <Box>
{(req.driver as any)?.name || 'Unknown'} <Text weight="medium" color="text-white" block>{req.driver.name}</Text>
</Text> <Text size="xs" color="text-gray-400" block mt={1}>{req.requestedAt}</Text>
<Text size="xs" className="text-gray-400 truncate"> {req.message && (
{req.requestedAt} <Text size="xs" color="text-gray-500" block mt={1} truncate>{req.message}</Text>
</Text> )}
{req.message && ( </Box>
<Text size="xs" className="text-gray-500 truncate">
{req.message}
</Text>
)}
</div>
<div className="flex items-center gap-2"> <Stack direction="row" gap={2}>
<Button <Button
data-testid={`join-request-${req.id}-approve`} onClick={() => onApprove(req.id)}
onClick={() => onApprove(req.id)} variant="primary"
className="bg-primary-blue text-white" size="sm"
> >
Approve Approve
</Button> </Button>
<Button <Button
data-testid={`join-request-${req.id}-reject`} onClick={() => onReject(req.id)}
onClick={() => onReject(req.id)} variant="secondary"
className="bg-iron-gray text-gray-200" size="sm"
> >
Reject Reject
</Button> </Button>
</div> </Stack>
</div> </Stack>
</Surface>
))} ))}
</div> </Stack>
) : ( ) : (
<Text size="sm" className="text-gray-500"> <Text size="sm" color="text-gray-500">No pending join requests.</Text>
No pending join requests.
</Text>
)} )}
</Section> </Box>
<Section> <Box pt={6} style={{ borderTop: '1px solid #262626' }}>
<Text size="lg" weight="semibold" className="text-white"> <Box mb={4}>
Members <Heading level={2}>Members</Heading>
</Text> </Box>
{loading ? ( {loading ? (
<Text size="sm" className="text-gray-400"> <Text size="sm" color="text-gray-400">Loading</Text>
Loading ) : members.length > 0 ? (
</Text> <Stack gap={3}>
) : members.length ? (
<div className="space-y-2">
{members.map((member) => ( {members.map((member) => (
<div <Surface
key={member.driverId} 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"> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<Text weight="medium" className="text-white truncate"> <Box>
{(member.driver as any)?.name || 'Unknown'} <Text weight="medium" color="text-white" block>{member.driver.name}</Text>
</Text> <Text size="xs" color="text-gray-400" block mt={1}>{member.joinedAt}</Text>
<Text size="xs" className="text-gray-400 truncate"> </Box>
{member.joinedAt}
</Text>
</div>
<div className="flex flex-col md:flex-row md:items-center gap-2"> <Stack direction="row" align="center" gap={3}>
<label className="text-xs text-gray-400" htmlFor={`role-${member.driverId}`}> <Box>
Role for {(member.driver as any)?.name || 'Unknown'} <Select
</label> value={member.role}
<Select onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
id={`role-${member.driverId}`} options={roleOptions.map((role) => ({ value: role, label: role }))}
aria-label={`Role for ${(member.driver as any)?.name || 'Unknown'}`} />
value={member.role} </Box>
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)} <Button
options={roleOptions.map((role) => ({ value: role, label: role }))} onClick={() => onRemove(member.driverId)}
className="bg-iron-gray text-white px-3 py-2 rounded" variant="secondary"
/> size="sm"
<Button >
data-testid={`member-${member.driverId}-remove`} Remove
onClick={() => onRemove(member.driverId)} </Button>
className="bg-iron-gray text-gray-200" </Stack>
> </Stack>
Remove </Surface>
</Button>
</div>
</div>
))} ))}
</div> </Stack>
) : ( ) : (
<Text size="sm" className="text-gray-500"> <Text size="sm" color="text-gray-500">No members found.</Text>
No members found.
</Text>
)} )}
</Section> </Box>
</Section> </Stack>
</Card> </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 { 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 { interface RulebookTemplateProps {
viewData: RulebookViewData; viewData: RulebookViewData;
@@ -8,95 +18,103 @@ interface RulebookTemplateProps {
export function RulebookTemplate({ viewData }: RulebookTemplateProps) { export function RulebookTemplate({ viewData }: RulebookTemplateProps) {
return ( return (
<Section> <Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<div> <Box>
<h1 className="text-2xl font-bold text-white">Rulebook</h1> <Heading level={1}>Rulebook</Heading>
<p className="text-sm text-gray-400 mt-1">Official rules and regulations</p> <Text size="sm" color="text-gray-400" block mt={1}>Official rules and regulations</Text>
</div> </Box>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/20"> <Badge variant="primary">
<span className="text-sm font-medium text-primary-blue">{viewData.scoringPresetName || 'Custom Rules'}</span> {viewData.scoringPresetName || 'Custom Rules'}
</div> </Badge>
</div> </Stack>
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <Grid cols={4} gap={4}>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline"> <StatItem label="Platform" value={viewData.gameName} />
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Platform</p> <StatItem label="Championships" value={viewData.championshipsCount} />
<p className="text-lg font-semibold text-white">{viewData.gameName}</p> <StatItem label="Sessions Scored" value={viewData.sessionTypes} capitalize />
</div> <StatItem label="Drop Policy" value={viewData.hasActiveDropPolicy ? 'Active' : 'None'} />
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline"> </Grid>
<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>
{/* Points Table */} {/* Points Table */}
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Points System</h2> <Box mb={4}>
<div className="overflow-x-auto"> <Heading level={2}>Points System</Heading>
<table className="w-full"> </Box>
<thead> <Table>
<tr className="border-b border-charcoal-outline"> <TableHead>
<th className="text-left py-2 font-medium text-gray-400">Position</th> <TableRow>
<th className="text-left py-2 font-medium text-gray-400">Points</th> <TableHeader>Position</TableHeader>
</tr> <TableHeader>Points</TableHeader>
</thead> </TableRow>
<tbody> </TableHead>
{viewData.positionPoints.map((point) => ( <TableBody>
<tr key={point.position} className="border-b border-charcoal-outline/50"> {viewData.positionPoints.map((point) => (
<td className="py-3 text-white">{point.position}</td> <TableRow key={point.position}>
<td className="py-3 text-white">{point.points}</td> <TableCell>
</tr> <Text color="text-white">{point.position}</Text>
))} </TableCell>
</tbody> <TableCell>
</table> <Text color="text-white">{point.points}</Text>
</div> </TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card> </Card>
{/* Bonus Points */} {/* Bonus Points */}
{viewData.hasBonusPoints && ( {viewData.hasBonusPoints && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Bonus Points</h2> <Box mb={4}>
<div className="space-y-2"> <Heading level={2}>Bonus Points</Heading>
</Box>
<Stack gap={2}>
{viewData.bonusPoints.map((bonus, idx) => ( {viewData.bonusPoints.map((bonus, idx) => (
<div <Surface
key={idx} 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"> <Stack direction="row" align="center" gap={4}>
<span className="text-performance-green text-sm font-bold">+</span> <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' }}>
</div> <Text color="text-performance-green" weight="bold">+</Text>
<p className="text-sm text-gray-300">{bonus}</p> </Surface>
</div> <Text size="sm" color="text-gray-300">{bonus}</Text>
</Stack>
</Surface>
))} ))}
</div> </Stack>
</Card> </Card>
)} )}
{/* Drop Policy */} {/* Drop Policy */}
{viewData.hasActiveDropPolicy && ( {viewData.hasActiveDropPolicy && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Drop Policy</h2> <Box mb={4}>
<p className="text-sm text-gray-300">{viewData.dropPolicySummary}</p> <Heading level={2}>Drop Policy</Heading>
<p className="text-xs text-gray-500 mt-3"> </Box>
Drop rules are applied automatically when calculating championship standings. <Text size="sm" color="text-gray-300">{viewData.dropPolicySummary}</Text>
</p> <Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings.
</Text>
</Box>
</Card> </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'; 'use client';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import React from 'react';
import StatusBadge from '@/components/ui/StatusBadge'; import { Card } from '@/ui/Card';
import InfoBanner from '@/components/ui/InfoBanner'; import { Button } from '@/ui/Button';
import MetricCard from '@/components/sponsors/MetricCard'; import { Heading } from '@/ui/Heading';
import SponsorshipCategoryCard from '@/components/sponsors/SponsorshipCategoryCard'; import { Box } from '@/ui/Box';
import ActivityItem from '@/components/sponsors/ActivityItem'; import { Stack } from '@/ui/Stack';
import RenewalAlert from '@/components/sponsors/RenewalAlert'; 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 { import {
BarChart3,
Eye, Eye,
Users, Users,
Trophy, Trophy,
TrendingUp, TrendingUp,
Calendar,
DollarSign, DollarSign,
Target, Target,
ArrowUpRight,
ArrowDownRight,
ExternalLink, ExternalLink,
Loader2,
Car, Car,
Flag, Flag,
Megaphone, Megaphone,
@@ -29,308 +35,325 @@ import {
Settings, Settings,
CreditCard, CreditCard,
FileText, FileText,
RefreshCw RefreshCw,
BarChart3,
Calendar
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link';
import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData'; import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData';
import { routes } from '@/lib/routing/RouteConfig';
interface SponsorDashboardTemplateProps { interface SponsorDashboardTemplateProps {
viewData: SponsorDashboardViewData; viewData: SponsorDashboardViewData;
} }
export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) { export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) {
const shouldReduceMotion = useReducedMotion();
const categoryData = viewData.categoryData; const categoryData = viewData.categoryData;
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4"> <Container size="lg" py={8}>
{/* Header */} <Stack gap={8}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> {/* Header */}
<div> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<h2 className="text-2xl font-bold text-white">Sponsor Dashboard</h2> <Box>
<p className="text-gray-400">Welcome back, {viewData.sponsorName}</p> <Heading level={2}>Sponsor Dashboard</Heading>
</div> <Text color="text-gray-400" block mt={1}>Welcome back, {viewData.sponsorName}</Text>
<div className="flex items-center gap-3"> </Box>
{/* Time Range Selector */} <Stack direction="row" align="center" gap={3}>
<div className="flex items-center bg-iron-gray/50 rounded-lg p-1"> {/* Time Range Selector */}
{(['7d', '30d', '90d', 'all'] as const).map((range) => ( <Surface variant="muted" rounded="lg" padding={1}>
<button <Stack direction="row" align="center">
key={range} {(['7d', '30d', '90d', 'all'] as const).map((range) => (
onClick={() => {}} <Button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ key={range}
false variant="ghost"
? 'bg-primary-blue text-white' size="sm"
: 'text-gray-400 hover:text-white' >
}`} {range === 'all' ? 'All' : range}
>
{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" />
</Button> </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> </Stack>
</Card> </Surface>
)}
{/* Recent Activity */} <Button variant="secondary">
<Card className="p-4"> <Icon icon={RefreshCw} size={4} />
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3> </Button>
<div> <Box>
{viewData.recentActivity.map((activity: any) => ( <Link href={routes.sponsor.settings} variant="ghost">
<ActivityItem key={activity.id} activity={activity} /> <Button variant="secondary">
))} <Icon icon={Settings} size={4} />
</div> </Button>
</Card> </Link>
</Box>
</Stack>
</Stack>
{/* Investment Summary */} {/* Key Metrics */}
<Card className="p-4"> <Grid cols={4} gap={4}>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <MetricCard
<FileText className="w-5 h-5 text-primary-blue" /> title="Total Impressions"
Investment Summary value={viewData.totalImpressions}
</h3> change={viewData.metrics.impressionsChange}
<div className="space-y-3"> icon={Eye}
<div className="flex items-center justify-between"> delay={0}
<span className="text-gray-400">Active Sponsorships</span> />
<span className="font-medium text-white">{viewData.activeSponsorships}</span> <MetricCard
</div> title="Unique Viewers"
<div className="flex items-center justify-between"> value="12.5k"
<span className="text-gray-400">Total Investment</span> change={viewData.metrics.viewersChange}
<span className="font-medium text-white">{viewData.formattedTotalInvestment}</span> icon={Users}
</div> delay={0.1}
<div className="flex items-center justify-between"> />
<span className="text-gray-400">Cost per 1K Views</span> <MetricCard
<span className="font-medium text-performance-green"> title="Engagement Rate"
{viewData.costPerThousandViews} value="4.2%"
</span> change={viewData.metrics.exposureChange}
</div> icon={TrendingUp}
<div className="flex items-center justify-between"> suffix="%"
<span className="text-gray-400">Next Invoice</span> delay={0.2}
<span className="font-medium text-white">Jan 1, 2026</span> />
</div> <MetricCard
<div className="pt-3 border-t border-charcoal-outline"> title="Total Investment"
<Link href=routes.sponsor.billing> value={viewData.totalInvestment}
<Button variant="secondary" className="w-full text-sm"> icon={DollarSign}
<CreditCard className="w-4 h-4 mr-2" /> prefix="$"
View Billing Details delay={0.3}
</Button> />
</Link> </Grid>
</div>
</div> {/* Sponsorship Categories */}
</Card> <Box>
</div> <Stack direction="row" align="center" justify="between" mb={4}>
</div> <Heading level={3}>Your Sponsorships</Heading>
</div> <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'; 'use client';
import { useState } from 'react'; import React, { 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 { import {
Trophy, Trophy,
Users, Users,
Calendar, Calendar,
Eye, Eye,
TrendingUp, TrendingUp,
Download,
Image as ImageIcon,
ExternalLink, ExternalLink,
ChevronRight,
Star, Star,
Clock,
CheckCircle2,
Flag, Flag,
Car,
BarChart3, BarChart3,
ArrowUpRight,
Megaphone, Megaphone,
CreditCard, CreditCard,
FileText FileText
} from 'lucide-react'; } 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 { interface SponsorLeagueDetailData {
league: { league: {
@@ -94,485 +98,322 @@ interface SponsorLeagueDetailData {
} }
interface SponsorLeagueDetailTemplateProps { interface SponsorLeagueDetailTemplateProps {
data: SponsorLeagueDetailData; viewData: SponsorLeagueDetailData;
} }
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor'; type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
export function SponsorLeagueDetailTemplate({ data }: SponsorLeagueDetailTemplateProps) { export function SponsorLeagueDetailTemplate({ viewData }: SponsorLeagueDetailTemplateProps) {
const shouldReduceMotion = useReducedMotion();
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main'); const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
const league = data.league; const league = viewData.league;
const config = league.tierConfig;
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4"> <Container size="lg" py={8}>
{/* Breadcrumb */} <Stack gap={8}>
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6"> {/* Breadcrumb */}
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link> <Box>
<ChevronRight className="w-4 h-4" /> <Stack direction="row" align="center" gap={2}>
<Link href=routes.sponsor.leagues className="hover:text-white transition-colors">Leagues</Link> <Link href={routes.sponsor.dashboard}>
<ChevronRight className="w-4 h-4" /> <Text size="sm" color="text-gray-400">Dashboard</Text>
<span className="text-white">{league.name}</span> </Link>
</div> <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 */} {/* Header */}
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8"> <Stack direction="row" align="start" justify="between" wrap gap={6}>
<div className="flex-1"> <Box style={{ flex: 1 }}>
<div className="flex items-center gap-3 mb-2"> <Stack direction="row" align="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}`}> <Badge variant="primary"> {league.tier}</Badge>
{league.tier} <Badge variant="success">Active Season</Badge>
</span> <Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green"> <Stack direction="row" align="center" gap={1}>
Active Season <Icon icon={Star} size={3.5} color="#facc15" />
</span> <Text size="sm" weight="medium" color="text-white">{league.rating}</Text>
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50"> </Stack>
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" /> </Surface>
<span className="text-sm font-medium text-white">{league.rating}</span> </Stack>
</div> <Heading level={1}>{league.name}</Heading>
</div> <Text color="text-gray-400" block mt={2}>
<h1 className="text-3xl font-bold text-white mb-2">{league.name}</h1> {league.game} {league.season} {league.completedRaces}/{league.races} races completed
<p className="text-gray-400 mb-4">{league.game} {league.season} {league.completedRaces}/{league.races} races completed</p> </Text>
<p className="text-gray-400 max-w-2xl">{league.description}</p> <Text color="text-gray-400" block mt={4} style={{ maxWidth: '42rem' }}>
</div> {league.description}
</Text>
</Box>
<div className="flex flex-col sm:flex-row gap-3"> <Stack direction="row" gap={3}>
<Link href={`/leagues/${league.id}`}> <Link href={`/leagues/${league.id}`}>
<Button variant="secondary"> <Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
<ExternalLink className="w-4 h-4 mr-2" /> View League
View League </Button>
</Button> </Link>
</Link> {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && ( <Button variant="primary" onClick={() => setActiveTab('sponsor')} icon={<Icon icon={Megaphone} size={4} />}>
<Button variant="primary" onClick={() => setActiveTab('sponsor')}> Become a Sponsor
<Megaphone className="w-4 h-4 mr-2" /> </Button>
Become a Sponsor )}
</Button> </Stack>
)} </Stack>
</div>
</div>
{/* Quick Stats */} {/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8"> <Grid cols={5} gap={4}>
<motion.div <StatCard icon={Eye} label="Total Views" value={league.formattedTotalImpressions} color="#3b82f6" />
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }} <StatCard icon={TrendingUp} label="Avg/Race" value={league.formattedAvgViewsPerRace} color="#10b981" />
animate={{ opacity: 1, y: 0 }} <StatCard icon={Users} label="Drivers" value={league.drivers} color="#a855f7" />
> <StatCard icon={BarChart3} label="Engagement" value={`${league.engagement}%`} color="#f59e0b" />
<Card className="p-4"> <StatCard icon={Calendar} label="Races Left" value={league.racesLeft} color="#ef4444" />
<div className="flex items-center gap-3"> </Grid>
<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>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto"> <Box style={{ borderBottom: '1px solid #262626' }}>
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => ( <Stack direction="row" gap={6}>
<button {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
key={tab} <Box
onClick={() => setActiveTab(tab)} key={tab}
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${ onClick={() => setActiveTab(tab)}
activeTab === tab pb={3}
? 'text-primary-blue border-primary-blue' style={{
: 'text-gray-400 border-transparent hover:text-white' cursor: 'pointer',
}`} borderBottom: activeTab === tab ? '2px solid #3b82f6' : '2px solid transparent',
> color: activeTab === tab ? '#3b82f6' : '#9ca3af'
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab} }}
</button> >
))} <Text size="sm" weight="medium" style={{ textTransform: 'capitalize' }}>
</div> {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</Text>
</Box>
))}
</Stack>
</Box>
{/* Tab Content */} {/* Tab Content */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <Grid cols={2} gap={6}>
<Card className="p-5"> <Card>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Box mb={4}>
<Trophy className="w-5 h-5 text-primary-blue" /> <Heading level={2} icon={<Icon icon={Trophy} size={5} color="#3b82f6" />}>
League Information League Information
</h3> </Heading>
<div className="space-y-3"> </Box>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <Stack gap={3}>
<span className="text-gray-400">Platform</span> <InfoRow label="Platform" value={league.game} />
<span className="text-white font-medium">{league.game}</span> <InfoRow label="Season" value={league.season} />
</div> <InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <InfoRow label="Drivers" value={league.drivers} />
<span className="text-gray-400">Season</span> <InfoRow label="Races" value={league.races} last />
<span className="text-white font-medium">{league.season}</span> </Stack>
</div> </Card>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Duration</span> <Card>
<span className="text-white font-medium">Oct 2025 - Feb 2026</span> <Box mb={4}>
</div> <Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> Sponsorship Value
<span className="text-gray-400">Drivers</span> </Heading>
<span className="text-white font-medium">{league.drivers}</span> </Box>
</div> <Stack gap={3}>
<div className="flex justify-between py-2"> <InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<span className="text-gray-400">Races</span> <InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
<span className="text-white font-medium">{league.races}</span> <InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
</div> <InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
</div> <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>
)}
<Card className="p-5"> {activeTab === 'races' && (
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Card p={0}>
<TrendingUp className="w-5 h-5 text-performance-green" /> <Box p={4} style={{ borderBottom: '1px solid #262626' }}>
Sponsorship Value <Heading level={2}>Race Calendar</Heading>
</h3> <Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
<div className="space-y-3"> </Box>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <Stack gap={0}>
<span className="text-gray-400">Total Season Views</span> {viewData.races.map((race, index) => (
<span className="text-white font-medium">{league.formattedTotalImpressions}</span> <Box key={race.id} p={4} style={{ borderBottom: index < viewData.races.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
</div> <Stack direction="row" align="center" justify="between">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <Stack direction="row" align="center" gap={4}>
<span className="text-gray-400">Projected Total</span> <Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.status === 'completed' ? '#10b981' : '#f59e0b' }} />
<span className="text-white font-medium">{league.formattedProjectedTotal}</span> <Box>
</div> <Text weight="medium" color="text-white" block>{race.name}</Text>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
<span className="text-gray-400">Main Sponsor CPM</span> </Box>
<span className="text-performance-green font-medium"> </Stack>
{league.formattedMainSponsorCpm} <Box>
</span> {race.status === 'completed' ? (
</div> <Box style={{ textAlign: 'right' }}>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<span className="text-gray-400">Engagement Rate</span> <Text size="xs" color="text-gray-500">views</Text>
<span className="text-white font-medium">{league.engagement}%</span> </Box>
</div> ) : (
<div className="flex justify-between py-2"> <Badge variant="warning">Upcoming</Badge>
<span className="text-gray-400">League Rating</span> )}
<div className="flex items-center gap-1"> </Box>
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" /> </Stack>
<span className="text-white font-medium">{league.rating}/5.0</span> </Box>
</div> ))}
</div> </Stack>
</div>
</Card> </Card>
)}
{/* Next Race */} {activeTab === 'sponsor' && (
{league.nextRace && ( <Stack gap={6}>
<Card className="p-5 lg:col-span-2"> <Grid cols={2} gap={6}>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <SponsorTierCard
<Flag className="w-5 h-5 text-warning-amber" /> type="main"
Next Race available={league.sponsorSlots.main.available}
</h3> price={league.sponsorSlots.main.price}
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30"> benefits={league.sponsorSlots.main.benefits}
<div className="flex items-center gap-4"> isSelected={selectedTier === 'main'}
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center"> onClick={() => setSelectedTier('main')}
<Flag className="w-6 h-6 text-warning-amber" /> />
</div> <SponsorTierCard
<div> type="secondary"
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p> available={league.sponsorSlots.secondary.available > 0}
<p className="text-sm text-gray-400">{league.nextRace.date}</p> availableCount={league.sponsorSlots.secondary.available}
</div> totalCount={league.sponsorSlots.secondary.total}
</div> price={league.sponsorSlots.secondary.price}
<Button variant="secondary"> benefits={league.sponsorSlots.secondary.benefits}
View Schedule 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> </Button>
</div> <Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
Download Info Pack
</Button>
</Stack>
</Card> </Card>
)} </Stack>
</div> )}
)} </Stack>
</Container>
{activeTab === 'drivers' && ( );
<Card> }
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Championship Standings</h3> function StatCard({ icon, label, value, color }: { icon: any, label: string, value: string | number, color: string }) {
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p> return (
</div> <Card>
<div className="divide-y divide-charcoal-outline/50"> <Stack direction="row" align="center" gap={3}>
{data.drivers.map((driver) => ( <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}1A` }}>
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors"> <Icon icon={icon} size={5} color={color} />
<div className="flex items-center gap-4"> </Surface>
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white"> <Box>
{driver.position} <Text size="xl" weight="bold" color="text-white" block>{value}</Text>
</div> <Text size="xs" color="text-gray-500" block>{label}</Text>
<div> </Box>
<div className="font-medium text-white">{driver.name}</div> </Stack>
<div className="text-sm text-gray-500">{driver.team} {driver.country}</div> </Card>
</div> );
</div> }
<div className="flex items-center gap-6">
<div className="text-right"> function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
<div className="font-medium text-white">{driver.races}</div> return (
<div className="text-xs text-gray-500">races</div> <Box py={2} style={{ borderBottom: last ? 'none' : '1px solid rgba(38, 38, 38, 0.5)' }}>
</div> <Stack direction="row" align="center" justify="between">
<div className="text-right"> <Text color="text-gray-400">{label}</Text>
<div className="font-semibold text-white">{driver.formattedImpressions}</div> <Text weight="medium" color={color as any}>{value}</Text>
<div className="text-xs text-gray-500">views</div> </Stack>
</div> </Box>
</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>
); );
} }

View File

@@ -1,27 +1,29 @@
'use client'; 'use client';
import { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { motion, useReducedMotion } from 'framer-motion'; import { Card } from '@/ui/Card';
import Link from 'next/link'; import { Button } from '@/ui/Button';
import Card from '@/components/ui/Card'; import { Heading } from '@/ui/Heading';
import Button from '@/components/ui/Button'; import { Box } from '@/ui/Box';
import { siteConfig } from '@/lib/siteConfig'; 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 { import {
Trophy, Trophy,
Users, Users,
Eye,
Search, Search,
Star,
ChevronRight, ChevronRight,
Filter,
Car, Car,
Flag,
TrendingUp,
CheckCircle2,
Clock,
Megaphone, Megaphone,
ArrowUpDown
} from 'lucide-react'; } from 'lucide-react';
import { siteConfig } from '@/lib/siteConfig';
import { routes } from '@/lib/routing/RouteConfig';
import { AvailableLeagueCard } from '@/components/sponsors/AvailableLeagueCard';
interface AvailableLeague { interface AvailableLeague {
id: string; id: string;
@@ -39,8 +41,6 @@ interface AvailableLeague {
formattedAvgViews: string; formattedAvgViews: string;
formattedCpm: string; formattedCpm: string;
cpm: number; cpm: number;
tierConfig: any;
statusConfig: any;
} }
type SortOption = 'rating' | 'drivers' | 'price' | 'views'; type SortOption = 'rating' | 'drivers' | 'price' | 'views';
@@ -48,7 +48,7 @@ type TierFilter = 'all' | 'premium' | 'standard' | 'starter';
type AvailabilityFilter = 'all' | 'main' | 'secondary'; type AvailabilityFilter = 'all' | 'main' | 'secondary';
interface SponsorLeaguesTemplateProps { interface SponsorLeaguesTemplateProps {
data: { viewData: {
leagues: AvailableLeague[]; leagues: AvailableLeague[];
stats: { stats: {
total: number; total: number;
@@ -60,367 +60,170 @@ interface SponsorLeaguesTemplateProps {
}; };
} }
function LeagueCard({ league, index }: { league: AvailableLeague; index: number }) { export function SponsorLeaguesTemplate({ viewData }: SponsorLeaguesTemplateProps) {
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();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState<TierFilter>('all'); const [tierFilter, setTierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all'); const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('rating'); const [sortBy, setSortBy] = useState<SortOption>('rating');
// Filter and sort leagues // Filter and sort leagues
const filteredLeagues = data.leagues const filteredLeagues = useMemo(() => {
.filter((league: any) => { return viewData.leagues
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { .filter((league) => {
return false; if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
} return false;
if (tierFilter !== 'all' && league.tier !== tierFilter) { }
return false; if (tierFilter !== 'all' && league.tier !== tierFilter) {
} return false;
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) { }
return false; if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
} return false;
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) { }
return false; if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
} return false;
return true; }
}) return true;
.sort((a: any, b: any) => { })
switch (sortBy) { .sort((a, b) => {
case 'rating': return b.rating - a.rating; switch (sortBy) {
case 'drivers': return b.drivers - a.drivers; case 'rating': return b.rating - a.rating;
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price; case 'drivers': return b.drivers - a.drivers;
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace; case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
default: return 0; 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 ( return (
<div className="max-w-7xl mx-auto py-8 px-4"> <Container size="lg" py={8}>
{/* Breadcrumb */} <Stack gap={8}>
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6"> {/* Breadcrumb */}
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link> <Box>
<ChevronRight className="w-4 h-4" /> <Stack direction="row" align="center" gap={2}>
<span className="text-white">Browse Leagues</span> <Link href={routes.sponsor.dashboard}>
</div> <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 */} {/* Header */}
<div className="mb-8"> <Box>
<h1 className="text-2xl font-bold text-white mb-2 flex items-center gap-3"> <Heading level={1} icon={<Icon icon={Trophy} size={7} color="#3b82f6" />}>
<Trophy className="w-7 h-7 text-primary-blue" /> League Sponsorship Marketplace
League Sponsorship Marketplace </Heading>
</h1> <Text color="text-gray-400" block mt={2}>
<p className="text-gray-400"> Discover racing leagues looking for sponsors. All prices shown exclude VAT.
Discover racing leagues looking for sponsors. All prices shown exclude VAT. </Text>
</p> </Box>
</div>
{/* Stats Overview */} {/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8"> <Grid cols={5} gap={4}>
<motion.div <StatCard label="Leagues" value={stats.total} />
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }} <StatCard label="Main Slots" value={stats.mainAvailable} color="text-performance-green" />
animate={{ opacity: 1, y: 0 }} <StatCard label="Secondary Slots" value={stats.secondaryAvailable} color="text-primary-blue" />
> <StatCard label="Total Drivers" value={stats.totalDrivers} />
<Card className="p-4 text-center"> <StatCard label="Avg CPM" value={`$${stats.avgCpm}`} color="text-warning-amber" />
<div className="text-2xl font-bold text-white">{stats.total}</div> </Grid>
<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>
{/* Filters */} {/* Filters (Simplified for template) */}
<div className="flex flex-col lg:flex-row gap-4 mb-6"> <Card>
{/* Search */} <Stack gap={4}>
<div className="relative flex-1"> <Text size="sm" color="text-gray-400">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> Use the search and filter options to find the perfect league for your brand.
<input </Text>
type="text" <Grid cols={4} gap={4}>
placeholder="Search leagues..." <Box>
value={searchQuery} <input
onChange={(e) => setSearchQuery(e.target.value)} type="text"
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" placeholder="Search leagues..."
/> value={searchQuery}
</div> 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"
{/* Tier Filter */} />
<select </Box>
value={tierFilter} {/* Selects would go here, using standard Select UI if available */}
onChange={(e) => setTierFilter(e.target.value as TierFilter)} </Grid>
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none" </Stack>
>
<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>
</Card> </Card>
)}
{/* Platform Fee Notice */} {/* Results Count */}
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <Stack direction="row" align="center" justify="between">
<div className="flex items-start gap-3"> <Text size="sm" color="text-gray-400">
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" /> Showing {filteredLeagues.length} of {viewData.leagues.length} leagues
<div> </Text>
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p> <Stack direction="row" align="center" gap={3}>
<p className="text-xs text-gray-500"> <Link href="/teams">
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} <Button variant="secondary" size="sm" icon={<Icon icon={Users} size={4} />}>
</p> Browse Teams
</div> </Button>
</div> </Link>
</div> <Link href="/drivers">
</div> <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 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 { export interface SponsorshipRequestsTemplateProps {
viewData: SponsorshipRequestsViewData; viewData: SponsorshipRequestsViewData;
@@ -16,65 +23,72 @@ export function SponsorshipRequestsTemplate({
onReject, onReject,
}: SponsorshipRequestsTemplateProps) { }: SponsorshipRequestsTemplateProps) {
return ( return (
<Container size="md" className="space-y-8"> <Container size="md" py={8}>
<div> <Stack gap={8}>
<Heading level={1} className="text-white mb-2"> <Box>
Sponsorship Requests <Heading level={1}>Sponsorship Requests</Heading>
</Heading> <Text size="sm" color="text-gray-400" block mt={2}>
<p className="text-gray-400 text-sm"> Manage pending sponsorship requests for your profile.
Manage pending sponsorship requests for your profile. </Text>
</p> </Box>
</div>
{viewData.sections.map((section) => ( {viewData.sections.map((section) => (
<Card key={`${section.entityType}-${section.entityId}`}> <Card key={`${section.entityType}-${section.entityId}`}>
<div className="flex items-center justify-between mb-4"> <Stack gap={4}>
<Heading level={2} className="text-white"> <Stack direction="row" align="center" justify="between">
{section.entityName} <Heading level={2}>{section.entityName}</Heading>
</Heading> <Text size="xs" color="text-gray-400">
<span className="text-xs text-gray-400"> {section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'}
{section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'} </Text>
</span> </Stack>
</div>
{section.requests.length === 0 ? ( {section.requests.length === 0 ? (
<p className="text-sm text-gray-400">No pending requests.</p> <Text size="sm" color="text-gray-400">No pending requests.</Text>
) : ( ) : (
<div className="space-y-3"> <Stack gap={3}>
{section.requests.map((request) => ( {section.requests.map((request) => (
<div <Surface
key={request.id} key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline" variant="muted"
> rounded="lg"
<div className="flex-1"> border
<p className="text-white font-medium">{request.sponsorName}</p> padding={4}
{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)}
> >
Accept <Stack direction="row" align="center" justify="between" wrap gap={4}>
</Button> <Box style={{ flex: 1, minWidth: 0 }}>
<Button <Text weight="medium" color="text-white" block>{request.sponsorName}</Text>
variant="secondary" {request.message && (
onClick={() => onReject(request.id)} <Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
> )}
Reject <Text size="xs" color="text-gray-500" block mt={2}>
</Button> {new Date(request.createdAtIso).toLocaleDateString()}
</div> </Text>
</div> </Box>
))} <Stack direction="row" gap={2}>
</div> <Button
)} variant="primary"
</Card> 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> </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 { 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 { Flag, AlertCircle, Calendar, MapPin, Gavel } from 'lucide-react';
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
interface StewardingTemplateProps { interface StewardingTemplateProps {
viewData: StewardingViewData; viewData: StewardingViewData;
@@ -11,146 +18,166 @@ interface StewardingTemplateProps {
export function StewardingTemplate({ viewData }: StewardingTemplateProps) { export function StewardingTemplate({ viewData }: StewardingTemplateProps) {
return ( return (
<Section> <Stack gap={6}>
<Card> <Card>
<div className="flex items-center justify-between mb-6"> <Stack gap={6}>
<div> <Box>
<h2 className="text-xl font-semibold text-white">Stewarding</h2> <Heading level={1}>Stewarding</Heading>
<p className="text-sm text-gray-400 mt-1"> <Text size="sm" color="text-gray-400" block mt={1}>
Quick overview of protests and penalties across all races Quick overview of protests and penalties across all races
</p> </Text>
</div> </Box>
</div>
{/* Stats summary */} {/* Stats summary */}
<div className="grid grid-cols-3 gap-4 mb-6"> <Grid cols={3} gap={4}>
<div className="bg-iron-gray rounded-lg p-4 border border-charcoal-outline text-center"> <StatItem label="Pending" value={viewData.totalPending} color="#f59e0b" />
<div className="text-2xl font-bold text-warning-amber">{viewData.totalPending}</div> <StatItem label="Resolved" value={viewData.totalResolved} color="#10b981" />
<div className="text-sm text-gray-400">Pending</div> <StatItem label="Penalties" value={viewData.totalPenalties} color="#ef4444" />
</div> </Grid>
<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>
{/* Content */} {/* Content */}
{viewData.races.length === 0 ? ( {viewData.races.length === 0 ? (
<div className="text-center py-12"> <Stack align="center" py={12} gap={4}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={4}>
<Flag className="w-8 h-8 text-performance-green" /> <Icon icon={Flag} size={8} color="#10b981" />
</div> </Surface>
<p className="font-semibold text-lg text-white mb-2">All Clear!</p> <Box style={{ textAlign: 'center' }}>
<p className="text-sm text-gray-400">No protests or penalties to review.</p> <Text weight="semibold" size="lg" color="text-white" block mb={1}>All Clear!</Text>
</div> <Text size="sm" color="text-gray-400">No protests or penalties to review.</Text>
) : ( </Box>
<div className="space-y-4"> </Stack>
{viewData.races.map((race) => ( ) : (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden"> <Stack gap={4}>
{/* Race Header */} {viewData.races.map((race) => (
<div className="px-4 py-3 bg-iron-gray/30"> <Surface
<div className="flex items-center gap-4"> key={race.id}
<div className="flex items-center gap-2"> variant="muted"
<MapPin className="w-4 h-4 text-gray-400" /> rounded="lg"
<span className="font-medium text-white">{race.track}</span> border
</div> style={{ overflow: 'hidden', borderColor: '#262626' }}
<div className="flex items-center gap-2 text-gray-400 text-sm"> >
<Calendar className="w-4 h-4" /> {/* Race Header */}
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span> <Box p={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626' }}>
</div> <Stack direction="row" align="center" gap={4} wrap>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full"> <Stack direction="row" align="center" gap={2}>
{race.pendingProtests.length} pending <Icon icon={MapPin} size={4} color="#9ca3af" />
</span> <Text weight="medium" color="text-white">{race.track}</Text>
</div> </Stack>
</div> <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 */} {/* Race Content */}
<div className="p-4 space-y-3 bg-deep-graphite/50"> <Box p={4}>
{race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? ( {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> <Box py={4}>
) : ( <Text size="sm" color="text-gray-400" block style={{ textAlign: 'center' }}>No items to display</Text>
<> </Box>
{race.pendingProtests.map((protest) => { ) : (
const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId); <Stack gap={3}>
const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId); {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 ( return (
<div <Surface
key={protest.id} key={protest.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4" variant="muted"
> rounded="lg"
<div className="flex items-start justify-between gap-4"> border
<div className="flex-1 min-w-0"> padding={4}
<div className="flex items-center gap-2 mb-2"> style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" /> >
<span className="font-medium text-white"> <Stack direction="row" align="start" justify="between" gap={4}>
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} <Box style={{ flex: 1, minWidth: 0 }}>
</span> <Stack direction="row" align="center" gap={2} mb={2} wrap>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span> <Icon icon={AlertCircle} size={4} color="#f59e0b" />
</div> <Text weight="medium" color="text-white">
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2"> {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
<span>Lap {protest.incident.lap}</span> </Text>
<span></span> <Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span> <Text size="xs" weight="medium" color="text-warning-amber">Pending</Text>
</div> </Surface>
<p className="text-sm text-gray-300 line-clamp-2"> </Stack>
{protest.incident.description} <Stack direction="row" align="center" gap={4} mb={2}>
</p> <Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
</div> <Text size="sm" color="text-gray-400"></Text>
<div className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">Filed {new Date(protest.filedAt).toLocaleDateString()}</Text>
Review needed </Stack>
</div> <Text size="sm" color="text-gray-300" block truncate>{protest.incident.description}</Text>
</div> </Box>
</div> <Text size="sm" color="text-gray-500">Review needed</Text>
); </Stack>
})} </Surface>
);
})}
{race.penalties.map((penalty) => { {race.penalties.map((penalty) => {
const driver = viewData.drivers.find(d => d.id === penalty.driverId); const driver = viewData.drivers.find(d => d.id === penalty.driverId);
return ( return (
<div <Surface
key={penalty.id} key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4" variant="muted"
> rounded="lg"
<div className="flex items-center gap-3"> border
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0"> padding={4}
<Gavel className="w-4 h-4 text-red-400" /> style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
</div> >
<div className="flex-1"> <Stack direction="row" align="center" justify="between" gap={4}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={3}>
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span> <Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)' }}>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full"> <Icon icon={Gavel} size={4} color="#ef4444" />
{penalty.type.replace('_', ' ')} </Surface>
</span> <Box>
</div> <Stack direction="row" align="center" gap={2}>
<p className="text-sm text-gray-400">{penalty.reason}</p> <Text weight="medium" color="text-white">{driver?.name || 'Unknown'}</Text>
</div> <Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<div className="text-right"> <Text size="xs" weight="medium" color="text-error-red" style={{ textTransform: 'capitalize' }}>
<span className="text-lg font-bold text-red-400"> {penalty.type.replace('_', ' ')}
{penalty.type === 'time_penalty' && `+${penalty.value}s`} </Text>
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`} </Surface>
{penalty.type === 'points_deduction' && `-${penalty.value} pts`} </Stack>
{penalty.type === 'disqualification' && 'DSQ'} <Text size="sm" color="text-gray-400" block mt={1}>{penalty.reason}</Text>
{penalty.type === 'warning' && 'Warning'} </Box>
{penalty.type === 'license_points' && `${penalty.value} LP`} </Stack>
</span> <Box style={{ textAlign: 'right' }}>
</div> <Text weight="bold" color="text-error-red" style={{ fontSize: '1.125rem' }}>
</div> {penalty.type === 'time_penalty' && `+${penalty.value}s`}
</div> {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`}
</div> </Text>
</div> </Box>
))} </Stack>
</div> </Surface>
)} );
})}
</Stack>
)}
</Box>
</Surface>
))}
</Stack>
)}
</Stack>
</Card> </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'; 'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs'; import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import { SlotTemplates } from '@/components/sponsors/SlotTemplates'; import { SlotTemplates } from '@/components/sponsors/SlotTemplates';
import { useSponsorMode } from '@/components/sponsors/useSponsorMode'; import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button'; import { useSponsorMode } from '@/hooks/sponsor/useSponsorMode';
import Card from '@/components/ui/Card'; import { Box } from '@/ui/Box';
import Image from 'next/image'; 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 TeamAdmin from '@/components/teams/TeamAdmin';
import { TeamHero } from '@/components/teams/TeamHero';
import TeamRoster from '@/components/teams/TeamRoster'; import TeamRoster from '@/components/teams/TeamRoster';
import TeamStandings from '@/components/teams/TeamStandings'; 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'; import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
type Tab = 'overview' | 'roster' | 'standings' | 'admin'; type Tab = 'overview' | 'roster' | 'standings' | 'admin';
// ============================================================================
// TEMPLATE PROPS
// ============================================================================
export interface TeamDetailTemplateProps { export interface TeamDetailTemplateProps {
viewData: TeamDetailViewData; viewData: TeamDetailViewData;
activeTab: Tab; activeTab: Tab;
@@ -36,10 +35,6 @@ export interface TeamDetailTemplateProps {
onGoBack: () => void; onGoBack: () => void;
} }
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function TeamDetailTemplate({ export function TeamDetailTemplate({
viewData, viewData,
activeTab, activeTab,
@@ -55,28 +50,32 @@ export function TeamDetailTemplate({
// Show loading state // Show loading state
if (loading) { if (loading) {
return ( return (
<div className="max-w-6xl mx-auto"> <Container size="lg" py={12}>
<div className="text-center text-gray-400">Loading team...</div> <Stack align="center">
</div> <Text color="text-gray-400">Loading team...</Text>
</Stack>
</Container>
); );
} }
// Show not found state // Show not found state
if (!viewData.team) { if (!viewData.team) {
return ( return (
<div className="max-w-6xl mx-auto"> <Container size="md" py={12}>
<Card> <Card>
<div className="text-center py-12"> <Stack align="center" py={12} gap={6}>
<h2 className="text-2xl font-bold text-white mb-2">Team Not Found</h2> <Box style={{ textAlign: 'center' }}>
<p className="text-gray-400 mb-6"> <Heading level={1}>Team Not Found</Heading>
The team you're looking for doesn't exist or has been disbanded. <Text color="text-gray-400" block mt={2}>
</p> The team you're looking for doesn't exist or has been disbanded.
</Text>
</Box>
<Button variant="primary" onClick={onGoBack}> <Button variant="primary" onClick={onGoBack}>
Go Back Go Back
</Button> </Button>
</div> </Stack>
</Card> </Card>
</div> </Container>
); );
} }
@@ -90,164 +89,128 @@ export function TeamDetailTemplate({
const visibleTabs = tabs.filter(tab => tab.visible); const visibleTabs = tabs.filter(tab => tab.visible);
return ( return (
<div className="max-w-6xl mx-auto"> <Container size="lg" py={8}>
{/* Breadcrumb */} <Stack gap={6}>
<Breadcrumbs {/* Breadcrumb */}
items={[ <Breadcrumbs
{ label: 'Home', href: '/' }, items={[
{ label: 'Teams', href: '/teams' }, { label: 'Home', href: '/' },
{ label: viewData.team.name } { 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}
/> />
)}
<Card className="mb-6"> {/* Sponsor Insights Card */}
<div className="flex items-start justify-between"> {isSponsorMode && viewData.team && (
<div className="flex items-start gap-6"> <SponsorInsightsCard
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"> entityType="team"
<Image entityId={viewData.team.id}
src={getMediaUrl('team-logo', viewData.team.id)} entityName={viewData.team.name}
alt={viewData.team.name} tier="standard"
width={96} metrics={viewData.teamMetrics}
height={96} slots={SlotTemplates.team(true, true, 500, 250)}
className="w-full h-full object-cover" trustScore={90}
/> monthlyActivity={85}
</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}
/> />
)} )}
{activeTab === 'standings' && ( <TeamHero
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} /> team={viewData.team}
)} memberCount={viewData.memberships.length}
onUpdate={onUpdate}
/>
{activeTab === 'admin' && viewData.isAdmin && ( {/* Tabs */}
<TeamAdmin team={viewData.team} onUpdate={onUpdate} /> <Box style={{ borderBottom: '1px solid #262626' }}>
)} <Stack direction="row" gap={6}>
</div> {visibleTabs.map((tab) => (
</div> <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'; 'use client';
import React from 'react'; import React, { useMemo } from 'react';
import { Users, Trophy, Crown, Award, ArrowLeft, Medal, Target, Globe, Languages } from 'lucide-react'; import { Award, ArrowLeft } from 'lucide-react';
import Button from '@/components/ui/Button'; import { Button } from '@/ui/Button';
import Input from '@/components/ui/Input'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/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 TopThreePodium from '@/components/teams/TopThreePodium';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamRankingsFilter from '@/components/TeamRankingsFilter'; import TeamRankingsFilter from '@/components/TeamRankingsFilter';
import Image from 'next/image'; import { TeamRankingsTable } from '@/components/teams/TeamRankingsTable';
import { getMediaUrl } from '@/lib/utilities/media';
// ============================================================================
// TYPES
// ============================================================================
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
@@ -30,56 +30,6 @@ interface TeamLeaderboardTemplateProps {
onBackToTeams: () => void; 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({ export default function TeamLeaderboardTemplate({
teams, teams,
searchQuery, searchQuery,
@@ -92,283 +42,78 @@ export default function TeamLeaderboardTemplate({
onBackToTeams, onBackToTeams,
}: TeamLeaderboardTemplateProps) { }: TeamLeaderboardTemplateProps) {
// Filter and sort teams // Filter and sort teams
const filteredAndSortedTeams = teams const filteredAndSortedTeams = useMemo(() => {
.filter((team) => { return teams
// Search filter .filter((team) => {
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) { if (!team.name.toLowerCase().includes(query) && !(team.description ?? '').toLowerCase().includes(query)) {
return false;
}
}
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) {
return false; return false;
} }
} return true;
// Level filter })
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) { .sort((a, b) => {
return false; switch (sortBy) {
} case 'rating': return 0; // Placeholder
return true; case 'wins': return (b.totalWins || 0) - (a.totalWins || 0);
}) case 'races': return (b.totalRaces || 0) - (a.totalRaces || 0);
.sort((a, b) => { default: return 0;
switch (sortBy) {
case 'rating': {
const aRating = getSafeRating(a);
const bRating = getSafeRating(b);
return bRating - aRating;
} }
case 'wins': { });
const aWinsSort = getSafeTotalWins(a); }, [teams, searchQuery, filterLevel, sortBy]);
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;
}
});
return ( return (
<div className="max-w-7xl mx-auto px-4 pb-12"> <Container size="lg" py={8}>
{/* Header */} <Stack gap={8}>
<div className="mb-8"> {/* Header */}
<Button <Box>
variant="secondary" <Box mb={6}>
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>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={onBackToTeams}
onSearchChange(''); icon={<Icon icon={ArrowLeft} size={4} />}
onFilterLevelChange('all');
}}
className="mt-4"
> >
Clear Filters Back to Teams
</Button> </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'; 'use client';
import { Trophy, Users } from 'lucide-react'; import React from 'react';
import Link from 'next/link'; 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'; interface TeamsTemplateProps {
import Button from '@/components/ui/Button'; viewData: TeamsViewData;
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;
onTeamClick?: (teamId: string) => void; onTeamClick?: (teamId: string) => void;
onCreateSuccess?: (teamId: string) => void; onViewFullLeaderboard: () => void;
onBrowseTeams?: () => void; onCreateTeam: () => void;
onSkillLevelClick?: (level: string) => void;
} }
export function TeamsTemplate({ teams }: TeamsTemplateProps) { export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, onCreateTeam }: TeamsTemplateProps) {
const { teams } = viewData;
return ( return (
<main className="min-h-screen bg-deep-graphite py-8"> <Box as="main">
<div className="max-w-7xl mx-auto px-6"> <Container size="lg" py={8}>
{/* Header */} <Stack gap={8}>
<div className="flex items-center justify-between mb-8"> {/* Header */}
<div> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1> <Box>
<p className="text-gray-400">Browse and manage your racing teams</p> <Heading level={1}>Teams</Heading>
</div> <Text color="text-gray-400">Browse and manage your racing teams</Text>
<Link href=routes.team.detail('create')> </Box>
<Button variant="primary">Create Team</Button> <Box>
</Link> <Button variant="primary" onClick={onCreateTeam}>Create Team</Button>
</div> </Box>
</Stack>
{/* Teams Grid */} {/* Teams Grid */}
{teams.length > 0 ? ( {teams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <Grid cols={3} gap={6}>
{teams.map((team: TeamSummaryData) => ( {teams.map((team: TeamSummaryData) => (
<Card key={team.teamId} className="hover:border-primary-blue/50 transition-colors"> <TeamCard
<div className="flex items-start justify-between mb-4"> key={team.teamId}
<div className="flex items-center gap-3"> id={team.teamId}
{team.logoUrl ? ( name={team.teamName}
<img logo={team.logoUrl}
src={team.logoUrl} memberCount={team.memberCount}
alt={team.teamName} leagues={[team.leagueName]}
className="w-12 h-12 rounded-lg object-cover bg-iron-gray" onClick={() => onTeamClick?.(team.teamId)}
/> />
) : ( ))}
<div className="w-12 h-12 rounded-lg bg-iron-gray flex items-center justify-center"> </Grid>
<Users className="w-6 h-6 text-gray-500" /> ) : (
</div> <EmptyState
)} icon={Users}
<div> title="No teams yet"
<h3 className="font-semibold text-white">{team.teamName}</h3> description="Get started by creating your first racing team"
<p className="text-sm text-gray-400">{team.leagueName}</p> action={{
</div> label: 'Create Team',
</div> onClick: onCreateTeam,
</div> variant: 'primary'
}}
/>
)}
<div className="flex items-center gap-4 text-sm text-gray-400 mb-4"> {/* Team Leaderboard Preview */}
<span className="flex items-center gap-1"> <Box mt={12}>
<Users className="w-4 h-4" /> <TeamLeaderboardPreview
{team.memberCount} members topTeams={[]}
</span> onTeamClick={(id) => onTeamClick?.(id)}
</div> onViewFullLeaderboard={onViewFullLeaderboard}
/>
<div className="flex gap-2"> </Box>
<Link href={`/teams/${team.teamId}`} className="flex-1"> </Stack>
<Button variant="secondary" className="w-full text-sm"> </Container>
View Team </Box>
</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>
); );
} }

View File

@@ -1,14 +1,6 @@
/**
* Forgot Password Template
*
* Pure presentation component that accepts ViewData only.
* No business logic, no state management.
*/
'use client'; 'use client';
import Link from 'next/link'; import React from 'react';
import { motion } from 'framer-motion';
import { import {
Mail, Mail,
ArrowLeft, ArrowLeft,
@@ -17,11 +9,17 @@ import {
Shield, Shield,
CheckCircle2, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/ui/Card';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Input } from '@/ui/Input';
import Input from '@/components/ui/Input'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/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'; import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
interface ForgotPasswordTemplateProps { interface ForgotPasswordTemplateProps {
@@ -39,156 +37,145 @@ interface ForgotPasswordTemplateProps {
export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) { export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) {
return ( 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 */} {/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" /> <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))' }} />
<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: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <Box style={{ textAlign: '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"> <Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Flag className="w-8 h-8 text-primary-blue" /> <Icon icon={Flag} size={8} color="#3b82f6" />
</div> </Surface>
<Heading level={1} className="mb-2">Reset Password</Heading> <Heading level={1}>Reset Password</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Enter your email and we will send you a reset link Enter your email and we will send you a reset link
</p> </Text>
</div> </Box>
<Card className="relative overflow-hidden"> <Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */} {/* 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 ? ( {!viewData.showSuccess ? (
<form onSubmit={formActions.handleSubmit} className="relative space-y-5"> <form onSubmit={formActions.handleSubmit}>
{/* Email */} <Stack gap={5} style={{ position: 'relative' }}>
<div> {/* Email */}
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2"> <Box>
Email Address <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
</label> Email Address
<div className="relative"> </Text>
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box position="relative">
<Input <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
id="email" <Icon icon={Mail} size={4} color="#6b7280" />
type="email" </Box>
value={viewData.formState.fields.email.value} <Input
onChange={formActions.handleChange} id="email"
error={!!viewData.formState.fields.email.error} type="email"
errorMessage={viewData.formState.fields.email.error} value={viewData.formState.fields.email.value}
placeholder="you@example.com" onChange={formActions.handleChange}
disabled={mutationState.isPending} variant={viewData.formState.fields.email.error ? 'error' : 'default'}
className="pl-10" placeholder="you@example.com"
autoComplete="email" disabled={mutationState.isPending}
/> style={{ paddingLeft: '2.5rem' }}
</div> autoComplete="email"
</div> />
</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 */} {/* Error Message */}
{mutationState.error && ( {mutationState.error && (
<motion.div <Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
initial={{ opacity: 0, y: -10 }} <Stack direction="row" align="start" gap={3}>
animate={{ opacity: 1, y: 0 }} <Icon icon={AlertCircle} size={5} color="#ef4444" />
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30" <Text size="sm" color="text-error-red">{mutationState.error}</Text>
> </Stack>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" /> </Surface>
<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
</>
)} )}
</Button>
{/* Back to Login */} {/* Submit Button */}
<div className="text-center"> <Button
<Link type="submit"
href="/auth/login" variant="primary"
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1" disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
> >
<ArrowLeft className="w-4 h-4" /> {mutationState.isPending ? 'Sending...' : 'Send Reset Link'}
Back to Login </Button>
</Link>
</div> {/* 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> </form>
) : ( ) : (
<motion.div <Stack gap={4} style={{ position: 'relative' }}>
initial={{ opacity: 0, y: 10 }} <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
animate={{ opacity: 1, y: 0 }} <Stack direction="row" align="start" gap={3}>
className="relative space-y-4" <Icon icon={CheckCircle2} size={6} color="#10b981" />
> <Box>
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30"> <Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" /> {viewData.magicLink && (
<div> <Box mt={2}>
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p> <Text size="xs" color="text-gray-400" block mb={1}>Development Mode - Magic Link:</Text>
{viewData.magicLink && ( <Surface variant="muted" rounded="md" border padding={2} style={{ backgroundColor: '#262626' }}>
<div className="mt-2"> <Text size="xs" color="text-primary-blue" style={{ wordBreak: 'break-all' }}>{viewData.magicLink}</Text>
<p className="text-xs text-gray-400 mb-1">Development Mode - Magic Link:</p> </Surface>
<div className="bg-iron-gray p-2 rounded border border-charcoal-outline"> <Text size="xs" color="text-gray-500" block mt={1}>
<code className="text-xs text-primary-blue break-all"> In production, this would be sent via email
{viewData.magicLink} </Text>
</code> </Box>
</div> )}
<p className="text-[10px] text-gray-500 mt-1"> </Box>
In production, this would be sent via email </Stack>
</p> </Surface>
</div>
)}
</div>
</div>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => window.location.href = '/auth/login'} onClick={() => window.location.href = '/auth/login'}
className="w-full" fullWidth
> >
Return to Login Return to Login
</Button> </Button>
</motion.div> </Stack>
)} )}
</Card> </Card>
{/* Trust Indicators */} {/* Trust Indicators */}
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500"> <Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Shield className="w-4 h-4" /> <Icon icon={Shield} size={4} color="#737373" />
<span>Secure reset process</span> <Text size="sm" color="text-gray-500">Secure reset process</Text>
</div> </Stack>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<CheckCircle2 className="w-4 h-4" /> <Icon icon={CheckCircle2} size={4} color="#737373" />
<span>15 minute expiration</span> <Text size="sm" color="text-gray-500">15 minute expiration</Text>
</div> </Stack>
</div> </Stack>
{/* Footer */} {/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500"> <Box mt={6} style={{ textAlign: 'center' }}>
Need help?{' '} <Text size="xs" color="text-gray-500">
<Link href="/support" className="text-gray-400 hover:underline"> Need help?{' '}
Contact support <Link href="/support">
</Link> <Text color="text-gray-400">Contact support</Text>
</p> </Link>
</div> </Text>
</main> </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'; 'use client';
import Link from 'next/link'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Mail, Mail,
Lock, Lock,
@@ -19,11 +11,17 @@ import {
Flag, Flag,
Shield, Shield,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/ui/Card';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Input } from '@/ui/Input';
import Input from '@/components/ui/Input'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/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 { EnhancedFormError } from '@/components/errors/EnhancedFormError';
import UserRolesPreview from '@/components/auth/UserRolesPreview'; import UserRolesPreview from '@/components/auth/UserRolesPreview';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
@@ -47,141 +45,151 @@ interface LoginTemplateProps {
export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) { export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) {
return ( return (
<main className="min-h-screen bg-deep-graphite flex"> <Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
{/* Background Pattern */} {/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" /> <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))' }} />
<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>
{/* Left Side - Info Panel (Hidden on mobile) */} {/* Left Side - Info Panel (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12"> <Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<div className="max-w-lg"> <Box style={{ maxWidth: '32rem' }}>
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-3 mb-8"> <Stack direction="row" align="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"> <Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Flag className="w-6 h-6 text-primary-blue" /> <Icon icon={Flag} size={6} color="#3b82f6" />
</div> </Surface>
<span className="text-2xl font-bold text-white">GridPilot</span> <Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
</div> </Stack>
<Heading level={2} className="text-white mb-4"> <Box mb={4}>
Your Sim Racing Infrastructure <Heading level={2}>
</Heading> 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. Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles.
</p> </Text>
{/* Role Cards */} {/* Role Cards */}
<UserRolesPreview variant="full" /> <UserRolesPreview variant="full" />
{/* Workflow Mockup */} {/* Workflow Mockup */}
<AuthWorkflowMockup /> <Box mt={8}>
<AuthWorkflowMockup />
</Box>
{/* Trust Indicators */} {/* Trust Indicators */}
<div className="mt-8 flex items-center gap-6 text-sm text-gray-500"> <Stack direction="row" align="center" gap={6} mt={8}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Shield className="w-4 h-4" /> <Icon icon={Shield} size={4} color="#737373" />
<span>Secure login</span> <Text size="sm" color="text-gray-500">Secure login</Text>
</div> </Stack>
<div className="flex items-center gap-2"> <Text size="sm" color="text-gray-500">iRacing verified</Text>
<span className="text-sm">iRacing verified</span> </Stack>
</div> </Box>
</div> </Box>
</div>
</div>
{/* Right Side - Login Form */} {/* Right Side - Login Form */}
<div className="flex-1 flex items-center justify-center px-4 py-12"> <Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative' }}>
<div className="relative w-full max-w-md"> <Box style={{ width: '100%', maxWidth: '28rem' }}>
{/* Mobile Logo/Header */} {/* Mobile Logo/Header */}
<div className="text-center mb-8 lg:hidden"> <Box className="lg:hidden" style={{ textAlign: '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"> <Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Flag className="w-8 h-8 text-primary-blue" /> <Icon icon={Flag} size={8} color="#3b82f6" />
</div> </Surface>
<Heading level={1} className="mb-2">Welcome Back</Heading> <Heading level={1}>Welcome Back</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Sign in to continue to GridPilot Sign in to continue to GridPilot
</p> </Text>
</div> </Box>
{/* Desktop Header */} {/* Desktop Header */}
<div className="hidden lg:block text-center mb-8"> <Box className="hidden lg:block" style={{ textAlign: 'center' }} mb={8}>
<Heading level={2} className="mb-2">Welcome Back</Heading> <Heading level={2}>Welcome Back</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Sign in to access your racing dashboard Sign in to access your racing dashboard
</p> </Text>
</div> </Box>
<Card className="relative overflow-hidden"> <Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */} {/* 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"> <form onSubmit={formActions.handleSubmit}>
{/* Email */} <Stack gap={5} style={{ position: 'relative' }}>
<div> {/* Email */}
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2"> <Box>
Email Address <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
</label> Email Address
<div className="relative"> </Text>
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box position="relative">
<Input <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
id="email" <Icon icon={Mail} size={4} color="#6b7280" />
name="email" </Box>
type="email" <Input
value={viewData.formState.fields.email.value as string} id="email"
onChange={formActions.handleChange} name="email"
error={!!viewData.formState.fields.email.error} type="email"
errorMessage={viewData.formState.fields.email.error} value={viewData.formState.fields.email.value as string}
placeholder="you@example.com" onChange={formActions.handleChange}
disabled={viewData.formState.isSubmitting || mutationState.isPending} variant={viewData.formState.fields.email.error ? 'error' : 'default'}
className="pl-10" placeholder="you@example.com"
autoComplete="email" disabled={viewData.formState.isSubmitting || mutationState.isPending}
/> style={{ paddingLeft: '2.5rem' }}
</div> autoComplete="email"
</div> />
</Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */} {/* Password */}
<div> <Box>
<div className="flex items-center justify-between mb-2"> <Stack direction="row" align="center" justify="between" mb={2}>
<label htmlFor="password" className="block text-sm font-medium text-gray-300"> <Text size="sm" weight="medium" color="text-gray-300">
Password Password
</label> </Text>
<Link href="/auth/forgot-password" className="text-xs text-primary-blue hover:underline"> <Link href="/auth/forgot-password">
Forgot password? <Text size="xs" color="text-primary-blue">Forgot password?</Text>
</Link> </Link>
</div> </Stack>
<div className="relative"> <Box position="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={Lock} size={4} color="#6b7280" />
id="password" </Box>
name="password" <Input
type={viewData.showPassword ? 'text' : 'password'} id="password"
value={viewData.formState.fields.password.value as string} name="password"
onChange={formActions.handleChange} type={viewData.showPassword ? 'text' : 'password'}
error={!!viewData.formState.fields.password.error} value={viewData.formState.fields.password.value as string}
errorMessage={viewData.formState.fields.password.error} onChange={formActions.handleChange}
placeholder="••••••••" variant={viewData.formState.fields.password.error ? 'error' : 'default'}
disabled={viewData.formState.isSubmitting || mutationState.isPending} placeholder="••••••••"
className="pl-10 pr-10" disabled={viewData.formState.isSubmitting || mutationState.isPending}
autoComplete="current-password" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
/> autoComplete="current-password"
<button />
type="button" <Box
onClick={() => formActions.setShowPassword(!viewData.showPassword)} as="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" type="button"
> onClick={() => formActions.setShowPassword(!viewData.showPassword)}
{viewData.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
</button> >
</div> <Icon icon={viewData.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</div> </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 */} {/* Remember Me */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" gap={2}>
<label className="flex items-center gap-2 cursor-pointer">
<input <input
id="rememberMe" id="rememberMe"
name="rememberMe" name="rememberMe"
@@ -191,34 +199,25 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
disabled={viewData.formState.isSubmitting || mutationState.isPending} 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" 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> <Text size="sm" color="text-gray-300">Keep me signed in</Text>
</label> </Stack>
</div>
{/* Insufficient Permissions Message */} {/* Insufficient Permissions Message */}
<AnimatePresence>
{viewData.hasInsufficientPermissions && ( {viewData.hasInsufficientPermissions && (
<motion.div <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
initial={{ opacity: 0, y: -10 }} <Stack direction="row" align="start" gap={3}>
animate={{ opacity: 1, y: 0 }} <Icon icon={AlertCircle} size={5} color="#f59e0b" />
exit={{ opacity: 0, y: -10 }} <Box>
className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30" <Text weight="bold" color="text-warning-amber" block>Insufficient Permissions</Text>
> <Text size="sm" color="text-gray-300" block mt={1}>
<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">
You don't have permission to access that page. Please log in with an account that has the required role. You don't have permission to access that page. Please log in with an account that has the required role.
</p> </Text>
</div> </Box>
</div> </Stack>
</motion.div> </Surface>
)} )}
</AnimatePresence>
{/* Enhanced Error Display */} {/* Enhanced Error Display */}
<AnimatePresence>
{viewData.submitError && ( {viewData.submitError && (
<EnhancedFormError <EnhancedFormError
error={new Error(viewData.submitError)} error={new Error(viewData.submitError)}
@@ -228,73 +227,77 @@ export function LoginTemplate({ viewData, formActions, mutationState }: LoginTem
showDeveloperDetails={viewData.showErrorDetails} showDeveloperDetails={viewData.showErrorDetails}
/> />
)} )}
</AnimatePresence>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={viewData.formState.isSubmitting || mutationState.isPending} disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="w-full flex items-center justify-center gap-2" fullWidth
> icon={mutationState.isPending || viewData.formState.isSubmitting ? <LoadingSpinner size={4} color="white" /> : <Icon icon={LogIn} size={4} />}
{mutationState.isPending || viewData.formState.isSubmitting ? ( >
<> {mutationState.isPending || viewData.formState.isSubmitting ? 'Signing in...' : 'Sign In'}
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> </Button>
Signing in... </Stack>
</>
) : (
<>
<LogIn className="w-4 h-4" />
Sign In
</>
)}
</Button>
</form> </form>
{/* Divider */} {/* Divider */}
<div className="relative my-6"> <Box style={{ position: 'relative' }} my={6}>
<div className="absolute inset-0 flex items-center"> <Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
<div className="w-full border-t border-charcoal-outline" /> <Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
</div> </Box>
<div className="relative flex justify-center text-xs"> <Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span> <Box px={4} style={{ backgroundColor: '#171717' }}>
</div> <Text size="xs" color="text-gray-500">or continue with</Text>
</div> </Box>
</Box>
</Box>
{/* Sign Up Link */} {/* Sign Up Link */}
<p className="mt-6 text-center text-sm text-gray-400"> <Box style={{ textAlign: 'center' }} mt={6}>
Don't have an account?{''} <Text size="sm" color="text-gray-400">
<Link Don't have an account?{' '}
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'} <Link
className="text-primary-blue hover:underline font-medium" href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
> >
Create one <Text color="text-primary-blue" weight="medium">Create one</Text>
</Link> </Link>
</p> </Text>
</Box>
</Card> </Card>
{/* Name Immutability Notice */} {/* Name Immutability Notice */}
<div className="mt-6 p-4 rounded-lg bg-iron-gray/30 border border-charcoal-outline"> <Box mt={6}>
<div className="flex items-start gap-3"> <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}>
<AlertCircle className="w-5 h-5 text-gray-400 flex-shrink-0 mt-0.5" /> <Stack direction="row" align="start" gap={3}>
<div className="text-xs text-gray-400"> <Icon icon={AlertCircle} size={5} color="#737373" />
<strong>Note:</strong> Your display name cannot be changed after signup. Please ensure it's correct when creating your account. <Text size="xs" color="text-gray-400">
</div> <Text weight="bold">Note:</Text> Your display name cannot be changed after signup. Please ensure it's correct when creating your account.
</div> </Text>
</div> </Stack>
</Surface>
</Box>
{/* Footer */} {/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500"> <Box mt={6} style={{ textAlign: 'center' }}>
By signing in, you agree to our{''} <Text size="xs" color="text-gray-500">
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link> By signing in, you agree to our{' '}
{''}and{''} <Link href="/terms">
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link> <Text color="text-gray-400">Terms of Service</Text>
</p> </Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */} {/* Mobile Role Info */}
<UserRolesPreview variant="compact" /> <Box mt={8} className="lg:hidden">
</div> <UserRolesPreview variant="compact" />
</div> </Box>
</main> </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'; 'use client';
import Link from 'next/link'; import React from 'react';
import { motion } from 'framer-motion';
import { import {
Lock, Lock,
Eye, Eye,
@@ -19,11 +11,17 @@ import {
CheckCircle2, CheckCircle2,
ArrowLeft, ArrowLeft,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/ui/Card';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Input } from '@/ui/Input';
import Input from '@/components/ui/Input'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/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'; import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
interface ResetPasswordTemplateProps extends ResetPasswordViewData { interface ResetPasswordTemplateProps extends ResetPasswordViewData {
@@ -48,184 +46,183 @@ export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) {
const { formActions, uiState, mutationState, ...viewData } = props; const { formActions, uiState, mutationState, ...viewData } = props;
return ( 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 */} {/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" /> <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))' }} />
<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: 'relative', width: '100%', maxWidth: '28rem', padding: '0 1rem' }}>
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <Box style={{ textAlign: '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"> <Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Flag className="w-8 h-8 text-primary-blue" /> <Icon icon={Flag} size={8} color="#3b82f6" />
</div> </Surface>
<Heading level={1} className="mb-2">Reset Password</Heading> <Heading level={1}>Reset Password</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Create a new secure password for your account Create a new secure password for your account
</p> </Text>
</div> </Box>
<Card className="relative overflow-hidden"> <Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */} {/* 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 ? ( {!viewData.showSuccess ? (
<form onSubmit={formActions.handleSubmit} className="relative space-y-5"> <form onSubmit={formActions.handleSubmit}>
{/* New Password */} <Stack gap={5} style={{ position: 'relative' }}>
<div> {/* New Password */}
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-300 mb-2"> <Box>
New Password <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
</label> New Password
<div className="relative"> </Text>
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box position="relative">
<Input <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
id="newPassword" <Icon icon={Lock} size={4} color="#6b7280" />
name="newPassword" </Box>
type={uiState.showPassword ? 'text' : 'password'} <Input
value={viewData.formState.fields.newPassword.value} id="newPassword"
onChange={formActions.handleChange} name="newPassword"
error={!!viewData.formState.fields.newPassword.error} type={uiState.showPassword ? 'text' : 'password'}
errorMessage={viewData.formState.fields.newPassword.error} value={viewData.formState.fields.newPassword.value}
placeholder="••••••••" onChange={formActions.handleChange}
disabled={mutationState.isPending} variant={viewData.formState.fields.newPassword.error ? 'error' : 'default'}
className="pl-10 pr-10" placeholder="••••••••"
autoComplete="new-password" disabled={mutationState.isPending}
/> style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
<button autoComplete="new-password"
type="button" />
onClick={() => formActions.setShowPassword(!uiState.showPassword)} <Box
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" as="button"
> type="button"
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} onClick={() => formActions.setShowPassword(!uiState.showPassword)}
</button> style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
</div> >
</div> <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 */} {/* Confirm Password */}
<div> <Box>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password Confirm Password
</label> </Text>
<div className="relative"> <Box position="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={Lock} size={4} color="#6b7280" />
id="confirmPassword" </Box>
name="confirmPassword" <Input
type={uiState.showConfirmPassword ? 'text' : 'password'} id="confirmPassword"
value={viewData.formState.fields.confirmPassword.value} name="confirmPassword"
onChange={formActions.handleChange} type={uiState.showConfirmPassword ? 'text' : 'password'}
error={!!viewData.formState.fields.confirmPassword.error} value={viewData.formState.fields.confirmPassword.value}
errorMessage={viewData.formState.fields.confirmPassword.error} onChange={formActions.handleChange}
placeholder="••••••••" variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
disabled={mutationState.isPending} placeholder="••••••••"
className="pl-10 pr-10" disabled={mutationState.isPending}
autoComplete="new-password" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
/> autoComplete="new-password"
<button />
type="button" <Box
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} as="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" type="button"
> onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
</button> >
</div> <Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</div> </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 */} {/* Error Message */}
{mutationState.error && ( {mutationState.error && (
<motion.div <Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.3)' }}>
initial={{ opacity: 0, y: -10 }} <Stack direction="row" align="start" gap={3}>
animate={{ opacity: 1, y: 0 }} <Icon icon={AlertCircle} size={5} color="#ef4444" />
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30" <Text size="sm" color="text-error-red">{mutationState.error}</Text>
> </Stack>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" /> </Surface>
<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
</>
)} )}
</Button>
{/* Back to Login */} {/* Submit Button */}
<div className="text-center"> <Button
<Link type="submit"
href="/auth/login" variant="primary"
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1" disabled={mutationState.isPending}
fullWidth
icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={Shield} size={4} />}
> >
<ArrowLeft className="w-4 h-4" /> {mutationState.isPending ? 'Resetting...' : 'Reset Password'}
Back to Login </Button>
</Link>
</div> {/* 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> </form>
) : ( ) : (
<motion.div <Stack gap={4} style={{ position: 'relative' }}>
initial={{ opacity: 0, y: 10 }} <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' }}>
animate={{ opacity: 1, y: 0 }} <Stack direction="row" align="start" gap={3}>
className="relative space-y-4" <Icon icon={CheckCircle2} size={6} color="#10b981" />
> <Box>
<div className="flex items-start gap-3 p-4 rounded-lg bg-performance-green/10 border border-performance-green/30"> <Text size="sm" color="text-performance-green" weight="medium" block>{viewData.successMessage}</Text>
<CheckCircle2 className="w-6 h-6 text-performance-green flex-shrink-0 mt-0.5" /> <Text size="xs" color="text-gray-400" block mt={1}>
<div> Your password has been successfully reset
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p> </Text>
<p className="text-xs text-gray-400 mt-1"> </Box>
Your password has been successfully reset </Stack>
</p> </Surface>
</div>
</div>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => window.location.href = '/auth/login'} onClick={() => window.location.href = '/auth/login'}
className="w-full" fullWidth
> >
Return to Login Return to Login
</Button> </Button>
</motion.div> </Stack>
)} )}
</Card> </Card>
{/* Trust Indicators */} {/* Trust Indicators */}
<div className="mt-6 flex items-center justify-center gap-6 text-sm text-gray-500"> <Stack direction="row" align="center" justify="center" gap={6} mt={6}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Shield className="w-4 h-4" /> <Icon icon={Shield} size={4} color="#737373" />
<span>Secure password reset</span> <Text size="sm" color="text-gray-500">Secure password reset</Text>
</div> </Stack>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<CheckCircle2 className="w-4 h-4" /> <Icon icon={CheckCircle2} size={4} color="#737373" />
<span>Encrypted transmission</span> <Text size="sm" color="text-gray-500">Encrypted transmission</Text>
</div> </Stack>
</div> </Stack>
{/* Footer */} {/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500"> <Box mt={6} style={{ textAlign: 'center' }}>
Need help?{' '} <Text size="xs" color="text-gray-500">
<Link href="/support" className="text-gray-400 hover:underline"> Need help?{' '}
Contact support <Link href="/support">
</Link> <Text color="text-gray-400">Contact support</Text>
</p> </Link>
</div> </Text>
</main> </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'; 'use client';
import Link from 'next/link'; import React from 'react';
import { motion } from 'framer-motion';
import { import {
Mail, Mail,
Lock, Lock,
@@ -26,11 +18,17 @@ import {
Shield, Shield,
Sparkles, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/ui/Card';
import Card from '@/components/ui/Card'; import { Button } from '@/ui/Button';
import Button from '@/components/ui/Button'; import { Input } from '@/ui/Input';
import Input from '@/components/ui/Input'; import { Heading } from '@/ui/Heading';
import Heading from '@/components/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 { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { checkPasswordStrength } from '@/lib/utils/validation'; import { checkPasswordStrength } from '@/lib/utils/validation';
@@ -57,19 +55,19 @@ const USER_ROLES = [
icon: Car, icon: Car,
title: 'Driver', title: 'Driver',
description: 'Race, track stats, join teams', description: 'Race, track stats, join teams',
color: 'primary-blue', color: '#3b82f6',
}, },
{ {
icon: Trophy, icon: Trophy,
title: 'League Admin', title: 'League Admin',
description: 'Organize leagues and events', description: 'Organize leagues and events',
color: 'performance-green', color: '#10b981',
}, },
{ {
icon: Users, icon: Users,
title: 'Team Manager', title: 'Team Manager',
description: 'Manage team and drivers', description: 'Manage team and drivers',
color: 'purple-400', color: '#a855f7',
}, },
]; ];
@@ -93,362 +91,380 @@ export function SignupTemplate({ viewData, formActions, uiState, mutationState }
]; ];
return ( return (
<main className="min-h-screen bg-deep-graphite flex"> <Box as="main" style={{ minHeight: '100vh', display: 'flex', position: 'relative' }}>
{/* Background Pattern */} {/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/5 via-transparent to-purple-600/5" /> <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))' }} />
<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>
{/* Left Side - Info Panel (Hidden on mobile) */} {/* Left Side - Info Panel (Hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 relative items-center justify-center p-12"> <Box className="hidden lg:flex lg:w-1/2" style={{ position: 'relative', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
<div className="max-w-lg"> <Box style={{ maxWidth: '32rem' }}>
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-3 mb-8"> <Stack direction="row" align="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"> <Surface variant="muted" rounded="xl" border padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Flag className="w-6 h-6 text-primary-blue" /> <Icon icon={Flag} size={6} color="#3b82f6" />
</div> </Surface>
<span className="text-2xl font-bold text-white">GridPilot</span> <Text size="2xl" weight="bold" color="text-white">GridPilot</Text>
</div> </Stack>
<Heading level={2} className="text-white mb-4"> <Box mb={4}>
Start Your Racing Journey <Heading level={2}>Start Your Racing Journey</Heading>
</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. 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 */} {/* Role Cards */}
<div className="space-y-3 mb-8"> <Stack gap={3} mb={8}>
{USER_ROLES.map((role, index) => ( {USER_ROLES.map((role) => (
<motion.div <Surface
key={role.title} key={role.title}
initial={{ opacity: 0, x: -20 }} variant="muted"
animate={{ opacity: 1, x: 0 }} rounded="xl"
transition={{ delay: index * 0.1 }} border
className="flex items-center gap-4 p-4 rounded-xl bg-iron-gray/50 border border-charcoal-outline" 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`}> <Stack direction="row" align="center" gap={4}>
<role.icon className={`w-5 h-5 text-${role.color}`} /> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
</div> <Icon icon={role.icon} size={5} color={role.color} />
<div> </Surface>
<h4 className="text-white font-medium">{role.title}</h4> <Box>
<p className="text-sm text-gray-500">{role.description}</p> <Text weight="medium" color="text-white" block>{role.title}</Text>
</div> <Text size="sm" color="text-gray-500" block mt={1}>{role.description}</Text>
</motion.div> </Box>
</Stack>
</Surface>
))} ))}
</div> </Stack>
{/* Features List */} {/* Features List */}
<div className="bg-iron-gray/30 rounded-xl border border-charcoal-outline p-5 mb-8"> <Box mb={8}>
<div className="flex items-center gap-2 mb-4"> <Surface variant="muted" rounded="xl" border padding={5} style={{ backgroundColor: 'rgba(38, 38, 38, 0.2)', borderColor: '#262626' }}>
<Sparkles className="w-4 h-4 text-primary-blue" /> <Stack direction="row" align="center" gap={2} mb={4}>
<span className="text-sm font-medium text-white">What you'll get</span> <Icon icon={Sparkles} size={4} color="#3b82f6" />
</div> <Text size="sm" weight="medium" color="text-white">What you&apos;ll get</Text>
<ul className="space-y-2"> </Stack>
{FEATURES.map((feature, index) => ( <Stack gap={2}>
<li {FEATURES.map((feature, index) => (
key={index} <Stack key={index} direction="row" align="center" gap={2}>
className="flex items-center gap-2 text-sm text-gray-400" <Icon icon={Check} size={3.5} color="#10b981" />
> <Text size="sm" color="text-gray-400">{feature}</Text>
<Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" /> </Stack>
{feature} ))}
</li> </Stack>
))} </Surface>
</ul> </Box>
</div>
{/* Trust Indicators */} {/* Trust Indicators */}
<div className="flex items-center gap-6 text-sm text-gray-500"> <Stack direction="row" align="center" gap={6}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Shield className="w-4 h-4" /> <Icon icon={Shield} size={4} color="#737373" />
<span>Secure signup</span> <Text size="sm" color="text-gray-500">Secure signup</Text>
</div> </Stack>
<div className="flex items-center gap-2"> <Text size="sm" color="text-gray-500">iRacing integration</Text>
<span>iRacing integration</span> </Stack>
</div> </Box>
</div> </Box>
</div>
</div>
{/* Right Side - Signup Form */} {/* Right Side - Signup Form */}
<div className="flex-1 flex items-center justify-center px-4 py-12 overflow-y-auto"> <Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', position: 'relative', overflowY: 'auto' }}>
<div className="relative w-full max-w-md"> <Box style={{ width: '100%', maxWidth: '28rem' }}>
{/* Mobile Logo/Header */} {/* Mobile Logo/Header */}
<div className="text-center mb-8 lg:hidden"> <Box className="lg:hidden" style={{ textAlign: '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"> <Surface variant="muted" rounded="2xl" border padding={4} style={{ width: '4rem', height: '4rem', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1rem' }}>
<Flag className="w-8 h-8 text-primary-blue" /> <Icon icon={Flag} size={8} color="#3b82f6" />
</div> </Surface>
<Heading level={1} className="mb-2">Join GridPilot</Heading> <Heading level={1}>Join GridPilot</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Create your account and start racing Create your account and start racing
</p> </Text>
</div> </Box>
{/* Desktop Header */} {/* Desktop Header */}
<div className="hidden lg:block text-center mb-8"> <Box className="hidden lg:block" style={{ textAlign: 'center' }} mb={8}>
<Heading level={2} className="mb-2">Create Account</Heading> <Heading level={2}>Create Account</Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Get started with your free account Get started with your free account
</p> </Text>
</div> </Box>
<Card className="relative overflow-hidden"> <Card style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background accent */} {/* 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"> <form onSubmit={formActions.handleSubmit}>
{/* First Name */} <Stack gap={4} style={{ position: 'relative' }}>
<div> {/* First Name */}
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2"> <Box>
First Name <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
</label> First Name
<div className="relative"> </Text>
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box position="relative">
<Input <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
id="firstName" <Icon icon={User} size={4} color="#6b7280" />
name="firstName" </Box>
type="text" <Input
value={viewData.formState.fields.firstName.value} id="firstName"
onChange={formActions.handleChange} name="firstName"
error={!!viewData.formState.fields.firstName.error} type="text"
errorMessage={viewData.formState.fields.firstName.error} value={viewData.formState.fields.firstName.value}
placeholder="John" onChange={formActions.handleChange}
disabled={mutationState.isPending} variant={viewData.formState.fields.firstName.error ? 'error' : 'default'}
className="pl-10" placeholder="John"
autoComplete="given-name" disabled={mutationState.isPending}
/> style={{ paddingLeft: '2.5rem' }}
</div> autoComplete="given-name"
</div> />
</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 */} {/* Last Name */}
<div> <Box>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Last Name Last Name
</label> </Text>
<div className="relative"> <Box position="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={User} size={4} color="#6b7280" />
id="lastName" </Box>
name="lastName" <Input
type="text" id="lastName"
value={viewData.formState.fields.lastName.value} name="lastName"
onChange={formActions.handleChange} type="text"
error={!!viewData.formState.fields.lastName.error} value={viewData.formState.fields.lastName.value}
errorMessage={viewData.formState.fields.lastName.error} onChange={formActions.handleChange}
placeholder="Smith" variant={viewData.formState.fields.lastName.error ? 'error' : 'default'}
disabled={mutationState.isPending} placeholder="Smith"
className="pl-10" disabled={mutationState.isPending}
autoComplete="family-name" style={{ paddingLeft: '2.5rem' }}
/> 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> </Box>
</div> {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 */} {/* Name Immutability Warning */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/30"> <Surface variant="muted" rounded="lg" border padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<AlertCircle className="w-5 h-5 text-warning-amber flex-shrink-0 mt-0.5" /> <Stack direction="row" align="start" gap={3}>
<div className="text-sm text-warning-amber"> <Icon icon={AlertCircle} size={5} color="#f59e0b" />
<strong>Important:</strong> Your name cannot be changed after signup. Please ensure it's correct. <Text size="sm" color="text-warning-amber">
</div> <Text weight="bold">Important:</Text> Your name cannot be changed after signup. Please ensure it's correct.
</div> </Text>
</Stack>
</Surface>
{/* Email */} {/* Email */}
<div> <Box>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Email Address Email Address
</label> </Text>
<div className="relative"> <Box position="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={Mail} size={4} color="#6b7280" />
id="email" </Box>
name="email" <Input
type="email" id="email"
value={viewData.formState.fields.email.value} name="email"
onChange={formActions.handleChange} type="email"
error={!!viewData.formState.fields.email.error} value={viewData.formState.fields.email.value}
errorMessage={viewData.formState.fields.email.error} onChange={formActions.handleChange}
placeholder="you@example.com" variant={viewData.formState.fields.email.error ? 'error' : 'default'}
disabled={mutationState.isPending} placeholder="you@example.com"
className="pl-10" disabled={mutationState.isPending}
autoComplete="email" style={{ paddingLeft: '2.5rem' }}
/> autoComplete="email"
</div> />
</div> </Box>
{viewData.formState.fields.email.error && (
<Text size="xs" color="text-error-red" block mt={1}>
{viewData.formState.fields.email.error}
</Text>
)}
</Box>
{/* Password */} {/* Password */}
<div> <Box>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Password Password
</label> </Text>
<div className="relative"> <Box position="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={Lock} size={4} color="#6b7280" />
id="password" </Box>
name="password" <Input
type={uiState.showPassword ? 'text' : 'password'} id="password"
value={viewData.formState.fields.password.value} name="password"
onChange={formActions.handleChange} type={uiState.showPassword ? 'text' : 'password'}
error={!!viewData.formState.fields.password.error} value={viewData.formState.fields.password.value}
errorMessage={viewData.formState.fields.password.error} onChange={formActions.handleChange}
placeholder="••••••••" variant={viewData.formState.fields.password.error ? 'error' : 'default'}
disabled={mutationState.isPending} placeholder="••••••••"
className="pl-10 pr-10" disabled={mutationState.isPending}
autoComplete="new-password" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
/> autoComplete="new-password"
<button />
type="button" <Box
onClick={() => formActions.setShowPassword(!uiState.showPassword)} as="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" type="button"
> onClick={() => formActions.setShowPassword(!uiState.showPassword)}
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
</button> >
</div> <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 */} {/* Password Strength */}
{viewData.formState.fields.password.value && ( {viewData.formState.fields.password.value && (
<div className="mt-3 space-y-2"> <Box mt={3}>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2} mb={2}>
<div className="flex-1 h-1.5 rounded-full bg-charcoal-outline overflow-hidden"> <Box style={{ flex: 1, height: '0.375rem', borderRadius: '9999px', backgroundColor: '#262626', overflow: 'hidden' }}>
<motion.div <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' }} />
className={`h-full ${passwordStrength.color}`} </Box>
initial={{ width: 0 }} <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' }}>
animate={{ width: `${(passwordStrength.score / 5) * 100}%` }} {passwordStrength.label}
transition={{ duration: 0.3 }} </Text>
/> </Stack>
</div> <Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '0.25rem' }}>
<span className={`text-xs font-medium ${ {passwordRequirements.map((req, index) => (
passwordStrength.score <= 1 ? 'text-red-400' : <Stack key={index} direction="row" align="center" gap={1.5}>
passwordStrength.score <= 2 ? 'text-warning-amber' : <Icon icon={req.met ? Check : X} size={3} color={req.met ? '#10b981' : '#525252'} />
passwordStrength.score <= 3 ? 'text-primary-blue' : <Text size="xs" color={req.met ? 'text-gray-300' : 'text-gray-500'}>
'text-performance-green' {req.label}
}`}> </Text>
{passwordStrength.label} </Stack>
</span> ))}
</div> </Box>
<div className="grid grid-cols-2 gap-1"> </Box>
{passwordRequirements.map((req, index) => ( )}
<div key={index} className="flex items-center gap-1.5 text-xs"> </Box>
{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>
{/* Confirm Password */} {/* Confirm Password */}
<div> <Box>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
Confirm Password Confirm Password
</label> </Text>
<div className="relative"> <Box position="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" /> <Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Input <Icon icon={Lock} size={4} color="#6b7280" />
id="confirmPassword" </Box>
name="confirmPassword" <Input
type={uiState.showConfirmPassword ? 'text' : 'password'} id="confirmPassword"
value={viewData.formState.fields.confirmPassword.value} name="confirmPassword"
onChange={formActions.handleChange} type={uiState.showConfirmPassword ? 'text' : 'password'}
error={!!viewData.formState.fields.confirmPassword.error} value={viewData.formState.fields.confirmPassword.value}
errorMessage={viewData.formState.fields.confirmPassword.error} onChange={formActions.handleChange}
placeholder="••••••••" variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
disabled={mutationState.isPending} placeholder="••••••••"
className="pl-10 pr-10" disabled={mutationState.isPending}
autoComplete="new-password" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
/> autoComplete="new-password"
<button />
type="button" <Box
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} as="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" type="button"
> onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
</button> >
</div> <Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( </Box>
<p className="mt-1 text-xs text-performance-green flex items-center gap-1"> </Box>
<Check className="w-3 h-3" /> Passwords match {viewData.formState.fields.confirmPassword.error && (
</p> <Text size="xs" color="text-error-red" block mt={1}>
)} {viewData.formState.fields.confirmPassword.error}
</div> </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 */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="w-full flex items-center justify-center gap-2" fullWidth
> icon={mutationState.isPending ? <LoadingSpinner size={4} color="white" /> : <Icon icon={UserPlus} size={4} />}
{mutationState.isPending ? ( >
<> {mutationState.isPending ? 'Creating account...' : 'Create Account'}
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> </Button>
Creating account... </Stack>
</>
) : (
<>
<UserPlus className="w-4 h-4" />
Create Account
</>
)}
</Button>
</form> </form>
{/* Divider */} {/* Divider */}
<div className="relative my-6"> <Box style={{ position: 'relative' }} my={6}>
<div className="absolute inset-0 flex items-center"> <Box style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
<div className="w-full border-t border-charcoal-outline" /> <Box style={{ width: '100%', borderTop: '1px solid #262626' }} />
</div> </Box>
<div className="relative flex justify-center text-xs"> <Box style={{ position: 'relative', display: 'flex', justifyContent: 'center' }}>
<span className="px-4 bg-iron-gray text-gray-500">or continue with</span> <Box px={4} style={{ backgroundColor: '#171717' }}>
</div> <Text size="xs" color="text-gray-500">or continue with</Text>
</div> </Box>
</Box>
</Box>
{/* Login Link */} {/* Login Link */}
<p className="mt-6 text-center text-sm text-gray-400"> <Box style={{ textAlign: 'center' }} mt={6}>
Already have an account?{' '} <Text size="sm" color="text-gray-400">
<Link Already have an account?{' '}
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'} <Link
className="text-primary-blue hover:underline font-medium" href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
> >
Sign in <Text color="text-primary-blue" weight="medium">Sign in</Text>
</Link> </Link>
</p> </Text>
</Box>
</Card> </Card>
{/* Footer */} {/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500"> <Box mt={6} style={{ textAlign: 'center' }}>
By creating an account, you agree to our{' '} <Text size="xs" color="text-gray-500">
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link> By creating an account, you agree to our{' '}
{' '}and{' '} <Link href="/terms">
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link> <Text color="text-gray-400">Terms of Service</Text>
</p> </Link>
{' '}and{' '}
<Link href="/privacy">
<Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */} {/* Mobile Role Info */}
<div className="mt-8 lg:hidden"> <Box mt={8} className="lg:hidden">
<p className="text-center text-xs text-gray-500 mb-4">One account for all roles</p> <Text size="xs" color="text-gray-500" block mb={4} style={{ textAlign: 'center' }}>One account for all roles</Text>
<div className="flex justify-center gap-6"> <Stack direction="row" align="center" justify="center" gap={6}>
{USER_ROLES.map((role) => ( {USER_ROLES.map((role) => (
<div key={role.title} className="flex flex-col items-center"> <Stack key={role.title} align="center" gap={1}>
<div className={`w-8 h-8 rounded-lg bg-${role.color}/20 flex items-center justify-center mb-1`}> <Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${role.color}1A` }}>
<role.icon className={`w-4 h-4 text-${role.color}`} /> <Icon icon={role.icon} size={4} color={role.color} />
</div> </Surface>
<span className="text-xs text-gray-500">{role.title}</span> <Text size="xs" color="text-gray-500">{role.title}</Text>
</div> </Stack>
))} ))}
</div> </Stack>
</div> </Box>
</div> </Box>
</div> </Box>
</main> </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; children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>; onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string; className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
disabled?: boolean; disabled?: boolean;
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
icon?: ReactNode;
fullWidth?: boolean;
as?: 'button' | 'a';
href?: string;
} }
export function Button({ export function Button({
@@ -17,15 +22,22 @@ export function Button({
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
disabled = false, disabled = false,
type = 'button' type = 'button',
icon,
fullWidth = false,
as = 'button',
href,
...props
}: ButtonProps) { }: 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 = { 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', 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', 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 = { const sizeClasses = {
@@ -35,23 +47,45 @@ export function Button({
}; };
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'; const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
const widthClasses = fullWidth ? 'w-full' : '';
const classes = [ const classes = [
baseClasses, baseClasses,
variantClasses[variant], variantClasses[variant],
sizeClasses[size], sizeClasses[size],
disabledClasses, disabledClasses,
widthClasses,
className className
].filter(Boolean).join(' '); ].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 ( return (
<button <button
type={type} type={type}
className={classes} className={classes}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
{...props}
> >
{children} {content}
</button> </button>
); );
} }

View File

@@ -1,34 +1,59 @@
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; children: ReactNode;
className?: string; className?: string;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLDivElement>;
variant?: 'default' | 'highlight'; variant?: 'default' | 'highlight';
p?: Spacing;
px?: Spacing;
py?: Spacing;
pt?: Spacing;
pb?: Spacing;
pl?: Spacing;
pr?: Spacing;
} }
export function Card({ export function Card({
children, children,
className = '', className = '',
onClick, onClick,
variant = 'default' variant = 'default',
p, px, py, pt, pb, pl, pr,
...props
}: CardProps) { }: CardProps) {
const baseClasses = 'rounded-lg p-6 shadow-card border duration-200'; const baseClasses = 'rounded-lg shadow-card border duration-200';
const variantClasses = { const variantClasses = {
default: 'bg-iron-gray border-charcoal-outline', default: 'bg-iron-gray border-charcoal-outline',
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 border-blue-500/30' 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 = [ const classes = [
baseClasses, baseClasses,
variantClasses[variant], variantClasses[variant],
onClick ? 'cursor-pointer hover:scale-[1.02]' : '', 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 className
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
<div className={classes} onClick={onClick}> <div className={classes} onClick={onClick} {...props}>
{children} {children}
</div> </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 React from 'react';
import Container from '@/components/ui/Container'; import Container from '@/ui/Container';
interface HeaderProps { interface HeaderProps {
children: React.ReactNode; 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 { forwardRef } from 'react';
import { Text } from './Text';
import { Box } from './Box';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error'; variant?: 'default' | 'error';
errorMessage?: string;
} }
export const Input = forwardRef<HTMLInputElement, InputProps>( export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', variant = 'default', ...props }, ref) => { ({ 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'; 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' ? 'border-racing-red' : 'border-charcoal-outline'; const variantClasses = (variant === 'error' || errorMessage) ? 'border-racing-red' : 'border-charcoal-outline';
const classes = `${baseClasses} ${variantClasses} ${className}`; 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 React, { ReactNode, AnchorHTMLAttributes } from 'react';
import NextLink from 'next/link';
interface LinkProps { interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href: string; href: string;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
variant?: 'primary' | 'secondary' | 'ghost'; variant?: 'primary' | 'secondary' | 'ghost';
target?: '_blank' | '_self' | '_parent' | '_top'; target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string; rel?: string;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
style?: React.CSSProperties;
} }
export function Link({ export function Link({
@@ -16,7 +17,10 @@ export function Link({
className = '', className = '',
variant = 'primary', variant = 'primary',
target = '_self', target = '_self',
rel = '' rel = '',
onClick,
style,
...props
}: LinkProps) { }: LinkProps) {
const baseClasses = 'inline-flex items-center transition-colors'; const baseClasses = 'inline-flex items-center transition-colors';
@@ -33,13 +37,16 @@ export function Link({
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
<NextLink <a
href={href} href={href}
className={classes} className={classes}
target={target} target={target}
rel={rel} rel={rel}
onClick={onClick}
style={style}
{...props}
> >
{children} {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 React from 'react';
import { Text } from './Text';
interface QuickActionLinkProps { interface QuickActionLinkProps {
href: string; 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 React, { ReactNode } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Text } from './Text';
interface SectionProps { interface SectionProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
title?: string; title?: string;
description?: string; description?: string;
variant?: 'default' | 'card' | 'highlight'; variant?: 'default' | 'card' | 'highlight' | 'dark' | 'light';
id?: string;
py?: number;
} }
export function Section({ export function Section({
@@ -13,31 +20,34 @@ export function Section({
className = '', className = '',
title, title,
description, description,
variant = 'default' variant = 'default',
id,
py = 16
}: SectionProps) { }: SectionProps) {
const baseClasses = 'space-y-4';
const variantClasses = { const variantClasses = {
default: '', default: '',
card: 'bg-iron-gray rounded-lg p-6 border border-charcoal-outline', 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 = [ const classes = [
baseClasses,
variantClasses[variant], variantClasses[variant],
className className
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( return (
<section className={classes}> <Box as="section" id={id} className={classes} py={py as 0} px={4}>
{title && ( <Box className="mx-auto max-w-7xl">
<h2 className="text-xl font-semibold text-white">{title}</h2> {(title || description) && (
)} <Box mb={8}>
{description && ( {title && <Heading level={2}>{title}</Heading>}
<p className="text-sm text-gray-400">{description}</p> {description && <Text color="text-gray-400" block mt={2}>{description}</Text>}
)} </Box>
{children} )}
</section> {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; label: string;
} }
interface SelectProps { interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
id?: string; id?: string;
'aria-label'?: string; 'aria-label'?: string;
value?: string; value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void; onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
options: SelectOption[]; options: SelectOption[];
className?: string; className?: string;
style?: React.CSSProperties;
} }
export function Select({ export function Select({
@@ -21,6 +22,8 @@ export function Select({
onChange, onChange,
options, options,
className = '', className = '',
style,
...props
}: SelectProps) { }: 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 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; const classes = className ? `${defaultClasses} ${className}` : defaultClasses;
@@ -32,6 +35,8 @@ export function Select({
value={value} value={value}
onChange={onChange} onChange={onChange}
className={classes} className={classes}
style={style}
{...props}
> >
{options.map((option) => ( {options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>

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 { Card } from './Card';
import { Text } from './Text'; import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface StatCardProps { interface StatCardProps {
label: string; label: string;
value: string | number; value: string | number;
icon?: ReactNode; subValue?: string;
icon?: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange'; variant?: 'blue' | 'purple' | 'green' | 'orange';
className?: string; className?: string;
trend?: {
value: number;
isPositive: boolean;
};
} }
export function StatCard({ export function StatCard({
label, label,
value, value,
subValue,
icon, icon,
variant = 'blue', variant = 'blue',
className = '' className = '',
trend,
}: StatCardProps) { }: StatCardProps) {
const variantClasses = { const variantClasses = {
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30', 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 = { const iconColorClasses = {
blue: 'text-blue-400', blue: '#60a5fa',
purple: 'text-purple-400', purple: '#a78bfa',
green: 'text-green-400', green: '#34d399',
orange: 'text-orange-400' orange: '#fb923c'
}; };
return ( return (
<Card className={`${variantClasses[variant]} ${className}`}> <Card className={`${variantClasses[variant]} ${className}`}>
<div className="flex items-center justify-between"> <div className="flex items-start justify-between">
<div> <div>
<Text size="sm" color="text-gray-400" className="mb-1"> <Text size="sm" color="text-gray-400" className="mb-1" block>
{label} {label}
</Text> </Text>
<Text size="3xl" weight="bold" color="text-white"> <Text size="3xl" weight="bold" color="text-white" block>
{value} {value}
</Text> </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> </div>
{icon && (
<div className={iconColorClasses[variant]}>
{icon}
</div>
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -1,33 +1,46 @@
import React from 'react'; import React from 'react';
import { Text } from './Text'; import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
import { Stack } from './Stack';
interface StatusBadgeProps { interface StatusBadgeProps {
children: React.ReactNode; children: React.ReactNode;
variant?: 'success' | 'warning' | 'error' | 'info'; variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
className?: string; className?: string;
icon?: LucideIcon;
} }
export function StatusBadge({ export function StatusBadge({
children, children,
variant = 'success', variant = 'success',
className = '' className = '',
icon,
}: StatusBadgeProps) { }: StatusBadgeProps) {
const variantClasses = { const variantClasses = {
success: 'bg-performance-green/20 text-performance-green', success: 'bg-performance-green/20 text-performance-green border-performance-green/30',
warning: 'bg-warning-amber/20 text-warning-amber', warning: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
error: 'bg-red-600/20 text-red-400', error: 'bg-red-600/20 text-red-400 border-red-600/30',
info: 'bg-blue-500/20 text-blue-400' 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 = [ 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], variantClasses[variant],
className className
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
return ( const content = icon ? (
<Text size="xs" className={classes}> <Stack direction="row" align="center" gap={1.5}>
<Icon icon={icon} size={3} />
{children} {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,87 +1,87 @@
import { ReactNode } from 'react'; import React, { ReactNode, HTMLAttributes } from 'react';
interface TableProps { interface TableProps extends HTMLAttributes<HTMLTableElement> {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }
export function Table({ children, className = '' }: TableProps) { export function Table({ children, className = '', ...props }: TableProps) {
return ( return (
<div className={`overflow-x-auto ${className}`}> <div style={{ overflowX: 'auto' }}>
<table className="w-full"> <table className={`w-full ${className}`} {...props}>
{children} {children}
</table> </table>
</div> </div>
); );
} }
interface TableHeadProps { interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode; children: ReactNode;
} }
export function TableHead({ children }: TableHeadProps) { export function TableHead({ children, ...props }: TableHeadProps) {
return ( return (
<thead> <thead {...props}>
{children} {children}
</thead> </thead>
); );
} }
interface TableBodyProps { interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode; children: ReactNode;
} }
export function TableBody({ children }: TableBodyProps) { export function TableBody({ children, ...props }: TableBodyProps) {
return ( return (
<tbody> <tbody {...props}>
{children} {children}
</tbody> </tbody>
); );
} }
interface TableRowProps { interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
children: ReactNode; children: ReactNode;
className?: string; 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 baseClasses = 'border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors';
const classes = className ? `${baseClasses} ${className}` : baseClasses; const classes = className ? `${baseClasses} ${className}` : baseClasses;
return ( return (
<tr className={classes}> <tr className={classes} {...props}>
{children} {children}
</tr> </tr>
); );
} }
interface TableHeaderProps { interface TableHeaderProps extends HTMLAttributes<HTMLTableCellElement> {
children: ReactNode; children: ReactNode;
className?: string; 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 baseClasses = 'text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase';
const classes = className ? `${baseClasses} ${className}` : baseClasses; const classes = className ? `${baseClasses} ${className}` : baseClasses;
return ( return (
<th className={classes}> <th className={classes} {...props}>
{children} {children}
</th> </th>
); );
} }
interface TableCellProps { interface TableCellProps extends HTMLAttributes<HTMLTableCellElement> {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }
export function TableCell({ children, className = '' }: TableCellProps) { export function TableCell({ children, className = '', ...props }: TableCellProps) {
const baseClasses = 'py-3 px-4'; const baseClasses = 'py-3 px-4';
const classes = className ? `${baseClasses} ${className}` : baseClasses; const classes = className ? `${baseClasses} ${className}` : baseClasses;
return ( return (
<td className={classes}> <td className={classes} {...props}>
{children} {children}
</td> </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; children: ReactNode;
className?: string; className?: string;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'; size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
@@ -9,6 +11,12 @@ interface TextProps {
font?: 'mono' | 'sans'; font?: 'mono' | 'sans';
align?: 'left' | 'center' | 'right'; align?: 'left' | 'center' | 'right';
truncate?: boolean; truncate?: boolean;
style?: React.CSSProperties;
block?: boolean;
ml?: Spacing;
mr?: Spacing;
mt?: Spacing;
mb?: Spacing;
} }
export function Text({ export function Text({
@@ -19,7 +27,11 @@ export function Text({
color = '', color = '',
font = 'sans', font = 'sans',
align = 'left', align = 'left',
truncate = false truncate = false,
style,
block = false,
ml, mr, mt, mb,
...props
}: TextProps) { }: TextProps) {
const sizeClasses = { const sizeClasses = {
xs: 'text-xs', xs: 'text-xs',
@@ -50,15 +62,27 @@ export function Text({
right: 'text-right' 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 = [ const classes = [
block ? 'block' : 'inline',
sizeClasses[size], sizeClasses[size],
weightClasses[weight], weightClasses[weight],
fontClasses[font], fontClasses[font],
alignClasses[align], alignClasses[align],
color, color,
truncate ? 'truncate' : '', truncate ? 'truncate' : '',
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
className className
].filter(Boolean).join(' '); ].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>
);
}