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,111 +29,110 @@ 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}>
<Stack gap={6}>
{/* Header */} {/* Header */}
<Layout flex flexCol={false} items="center" justify="between"> <Stack direction="row" align="center" justify="between">
<div> <Box>
<Text size="2xl" weight="bold" color="text-white"> <Heading level={1}>Admin Dashboard</Heading>
Admin Dashboard <Text size="sm" color="text-gray-400" block mt={1}>
</Text>
<Text size="sm" color="text-gray-400" className="mt-1">
System overview and statistics System overview and statistics
</Text> </Text>
</div> </Box>
<Button <Button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
variant="secondary" variant="secondary"
className="flex items-center gap-2" icon={<Icon icon={RefreshCw} size={4} className={isLoading ? 'animate-spin' : ''} />}
> >
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</Layout> </Stack>
{/* 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>
</Layout> </Stack>
<Layout flex flexCol={false} items="center" justify="between"> <Stack direction="row" align="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>
</Layout> </Stack>
<Layout flex flexCol={false} items="center" justify="between"> <Stack direction="row" align="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>
</Layout> </Stack>
<Layout flex flexCol={false} items="center" justify="between"> <Stack direction="row" align="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>
</Layout> </Stack>
</Layout> </Stack>
</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>
@@ -135,8 +142,10 @@ export function AdminDashboardTemplate(props: {
<QuickActionLink href="/admin" variant="orange"> <QuickActionLink href="/admin" variant="orange">
View Audit Log View Audit Log
</QuickActionLink> </QuickActionLink>
</Layout> </Grid>
</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,9 +38,10 @@ export function AdminUsersTemplate(props: {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
deletingUser: string | null; deletingUser: string | null;
}) { }
const {
adminUsersViewData: viewData, export function AdminUsersTemplate({
viewData,
onRefresh, onRefresh,
onSearch, onSearch,
onFilterRole, onFilterRole,
@@ -53,159 +55,83 @@ export function AdminUsersTemplate(props: {
loading, loading,
error, error,
deletingUser deletingUser
} = props; }: AdminUsersTemplateProps) {
const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => {
const toStatusBadgeProps = (
status: string,
): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => {
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}>
<Stack gap={6}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<div> <Box>
<Text size="2xl" weight="bold" color="text-white">User Management</Text> <Heading level={1}>User Management</Heading>
<Text size="sm" color="text-gray-400" className="mt-1">Manage and monitor all system users</Text> <Text size="sm" color="text-gray-400" block mt={1}>Manage and monitor all system users</Text>
</div> </Box>
<Button <Button
onClick={onRefresh} onClick={onRefresh}
disabled={loading} disabled={loading}
variant="secondary" variant="secondary"
className="flex items-center gap-2" icon={<Icon icon={RefreshCw} size={4} className={loading ? 'animate-spin' : ''} />}
> >
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</div> </Stack>
{/* Error Banner */} {/* Error Banner */}
{error && ( {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"> <InfoBox
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" /> icon={Users}
<div className="flex-1"> title="Error"
<Text weight="medium">Error</Text> description={error}
<Text size="sm" className="opacity-90">{error}</Text> variant="warning"
</div> />
<Button
onClick={() => {}}
variant="secondary"
className="text-racing-red hover:opacity-70 p-0"
>
×
</Button>
</div>
)} )}
{/* Filters Card */} {/* Filters Card */}
<Card> <UserFilters
<div className="space-y-4"> search={search}
<div className="flex items-center justify-between"> roleFilter={roleFilter}
<div className="flex items-center gap-2"> statusFilter={statusFilter}
<Filter className="w-4 h-4 text-gray-400" /> onSearch={onSearch}
<Text weight="medium" color="text-white">Filters</Text> onFilterRole={onFilterRole}
</div> onFilterStatus={onFilterStatus}
{(search || roleFilter || statusFilter) && ( onClearFilters={onClearFilters}
<Button
onClick={onClearFilters}
variant="secondary"
className="text-xs p-0"
>
Clear all
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
className="pl-9"
/> />
</div>
<Select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
options={[
{ value: '', label: 'All Roles' },
{ value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
options={[
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
]}
/>
</div>
</div>
</Card>
{/* Users Table */} {/* Users Table */}
<Card> <Card p={0}>
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3"> <Stack center py={12} gap={3}>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div> <Box className="animate-spin" style={{ borderRadius: '9999px', height: '2rem', width: '2rem', borderBottom: '2px solid #3b82f6' }} />
<Text color="text-gray-400">Loading users...</Text> <Text color="text-gray-400">Loading users...</Text>
</div> </Stack>
) : !viewData.users || viewData.users.length === 0 ? ( ) : !viewData.users || viewData.users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3"> <Stack center py={12} gap={3}>
<Users className="w-12 h-12 text-gray-600" /> <Icon icon={Users} size={12} color="#525252" />
<Text color="text-gray-400">No users found</Text> <Text color="text-gray-400">No users found</Text>
<Button <Button
onClick={onClearFilters} onClick={onClearFilters}
variant="secondary" variant="ghost"
className="text-sm p-0" size="sm"
> >
Clear filters Clear filters
</Button> </Button>
</div> </Stack>
) : ( ) : (
<Table> <Table>
<TableHead> <TableHead>
@@ -219,58 +145,67 @@ export function AdminUsersTemplate(props: {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{viewData.users.map((user, index: number) => ( {viewData.users.map((user) => (
<TableRow <TableRow key={user.id}>
key={user.id}
className={index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}
>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <Stack direction="row" align="center" gap={3}>
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center"> <Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)' }}>
<Shield className="w-4 h-4 text-primary-blue" /> <Icon icon={Shield} size={4} color="#3b82f6" />
</div> </Surface>
<div> <Box>
<div className="font-medium text-white">{user.displayName}</div> <Text weight="medium" color="text-white" block>{user.displayName}</Text>
<div className="text-xs text-gray-500">ID: {user.id}</div> <Text size="xs" color="text-gray-500" block>ID: {user.id}</Text>
{user.primaryDriverId && ( {user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div> <Text size="xs" color="text-gray-500" block>Driver: {user.primaryDriverId}</Text>
)} )}
</div> </Box>
</div> </Stack>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm text-gray-300">{user.email}</div> <Text size="sm" color="text-gray-300">{user.email}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <Stack direction="row" gap={1} wrap>
{user.roles.map((role: string, idx: number) => ( {user.roles.map((role, idx) => {
<span const style = getRoleBadgeStyle(role);
return (
<Surface
key={idx} key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${getRoleBadgeClass(role)}`} 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'
}}
> >
{getRoleBadgeLabel(role)} <Text size="xs" weight="medium">{role.charAt(0).toUpperCase() + role.slice(1)}</Text>
</span> </Surface>
))} );
</div> })}
</Stack>
</TableCell> </TableCell>
<TableCell> <TableCell>
{(() => { <StatusBadge variant={getStatusBadgeVariant(user.status)}>
const badge = toStatusBadgeProps(user.status); {user.status.charAt(0).toUpperCase() + user.status.slice(1)}
return <StatusBadge status={badge.status} label={badge.label} />; </StatusBadge>
})()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'} {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</div> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
{user.status === 'active' && ( {user.status === 'active' && (
<Button <Button
onClick={() => onUpdateStatus(user.id, 'suspended')} onClick={() => onUpdateStatus(user.id, 'suspended')}
variant="secondary" variant="secondary"
className="px-3 py-1 text-xs bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30" size="sm"
> >
Suspend Suspend
</Button> </Button>
@@ -279,7 +214,7 @@ export function AdminUsersTemplate(props: {
<Button <Button
onClick={() => onUpdateStatus(user.id, 'active')} onClick={() => onUpdateStatus(user.id, 'active')}
variant="secondary" variant="secondary"
className="px-3 py-1 text-xs bg-performance-green/20 text-performance-green hover:bg-performance-green/30" size="sm"
> >
Activate Activate
</Button> </Button>
@@ -289,13 +224,13 @@ export function AdminUsersTemplate(props: {
onClick={() => onDeleteUser(user.id)} onClick={() => onDeleteUser(user.id)}
disabled={deletingUser === user.id} disabled={deletingUser === user.id}
variant="secondary" 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" size="sm"
icon={<Icon icon={Trash2} size={3} />}
> >
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'} {deletingUser === user.id ? 'Deleting...' : 'Delete'}
</Button> </Button>
)} )}
</div> </Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -306,40 +241,13 @@ export function AdminUsersTemplate(props: {
{/* 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>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">Active</Text>
<Text size="2xl" weight="bold" color="text-white">
{viewData.activeUserCount}
</Text>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">Admins</Text>
<Text size="2xl" weight="bold" color="text-white">
{viewData.adminCount}
</Text>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)} )}
</div> </Stack>
</Container>
); );
} }

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">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Welcome Message */}
<div className="flex items-start gap-5">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<img
src={currentDriver.avatarUrl}
alt={currentDriver.name}
className="w-full h-full object-cover"
/> />
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
</div>
<div>
<p className="text-gray-400 text-sm mb-1">Good morning,</p>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
{currentDriver.name}
<span className="ml-3 text-2xl">{currentDriver.country}</span>
</h1>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<span className="text-sm font-semibold text-primary-blue">{currentDriver.rating}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<span className="text-sm font-semibold text-yellow-400">#{currentDriver.rank}</span>
</div>
<span className="text-xs text-gray-500">{currentDriver.totalRaces} races completed</span>
</div>
</div>
</div>
{/* Quick Actions */} <Container size="lg" py={8}>
<div className="flex flex-wrap gap-3"> <Grid cols={12} gap={6}>
<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,255 +1,100 @@
'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}>
<Stack gap={6}>
{/* Back Navigation */} {/* Back Navigation */}
<Box>
<Button <Button
variant="secondary" variant="secondary"
onClick={onBackClick} onClick={onBackClick}
className="flex items-center gap-2 mb-4" icon={<ArrowLeft size={4} />}
> >
<ArrowLeft className="w-4 h-4" />
Back to Drivers Back to Drivers
</Button> </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 },
]} ]}
/> />
@@ -257,560 +102,89 @@ export function DriverProfileTemplate({
{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={{
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")`,
}} }}
stats={stats ? { rating: stats.rating || 0 } : null}
globalRank={currentDriver.globalRank || 0}
timezone={extendedProfile?.timezone || 'UTC'}
socialHandles={extendedProfile?.socialHandles || []}
onAddFriend={onAddFriend}
friendRequestSent={friendRequestSent}
/> />
</div>
<div className="relative p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-start gap-6">
{/* Avatar */}
<div className="relative">
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
className="w-full h-full object-cover"
/>
</div>
</div>
</div>
{/* Driver Info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-3xl md:text-4xl font-bold text-white">{driver.name}</h1>
<span className="text-4xl" aria-label={`Country: ${driver.country}`}>
{getCountryFlag(driver.country)}
</span>
</div>
{/* Rating and Rank */}
<div className="flex flex-wrap items-center gap-4 mb-4">
{stats && (
<>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
<span className="text-xs text-gray-400">Rating</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="font-mono font-bold text-yellow-400">#{globalRank}</span>
<span className="text-xs text-gray-400">Global</span>
</div>
</>
)}
</div>
{/* Meta info */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
iRacing: {driver.iracingId}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
Joined{' '}
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{extendedProfile.timezone}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2">
<Button
variant="primary"
onClick={onAddFriend}
disabled={friendRequestSent}
className="flex items-center gap-2"
>
<UserPlus className="w-4 h-4" />
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</div>
</div>
{/* Social Handles */}
{extendedProfile.socialHandles.length > 0 && (
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500 mr-2">Connect:</span>
{extendedProfile.socialHandles.map((social: SocialHandle) => {
const Icon = getSocialIcon(social.platform);
return (
<a
key={social.platform}
href={social.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-iron-gray/50 border border-charcoal-outline text-gray-400 transition-all ${getSocialColor(social.platform)}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{social.handle}</span>
<ExternalLink className="w-3 h-3 opacity-50" />
</a>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Bio Section */} {/* Bio Section */}
{driver.bio && ( {currentDriver.bio && <ProfileBio bio={currentDriver.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 */} {/* Team Memberships */}
{allTeamMemberships.length > 0 && ( {teamMemberships.length > 0 && (
<Card> <TeamMembershipGrid
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> memberships={teamMemberships.map((m) => ({
<Shield className="w-5 h-5 text-purple-400" /> team: { id: m.teamId, name: m.teamName },
Team Memberships role: m.role,
<span className="text-sm text-gray-500 font-normal">({allTeamMemberships.length})</span> joinedAt: new Date(m.joinedAt)
</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 */} {/* Performance Overview */}
{stats && ( {stats && (
<Card> <PerformanceOverview
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2"> stats={{
<Activity className="w-5 h-5 text-neon-aqua" /> wins: stats.wins,
Performance Overview podiums: stats.podiums,
</h2> totalRaces: stats.totalRaces,
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> consistency: stats.consistency,
{/* Circular Progress Charts */} dnfs: stats.dnfs,
<div className="flex flex-col items-center"> bestFinish: stats.bestFinish || 0,
<div className="flex gap-6 mb-4"> avgFinish: stats.avgFinish
<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">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-performance-green" />
<span className="text-xs text-gray-500 uppercase">Best Finish</span>
</div>
<p className="text-2xl font-bold text-performance-green">P{stats.bestFinish}</p>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-2 mb-2">
<Target className="w-4 h-4 text-primary-blue" />
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
</div>
<p className="text-2xl font-bold text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</p>
</div>
</div>
</div>
</div>
</Card>
)} )}
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="flex items-center gap-1 p-1 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit"> <ProfileTabs activeTab={activeTab} onTabChange={onTabChange} />
<button
type="button"
onClick={() => setActiveTab('overview')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'overview'
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
<User className="w-4 h-4" />
Overview
</button>
<button
type="button"
onClick={() => setActiveTab('stats')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'stats'
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
<BarChart3 className="w-4 h-4" />
Detailed Stats
</button>
</div>
{/* Tab Content */} {/* Tab Content */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<> <Stack gap={6}>
{/* Stats and Profile Grid */} <CareerStats stats={stats || { totalRaces: 0, wins: 0, podiums: 0, consistency: 0 }} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Career Stats */}
<Card className="lg:col-span-2">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-performance-green" />
Career Statistics
</h2>
{stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-white mb-1">{stats.totalRaces}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-performance-green mb-1">{stats.wins}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Wins</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-warning-amber mb-1">{stats.podiums}</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
</div>
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
</div>
</div>
) : (
<p className="text-gray-400 text-sm">No race statistics available yet.</p>
)}
</Card>
{/* Racing Preferences */} {extendedProfile && (
<Card> <RacingProfile
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> racingStyle={extendedProfile.racingStyle}
<Flag className="w-5 h-5 text-neon-aqua" /> favoriteTrack={extendedProfile.favoriteTrack}
Racing Profile favoriteCar={extendedProfile.favoriteCar}
</h2> availableHours={extendedProfile.availableHours}
<div className="space-y-4"> lookingForTeam={extendedProfile.lookingForTeam}
<div> openToRequests={extendedProfile.openToRequests}
<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 && ( {extendedProfile && extendedProfile.achievements.length > 0 && (
<div className="space-y-6"> <AchievementGrid
{/* Detailed Performance Metrics */} achievements={extendedProfile.achievements.map((a) => ({
<Card> ...a,
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2"> earnedAt: new Date(a.earnedAt)
<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 */} {socialSummary.friends.length > 0 && (
<div className="grid grid-cols-2 gap-4"> <FriendsPreview friends={socialSummary.friends} />
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline"> )}
<div className="flex items-center gap-2 mb-2"> </Stack>
<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 && ( {activeTab === 'stats' && !stats && (
<Card className="text-center py-12"> <Stack align="center" py={12} gap={4}>
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" /> <Text color="text-gray-400">No statistics available yet</Text>
<p className="text-gray-400 mb-2">No statistics available yet</p> <Text size="sm" color="text-gray-500">This driver hasn&apos;t completed any races yet</Text>
<p className="text-sm text-gray-500">This driver hasn't completed any races yet</p> </Stack>
</Card>
)} )}
</div> </Stack>
</Container>
); );
} }

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}>
<Stack gap={8}>
{/* Header */} {/* Header */}
<div className="mb-8"> <Box>
{onBackToLeaderboards && ( {onBackToLeaderboards && (
<Box mb={6}>
<Button <Button
variant="secondary" variant="secondary"
onClick={onBackToLeaderboards} onClick={onBackToLeaderboards}
className="flex items-center gap-2 mb-6" icon={<Icon icon={ArrowLeft} size={4} />}
> >
<ArrowLeft className="w-4 h-4" />
Back to Leaderboards Back to Leaderboards
</Button> </Button>
</Box>
)} )}
<div className="flex items-center gap-4 mb-2"> <Stack direction="row" align="center" gap={4}>
<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"> <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)' }}>
<Trophy className="w-7 h-7 text-primary-blue" /> <Icon icon={Trophy} size={7} color="#3b82f6" />
</div> </Surface>
<div> <Box>
<Heading level={1} className="text-3xl lg:text-4xl"> <Heading level={1}>Driver Leaderboard</Heading>
Driver Leaderboard <Text color="text-gray-400" block mt={1}>Full rankings of all drivers by performance metrics</Text>
</Heading> </Box>
<p className="text-gray-400">Full rankings of all drivers by performance metrics</p> </Stack>
</div> </Box>
</div>
</div>
{/* Top 3 Podium */} {/* Top 3 Podium */}
{viewData.podium.length > 0 && ( {viewData.podium.length > 0 && (
<div className="mb-10"> <RankingsPodium
<div className="flex items-end justify-center gap-4 lg:gap-8"> podium={viewData.podium.map(d => ({
{[1, 0, 2].map((index) => { ...d,
const driver = viewData.podium[index]; rating: Number(d.rating),
if (!driver) return null; wins: Number(d.wins),
podiums: Number(d.podiums)
const position = index === 1 ? 1 : index === 0 ? 2 : 3; }))}
const config = { onDriverClick={onDriverClick}
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 */} {/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden"> <RankingsTable
{/* Table Header */} drivers={viewData.drivers.map(d => ({
<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"> ...d,
<div className="col-span-1 text-center">Rank</div> rating: Number(d.rating),
<div className="col-span-5 lg:col-span-4">Driver</div> wins: Number(d.wins)
<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> onDriverClick={onDriverClick}
<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> </Stack>
<div className="col-span-2 text-center">Win Rate</div> </Container>
</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 */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}>
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
</div>
</div>
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className="font-mono font-semibold text-white">
{driver.rating.toString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className="font-mono font-semibold text-performance-green">
{driver.wins}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className="font-mono font-semibold text-warning-amber">
{driver.podiums}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className="font-mono font-semibold text-white">
{driver.winRate}%
</span>
</div>
</button>
);
})}
</div>
{/* Empty State */}
{viewData.drivers.length === 0 && (
<div className="py-16 text-center">
<span className="text-5xl mb-4 block">🔍</span>
<p className="text-gray-400 mb-2">No drivers found</p>
<p className="text-sm text-gray-500">There are no drivers in the system yet</p>
</div>
)}
</div>
</div>
); );
} }

View File

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

View File

@@ -1,4 +1,6 @@
import Image from 'next/image'; 'use client';
import React from 'react';
import Hero from '@/components/landing/Hero'; import 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"
className="text-[11px] px-3 py-1.5"
>
View all View all
</Button> </Button>
</div> </Link>
<ul className="space-y-3 text-sm"> </Stack>
{data.topLeagues.slice(0, 4).map((league: any) => ( <Stack gap={3}>
<li key={league.id} className="flex items-start gap-3"> {viewData.topLeagues.slice(0, 4).map((league) => (
<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"> <Box key={league.id}>
{league.name <Stack direction="row" align="start" gap={3}>
.split(' ') <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' }}>
.map((word: string) => word[0]) <Text size="xs" weight="bold" color="text-primary-blue">
.join('') {league.name.split(' ').map((word) => word[0]).join('').slice(0, 3).toUpperCase()}
.slice(0, 3) </Text>
.toUpperCase()} </Surface>
</div> <Box style={{ flex: 1, minWidth: 0 }}>
<div className="flex-1 min-w-0"> <Text color="text-white" block truncate>{league.name}</Text>
<p className="text-white truncate">{league.name}</p> <Text size="xs" color="text-gray-400" block mt={1} truncate>{league.description}</Text>
<p className="text-xs text-gray-400 line-clamp-2"> </Box>
{league.description} </Stack>
</p> </Box>
</div>
</li>
))} ))}
</ul> </Stack>
</Stack>
</Card> </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"
className="text-[11px] px-3 py-1.5"
>
Browse teams Browse teams
</Button> </Button>
</div> </Link>
<ul className="space-y-3 text-sm"> </Stack>
{data.teams.slice(0, 4).map(team => ( <Stack gap={3}>
<li key={team.id} className="flex items-start gap-3"> {viewData.teams.slice(0, 4).map(team => (
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline"> <Box key={team.id}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="md" border padding={1} style={{ width: '2.5rem', height: '2.5rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image <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}
className="w-full h-full object-cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
</div> </Surface>
<div className="flex-1 min-w-0"> <Box style={{ flex: 1, minWidth: 0 }}>
<p className="text-white truncate">{team.name}</p> <Text color="text-white" block truncate>{team.name}</Text>
<p className="text-xs text-gray-400 line-clamp-2"> <Text size="xs" color="text-gray-400" block mt={1} truncate>{team.description}</Text>
{team.description} </Box>
</p> </Stack>
</div> </Box>
</li>
))} ))}
</ul> </Stack>
</Stack>
</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"
className="text-[11px] px-3 py-1.5"
>
View schedule View schedule
</Button> </Button>
</div> </Link>
{data.upcomingRaces.length === 0 ? ( </Stack>
<p className="text-xs text-gray-400"> {viewData.upcomingRaces.length === 0 ? (
<Text size="xs" color="text-gray-400">
No races scheduled in this demo snapshot. No races scheduled in this demo snapshot.
</p> </Text>
) : ( ) : (
<ul className="space-y-3 text-sm"> <Stack gap={3}>
{data.upcomingRaces.map(race => ( {viewData.upcomingRaces.map(race => (
<li key={race.id} className="flex items-start justify-between gap-3"> <Box key={race.id}>
<div className="flex-1 min-w-0"> <Stack direction="row" align="start" justify="between" gap={3}>
<p className="text-white truncate">{race.track}</p> <Box style={{ flex: 1, minWidth: 0 }}>
<p className="text-xs text-gray-400 truncate">{race.car}</p> <Text color="text-white" block truncate>{race.track}</Text>
</div> <Text size="xs" color="text-gray-400" block mt={1} truncate>{race.car}</Text>
<div className="text-right text-xs text-gray-500 whitespace-nowrap"> </Box>
<Text size="xs" color="text-gray-500" style={{ whiteSpace: 'nowrap' }}>
{race.formattedDate} {race.formattedDate}
</div> </Text>
</li> </Stack>
</Box>
))} ))}
</ul> </Stack>
)} )}
</Stack>
</Card> </Card>
</div> </Grid>
</section> </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>
{seasons.length > 0 ? (
<select
id="seasonId"
value={seasonId} value={seasonId}
onChange={(e) => onSeasonChange(e.target.value)} onChange={(e) => onSeasonChange(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded" options={seasons.map(s => ({ value: s.seasonId, label: s.name }))}
>
{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"
/> />
)} <Text size="xs" color="text-gray-500" block mt={1}>Selected: {selectedSeasonLabel}</Text>
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p> </Box>
</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)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200" variant="secondary"
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}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200" variant="secondary"
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>
<Stack gap={2}>
{primaryChampionship.bonusSummary.map((bonus, idx) => ( {primaryChampionship.bonusSummary.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>
</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>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings. Drop rules are applied automatically when calculating championship standings.
</p> </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>
</tr>
<tr>
<td className="py-3">Repeated track limit violations</td>
<td className="py-3 text-warning-amber">5 second penalty</td>
</tr>
<tr>
<td className="py-3">Intentional wrecking</td>
<td className="py-3 text-red-400">Disqualification</td>
</tr>
<tr>
<td className="py-3">Unsportsmanlike conduct</td>
<td className="py-3 text-red-400">Points deduction or ban</td>
</tr>
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4">
Penalties are applied at steward discretion based on incident severity and driver history. Penalties are applied at steward discretion based on incident severity and driver history.
</p> </Text>
</div> </Box>
</Stack>
</Stack>
</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
</p> </Text>
</div> </Box>
</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
</p> </Text>
</div> </Box>
</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>
<Text size="sm" color="text-gray-400" block mt={2}>
Form-based editing and ownership transfer functionality will be implemented in future updates. Form-based editing and ownership transfer functionality will be implemented in future updates.
</p> </Text>
</div> </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
</p> </Text>
</div> </Box>
</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 ${
slot.isAvailable
? 'border-performance-green bg-performance-green/5'
: 'border-charcoal-outline bg-iron-gray/30'
}`}
>
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-white">{slot.name}</h4>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
slot.isAvailable
? 'bg-performance-green/20 text-performance-green'
: 'bg-gray-500/20 text-gray-400'
}`}>
{slot.isAvailable ? 'Available' : 'Taken'}
</span>
</div>
<p className="text-sm text-gray-300 mb-3">{slot.description}</p>
<div className="flex items-center gap-2 mb-3">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-white font-semibold">
{slot.price} {slot.currency}
</span>
</div>
{!slot.isAvailable && slot.sponsoredBy && (
<div className="pt-3 border-t border-charcoal-outline">
<p className="text-xs text-gray-400 mb-1">Sponsored by</p>
<p className="text-sm font-medium text-white">{slot.sponsoredBy.name}</p>
</div>
)}
</div>
))} ))}
</div> </Grid>
)} )}
</Stack>
</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 = {
pending: <AlertCircle className="w-5 h-5 text-warning-amber" />,
approved: <CheckCircle className="w-5 h-5 text-performance-green" />,
rejected: <XCircle className="w-5 h-5 text-red-400" />,
}[request.status];
const statusColor = {
pending: 'border-warning-amber bg-warning-amber/5',
approved: 'border-performance-green bg-performance-green/5',
rejected: 'border-red-400 bg-red-400/5',
}[request.status];
return ( return (
<div <SponsorshipRequestCard
key={request.id} key={request.id}
className={`rounded-lg border p-4 ${statusColor}`} request={{
> ...request,
<div className="flex items-start justify-between"> status: request.status as any,
<div className="flex-1 min-w-0"> slotName: slot?.name || 'Unknown slot'
<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> </Stack>
)} )}
</Stack>
</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>
<Text size="sm" color="text-gray-400" block mt={2}>
Interactive management features for approving requests and managing slots will be implemented in future updates. Interactive management features for approving requests and managing slots will be implemented in future updates.
</p> </Text>
</div> </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,19 +25,20 @@ 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}>
<Heading level={2}>Championship Standings</Heading>
<StandingsTable <StandingsTable
standings={viewData.standings} standings={viewData.standings}
drivers={viewData.drivers} drivers={viewData.drivers}
@@ -50,7 +48,8 @@ export function LeagueStandingsTemplate({
onRemoveMember={onRemoveMember} onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole} 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
</p> </Text>
</div> </Box>
</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"
>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{getTransactionIcon(transaction.type)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">
{transaction.description}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{new Date(transaction.createdAt).toLocaleDateString()}</span>
<span></span>
<span className={`capitalize ${getTransactionColor(transaction.type)}`}>
{transaction.type}
</span>
<span></span>
<span className={`capitalize ${
transaction.status === 'completed'
? 'text-performance-green'
: transaction.status === 'pending'
? 'text-warning-amber'
: 'text-red-400'
}`}>
{transaction.status}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${
transaction.amount >= 0 ? 'text-performance-green' : 'text-red-400'
}`}>
{transaction.amount >= 0 ? '+' : '-'}{formatCurrency(transaction.amount)}
</p>
</div>
</div>
))} ))}
</div> </Stack>
)} )}
</Stack>
</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>
<Text size="sm" color="text-gray-400" block mt={2}>
Interactive withdrawal and export features will be implemented in future updates. Interactive withdrawal and export features will be implemented in future updates.
</p> </Text>
</div> </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>
<Text color="text-gray-400" size="sm" block mt={2}>
View leagues you own and participate in, and jump into league admin tools. View leagues you own and participate in, and jump into league admin tools.
</p> </Text>
</div> </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">
<Heading level={2}>Leagues you own</Heading>
{viewData.ownedLeagues.length > 0 && ( {viewData.ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400">
{viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span> </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"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
</div>
<div className="flex items-center gap-2">
<a
href={`/leagues/${league.leagueId}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</a>
<a href={`/leagues/${league.leagueId}?tab=admin`}>
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
Manage
</button>
</a>
</div>
</div>
))} ))}
</div> </Stack>
)} )}
</div> </Stack>
</Surface>
{/* 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">
<Heading level={2}>Leagues you're in</Heading>
{viewData.memberLeagues.length > 0 && ( {viewData.memberLeagues.length > 0 && (
<span className="text-xs text-gray-400"> <Text size="xs" color="text-gray-400">
{viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'}
</span> </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"
>
<div>
<h3 className="text-white font-medium">{league.name}</h3>
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
{league.description}
</p>
<p className="text-xs text-gray-500 mt-1">
Your role:{' '}
{league.membershipRole.charAt(0).toUpperCase() + league.membershipRole.slice(1)}
</p>
</div>
<a
href={`/leagues/${league.leagueId}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</a>
</div>
))} ))}
</div> </Stack>
)} )}
</div> </Stack>
</div> </Surface>
</Stack>
</Container>
); );
} }

View File

@@ -1,169 +1,105 @@
'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>
<Heading level={2}>Get Started</Heading>
<Text size="sm" color="text-gray-400">
Create your driver profile to join leagues, compete in races, and connect with other drivers. Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p> </Text>
</div> </Box>
<CreateDriverForm /> <CreateDriverForm />
</Stack>
</Card> </Card>
</div> </Box>
); </Container>
}
if (!viewData) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">Unable to load profile</p>
</Card>
</div>
); );
} }
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}>
<Stack direction="row" align="center" justify="between">
<Heading level={1}>Edit Profile</Heading> <Heading level={1}>Edit Profile</Heading>
<Button variant="secondary" onClick={() => setEditMode(false)}> <Button variant="secondary" onClick={() => onEditModeChange(false)}>
Cancel Cancel
</Button> </Button>
</div> </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,
@@ -173,275 +109,124 @@ export function ProfileTemplate({ viewData, mode, onSaveSettings }: ProfileTempl
}} }}
onSave={async (updates) => { onSave={async (updates) => {
await onSaveSettings(updates); await onSaveSettings(updates);
setEditMode(false); onEditModeChange(false);
}} }}
/> />
</div> </Stack>
</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">
<div className="relative">
<div className="w-28 h-28 md:w-36 md:h-36 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-1 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={viewData.driver.avatarUrl}
alt={viewData.driver.name}
width={144}
height={144}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-performance-green border-4 border-iron-gray" />
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h1 className="text-3xl md:text-4xl font-bold text-white">{viewData.driver.name}</h1>
<span className="text-4xl" aria-label={`Country: ${viewData.driver.countryCode}`}>{viewData.driver.countryFlag}</span>
{viewData.teamMemberships[0] && (
<span className="px-3 py-1 bg-purple-600/20 text-purple-400 rounded-full text-sm font-semibold border border-purple-600/30">
[{viewData.teamMemberships[0].teamTag || 'TEAM'}]
</span>
)}
</div>
{viewData.stats && (
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-4 h-4 text-primary-blue" />
<span className="font-mono font-bold text-primary-blue">{viewData.stats.ratingLabel}</span>
<span className="text-xs text-gray-400">Rating</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-4 h-4 text-yellow-400" />
<span className="font-mono font-bold text-yellow-400">{viewData.stats.globalRankLabel}</span>
<span className="text-xs text-gray-400">Global</span>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4" />
iRacing: {viewData.driver.iracingId ?? '—'}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
Joined {viewData.driver.joinedAtLabel}
</span>
{viewData.extendedProfile && (
<span className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{viewData.extendedProfile.timezone}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="primary" onClick={() => setEditMode(true)} className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
Edit Profile
</Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => setFriendRequestSent(true)} onClick={() => {}}
disabled={friendRequestSent} icon={<Icon icon={History} size={4} />}
className="w-full flex items-center gap-2"
> >
<UserPlus className="w-4 h-4" /> Back to Drivers
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button> </Button>
<Link href=routes.protected.profileLeagues> </Box>
<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 && ( {/* Breadcrumb */}
<div className="mt-6 pt-6 border-t border-charcoal-outline/50"> <Breadcrumbs
<div className="flex flex-wrap items-center gap-2"> items={[
<span className="text-sm text-gray-500 mr-2">Connect:</span> { label: 'Home', href: '/' },
{viewData.extendedProfile.socialHandles.map((social) => { { label: 'Drivers', href: '/drivers' },
const Icon = getSocialIcon(social.platformLabel); { label: viewData.driver.name },
return ( ]}
<a />
key={`${social.platformLabel}-${social.handle}`}
href={social.url} <ProfileHero
target="_blank" driver={{
rel="noopener noreferrer" ...viewData.driver,
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" country: viewData.driver.countryCode,
> iracingId: Number(viewData.driver.iracingId) || 0,
<Icon className="w-4 h-4" /> joinedAt: new Date().toISOString(), // Placeholder
<span className="text-sm">{social.handle}</span> }}
<ExternalLink className="w-3 h-3 opacity-50" /> stats={viewData.stats ? { rating: Number(viewData.stats.ratingLabel) || 0 } : null}
</a> globalRank={Number(viewData.stats?.globalRankLabel) || 0}
); timezone={viewData.extendedProfile?.timezone || 'UTC'}
})} socialHandles={viewData.extendedProfile?.socialHandles.map(s => ({ ...s, platform: s.platformLabel as any })) || []}
</div> onAddFriend={onFriendRequestSend}
</div> friendRequestSent={friendRequestSent}
)} />
</div>
</div>
{viewData.driver.bio && ( {viewData.driver.bio && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2"> <Stack gap={3}>
<User className="w-5 h-5 text-primary-blue" /> <Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
About About
</h2> </Heading>
<p className="text-gray-300 leading-relaxed">{viewData.driver.bio}</p> <Text color="text-gray-300" block>{viewData.driver.bio}</Text>
</Stack>
</Card> </Card>
)} )}
{viewData.teamMemberships.length > 0 && ( {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 => ({
<Shield className="w-5 h-5 text-purple-400" /> team: { id: m.teamId, name: m.teamName },
Team Memberships role: m.roleLabel,
<span className="text-sm text-gray-500 font-normal">({viewData.teamMemberships.length})</span> joinedAt: new Date() // Placeholder
</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 */} <ProfileTabs activeTab={activeTab as any} onTabChange={onTabChange as any} />
<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' && ( {activeTab === 'history' && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Stack gap={4}>
<History className="w-5 h-5 text-red-400" /> <Heading level={2} icon={<Icon icon={History} size={5} color="#f87171" />}>
Race History Race History
</h2> </Heading>
<ProfileRaceHistory driverId={viewData.driver.id} /> <ProfileRaceHistory driverId={viewData.driver.id} />
</Stack>
</Card> </Card>
)} )}
{activeTab === 'stats' && viewData.stats && ( {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={6}>
<Activity className="w-5 h-5 text-neon-aqua" /> <Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview Performance Overview
</h2> </Heading>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <ProfileStatGrid
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center"> stats={[
<div className="text-3xl font-bold text-white mb-1">{viewData.stats.totalRacesLabel}</div> { label: 'Races', value: viewData.stats.totalRacesLabel },
<div className="text-xs text-gray-500 uppercase tracking-wider">Races</div> { label: 'Wins', value: viewData.stats.winsLabel, color: '#10b981' },
</div> { label: 'Podiums', value: viewData.stats.podiumsLabel, color: '#f59e0b' },
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center"> { label: 'Consistency', value: viewData.stats.consistencyLabel, color: '#3b82f6' },
<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> </Stack>
<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 && ( {activeTab === 'overview' && viewData.extendedProfile && (
<Card> <Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Stack gap={4}>
<Award className="w-5 h-5 text-yellow-400" /> <Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements Achievements
<span className="ml-auto text-sm text-gray-500">{viewData.extendedProfile.achievements.length} earned</span> </Heading>
</h2> <Text size="sm" color="text-gray-400" weight="normal">{viewData.extendedProfile.achievements.length} earned</Text>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> </Stack>
{viewData.extendedProfile.achievements.map((achievement) => { <AchievementGrid
const Icon = getAchievementIcon(achievement.icon); achievements={viewData.extendedProfile.achievements.map(a => ({
return ( ...a,
<div key={achievement.id} className="p-4 rounded-xl border border-charcoal-outline bg-iron-gray/30"> rarity: a.rarityLabel,
<div className="flex items-start gap-3"> earnedAt: new Date() // Placeholder
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50 border border-charcoal-outline"> }))}
<Icon className="w-5 h-5 text-yellow-400" /> />
</div> </Stack>
<div className="flex-1 min-w-0">
<p className="text-white font-semibold text-sm">{achievement.title}</p>
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
<p className="text-gray-500 text-xs mt-1">{achievement.earnedAtLabel}</p>
</div>
</div>
</div>
);
})}
</div>
</Card> </Card>
)} )}
</div> </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}>
// Set rating change when viewModel changes <Stack gap={6}>
useEffect(() => { <Skeleton width="8rem" height="1.5rem" />
if (viewModel?.userResult?.ratingChange !== undefined) { <Skeleton width="100%" height="12rem" />
setRatingChange(viewModel.userResult.ratingChange); <Grid cols={3} gap={6}>
<GridItem colSpan={2}>
<Skeleton width="100%" height="16rem" />
</GridItem>
<Skeleton width="100%" height="16rem" />
</Grid>
</Stack>
</Container>
);
} }
}, [viewModel?.userResult?.ratingChange]);
// Animate rating change when it changes if (error || !viewData || !viewData.race) {
useEffect(() => { return (
if (ratingChange !== null) { <Container size="md" py={8}>
let start = 0; <Stack gap={6}>
const end = ratingChange; <Breadcrumbs items={[{ label: 'Races', href: '/races' }, { label: 'Error' }]} />
const duration = 1000;
const startTime = performance.now();
const animate = (currentTime: number) => { <Card>
const elapsed = currentTime - startTime; <Stack align="center" gap={4} py={12}>
const progress = Math.min(elapsed / duration, 1); <Surface variant="muted" rounded="full" padding={4}>
const eased = 1 - Math.pow(1 - progress, 3); <Icon icon={AlertTriangle} size={8} color="#f59e0b" />
const current = Math.round(start + (end - start) * eased); </Surface>
setAnimatedRatingChange(current); <Box>
<Text weight="medium" color="text-white" block mb={1}>{error instanceof Error ? error.message : error || 'Race not found'}</Text>
if (progress < 1) { <Text size="sm" color="text-gray-500">
requestAnimationFrame(animate); 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>
);
} }
};
requestAnimationFrame(animate); const { race, league, entryList, userResult } = viewData;
}
}, [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" />
<div className="relative z-10">
{/* Status Badge */}
<div className="flex items-center gap-3 mb-4">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${config.bg} border ${config.border}`}>
{race.status === 'running' && (
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
)}
<StatusIcon className={`w-4 h-4 ${config.color}`} />
<span className={`text-sm font-semibold ${config.color}`}>{config.label}</span>
</div>
{timeUntil && (
<span className="text-sm text-gray-400">
Starts in <span className="text-white font-medium">{timeUntil}</span>
</span>
)}
</div>
{/* Title */}
<Heading level={1} className="text-2xl sm:text-3xl font-bold text-white mb-2">
{race.track}
</Heading>
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-gray-400">
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(new Date(race.scheduledAt))}
</span>
<span className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatTime(new Date(race.scheduledAt))}
</span>
<span className="flex items-center gap-2">
<Car className="w-4 h-4" />
{race.car}
</span>
</div>
</div>
{/* Prominent SOF Badge - Electric Design */}
{raceSOF != null && (
<div className="absolute top-6 right-6 sm:top-8 sm:right-8">
<div className="relative group">
{/* Glow effect */}
<div className="absolute inset-0 bg-warning-amber/40 rounded-2xl blur-xl group-hover:blur-2xl transition-all duration-300" />
<div className="relative flex items-center gap-4 px-6 py-4 rounded-2xl bg-gradient-to-br from-warning-amber/30 via-warning-amber/20 to-orange-500/20 border border-warning-amber/50 shadow-2xl backdrop-blur-sm">
{/* Electric bolt with animation */}
<div className="relative">
<Zap className="w-8 h-8 text-warning-amber drop-shadow-lg" />
<Zap className="absolute inset-0 w-8 h-8 text-warning-amber animate-pulse opacity-50" />
</div>
<div>
<div className="text-[10px] text-warning-amber/90 uppercase tracking-widest font-bold mb-0.5">
Strength of Field
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-black text-warning-amber font-mono tracking-tight drop-shadow-lg">
{raceSOF}
</span>
<span className="text-sm text-warning-amber/70 font-medium">SOF</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Race Details */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-primary-blue" />
Race Details
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Track</p>
<p className="text-white font-medium">{race.track}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Car</p>
<p className="text-white font-medium">{race.car}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Session Type</p>
<p className="text-white font-medium capitalize">{race.sessionType}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<p className={`font-medium ${config.color}`}>{config.label}</p>
</div>
<div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Strength of Field</p>
<p className="text-warning-amber font-medium flex items-center gap-1.5">
<Zap className="w-4 h-4" />
{raceSOF ?? '—'}
</p>
</div>
</div>
</Card>
{/* Entry List */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-primary-blue" />
Entry List
</h2>
<span className="text-sm text-gray-400">
{entryList.length} driver{entryList.length !== 1 ? 's' : ''}
</span>
</div>
{entryList.length === 0 ? (
<div className="text-center py-8">
<div className="p-4 bg-iron-gray rounded-full inline-block mb-3">
<Users className="w-6 h-6 text-gray-500" />
</div>
<p className="text-gray-400">No drivers registered yet</p>
<p className="text-sm text-gray-500">Be the first to sign up!</p>
</div>
) : (
<div className="space-y-1">
{entryList.map((driver, index) => {
const isCurrentUser = driver.isCurrentUser;
const countryFlag = getCountryFlag(driver.country);
return (
<div
key={driver.id}
onClick={() => onDriverClick(driver.id)}
className={`
flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200
${
isCurrentUser
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent border border-primary-blue/40 shadow-lg shadow-primary-blue/10'
: 'bg-deep-graphite hover:bg-charcoal-outline/50 border border-transparent'
}
`}
>
{/* Position number */}
<div
className={`
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${
race.status === 'completed' && index === 0
? 'bg-yellow-500/20 text-yellow-400'
: race.status === 'completed' && index === 1
? 'bg-gray-400/20 text-gray-300'
: race.status === 'completed' && index === 2
? 'bg-amber-600/20 text-amber-500'
: 'bg-iron-gray text-gray-500'
}
`}
>
{index + 1}
</div>
{/* Avatar with nation flag */}
<div className="relative flex-shrink-0">
<img
src={driver.avatarUrl}
alt={driver.name}
className={`
w-10 h-10 rounded-full object-cover
${isCurrentUser ? 'ring-2 ring-primary-blue/50' : ''}
`}
/> />
{/* Nation flag */}
<div className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-deep-graphite border-2 border-deep-graphite flex items-center justify-center text-xs shadow-sm">
{countryFlag}
</div>
</div>
{/* Driver info */} <Grid cols={12} gap={6}>
<div className="flex-1 min-w-0"> <GridItem lgSpan={8} colSpan={12}>
<div className="flex items-center gap-2"> <Stack gap={6}>
<p <RaceDetailCard
className={`text-sm font-semibold truncate ${ track={race.track}
isCurrentUser ? 'text-primary-blue' : 'text-white' car={race.car}
}`} sessionType={race.sessionType}
> statusLabel={config.label}
{driver.name} statusColor={config.variant === 'success' ? '#10b981' : config.variant === 'primary' ? '#3b82f6' : '#9ca3af'}
</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 && ( <RaceEntryList
<p className="text-sm text-gray-400 mb-4 line-clamp-2">{league.description}</p> entries={entryList}
)} onDriverClick={onDriverClick}
/>
</Stack>
</GridItem>
<div className="grid grid-cols-2 gap-3 mb-4"> <GridItem lgSpan={4} colSpan={12}>
<div className="p-3 rounded-lg bg-deep-graphite"> <Stack gap={6}>
<p className="text-xs text-gray-500 mb-1">Max Drivers</p> {league && <LeagueSummaryCard league={league} />}
<p className="text-white font-medium">{league.settings.maxDrivers ?? 32}</p>
</div>
<div className="p-3 rounded-lg bg-deep-graphite">
<p className="text-xs text-gray-500 mb-1">Format</p>
<p className="text-white font-medium capitalize">
{league.settings.qualifyingFormat ?? 'Open'}
</p>
</div>
</div>
<Link {/* Actions Card */}
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> <Card>
<h2 className="text-lg font-semibold text-white mb-4">Actions</h2> <Stack gap={4}>
<Text size="xl" weight="bold" color="text-white">Actions</Text>
<div className="space-y-3"> <Stack gap={3}>
{/* Registration Actions */}
<RaceJoinButton <RaceJoinButton
raceStatus={race.status} raceStatus={race.status}
isUserRegistered={viewModel.registration.isUserRegistered} isUserRegistered={viewData.registration.isUserRegistered}
canRegister={viewModel.registration.canRegister} canRegister={viewData.registration.canRegister}
onRegister={onRegister} onRegister={onRegister}
onWithdraw={onWithdraw} onWithdraw={onWithdraw}
onCancel={onCancel} onCancel={onCancel}
onReopen={onReopen} onReopen={onReopen}
onEndRace={onEndRace} onEndRace={onEndRace}
canReopenRace={viewModel.canReopenRace} canReopenRace={viewData.canReopenRace}
isOwnerOrAdmin={isOwnerOrAdmin} isOwnerOrAdmin={isOwnerOrAdmin}
isLoading={mutationLoading} isLoading={mutationLoading}
/> />
{/* Results and Stewarding for completed races */}
{race.status === 'completed' && ( {race.status === 'completed' && (
<> <>
<Button <Button variant="primary" fullWidth onClick={onResultsClick} icon={<Icon icon={Trophy} size={4} />}>
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={onResultsClick}
>
<Trophy className="w-4 h-4" />
View Results View Results
</Button> </Button>
{userResult && ( {userResult && (
<Button <Button variant="secondary" fullWidth onClick={onFileProtest} icon={<Icon icon={Scale} size={4} />}>
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onFileProtest}
>
<Scale className="w-4 h-4" />
File Protest File Protest
</Button> </Button>
)} )}
<Button <Button variant="secondary" fullWidth onClick={onStewardingClick} icon={<Icon icon={Scale} size={4} />}>
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={onStewardingClick}
>
<Scale className="w-4 h-4" />
Stewarding Stewarding
</Button> </Button>
</> </>
)} )}
</div> </Stack>
</Stack>
</Card> </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>
</Stack>
</Card> </Card>
</div> </Container>
</div>
); );
} }
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);
const points = pointsSystem[result.position.toString()] ?? 0;
return (
<div
key={result.driverId} key={result.driverId}
className={` result={result as any}
flex items-center gap-3 p-3 rounded-xl points={viewData.pointsSystem[result.position.toString()] ?? 0}
${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} </Stack>
</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>
<Text size="sm" color="text-gray-400" block mt={2}>
No results imported. Upload CSV to test the standings system. No results imported. Upload CSV to test the standings system.
</p> </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>
<Box>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => onImportResults([])}
// Mock import for demo
onImportResults([]);
}}
disabled={importing} disabled={importing}
> >
Import Results (Demo) Import Results (Demo)
</Button> </Button>
</div> </Box>
</Stack>
)} )}
</> </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.
</p>
</div>
<Button variant="secondary" onClick={onBack}> <Button variant="secondary" onClick={onBack}>
Back to Races Back to Races
</Button> </Button>
</div> </Stack>
</Card> </Card>
</div> </Container>
</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];
const daysSinceFiled = Math.floor(
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
);
const isUrgent = daysSinceFiled > 2;
return (
<Card
key={protest.id} key={protest.id}
className={`${isUrgent ? 'border-l-4 border-l-red-500' : ''}`} protest={protest as any}
> protester={viewData.driverMap[protest.protestingDriverId]}
<div className="flex items-start justify-between gap-4"> accused={viewData.driverMap[protest.accusedDriverId]}
<div className="flex-1 min-w-0"> isAdmin={isAdmin}
<div className="flex items-center gap-2 mb-2"> onReview={onReviewProtest}
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" /> formatDate={formatDate}
<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> </Stack>
<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>
)} )}
{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> </Stack>
</div>
</Card>
);
})
)}
</div>
)} )}
{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> </Stack>
<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>
)} </Container>
</div>
</div>
); );
} }

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 => (
<div key={i} className="h-24 bg-iron-gray rounded-lg" /> <Skeleton key={i} width="100%" height="6rem" />
))} ))}
</div> </Stack>
</div> </Stack>
</div> </Container>
</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>
{/* 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> </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,565 +45,54 @@ 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 ( 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"> <Container size="lg" py={8}>
<div className="animate-pulse space-y-6"> <Stack gap={8}>
<div className="h-10 bg-iron-gray rounded w-1/4" /> <RacePageHeader
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> totalCount={viewData.totalCount}
{[1, 2, 3, 4].map(i => ( scheduledCount={viewData.scheduledCount}
<div key={i} className="h-24 bg-iron-gray rounded-lg" /> runningCount={viewData.runningCount}
))} completedCount={viewData.completedCount}
</div> />
<div className="h-64 bg-iron-gray rounded-lg" />
</div>
</div>
</div>
);
}
return ( <LiveRacesBanner
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8"> liveRaces={viewData.liveRaces}
<div className="max-w-7xl mx-auto space-y-8"> onRaceClick={onRaceClick}
{/* Hero Header */} />
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border border-charcoal-outline p-8">
<div className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
<div className="relative z-10"> <Grid cols={12} gap={6}>
<div className="flex items-center gap-3 mb-2"> <GridItem colSpan={12} lgSpan={8}>
<div className="p-2 bg-primary-blue/10 rounded-lg"> <Stack gap={6}>
<Flag className="w-6 h-6 text-primary-blue" /> <RaceFilterBar
</div> timeFilter={timeFilter}
<Heading level={1} className="text-3xl font-bold text-white"> setTimeFilter={setTimeFilter}
Race Calendar leagueFilter={leagueFilter}
</Heading> setLeagueFilter={setLeagueFilter}
</div> leagues={viewData.leagues}
<p className="text-gray-400 max-w-2xl"> onShowMoreFilters={() => setShowFilterModal(true)}
Track upcoming races, view live events, and explore results across all your leagues. />
</p>
</div>
{/* Quick Stats */} <RaceList
<div className="relative z-10 grid grid-cols-2 md:grid-cols-4 gap-4 mt-6"> racesByDate={viewData.racesByDate}
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50"> totalCount={viewData.totalCount}
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1"> onRaceClick={onRaceClick}
<CalendarDays className="w-4 h-4" /> />
<span>Total</span> </Stack>
</div> </GridItem>
<p className="text-2xl font-bold text-white">{stats.total}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
<Clock className="w-4 h-4" />
<span>Scheduled</span>
</div>
<p className="text-2xl font-bold text-white">{stats.scheduled}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-performance-green text-sm mb-1">
<Zap className="w-4 h-4" />
<span>Live Now</span>
</div>
<p className="text-2xl font-bold text-white">{stats.running}</p>
</div>
<div className="bg-deep-graphite/60 backdrop-blur rounded-xl p-4 border border-charcoal-outline/50">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<Trophy className="w-4 h-4" />
<span>Completed</span>
</div>
<p className="text-2xl font-bold text-white">{stats.completed}</p>
</div>
</div>
</div>
{/* Live Races Banner */} <GridItem colSpan={12} lgSpan={4}>
{liveRaces.length > 0 && ( <RaceSidebar
<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"> upcomingRaces={viewData.upcomingRaces}
<div className="absolute top-0 right-0 w-32 h-32 bg-performance-green/20 rounded-full blur-2xl animate-pulse" /> recentResults={viewData.recentResults}
onRaceClick={onRaceClick}
/>
</GridItem>
</Grid>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 bg-performance-green/20 rounded-full">
<span className="w-2 h-2 bg-performance-green rounded-full animate-pulse" />
<span className="text-performance-green font-semibold text-sm">LIVE NOW</span>
</div>
</div>
<div className="space-y-3">
{liveRaces.map((race) => (
<div
key={race.id}
onClick={() => onRaceClick(race.id)}
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
>
<div className="flex items-center gap-4">
<div className="p-2 bg-performance-green/20 rounded-lg">
<PlayCircle className="w-5 h-5 text-performance-green" />
</div>
<div>
<h3 className="font-semibold text-white">{race.track}</h3>
<p className="text-sm text-gray-400">{race.leagueName}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</div>
))}
</div>
</div>
</div>
)}
<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 <RaceFilterModal
isOpen={showFilterModal} isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)} onClose={() => setShowFilterModal(false)}
@@ -653,11 +104,12 @@ export function RacesTemplate({
setTimeFilter={setTimeFilter} setTimeFilter={setTimeFilter}
searchQuery="" searchQuery=""
setSearchQuery={() => {}} setSearchQuery={() => {}}
leagues={[...new Set(races.map(r => ({ id: r.leagueId || '', name: r.leagueName || '' })))]} leagues={viewData.leagues}
showSearch={false} showSearch={false}
showTimeFilter={false} showTimeFilter={false}
/> />
</div> </Stack>
</div> </Container>
</Box>
); );
} }

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.requestedAt}
</Text>
{req.message && ( {req.message && (
<Text size="xs" className="text-gray-500 truncate"> <Text size="xs" color="text-gray-500" block mt={1} truncate>{req.message}</Text>
{req.message}
</Text>
)} )}
</div> </Box>
<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)}
className="bg-primary-blue text-white" variant="primary"
size="sm"
> >
Approve Approve
</Button> </Button>
<Button <Button
data-testid={`join-request-${req.id}-reject`}
onClick={() => onReject(req.id)} onClick={() => onReject(req.id)}
className="bg-iron-gray text-gray-200" variant="secondary"
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'}
</label>
<Select <Select
id={`role-${member.driverId}`}
aria-label={`Role for ${(member.driver as any)?.name || 'Unknown'}`}
value={member.role} value={member.role}
onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)} onChange={(e) => onRoleChange(member.driverId, e.target.value as MembershipRole)}
options={roleOptions.map((role) => ({ value: role, label: role }))} options={roleOptions.map((role) => ({ value: role, label: role }))}
className="bg-iron-gray text-white px-3 py-2 rounded"
/> />
</Box>
<Button <Button
data-testid={`member-${member.driverId}-remove`}
onClick={() => onRemove(member.driverId)} onClick={() => onRemove(member.driverId)}
className="bg-iron-gray text-gray-200" variant="secondary"
size="sm"
> >
Remove Remove
</Button> </Button>
</div> </Stack>
</div> </Stack>
</Surface>
))} ))}
</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>
<TableBody>
{viewData.positionPoints.map((point) => ( {viewData.positionPoints.map((point) => (
<tr key={point.position} className="border-b border-charcoal-outline/50"> <TableRow key={point.position}>
<td className="py-3 text-white">{point.position}</td> <TableCell>
<td className="py-3 text-white">{point.points}</td> <Text color="text-white">{point.position}</Text>
</tr> </TableCell>
<TableCell>
<Text color="text-white">{point.points}</Text>
</TableCell>
</TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div>
</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>
<Text size="sm" color="text-gray-300">{viewData.dropPolicySummary}</Text>
<Box mt={3}>
<Text size="xs" color="text-gray-500" block>
Drop rules are applied automatically when calculating championship standings. Drop rules are applied automatically when calculating championship standings.
</p> </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,60 +35,60 @@ 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}>
<Stack gap={8}>
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<div> <Box>
<h2 className="text-2xl font-bold text-white">Sponsor Dashboard</h2> <Heading level={2}>Sponsor Dashboard</Heading>
<p className="text-gray-400">Welcome back, {viewData.sponsorName}</p> <Text color="text-gray-400" block mt={1}>Welcome back, {viewData.sponsorName}</Text>
</div> </Box>
<div className="flex items-center gap-3"> <Stack direction="row" align="center" gap={3}>
{/* Time Range Selector */} {/* Time Range Selector */}
<div className="flex items-center bg-iron-gray/50 rounded-lg p-1"> <Surface variant="muted" rounded="lg" padding={1}>
<Stack direction="row" align="center">
{(['7d', '30d', '90d', 'all'] as const).map((range) => ( {(['7d', '30d', '90d', 'all'] as const).map((range) => (
<button <Button
key={range} key={range}
onClick={() => {}} variant="ghost"
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ size="sm"
false
? 'bg-primary-blue text-white'
: '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> </Button>
<Link href=routes.sponsor.settings> ))}
<Button variant="secondary" className="hidden sm:flex"> </Stack>
<Settings className="w-4 h-4" /> </Surface>
<Button variant="secondary">
<Icon icon={RefreshCw} size={4} />
</Button>
<Box>
<Link href={routes.sponsor.settings} variant="ghost">
<Button variant="secondary">
<Icon icon={Settings} size={4} />
</Button> </Button>
</Link> </Link>
</div> </Box>
</div> </Stack>
</Stack>
{/* Key Metrics */} {/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <Grid cols={4} gap={4}>
<MetricCard <MetricCard
title="Total Impressions" title="Total Impressions"
value={viewData.totalImpressions} value={viewData.totalImpressions}
@@ -92,14 +98,14 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
/> />
<MetricCard <MetricCard
title="Unique Viewers" title="Unique Viewers"
value="12.5k" // Mock value="12.5k"
change={viewData.metrics.viewersChange} change={viewData.metrics.viewersChange}
icon={Users} icon={Users}
delay={0.1} delay={0.1}
/> />
<MetricCard <MetricCard
title="Engagement Rate" title="Engagement Rate"
value="4.2%" // Mock value="4.2%"
change={viewData.metrics.exposureChange} change={viewData.metrics.exposureChange}
icon={TrendingUp} icon={TrendingUp}
suffix="%" suffix="%"
@@ -112,27 +118,28 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
prefix="$" prefix="$"
delay={0.3} delay={0.3}
/> />
</div> </Grid>
{/* Sponsorship Categories */} {/* Sponsorship Categories */}
<div className="mb-8"> <Box>
<div className="flex items-center justify-between mb-4"> <Stack direction="row" align="center" justify="between" mb={4}>
<h3 className="text-lg font-semibold text-white">Your Sponsorships</h3> <Heading level={3}>Your Sponsorships</Heading>
<Link href=routes.sponsor.campaigns> <Box>
<Button variant="secondary" className="text-sm"> <Link href={routes.sponsor.campaigns} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={ChevronRight} size={4} />}>
View All View All
<ChevronRight className="w-4 h-4 ml-1" />
</Button> </Button>
</Link> </Link>
</div> </Box>
</Stack>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4"> <Grid cols={5} gap={4}>
<SponsorshipCategoryCard <SponsorshipCategoryCard
icon={Trophy} icon={Trophy}
title="Leagues" title="Leagues"
count={categoryData.leagues.count} count={categoryData.leagues.count}
impressions={categoryData.leagues.impressions} impressions={categoryData.leagues.impressions}
color="text-primary-blue" color="#3b82f6"
href="/sponsor/campaigns?type=leagues" href="/sponsor/campaigns?type=leagues"
/> />
<SponsorshipCategoryCard <SponsorshipCategoryCard
@@ -140,7 +147,7 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
title="Teams" title="Teams"
count={categoryData.teams.count} count={categoryData.teams.count}
impressions={categoryData.teams.impressions} impressions={categoryData.teams.impressions}
color="text-purple-400" color="#a855f7"
href="/sponsor/campaigns?type=teams" href="/sponsor/campaigns?type=teams"
/> />
<SponsorshipCategoryCard <SponsorshipCategoryCard
@@ -148,7 +155,7 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
title="Drivers" title="Drivers"
count={categoryData.drivers.count} count={categoryData.drivers.count}
impressions={categoryData.drivers.impressions} impressions={categoryData.drivers.impressions}
color="text-performance-green" color="#10b981"
href="/sponsor/campaigns?type=drivers" href="/sponsor/campaigns?type=drivers"
/> />
<SponsorshipCategoryCard <SponsorshipCategoryCard
@@ -156,7 +163,7 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
title="Races" title="Races"
count={categoryData.races.count} count={categoryData.races.count}
impressions={categoryData.races.impressions} impressions={categoryData.races.impressions}
color="text-warning-amber" color="#f59e0b"
href="/sponsor/campaigns?type=races" href="/sponsor/campaigns?type=races"
/> />
<SponsorshipCategoryCard <SponsorshipCategoryCard
@@ -164,173 +171,189 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP
title="Platform Ads" title="Platform Ads"
count={categoryData.platform.count} count={categoryData.platform.count}
impressions={categoryData.platform.impressions} impressions={categoryData.platform.impressions}
color="text-racing-red" color="#ef4444"
href="/sponsor/campaigns?type=platform" href="/sponsor/campaigns?type=platform"
/> />
</div> </Grid>
</div> </Box>
{/* Main Content Grid */} {/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <Grid cols={12} gap={6}>
{/* Left Column - Sponsored Entities */} <GridItem colSpan={12} lgSpan={8}>
<div className="lg:col-span-2 space-y-6"> <Stack gap={6}>
{/* Top Performing Sponsorships */} {/* Top Performing Sponsorships */}
<Card> <Card p={0}>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline"> <Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<h3 className="text-lg font-semibold text-white">Top Performing</h3> <Stack direction="row" align="center" justify="between">
<Link href="/leagues"> <Heading level={3}>Top Performing</Heading>
<Button variant="secondary" className="text-sm"> <Box>
<Plus className="w-4 h-4 mr-1" /> <Link href={routes.public.leagues} variant="primary">
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Find More Find More
</Button> </Button>
</Link> </Link>
</div> </Box>
<div className="divide-y divide-charcoal-outline/50"> </Stack>
{/* Mock data for now */} </Box>
<div className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors"> <Box p={4}>
<div className="flex items-center gap-4"> <Surface variant="muted" rounded="lg" padding={4}>
<div className="px-2 py-1 rounded text-xs font-medium bg-primary-blue/20 text-primary-blue border border-primary-blue/30"> <Stack direction="row" align="center" justify="between">
Main <Stack direction="row" align="center" gap={4}>
</div> <Badge variant="primary">Main</Badge>
<div> <Box>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Trophy className="w-4 h-4 text-gray-500" /> <Icon icon={Trophy} size={4} color="#737373" />
<span className="font-medium text-white">Sample League</span> <Text weight="medium" color="text-white">Sample League</Text>
</div> </Stack>
<div className="text-sm text-gray-500">Sample details</div> <Text size="sm" color="text-gray-500" block mt={1}>Sample details</Text>
</div> </Box>
</div> </Stack>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="text-right"> <Box style={{ textAlign: 'right' }}>
<div className="font-semibold text-white">1.2k</div> <Text weight="semibold" color="text-white" block>1.2k</Text>
<div className="text-xs text-gray-500">impressions</div> <Text size="xs" color="text-gray-500">impressions</Text>
</div> </Box>
<Button variant="secondary" className="text-xs"> <Button variant="secondary" size="sm">
<ExternalLink className="w-3 h-3" /> <Icon icon={ExternalLink} size={3} />
</Button> </Button>
</div> </Stack>
</div> </Stack>
</div> </Surface>
</Box>
</Card> </Card>
{/* Upcoming Events */} {/* Upcoming Events */}
<Card> <Card p={0}>
<div className="p-4 border-b border-charcoal-outline"> <Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <Heading level={3} icon={<Icon icon={Calendar} size={5} color="#f59e0b" />}>
<Calendar className="w-5 h-5 text-warning-amber" />
Upcoming Sponsored Events Upcoming Sponsored Events
</h3> </Heading>
</div> </Box>
<div className="p-4"> <Box p={4}>
<div className="text-center py-8 text-gray-500"> <Stack align="center" gap={2} py={8}>
<Calendar className="w-8 h-8 mx-auto mb-2 opacity-50" /> <Icon icon={Calendar} size={8} color="#737373" />
<p>No upcoming sponsored events</p> <Text color="text-gray-400">No upcoming sponsored events</Text>
</div> </Stack>
</div> </Box>
</Card> </Card>
</div> </Stack>
</GridItem>
{/* Right Column - Activity & Quick Actions */} <GridItem colSpan={12} lgSpan={4}>
<div className="space-y-6"> <Stack gap={6}>
{/* Quick Actions */} {/* Quick Actions */}
<Card className="p-4"> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3> <Stack gap={4}>
<div className="space-y-2"> <Heading level={3}>Quick Actions</Heading>
<Link href="/leagues" className="block"> <Stack gap={2}>
<Button variant="secondary" className="w-full justify-start"> <Box>
<Target className="w-4 h-4 mr-2" /> <Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Target} size={4} />}>
Find Leagues to Sponsor Find Leagues to Sponsor
</Button> </Button>
</Link> </Link>
<Link href="/teams" className="block"> </Box>
<Button variant="secondary" className="w-full justify-start"> <Box>
<Users className="w-4 h-4 mr-2" /> <Link href={routes.public.teams} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Users} size={4} />}>
Browse Teams Browse Teams
</Button> </Button>
</Link> </Link>
<Link href="/drivers" className="block"> </Box>
<Button variant="secondary" className="w-full justify-start"> <Box>
<Car className="w-4 h-4 mr-2" /> <Link href={routes.public.drivers} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={Car} size={4} />}>
Discover Drivers Discover Drivers
</Button> </Button>
</Link> </Link>
<Link href=routes.sponsor.billing className="block"> </Box>
<Button variant="secondary" className="w-full justify-start"> <Box>
<CreditCard className="w-4 h-4 mr-2" /> <Link href={routes.sponsor.billing} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={CreditCard} size={4} />}>
Manage Billing Manage Billing
</Button> </Button>
</Link> </Link>
<Link href=routes.sponsor.campaigns className="block"> </Box>
<Button variant="secondary" className="w-full justify-start"> <Box>
<BarChart3 className="w-4 h-4 mr-2" /> <Link href={routes.sponsor.campaigns} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={BarChart3} size={4} />}>
View Analytics View Analytics
</Button> </Button>
</Link> </Link>
</div> </Box>
</Stack>
</Stack>
</Card> </Card>
{/* Renewal Alerts */} {/* Renewal Alerts */}
{viewData.upcomingRenewals.length > 0 && ( {viewData.upcomingRenewals.length > 0 && (
<Card className="p-4"> <Card>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Stack gap={4}>
<Bell className="w-5 h-5 text-warning-amber" /> <Heading level={3} icon={<Icon icon={Bell} size={5} color="#f59e0b" />}>
Upcoming Renewals Upcoming Renewals
</h3> </Heading>
<div className="space-y-3"> <Stack gap={3}>
{viewData.upcomingRenewals.map((renewal: any) => ( {viewData.upcomingRenewals.map((renewal) => (
<RenewalAlert key={renewal.id} renewal={renewal} /> <RenewalAlert key={renewal.id} renewal={renewal} />
))} ))}
</div> </Stack>
</Stack>
</Card> </Card>
)} )}
{/* Recent Activity */} {/* Recent Activity */}
<Card className="p-4"> <Card>
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3> <Stack gap={4}>
<div> <Heading level={3}>Recent Activity</Heading>
{viewData.recentActivity.map((activity: any) => ( <Box>
{viewData.recentActivity.map((activity) => (
<ActivityItem key={activity.id} activity={activity} /> <ActivityItem key={activity.id} activity={activity} />
))} ))}
</div> </Box>
</Stack>
</Card> </Card>
{/* Investment Summary */} {/* Investment Summary */}
<Card className="p-4"> <Card>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Stack gap={4}>
<FileText className="w-5 h-5 text-primary-blue" /> <Heading level={3} icon={<Icon icon={FileText} size={5} color="#3b82f6" />}>
Investment Summary Investment Summary
</h3> </Heading>
<div className="space-y-3"> <Stack gap={3}>
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<span className="text-gray-400">Active Sponsorships</span> <Text color="text-gray-400">Active Sponsorships</Text>
<span className="font-medium text-white">{viewData.activeSponsorships}</span> <Text weight="medium" color="text-white">{viewData.activeSponsorships}</Text>
</div> </Stack>
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<span className="text-gray-400">Total Investment</span> <Text color="text-gray-400">Total Investment</Text>
<span className="font-medium text-white">{viewData.formattedTotalInvestment}</span> <Text weight="medium" color="text-white">{viewData.formattedTotalInvestment}</Text>
</div> </Stack>
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<span className="text-gray-400">Cost per 1K Views</span> <Text color="text-gray-400">Cost per 1K Views</Text>
<span className="font-medium text-performance-green"> <Text weight="medium" color="text-performance-green">
{viewData.costPerThousandViews} {viewData.costPerThousandViews}
</span> </Text>
</div> </Stack>
<div className="flex items-center justify-between"> <Stack direction="row" align="center" justify="between">
<span className="text-gray-400">Next Invoice</span> <Text color="text-gray-400">Next Invoice</Text>
<span className="font-medium text-white">Jan 1, 2026</span> <Text weight="medium" color="text-white">Jan 1, 2026</Text>
</div> </Stack>
<div className="pt-3 border-t border-charcoal-outline"> <Box pt={3} style={{ borderTop: '1px solid #262626' }}>
<Link href=routes.sponsor.billing> <Box>
<Button variant="secondary" className="w-full text-sm"> <Link href={routes.sponsor.billing} variant="ghost">
<CreditCard className="w-4 h-4 mr-2" /> <Button variant="secondary" fullWidth size="sm" icon={<Icon icon={CreditCard} size={4} />}>
View Billing Details View Billing Details
</Button> </Button>
</Link> </Link>
</div> </Box>
</div> </Box>
</Stack>
</Stack>
</Card> </Card>
</div> </Stack>
</div> </GridItem>
</div> </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}>
<Stack gap={8}>
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6"> <Box>
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link> <Stack direction="row" align="center" gap={2}>
<ChevronRight className="w-4 h-4" /> <Link href={routes.sponsor.dashboard}>
<Link href=routes.sponsor.leagues className="hover:text-white transition-colors">Leagues</Link> <Text size="sm" color="text-gray-400">Dashboard</Text>
<ChevronRight className="w-4 h-4" /> </Link>
<span className="text-white">{league.name}</span> <Text size="sm" color="text-gray-500">/</Text>
</div> <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')}> <Button variant="primary" onClick={() => setActiveTab('sponsor')} icon={<Icon icon={Megaphone} size={4} />}>
<Megaphone className="w-4 h-4 mr-2" />
Become a Sponsor Become a Sponsor
</Button> </Button>
)} )}
</div> </Stack>
</div> </Stack>
{/* 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' }}>
<Stack direction="row" gap={6}>
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => ( {(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<button <Box
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${ pb={3}
activeTab === tab style={{
? 'text-primary-blue border-primary-blue' cursor: 'pointer',
: 'text-gray-400 border-transparent hover:text-white' borderBottom: activeTab === tab ? '2px solid #3b82f6' : '2px solid transparent',
}`} color: activeTab === tab ? '#3b82f6' : '#9ca3af'
}}
> >
<Text size="sm" weight="medium" style={{ textTransform: 'capitalize' }}>
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab} {tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</button> </Text>
</Box>
))} ))}
</div> </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>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Duration</span>
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Drivers</span>
<span className="text-white font-medium">{league.drivers}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Races</span>
<span className="text-white font-medium">{league.races}</span>
</div>
</div>
</Card> </Card>
<Card className="p-5"> <Card>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Box mb={4}>
<TrendingUp className="w-5 h-5 text-performance-green" /> <Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Sponsorship Value Sponsorship Value
</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">Total Season Views</span> <InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
<span className="text-white font-medium">{league.formattedTotalImpressions}</span> <InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
</div> <InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
<div className="flex justify-between py-2 border-b border-charcoal-outline/50"> <InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
<span className="text-gray-400">Projected Total</span> <InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
<span className="text-white font-medium">{league.formattedProjectedTotal}</span> </Stack>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Main Sponsor CPM</span>
<span className="text-performance-green font-medium">
{league.formattedMainSponsorCpm}
</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Engagement Rate</span>
<span className="text-white font-medium">{league.engagement}%</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">League Rating</span>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{league.rating}/5.0</span>
</div>
</div>
</div>
</Card> </Card>
{/* Next Race */}
{league.nextRace && ( {league.nextRace && (
<Card className="p-5 lg:col-span-2"> <GridItem colSpan={2}>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <Card>
<Flag className="w-5 h-5 text-warning-amber" /> <Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#f59e0b" />}>
Next Race Next Race
</h3> </Heading>
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30"> </Box>
<div className="flex items-center gap-4"> <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(245, 158, 11, 0.05)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center"> <Stack direction="row" align="center" justify="between">
<Flag className="w-6 h-6 text-warning-amber" /> <Stack direction="row" align="center" gap={4}>
</div> <Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)' }}>
<div> <Icon icon={Flag} size={6} color="#f59e0b" />
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p> </Surface>
<p className="text-sm text-gray-400">{league.nextRace.date}</p> <Box>
</div> <Text size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</Text>
</div> <Text size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</Text>
</Box>
</Stack>
<Button variant="secondary"> <Button variant="secondary">
View Schedule View Schedule
</Button> </Button>
</div> </Stack>
</Surface>
</Card> </Card>
</GridItem>
)} )}
</div> </Grid>
)} )}
{activeTab === 'drivers' && ( {activeTab === 'drivers' && (
<Card> <Card p={0}>
<div className="p-4 border-b border-charcoal-outline"> <Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<h3 className="text-lg font-semibold text-white">Championship Standings</h3> <Heading level={2}>Championship Standings</Heading>
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p> <Text size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</Text>
</div> </Box>
<div className="divide-y divide-charcoal-outline/50"> <Stack gap={0}>
{data.drivers.map((driver) => ( {viewData.drivers.map((driver, index) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors"> <Box key={driver.id} p={4} style={{ borderBottom: index < viewData.drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" justify="between">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white"> <Stack direction="row" align="center" gap={4}>
{driver.position} <Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#262626' }}>
</div> <Text weight="bold" color="text-white">{driver.position}</Text>
<div> </Surface>
<div className="font-medium text-white">{driver.name}</div> <Box>
<div className="text-sm text-gray-500">{driver.team} {driver.country}</div> <Text weight="medium" color="text-white" block>{driver.name}</Text>
</div> <Text size="sm" color="text-gray-500" block mt={1}>{driver.team} {driver.country}</Text>
</div> </Box>
<div className="flex items-center gap-6"> </Stack>
<div className="text-right"> <Stack direction="row" align="center" gap={8}>
<div className="font-medium text-white">{driver.races}</div> <Box style={{ textAlign: 'right' }}>
<div className="text-xs text-gray-500">races</div> <Text weight="medium" color="text-white" block>{driver.races}</Text>
</div> <Text size="xs" color="text-gray-500">races</Text>
<div className="text-right"> </Box>
<div className="font-semibold text-white">{driver.formattedImpressions}</div> <Box style={{ textAlign: 'right' }}>
<div className="text-xs text-gray-500">views</div> <Text weight="semibold" color="text-white" block>{driver.formattedImpressions}</Text>
</div> <Text size="xs" color="text-gray-500">views</Text>
</div> </Box>
</div> </Stack>
</Stack>
</Box>
))} ))}
</div> </Stack>
</Card> </Card>
)} )}
{activeTab === 'races' && ( {activeTab === 'races' && (
<Card> <Card p={0}>
<div className="p-4 border-b border-charcoal-outline"> <Box p={4} style={{ borderBottom: '1px solid #262626' }}>
<h3 className="text-lg font-semibold text-white">Race Calendar</h3> <Heading level={2}>Race Calendar</Heading>
<p className="text-sm text-gray-400">Season schedule with view statistics</p> <Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
</div> </Box>
<div className="divide-y divide-charcoal-outline/50"> <Stack gap={0}>
{data.races.map((race) => ( {viewData.races.map((race, index) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors"> <Box key={race.id} p={4} style={{ borderBottom: index < viewData.races.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' }}>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" justify="between">
<div className={`w-3 h-3 rounded-full ${ <Stack direction="row" align="center" gap={4}>
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber' <Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.status === 'completed' ? '#10b981' : '#f59e0b' }} />
}`} /> <Box>
<div> <Text weight="medium" color="text-white" block>{race.name}</Text>
<div className="font-medium text-white">{race.name}</div> <Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
<div className="text-sm text-gray-500">{race.formattedDate}</div> </Box>
</div> </Stack>
</div> <Box>
<div className="flex items-center gap-4">
{race.status === 'completed' ? ( {race.status === 'completed' ? (
<div className="text-right"> <Box style={{ textAlign: 'right' }}>
<div className="font-semibold text-white">{race.views.toLocaleString()}</div> <Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<div className="text-xs text-gray-500">views</div> <Text size="xs" color="text-gray-500">views</Text>
</div> </Box>
) : ( ) : (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber"> <Badge variant="warning">Upcoming</Badge>
Upcoming
</span>
)} )}
</div> </Box>
</div> </Stack>
</Box>
))} ))}
</div> </Stack>
</Card> </Card>
)} )}
{activeTab === 'sponsor' && ( {activeTab === 'sponsor' && (
<div className="space-y-6"> <Stack gap={6}>
{/* Tier Selection */} <Grid cols={2} gap={6}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <SponsorTierCard
{/* Main Sponsor */} type="main"
<Card available={league.sponsorSlots.main.available}
className={`p-5 cursor-pointer transition-all ${ price={league.sponsorSlots.main.price}
selectedTier === 'main' benefits={league.sponsorSlots.main.benefits}
? 'border-primary-blue ring-2 ring-primary-blue/20' isSelected={selectedTier === 'main'}
: 'hover:border-charcoal-outline/80' onClick={() => setSelectedTier('main')}
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`} />
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')} <SponsorTierCard
> type="secondary"
<div className="flex items-start justify-between mb-4"> available={league.sponsorSlots.secondary.available > 0}
<div> availableCount={league.sponsorSlots.secondary.available}
<div className="flex items-center gap-2 mb-1"> totalCount={league.sponsorSlots.secondary.total}
<Trophy className="w-5 h-5 text-yellow-400" /> price={league.sponsorSlots.secondary.price}
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3> benefits={league.sponsorSlots.secondary.benefits}
</div> isSelected={selectedTier === 'secondary'}
<p className="text-sm text-gray-400">Primary branding position</p> onClick={() => setSelectedTier('secondary')}
</div> />
{league.sponsorSlots.main.available ? ( </Grid>
<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"> <Card>
${league.sponsorSlots.main.price} <Box mb={4}>
<span className="text-sm font-normal text-gray-500">/season</span> <Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
</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 Sponsorship Summary
</h3> </Heading>
</Box>
<div className="space-y-3 mb-6"> <Stack gap={3} mb={6}>
<div className="flex justify-between py-2"> <InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
<span className="text-gray-400">Selected Tier</span> <InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span> <InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
</div> <Box pt={4} style={{ borderTop: '1px solid #262626' }}>
<div className="flex justify-between py-2"> <Stack direction="row" align="center" justify="between">
<span className="text-gray-400">Season Price</span> <Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
<span className="text-white font-medium"> <Text size="xl" weight="bold" color="text-white">
${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)} ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</span> </Text>
</div> </Stack>
</div> </Box>
</Stack>
<p className="text-xs text-gray-500 mb-4"> <Text size="xs" color="text-gray-500" block mb={4}>
{siteConfig.vat.notice} {siteConfig.vat.notice}
</p> </Text>
<div className="flex gap-3"> <Stack direction="row" gap={3}>
<Button variant="primary" className="flex-1"> <Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
<Megaphone className="w-4 h-4 mr-2" />
Request Sponsorship Request Sponsorship
</Button> </Button>
<Button variant="secondary"> <Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
<FileText className="w-4 h-4 mr-2" />
Download Info Pack Download Info Pack
</Button> </Button>
</div> </Stack>
</Card> </Card>
</div> </Stack>
)} )}
</div> </Stack>
</Container>
);
}
function StatCard({ icon, label, value, color }: { icon: any, label: string, value: string | number, color: string }) {
return (
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}1A` }}>
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Text size="xl" weight="bold" color="text-white" block>{value}</Text>
<Text size="xs" color="text-gray-500" block>{label}</Text>
</Box>
</Stack>
</Card>
);
}
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
return (
<Box py={2} style={{ borderBottom: last ? 'none' : '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack direction="row" align="center" justify="between">
<Text color="text-gray-400">{label}</Text>
<Text weight="medium" color={color as any}>{value}</Text>
</Stack>
</Box>
); );
} }

View File

@@ -1,27 +1,29 @@
'use client'; '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,163 +60,16 @@ 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
.filter((league) => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false; return false;
} }
@@ -231,7 +84,7 @@ export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
} }
return true; return true;
}) })
.sort((a: any, b: any) => { .sort((a, b) => {
switch (sortBy) { switch (sortBy) {
case 'rating': return b.rating - a.rating; case 'rating': return b.rating - a.rating;
case 'drivers': return b.drivers - a.drivers; case 'drivers': return b.drivers - a.drivers;
@@ -240,165 +93,102 @@ export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
default: return 0; 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}>
<Stack gap={8}>
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6"> <Box>
<Link href=routes.sponsor.dashboard className="hover:text-white transition-colors">Dashboard</Link> <Stack direction="row" align="center" gap={2}>
<ChevronRight className="w-4 h-4" /> <Link href={routes.sponsor.dashboard}>
<span className="text-white">Browse Leagues</span> <Text size="sm" color="text-gray-400">Dashboard</Text>
</div> </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
</h1> </Heading>
<p className="text-gray-400"> <Text color="text-gray-400" block mt={2}>
Discover racing leagues looking for sponsors. All prices shown exclude VAT. Discover racing leagues looking for sponsors. All prices shown exclude VAT.
</p> </Text>
</div> </Box>
{/* 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.
</Text>
<Grid cols={4} gap={4}>
<Box>
<input <input
type="text" type="text"
placeholder="Search leagues..." placeholder="Search leagues..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" 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"
/> />
</div> </Box>
{/* Selects would go here, using standard Select UI if available */}
{/* Tier Filter */} </Grid>
<select </Stack>
value={tierFilter} </Card>
onChange={(e) => setTierFilter(e.target.value as TierFilter)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Tiers</option>
<option value="premium"> Premium</option>
<option value="standard">🏆 Standard</option>
<option value="starter">🚀 Starter</option>
</select>
{/* Availability Filter */}
<select
value={availabilityFilter}
onChange={(e) => setAvailabilityFilter(e.target.value as AvailabilityFilter)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="all">All Slots</option>
<option value="main">Main Available</option>
<option value="secondary">Secondary Available</option>
</select>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
>
<option value="rating">Sort by Rating</option>
<option value="drivers">Sort by Drivers</option>
<option value="views">Sort by Views</option>
<option value="price">Sort by Price</option>
</select>
</div>
{/* Results Count */} {/* Results Count */}
<div className="flex items-center justify-between mb-6"> <Stack direction="row" align="center" justify="between">
<p className="text-sm text-gray-400"> <Text size="sm" color="text-gray-400">
Showing {filteredLeagues.length} of {data.leagues.length} leagues Showing {filteredLeagues.length} of {viewData.leagues.length} leagues
</p> </Text>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={3}>
<Link href="/teams"> <Link href="/teams">
<Button variant="secondary" className="text-sm"> <Button variant="secondary" size="sm" icon={<Icon icon={Users} size={4} />}>
<Users className="w-4 h-4 mr-2" />
Browse Teams Browse Teams
</Button> </Button>
</Link> </Link>
<Link href="/drivers"> <Link href="/drivers">
<Button variant="secondary" className="text-sm"> <Button variant="secondary" size="sm" icon={<Icon icon={Car} size={4} />}>
<Car className="w-4 h-4 mr-2" />
Browse Drivers Browse Drivers
</Button> </Button>
</Link> </Link>
</div> </Stack>
</div> </Stack>
{/* League Grid */} {/* League Grid */}
{filteredLeagues.length > 0 ? ( {filteredLeagues.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <Grid cols={3} gap={6}>
{filteredLeagues.map((league: any, index: number) => ( {filteredLeagues.map((league) => (
<LeagueCard key={league.id} league={league} index={index} /> <GridItem key={league.id} colSpan={12} mdSpan={6} lgSpan={4}>
<AvailableLeagueCard league={league as any} />
</GridItem>
))} ))}
</div> </Grid>
) : ( ) : (
<Card className="text-center py-16"> <Card>
<Trophy className="w-12 h-12 text-gray-500 mx-auto mb-4" /> <Stack align="center" py={16} gap={4}>
<h3 className="text-lg font-medium text-white mb-2">No leagues found</h3> <Surface variant="muted" rounded="full" padding={4}>
<p className="text-gray-400 mb-6">Try adjusting your filters to see more results</p> <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={() => { <Button variant="secondary" onClick={() => {
setSearchQuery(''); setSearchQuery('');
setTierFilter('all'); setTierFilter('all');
@@ -406,21 +196,34 @@ export function SponsorLeaguesTemplate({ data }: SponsorLeaguesTemplateProps) {
}}> }}>
Clear Filters Clear Filters
</Button> </Button>
</Stack>
</Card> </Card>
)} )}
{/* Platform Fee Notice */} {/* Platform Fee Notice */}
<div className="mt-8 rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4"> <Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)' }}>
<div className="flex items-start gap-3"> <Stack direction="row" align="start" gap={3}>
<Megaphone className="w-5 h-5 text-primary-blue flex-shrink-0 mt-0.5" /> <Icon icon={Megaphone} size={5} color="#3b82f6" />
<div> <Box>
<p className="text-sm text-gray-300 font-medium mb-1">Platform Fee</p> <Text size="sm" color="text-gray-300" weight="medium" block mb={1}>Platform Fee</Text>
<p className="text-xs text-gray-500"> <Text size="xs" color="text-gray-500">
A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description}
</p> </Text>
</div> </Box>
</div> </Stack>
</div> </Surface>
</div> </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.
</p> </Text>
</div> </Box>
{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'}
</span> </Text>
</div> </Stack>
{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"
border
padding={4}
> >
<div className="flex-1"> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<p className="text-white font-medium">{request.sponsorName}</p> <Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="medium" color="text-white" block>{request.sponsorName}</Text>
{request.message && ( {request.message && (
<p className="text-xs text-gray-400 mt-1">{request.message}</p> <Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
)} )}
<p className="text-xs text-gray-500 mt-1"> <Text size="xs" color="text-gray-500" block mt={2}>
{new Date(request.createdAtIso).toLocaleDateString()} {new Date(request.createdAtIso).toLocaleDateString()}
</p> </Text>
</div> </Box>
<div className="flex gap-2"> <Stack direction="row" gap={2}>
<Button <Button
variant="primary" variant="primary"
onClick={() => onAccept(request.id)} onClick={() => onAccept(request.id)}
size="sm"
> >
Accept Accept
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => onReject(request.id)} onClick={() => onReject(request.id)}
size="sm"
> >
Reject Reject
</Button> </Button>
</div> </Stack>
</div> </Stack>
</Surface>
))} ))}
</div> </Stack>
)} )}
</Stack>
</Card> </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>
</Stack>
) : ( ) : (
<div className="space-y-4"> <Stack gap={4}>
{viewData.races.map((race) => ( {viewData.races.map((race) => (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden"> <Surface
key={race.id}
variant="muted"
rounded="lg"
border
style={{ overflow: 'hidden', borderColor: '#262626' }}
>
{/* Race Header */} {/* Race Header */}
<div className="px-4 py-3 bg-iron-gray/30"> <Box p={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626' }}>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4} wrap>
<div className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<MapPin className="w-4 h-4 text-gray-400" /> <Icon icon={MapPin} size={4} color="#9ca3af" />
<span className="font-medium text-white">{race.track}</span> <Text weight="medium" color="text-white">{race.track}</Text>
</div> </Stack>
<div className="flex items-center gap-2 text-gray-400 text-sm"> <Stack direction="row" align="center" gap={2}>
<Calendar className="w-4 h-4" /> <Icon icon={Calendar} size={4} color="#9ca3af" />
<span>{new Date(race.scheduledAt).toLocaleDateString()}</span> <Text size="sm" color="text-gray-400">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
</div> </Stack>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full"> <Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
{race.pendingProtests.length} pending <Text size="xs" weight="medium" color="text-warning-amber">{race.pendingProtests.length} pending</Text>
</span> </Surface>
</div> </Stack>
</div> </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>
) : ( ) : (
<> <Stack gap={3}>
{race.pendingProtests.map((protest) => { {race.pendingProtests.map((protest) => {
const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId); const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId);
const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId); 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"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
> >
<div className="flex items-start justify-between gap-4"> <Stack direction="row" align="start" justify="between" gap={4}>
<div className="flex-1 min-w-0"> <Box style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-2 mb-2"> <Stack direction="row" align="center" gap={2} mb={2} wrap>
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" /> <Icon icon={AlertCircle} size={4} color="#f59e0b" />
<span className="font-medium text-white"> <Text weight="medium" color="text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span> </Text>
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span> <Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
</div> <Text size="xs" weight="medium" color="text-warning-amber">Pending</Text>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2"> </Surface>
<span>Lap {protest.incident.lap}</span> </Stack>
<span></span> <Stack direction="row" align="center" gap={4} mb={2}>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span> <Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
</div> <Text size="sm" color="text-gray-400"></Text>
<p className="text-sm text-gray-300 line-clamp-2"> <Text size="sm" color="text-gray-400">Filed {new Date(protest.filedAt).toLocaleDateString()}</Text>
{protest.incident.description} </Stack>
</p> <Text size="sm" color="text-gray-300" block truncate>{protest.incident.description}</Text>
</div> </Box>
<div className="text-sm text-gray-400"> <Text size="sm" color="text-gray-500">Review needed</Text>
Review needed </Stack>
</div> </Surface>
</div>
</div>
); );
})} })}
{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"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
> >
<div className="flex items-center gap-3"> <Stack direction="row" align="center" justify="between" gap={4}>
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0"> <Stack direction="row" align="center" gap={3}>
<Gavel className="w-4 h-4 text-red-400" /> <Surface variant="muted" rounded="full" padding={2} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)' }}>
</div> <Icon icon={Gavel} size={4} color="#ef4444" />
<div className="flex-1"> </Surface>
<div className="flex items-center gap-2"> <Box>
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span> <Stack direction="row" align="center" gap={2}>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full"> <Text weight="medium" color="text-white">{driver?.name || 'Unknown'}</Text>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium" color="text-error-red" style={{ textTransform: 'capitalize' }}>
{penalty.type.replace('_', ' ')} {penalty.type.replace('_', ' ')}
</span> </Text>
</div> </Surface>
<p className="text-sm text-gray-400">{penalty.reason}</p> </Stack>
</div> <Text size="sm" color="text-gray-400" block mt={1}>{penalty.reason}</Text>
<div className="text-right"> </Box>
<span className="text-lg font-bold text-red-400"> </Stack>
<Box style={{ textAlign: 'right' }}>
<Text weight="bold" color="text-error-red" style={{ fontSize: '1.125rem' }}>
{penalty.type === 'time_penalty' && `+${penalty.value}s`} {penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`} {penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`} {penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'} {penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'} {penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`} {penalty.type === 'license_points' && `${penalty.value} LP`}
</span> </Text>
</div> </Box>
</div> </Stack>
</div> </Surface>
); );
})} })}
</> </Stack>
)} )}
</div> </Box>
</div> </Surface>
))} ))}
</div> </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>
<Text color="text-gray-400" block mt={2}>
The team you're looking for doesn't exist or has been disbanded. The team you're looking for doesn't exist or has been disbanded.
</p> </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,7 +89,8 @@ 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}>
<Stack gap={6}>
{/* Breadcrumb */} {/* Breadcrumb */}
<Breadcrumbs <Breadcrumbs
items={[ items={[
@@ -100,7 +100,7 @@ export function TeamDetailTemplate({
]} ]}
/> />
{/* Sponsor Insights Card - Consistent placement at top */} {/* Sponsor Insights Card */}
{isSponsorMode && viewData.team && ( {isSponsorMode && viewData.team && (
<SponsorInsightsCard <SponsorInsightsCard
entityType="team" entityType="team"
@@ -114,92 +114,51 @@ export function TeamDetailTemplate({
/> />
)} )}
<Card className="mb-6"> <TeamHero
<div className="flex items-start justify-between"> team={viewData.team}
<div className="flex items-start gap-6"> memberCount={viewData.memberships.length}
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"> onUpdate={onUpdate}
<Image
src={getMediaUrl('team-logo', viewData.team.id)}
alt={viewData.team.name}
width={96}
height={96}
className="w-full h-full object-cover"
/> />
</div>
<div> {/* Tabs */}
<div className="flex items-center gap-3 mb-2"> <Box style={{ borderBottom: '1px solid #262626' }}>
<h1 className="text-3xl font-bold text-white">{viewData.team.name}</h1> <Stack direction="row" gap={6}>
{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) => ( {visibleTabs.map((tab) => (
<button <Box
key={tab.id} key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
className={` pb={3}
px-4 py-3 font-medium transition-all relative style={{
${activeTab === tab.id cursor: 'pointer',
? 'text-primary-blue' borderBottom: activeTab === tab.id ? '2px solid #3b82f6' : '2px solid transparent',
: 'text-gray-400 hover:text-white' color: activeTab === tab.id ? '#3b82f6' : '#9ca3af'
} }}
`}
> >
{tab.label} <Text weight="medium">{tab.label}</Text>
{activeTab === tab.id && ( </Box>
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-blue" />
)}
</button>
))} ))}
</div> </Stack>
</div> </Box>
<div> <Box>
{activeTab === 'overview' && ( {activeTab === 'overview' && (
<div className="space-y-6"> <Stack gap={6}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <Grid cols={12} gap={6}>
<Card className="lg:col-span-2"> <GridItem colSpan={12} lgSpan={8}>
<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> <Card>
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3> <Box mb={4}>
<div className="space-y-3"> <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" /> <StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{viewData.team.category && ( {viewData.team.category && (
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" /> <StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
@@ -217,17 +176,20 @@ export function TeamDetailTemplate({
color="text-gray-300" color="text-gray-300"
/> />
)} )}
</div> </Stack>
</Card> </Card>
</div> </GridItem>
</Grid>
<Card> <Card>
<h3 className="text-xl font-semibold text-white mb-4">Recent Activity</h3> <Box mb={4}>
<div className="text-center py-8 text-gray-400"> <Heading level={2}>Recent Activity</Heading>
No recent activity to display </Box>
</div> <Box py={8}>
<Text color="text-gray-400" block style={{ textAlign: 'center' }}>No recent activity to display</Text>
</Box>
</Card> </Card>
</div> </Stack>
)} )}
{activeTab === 'roster' && ( {activeTab === 'roster' && (
@@ -247,7 +209,8 @@ export function TeamDetailTemplate({
{activeTab === 'admin' && viewData.isAdmin && ( {activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={viewData.team} onUpdate={onUpdate} /> <TeamAdmin team={viewData.team} onUpdate={onUpdate} />
)} )}
</div> </Box>
</div> </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,16 +42,15 @@ export default function TeamLeaderboardTemplate({
onBackToTeams, onBackToTeams,
}: TeamLeaderboardTemplateProps) { }: TeamLeaderboardTemplateProps) {
// Filter and sort teams // Filter and sort teams
const filteredAndSortedTeams = teams const filteredAndSortedTeams = useMemo(() => {
return teams
.filter((team) => { .filter((team) => {
// Search filter
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; return false;
} }
} }
// Level filter
if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) { if (filterLevel !== 'all' && team.performanceLevel !== filterLevel) {
return false; return false;
} }
@@ -109,60 +58,39 @@ export default function TeamLeaderboardTemplate({
}) })
.sort((a, b) => { .sort((a, b) => {
switch (sortBy) { switch (sortBy) {
case 'rating': { case 'rating': return 0; // Placeholder
const aRating = getSafeRating(a); case 'wins': return (b.totalWins || 0) - (a.totalWins || 0);
const bRating = getSafeRating(b); case 'races': return (b.totalRaces || 0) - (a.totalRaces || 0);
return bRating - aRating; default: return 0;
}
case 'wins': {
const aWinsSort = getSafeTotalWins(a);
const bWinsSort = getSafeTotalWins(b);
return bWinsSort - aWinsSort;
}
case 'winRate': {
const aRaces = getSafeTotalRaces(a);
const bRaces = getSafeTotalRaces(b);
const aWins = getSafeTotalWins(a);
const bWins = getSafeTotalWins(b);
const aRate = aRaces > 0 ? aWins / aRaces : 0;
const bRate = bRaces > 0 ? bWins / bRaces : 0;
return bRate - aRate;
}
case 'races': {
const aRacesSort = getSafeTotalRaces(a);
const bRacesSort = getSafeTotalRaces(b);
return bRacesSort - aRacesSort;
}
default:
return 0;
} }
}); });
}, [teams, searchQuery, filterLevel, sortBy]);
return ( return (
<div className="max-w-7xl mx-auto px-4 pb-12"> <Container size="lg" py={8}>
<Stack gap={8}>
{/* Header */} {/* Header */}
<div className="mb-8"> <Box>
<Box mb={6}>
<Button <Button
variant="secondary" variant="secondary"
onClick={onBackToTeams} onClick={onBackToTeams}
className="flex items-center gap-2 mb-6" icon={<Icon icon={ArrowLeft} size={4} />}
> >
<ArrowLeft className="w-4 h-4" />
Back to Teams Back to Teams
</Button> </Button>
</Box>
<div className="flex items-center gap-4 mb-2"> <Stack direction="row" align="center" gap={4}>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30"> <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)' }}>
<Award className="w-7 h-7 text-yellow-400" /> <Icon icon={Award} size={7} color="#facc15" />
</div> </Surface>
<div> <Box>
<Heading level={1} className="text-3xl lg:text-4xl"> <Heading level={1}>Team Leaderboard</Heading>
Team Leaderboard <Text color="text-gray-400" block mt={1}>Rankings of all teams by performance metrics</Text>
</Heading> </Box>
<p className="text-gray-400">Rankings of all teams by performance metrics</p> </Stack>
</div> </Box>
</div>
</div>
{/* Filters and Search */} {/* Filters and Search */}
<TeamRankingsFilter <TeamRankingsFilter
@@ -174,201 +102,18 @@ export default function TeamLeaderboardTemplate({
onSortChange={onSortChange} onSortChange={onSortChange}
/> />
{/* Podium for Top 3 - only show when viewing by rating without filters */} {/* Podium for Top 3 */}
{sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && (
<TopThreePodium teams={filteredAndSortedTeams} onClick={onTeamClick} /> <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 */} {/* Leaderboard Table */}
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden"> <TeamRankingsTable
{/* Table Header */} teams={filteredAndSortedTeams}
<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"> sortBy={sortBy}
<div className="col-span-1 text-center">Rank</div> onTeamClick={onTeamClick}
<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> </Stack>
<div className="min-w-0 flex-1"> </Container>
<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
variant="secondary"
onClick={() => {
onSearchChange('');
onFilterLevelChange('all');
}}
className="mt-4"
>
Clear Filters
</Button>
</div>
)}
</div>
</div>
); );
} }

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}>
<Stack gap={8}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-8"> <Stack direction="row" align="center" justify="between" wrap gap={4}>
<div> <Box>
<h1 className="text-3xl font-bold text-white mb-2">Teams</h1> <Heading level={1}>Teams</Heading>
<p className="text-gray-400">Browse and manage your racing teams</p> <Text color="text-gray-400">Browse and manage your racing teams</Text>
</div> </Box>
<Link href=routes.team.detail('create')> <Box>
<Button variant="primary">Create Team</Button> <Button variant="primary" onClick={onCreateTeam}>Create Team</Button>
</Link> </Box>
</div> </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">
<Users className="w-6 h-6 text-gray-500" />
</div>
)}
<div>
<h3 className="font-semibold text-white">{team.teamName}</h3>
<p className="text-sm text-gray-400">{team.leagueName}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-4">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{team.memberCount} members
</span>
</div>
<div className="flex gap-2">
<Link href={`/teams/${team.teamId}`} className="flex-1">
<Button variant="secondary" className="w-full text-sm">
View Team
</Button>
</Link>
</div>
</Card>
))} ))}
</div> </Grid>
) : ( ) : (
<div className="text-center py-16"> <EmptyState
<Users className="w-16 h-16 text-gray-600 mx-auto mb-4" /> icon={Users}
<h3 className="text-xl font-semibold text-white mb-2">No teams yet</h3> title="No teams yet"
<p className="text-gray-400 mb-4">Get started by creating your first racing team</p> description="Get started by creating your first racing team"
<Link href=routes.team.detail('create')> action={{
<Button variant="primary">Create Team</Button> label: 'Create Team',
</Link> onClick: onCreateTeam,
</div> variant: 'primary'
}}
/>
)} )}
{/* Team Leaderboard Preview */} {/* Team Leaderboard Preview */}
<div className="mt-12"> <Box mt={12}>
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-2"> <TeamLeaderboardPreview
<Trophy className="w-6 h-6 text-yellow-400" /> topTeams={[]}
Top Teams onTeamClick={(id) => onTeamClick?.(id)}
</h2> onViewFullLeaderboard={onViewFullLeaderboard}
<TeamLeaderboardPreview topTeams={[]} onTeamClick={() => {}} /> />
</div> </Box>
</div> </Stack>
</main> </Container>
</Box>
); );
} }

View File

@@ -1,14 +1,6 @@
/**
* Forgot Password Template
*
* Pure presentation component that accepts ViewData only.
* No business logic, no state management.
*/
'use client'; '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,65 +37,65 @@ 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}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* 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 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input <Input
id="email" id="email"
type="email" type="email"
value={viewData.formState.fields.email.value} value={viewData.formState.fields.email.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error} variant={viewData.formState.fields.email.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com" placeholder="you@example.com"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10" style={{ paddingLeft: '2.5rem' }}
autoComplete="email" autoComplete="email"
/> />
</div> </Box>
</div> {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 */} {/* Submit Button */}
@@ -105,90 +103,79 @@ export function ForgotPasswordTemplate({ viewData, formActions, mutationState }:
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={Shield} size={4} />}
> >
{mutationState.isPending ? ( {mutationState.isPending ? 'Sending...' : 'Send Reset Link'}
<>
<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> </Button>
{/* Back to Login */} {/* Back to Login */}
<div className="text-center"> <Box style={{ textAlign: 'center' }}>
<Link <Link href="/auth/login">
href="/auth/login" <Stack direction="row" align="center" justify="center" gap={1}>
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1" <Icon icon={ArrowLeft} size={4} color="#3b82f6" />
> <Text size="sm" color="text-primary-blue">Back to Login</Text>
<ArrowLeft className="w-4 h-4" /> </Stack>
Back to Login
</Link> </Link>
</div> </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" />
<div>
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
{viewData.magicLink && ( {viewData.magicLink && (
<div className="mt-2"> <Box mt={2}>
<p className="text-xs text-gray-400 mb-1">Development Mode - Magic Link:</p> <Text size="xs" color="text-gray-400" block mb={1}>Development Mode - Magic Link:</Text>
<div className="bg-iron-gray p-2 rounded border border-charcoal-outline"> <Surface variant="muted" rounded="md" border padding={2} style={{ backgroundColor: '#262626' }}>
<code className="text-xs text-primary-blue break-all"> <Text size="xs" color="text-primary-blue" style={{ wordBreak: 'break-all' }}>{viewData.magicLink}</Text>
{viewData.magicLink} </Surface>
</code> <Text size="xs" color="text-gray-500" block mt={1}>
</div>
<p className="text-[10px] text-gray-500 mt-1">
In production, this would be sent via email In production, this would be sent via email
</p> </Text>
</div> </Box>
)} )}
</div> </Box>
</div> </Stack>
</Surface>
<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' }}>
<Text size="xs" color="text-gray-500">
Need help?{' '} Need help?{' '}
<Link href="/support" className="text-gray-400 hover:underline"> <Link href="/support">
Contact support <Text color="text-gray-400">Contact support</Text>
</Link> </Link>
</p> </Text>
</div> </Box>
</main> </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}>
<Heading level={2}>
Your Sim Racing Infrastructure Your Sim Racing Infrastructure
</Heading> </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 */}
<Box mt={8}>
<AuthWorkflowMockup /> <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}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* 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 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={viewData.formState.fields.email.value as string} value={viewData.formState.fields.email.value as string}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error} variant={viewData.formState.fields.email.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com" placeholder="you@example.com"
disabled={viewData.formState.isSubmitting || mutationState.isPending} disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="pl-10" style={{ paddingLeft: '2.5rem' }}
autoComplete="email" autoComplete="email"
/> />
</div> </Box>
</div> {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 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input <Input
id="password" id="password"
name="password" name="password"
type={viewData.showPassword ? 'text' : 'password'} type={viewData.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value as string} value={viewData.formState.fields.password.value as string}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.password.error} variant={viewData.formState.fields.password.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••" placeholder="••••••••"
disabled={viewData.formState.isSubmitting || mutationState.isPending} disabled={viewData.formState.isSubmitting || mutationState.isPending}
className="pl-10 pr-10" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="current-password" autoComplete="current-password"
/> />
<button <Box
as="button"
type="button" type="button"
onClick={() => formActions.setShowPassword(!viewData.showPassword)} onClick={() => formActions.setShowPassword(!viewData.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
> >
{viewData.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <Icon icon={viewData.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</button> </Box>
</div> </Box>
</div> {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" />
Signing in...
</>
) : (
<>
<LogIn className="w-4 h-4" />
Sign In
</>
)}
</Button> </Button>
</Stack>
</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}>
<Text size="sm" color="text-gray-400">
Don't have an account?{' '} Don't have an account?{' '}
<Link <Link
href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'} href={viewData.returnTo && viewData.returnTo !== '/dashboard' ? `/auth/signup?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/signup'}
className="text-primary-blue hover:underline font-medium"
> >
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' }}>
<Text size="xs" color="text-gray-500">
By signing in, you agree to our{' '} By signing in, you agree to our{' '}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link> <Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '} {' '}and{' '}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link> <Link href="/privacy">
</p> <Text color="text-gray-400">Privacy Policy</Text>
</Link>
</Text>
</Box>
{/* Mobile Role Info */} {/* Mobile Role Info */}
<Box mt={8} className="lg:hidden">
<UserRolesPreview variant="compact" /> <UserRolesPreview variant="compact" />
</div> </Box>
</div> </Box>
</main> </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,103 +46,111 @@ 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}>
<Stack gap={5} style={{ position: 'relative' }}>
{/* New Password */} {/* New Password */}
<div> <Box>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
New Password New 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 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input <Input
id="newPassword" id="newPassword"
name="newPassword" name="newPassword"
type={uiState.showPassword ? 'text' : 'password'} type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.newPassword.value} value={viewData.formState.fields.newPassword.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.newPassword.error} variant={viewData.formState.fields.newPassword.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.newPassword.error}
placeholder="••••••••" placeholder="••••••••"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10 pr-10" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password" autoComplete="new-password"
/> />
<button <Box
as="button"
type="button" type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)} onClick={() => formActions.setShowPassword(!uiState.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
> >
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</button> </Box>
</div> </Box>
</div> {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 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input <Input
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'} type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value} value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.confirmPassword.error} variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••" placeholder="••••••••"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10 pr-10" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password" autoComplete="new-password"
/> />
<button <Box
as="button"
type="button" type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
> >
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</button> </Box>
</div> </Box>
</div> {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 */} {/* Submit Button */}
@@ -152,80 +158,71 @@ export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) {
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={Shield} size={4} />}
> >
{mutationState.isPending ? ( {mutationState.isPending ? 'Resetting...' : 'Reset Password'}
<>
<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> </Button>
{/* Back to Login */} {/* Back to Login */}
<div className="text-center"> <Box style={{ textAlign: 'center' }}>
<Link <Link href="/auth/login">
href="/auth/login" <Stack direction="row" align="center" justify="center" gap={1}>
className="text-sm text-primary-blue hover:underline flex items-center justify-center gap-1" <Icon icon={ArrowLeft} size={4} color="#3b82f6" />
> <Text size="sm" color="text-primary-blue">Back to Login</Text>
<ArrowLeft className="w-4 h-4" /> </Stack>
Back to Login
</Link> </Link>
</div> </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>
<p className="text-sm text-performance-green font-medium">{viewData.successMessage}</p>
<p className="text-xs text-gray-400 mt-1">
Your password has been successfully reset Your password has been successfully reset
</p> </Text>
</div> </Box>
</div> </Stack>
</Surface>
<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' }}>
<Text size="xs" color="text-gray-500">
Need help?{' '} Need help?{' '}
<Link href="/support" className="text-gray-400 hover:underline"> <Link href="/support">
Contact support <Text color="text-gray-400">Contact support</Text>
</Link> </Link>
</p> </Text>
</div> </Box>
</main> </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>
<Stack gap={2}>
{FEATURES.map((feature, index) => ( {FEATURES.map((feature, index) => (
<li <Stack key={index} direction="row" align="center" gap={2}>
key={index} <Icon icon={Check} size={3.5} color="#10b981" />
className="flex items-center gap-2 text-sm text-gray-400" <Text size="sm" color="text-gray-400">{feature}</Text>
> </Stack>
<Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" />
{feature}
</li>
))} ))}
</ul> </Stack>
</div> </Surface>
</Box>
{/* 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}>
<Stack gap={4} style={{ position: 'relative' }}>
{/* First Name */} {/* First Name */}
<div> <Box>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2"> <Text size="sm" weight="medium" color="text-gray-300" block mb={2}>
First Name First 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 }}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input <Input
id="firstName" id="firstName"
name="firstName" name="firstName"
type="text" type="text"
value={viewData.formState.fields.firstName.value} value={viewData.formState.fields.firstName.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.firstName.error} variant={viewData.formState.fields.firstName.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.firstName.error}
placeholder="John" placeholder="John"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10" style={{ paddingLeft: '2.5rem' }}
autoComplete="given-name" autoComplete="given-name"
/> />
</div> </Box>
</div> {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 }}>
<Icon icon={User} size={4} color="#6b7280" />
</Box>
<Input <Input
id="lastName" id="lastName"
name="lastName" name="lastName"
type="text" type="text"
value={viewData.formState.fields.lastName.value} value={viewData.formState.fields.lastName.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.lastName.error} variant={viewData.formState.fields.lastName.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.lastName.error}
placeholder="Smith" placeholder="Smith"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10" style={{ paddingLeft: '2.5rem' }}
autoComplete="family-name" autoComplete="family-name"
/> />
</div> </Box>
<p className="mt-1 text-xs text-gray-500">Your name will be used as-is and cannot be changed later</p> {viewData.formState.fields.lastName.error && (
</div> <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 }}>
<Icon icon={Mail} size={4} color="#6b7280" />
</Box>
<Input <Input
id="email" id="email"
name="email" name="email"
type="email" type="email"
value={viewData.formState.fields.email.value} value={viewData.formState.fields.email.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.email.error} variant={viewData.formState.fields.email.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.email.error}
placeholder="you@example.com" placeholder="you@example.com"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10" style={{ paddingLeft: '2.5rem' }}
autoComplete="email" autoComplete="email"
/> />
</div> </Box>
</div> {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 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input <Input
id="password" id="password"
name="password" name="password"
type={uiState.showPassword ? 'text' : 'password'} type={uiState.showPassword ? 'text' : 'password'}
value={viewData.formState.fields.password.value} value={viewData.formState.fields.password.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.password.error} variant={viewData.formState.fields.password.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.password.error}
placeholder="••••••••" placeholder="••••••••"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10 pr-10" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password" autoComplete="new-password"
/> />
<button <Box
as="button"
type="button" type="button"
onClick={() => formActions.setShowPassword(!uiState.showPassword)} onClick={() => formActions.setShowPassword(!uiState.showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
> >
{uiState.showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <Icon icon={uiState.showPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</button> </Box>
</div> </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}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className={`text-xs font-medium ${
passwordStrength.score <= 1 ? 'text-red-400' :
passwordStrength.score <= 2 ? 'text-warning-amber' :
passwordStrength.score <= 3 ? 'text-primary-blue' :
'text-performance-green'
}`}>
{passwordStrength.label} {passwordStrength.label}
</span> </Text>
</div> </Stack>
<div className="grid grid-cols-2 gap-1"> <Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '0.25rem' }}>
{passwordRequirements.map((req, index) => ( {passwordRequirements.map((req, index) => (
<div key={index} className="flex items-center gap-1.5 text-xs"> <Stack key={index} direction="row" align="center" gap={1.5}>
{req.met ? ( <Icon icon={req.met ? Check : X} size={3} color={req.met ? '#10b981' : '#525252'} />
<Check className="w-3 h-3 text-performance-green" /> <Text size="xs" color={req.met ? 'text-gray-300' : 'text-gray-500'}>
) : (
<X className="w-3 h-3 text-gray-500" />
)}
<span className={req.met ? 'text-gray-300' : 'text-gray-500'}>
{req.label} {req.label}
</span> </Text>
</div> </Stack>
))} ))}
</div> </Box>
</div> </Box>
)} )}
</div> </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 }}>
<Icon icon={Lock} size={4} color="#6b7280" />
</Box>
<Input <Input
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type={uiState.showConfirmPassword ? 'text' : 'password'} type={uiState.showConfirmPassword ? 'text' : 'password'}
value={viewData.formState.fields.confirmPassword.value} value={viewData.formState.fields.confirmPassword.value}
onChange={formActions.handleChange} onChange={formActions.handleChange}
error={!!viewData.formState.fields.confirmPassword.error} variant={viewData.formState.fields.confirmPassword.error ? 'error' : 'default'}
errorMessage={viewData.formState.fields.confirmPassword.error}
placeholder="••••••••" placeholder="••••••••"
disabled={mutationState.isPending} disabled={mutationState.isPending}
className="pl-10 pr-10" style={{ paddingLeft: '2.5rem', paddingRight: '2.5rem' }}
autoComplete="new-password" autoComplete="new-password"
/> />
<button <Box
as="button"
type="button" type="button"
onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} onClick={() => formActions.setShowConfirmPassword(!uiState.showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" style={{ position: 'absolute', right: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
> >
{uiState.showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} <Icon icon={uiState.showConfirmPassword ? EyeOff : Eye} size={4} color="#6b7280" />
</button> </Box>
</div> </Box>
{viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( {viewData.formState.fields.confirmPassword.error && (
<p className="mt-1 text-xs text-performance-green flex items-center gap-1"> <Text size="xs" color="text-error-red" block mt={1}>
<Check className="w-3 h-3" /> Passwords match {viewData.formState.fields.confirmPassword.error}
</p> </Text>
)} )}
</div> {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" />
Creating account...
</>
) : (
<>
<UserPlus className="w-4 h-4" />
Create Account
</>
)}
</Button> </Button>
</Stack>
</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}>
<Text size="sm" color="text-gray-400">
Already have an account?{' '} Already have an account?{' '}
<Link <Link
href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'} href={viewData.returnTo && viewData.returnTo !== '/onboarding' ? `/auth/login?returnTo=${encodeURIComponent(viewData.returnTo)}` : '/auth/login'}
className="text-primary-blue hover:underline font-medium"
> >
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' }}>
<Text size="xs" color="text-gray-500">
By creating an account, you agree to our{' '} By creating an account, you agree to our{' '}
<Link href="/terms" className="text-gray-400 hover:underline">Terms of Service</Link> <Link href="/terms">
<Text color="text-gray-400">Terms of Service</Text>
</Link>
{' '}and{' '} {' '}and{' '}
<Link href="/privacy" className="text-gray-400 hover:underline">Privacy Policy</Link> <Link href="/privacy">
</p> <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} {children}
</section> </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,29 +34,39 @@ 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>
</div> {subValue && (
{icon && ( <Text size="xs" color="text-gray-500" className="mt-1" block>
<div className={iconColorClasses[variant]}> {subValue}
{icon} </Text>
</div>
)} )}
</div> </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>
</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>
);
}