website refactor

This commit is contained in:
2026-01-14 23:46:04 +01:00
parent c1a86348d7
commit 4a2d7d15a5
294 changed files with 5637 additions and 3418 deletions

View File

@@ -0,0 +1,36 @@
'use client';
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
import { QueryClientProvider } from '@/lib/providers/QueryClientProvider';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
import NotificationProvider from '@/components/notifications/NotificationProvider';
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import DevToolbar from '@/components/dev/DevToolbar';
import React from 'react';
interface AppWrapperProps {
children: React.ReactNode;
enabledFlags: string[];
}
export function AppWrapper({ children, enabledFlags }: AppWrapperProps) {
return (
<ContainerProvider>
<QueryClientProvider>
<AuthProvider>
<FeatureFlagProvider flags={enabledFlags}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
{children}
{process.env.NODE_ENV === 'development' && <DevToolbar />}
</EnhancedErrorBoundary>
</NotificationProvider>
</FeatureFlagProvider>
</AuthProvider>
</QueryClientProvider>
</ContainerProvider>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Search, Star, Trophy, Percent, Hash } from 'lucide-react';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Button from '@/ui/Button';
import Input from '@/ui/Input';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';

View File

@@ -24,7 +24,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
return (
<AdminDashboardTemplate
adminDashboardViewData={initialViewData}
viewData={initialViewData}
onRefresh={handleRefresh}
isLoading={loading}
/>

View File

@@ -103,7 +103,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
return (
<AdminUsersTemplate
adminUsersViewData={initialViewData}
viewData={initialViewData}
onRefresh={handleRefresh}
onSearch={handleSearch}
onFilterRole={handleFilterRole}

View File

@@ -0,0 +1,92 @@
'use client';
import React from 'react';
import { Filter, Search } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Select } from '@/ui/Select';
interface UserFiltersProps {
search: string;
roleFilter: string;
statusFilter: string;
onSearch: (search: string) => void;
onFilterRole: (role: string) => void;
onFilterStatus: (status: string) => void;
onClearFilters: () => void;
}
export function UserFilters({
search,
roleFilter,
statusFilter,
onSearch,
onFilterRole,
onFilterStatus,
onClearFilters,
}: UserFiltersProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Filter} size={4} color="#9ca3af" />
<Text weight="medium" color="text-white">Filters</Text>
</Stack>
{(search || roleFilter || statusFilter) && (
<Button
onClick={onClearFilters}
variant="ghost"
size="sm"
>
Clear all
</Button>
)}
</Stack>
<Grid cols={3} gap={4}>
<Box position="relative">
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Icon icon={Search} size={4} color="#9ca3af" />
</Box>
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
style={{ paddingLeft: '2.25rem' }}
/>
</Box>
<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' },
]}
/>
</Grid>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import React from 'react';
import { Users, Shield } from 'lucide-react';
import { Grid } from '@/ui/Grid';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface UserStatsSummaryProps {
total: number;
activeCount: number;
adminCount: number;
}
export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) {
return (
<Grid cols={3} gap={4}>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(30, 58, 138, 0.2), rgba(29, 78, 216, 0.1))', borderColor: 'rgba(59, 130, 246, 0.2)' }}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Total Users</Text>
<Text size="2xl" weight="bold" color="text-white">{total}</Text>
</Box>
<Icon icon={Users} size={6} color="#60a5fa" />
</Stack>
</Surface>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(20, 83, 45, 0.2), rgba(21, 128, 61, 0.1))', borderColor: 'rgba(16, 185, 129, 0.2)' }}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Active</Text>
<Text size="2xl" weight="bold" color="text-white">{activeCount}</Text>
</Box>
<Text color="text-performance-green" weight="bold"></Text>
</Stack>
</Surface>
<Surface variant="muted" rounded="xl" border padding={4} style={{ background: 'linear-gradient(to bottom right, rgba(88, 28, 135, 0.2), rgba(126, 34, 206, 0.1))', borderColor: 'rgba(168, 85, 247, 0.2)' }}>
<Stack direction="row" align="center" justify="between">
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Admins</Text>
<Text size="2xl" weight="bold" color="text-white">{adminCount}</Text>
</Box>
<Icon icon={Shield} size={6} color="#a855f7" />
</Stack>
</Surface>
</Grid>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import React from 'react';
import { Activity } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
interface FeedItem {
id: string;
headline: string;
body?: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface ActivityFeedProps {
items: FeedItem[];
hasItems: boolean;
}
export function ActivityFeed({ items, hasItems }: ActivityFeedProps) {
return (
<Card>
<Stack gap={4}>
<Heading level={2} icon={<Activity style={{ width: '1.25rem', height: '1.25rem', color: '#3b82f6' }} />}>
Recent Activity
</Heading>
{hasItems ? (
<Stack gap={4}>
{items.slice(0, 5).map((item) => (
<Box key={item.id} style={{ display: 'flex', alignItems: 'start', gap: '0.75rem', padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Box style={{ flex: 1 }}>
<Text color="text-white" weight="medium" block>{item.headline}</Text>
{item.body && <Text size="sm" color="text-gray-400" block mt={1}>{item.body}</Text>}
<Text size="xs" color="text-gray-500" block mt={1}>{item.formattedTime}</Text>
</Box>
{item.ctaHref && item.ctaLabel && (
<Box>
<Link href={item.ctaHref} variant="primary">
<Text size="xs">{item.ctaLabel}</Text>
</Link>
</Box>
)}
</Box>
))}
</Stack>
) : (
<Stack align="center" gap={2} py={8}>
<Activity style={{ width: '2.5rem', height: '2.5rem', color: '#525252' }} />
<Text color="text-gray-400">No activity yet</Text>
<Text size="sm" color="text-gray-500">Join leagues and add friends to see activity here</Text>
</Stack>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import React from 'react';
import { Award, ChevronRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface Standing {
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}
interface ChampionshipStandingsProps {
standings: Standing[];
}
export function ChampionshipStandings({ standings }: ChampionshipStandingsProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Award style={{ width: '1.25rem', height: '1.25rem', color: '#facc15' }} />}>
Your Championship Standings
</Heading>
<Box>
<Link href={routes.protected.profileLeagues} variant="primary">
<Stack direction="row" align="center" gap={1}>
<Text size="sm">View all</Text>
<ChevronRight style={{ width: '1rem', height: '1rem' }} />
</Stack>
</Link>
</Box>
</Stack>
<Stack gap={3}>
{standings.map((summary) => (
<Box key={summary.leagueId} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Box>
<Text color="text-white" weight="medium" block>{summary.leagueName}</Text>
<Text size="xs" color="text-gray-500">Position {summary.position} {summary.points} points</Text>
</Box>
<Text size="xs" color="text-gray-400">{summary.totalDrivers} drivers</Text>
</Box>
))}
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import React from 'react';
import { Trophy, Medal, Target, Users, Flag, User } from 'lucide-react';
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 { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { StatBox } from './StatBox';
import { routes } from '@/lib/routing/RouteConfig';
interface DashboardHeroProps {
currentDriver: {
name: string;
avatarUrl: string;
country: string;
rating: string | number;
rank: string | number;
totalRaces: string | number;
wins: string | number;
podiums: string | number;
consistency: string;
};
activeLeaguesCount: string | number;
}
export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) {
return (
<Box as="section" style={{ position: 'relative', overflow: 'hidden' }}>
{/* Background Pattern */}
<Box style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.1), #0f1115, rgba(147, 51, 234, 0.05))' }} />
<Box style={{ position: 'relative', maxWidth: '80rem', margin: '0 auto', padding: '2.5rem 1.5rem' }}>
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" wrap gap={8}>
{/* Welcome Message */}
<Stack direction="row" align="start" gap={5}>
<Box style={{ position: 'relative' }}>
<Box style={{ width: '5rem', height: '5rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.125rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
<Box style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image
src={currentDriver.avatarUrl}
alt={currentDriver.name}
width={80}
height={80}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Box>
<Box style={{ position: 'absolute', bottom: '-0.25rem', right: '-0.25rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: '#10b981', border: '3px solid #0f1115' }} />
</Box>
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>Good morning,</Text>
<Heading level={1} style={{ marginBottom: '0.5rem' }}>
{currentDriver.name}
<Text size="2xl" style={{ marginLeft: '0.75rem' }}>{currentDriver.country}</Text>
</Heading>
<Stack direction="row" align="center" gap={3} wrap>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="sm" weight="semibold" color="text-primary-blue">{currentDriver.rating}</Text>
</Surface>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="sm" weight="semibold" style={{ color: '#facc15' }}>#{currentDriver.rank}</Text>
</Surface>
<Text size="xs" color="text-gray-500">{currentDriver.totalRaces} races completed</Text>
</Stack>
</Box>
</Stack>
{/* Quick Actions */}
<Stack direction="row" gap={3} wrap>
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" icon={<Flag style={{ width: '1rem', height: '1rem' }} />}>
Browse Leagues
</Button>
</Link>
<Link href={routes.protected.profile} variant="ghost">
<Button variant="primary" icon={<User style={{ width: '1rem', height: '1rem' }} />}>
View Profile
</Button>
</Link>
</Stack>
</Stack>
{/* Quick Stats Row */}
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
{/* At md this should be 4 columns */}
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" />
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#f59e0b" />
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#3b82f6" />
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" />
</Box>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { Users, UserPlus } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Image } from '@/ui/Image';
import { Button } from '@/ui/Button';
import { routes } from '@/lib/routing/RouteConfig';
interface Friend {
id: string;
name: string;
avatarUrl: string;
country: string;
}
interface FriendsSidebarProps {
friends: Friend[];
hasFriends: boolean;
}
export function FriendsSidebar({ friends, hasFriends }: FriendsSidebarProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} icon={<Users style={{ width: '1.25rem', height: '1.25rem', color: '#a855f7' }} />}>
Friends
</Heading>
<Text size="xs" color="text-gray-500">{friends.length} friends</Text>
</Stack>
{hasFriends ? (
<Stack gap={2}>
{friends.slice(0, 6).map((friend) => (
<Box key={friend.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem', borderRadius: '0.5rem' }}>
<Box style={{ width: '2.25rem', height: '2.25rem', borderRadius: '9999px', overflow: 'hidden', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)' }}>
<Image
src={friend.avatarUrl}
alt={friend.name}
width={36}
height={36}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" color="text-white" weight="medium" truncate block>{friend.name}</Text>
<Text size="xs" color="text-gray-500" block>{friend.country}</Text>
</Box>
</Box>
))}
{friends.length > 6 && (
<Box py={2}>
<Link
href={routes.protected.profile}
variant="primary"
>
<Text size="sm" block style={{ textAlign: 'center' }}>+{friends.length - 6} more</Text>
</Link>
</Box>
)}
</Stack>
) : (
<Stack align="center" gap={2} py={6}>
<UserPlus style={{ width: '2rem', height: '2rem', color: '#525252' }} />
<Text size="sm" color="text-gray-400">No friends yet</Text>
<Box>
<Link href={routes.public.drivers} variant="ghost">
<Button variant="secondary" size="sm">
Find Drivers
</Button>
</Link>
</Box>
</Stack>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import React from 'react';
import { Calendar, Clock, ChevronRight } from 'lucide-react';
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 { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
interface NextRaceCardProps {
nextRace: {
id: string;
track: string;
car: string;
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
};
}
export function NextRaceCard({ nextRace }: NextRaceCardProps) {
return (
<Surface variant="muted" rounded="xl" border padding={6} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<Box style={{ position: 'absolute', top: 0, right: 0, width: '10rem', height: '10rem', background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)', borderBottomLeftRadius: '9999px' }} />
<Box style={{ position: 'relative' }}>
<Stack direction="row" align="center" gap={2} mb={4}>
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.2)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Text size="xs" weight="semibold" color="text-primary-blue" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Next Race</Text>
</Surface>
{nextRace.isMyLeague && (
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(16, 185, 129, 0.2)', color: '#10b981', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium">Your League</Text>
</Surface>
)}
</Stack>
<Stack direction="row" align="end" justify="between" wrap gap={4}>
<Box>
<Heading level={2} style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>{nextRace.track}</Heading>
<Text color="text-gray-400" block mb={3}>{nextRace.car}</Text>
<Stack direction="row" align="center" gap={4}>
<Stack direction="row" align="center" gap={1.5}>
<Calendar style={{ width: '1rem', height: '1rem', color: '#6b7280' }} />
<Text size="sm" color="text-gray-400">{nextRace.formattedDate}</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Clock style={{ width: '1rem', height: '1rem', color: '#6b7280' }} />
<Text size="sm" color="text-gray-400">{nextRace.formattedTime}</Text>
</Stack>
</Stack>
</Box>
<Stack align="end" gap={3}>
<Box style={{ textAlign: 'right' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>Starts in</Text>
<Text size="3xl" weight="bold" color="text-primary-blue" font="mono">{nextRace.timeUntil}</Text>
</Box>
<Box>
<Link href={`/races/${nextRace.id}`} variant="ghost">
<Button variant="primary" icon={<ChevronRight style={{ width: '1rem', height: '1rem' }} />}>
View Details
</Button>
</Link>
</Box>
</Stack>
</Stack>
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import React from 'react';
import { Trophy, Medal, Target, Users, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface StatBoxProps {
icon: LucideIcon;
label: string;
value: string | number;
color: string;
}
export function StatBox({ icon, label, value, color }: StatBoxProps) {
return (
<Surface variant="muted" rounded="xl" border padding={4} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626', backdropFilter: 'blur(4px)' }}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}20`, color }}>
<Icon icon={icon} size={5} />
</Surface>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{value}</Text>
<Text size="xs" color="text-gray-500" block>{label}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { Calendar } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface UpcomingRace {
id: string;
track: string;
car: string;
formattedDate: string;
formattedTime: string;
isMyLeague: boolean;
}
interface UpcomingRacesProps {
races: UpcomingRace[];
hasRaces: boolean;
}
export function UpcomingRaces({ races, hasRaces }: UpcomingRacesProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={3} icon={<Calendar style={{ width: '1.25rem', height: '1.25rem', color: '#3b82f6' }} />}>
Upcoming Races
</Heading>
<Box>
<Link href={routes.public.races} variant="primary">
<Text size="xs">View all</Text>
</Link>
</Box>
</Stack>
{hasRaces ? (
<Stack gap={3}>
{races.slice(0, 5).map((race) => (
<Box key={race.id} style={{ padding: '0.75rem', backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Text color="text-white" weight="medium" block>{race.track}</Text>
<Text size="sm" color="text-gray-400" block>{race.car}</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{race.formattedDate}</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{race.formattedTime}</Text>
</Stack>
{race.isMyLeague && (
<Box style={{ display: 'inline-block', marginTop: '0.25rem', padding: '0.125rem 0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(16, 185, 129, 0.2)', color: '#10b981', fontSize: '0.75rem', fontWeight: 500 }}>
Your League
</Box>
)}
</Box>
))}
</Stack>
) : (
<Box py={4}>
<Text size="sm" color="text-gray-500" block style={{ textAlign: 'center' }}>No upcoming races</Text>
</Box>
)}
</Stack>
</Card>
);
}

View File

@@ -1,8 +1,6 @@
'use client';
import { BarChart3 } from 'lucide-react';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
@@ -13,7 +11,9 @@ const CATEGORIES = [
];
interface CategoryDistributionProps {
drivers: DriverLeaderboardItemViewModel[];
drivers: {
category?: string;
}[];
}
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import PlaceholderImage from '@/ui/PlaceholderImage';
export interface DriverIdentityProps {
driver: {

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
export interface DriverRanking {
type: 'overall' | 'league';

View File

@@ -0,0 +1,87 @@
'use client';
import React from 'react';
import { Users, Trophy, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { DecorativeBlur } from '@/ui/DecorativeBlur';
interface StatItemProps {
label: string;
value: string | number;
color: string;
animate?: boolean;
}
function StatItem({ label, value, color, animate }: StatItemProps) {
return (
<Stack direction="row" align="center" gap={2}>
<Box style={{ width: '0.5rem', height: '0.5rem', borderRadius: '9999px', backgroundColor: color }} className={animate ? 'animate-pulse' : ''} />
<Text size="sm" color="text-gray-400">
<Text weight="semibold" color="text-white">{value}</Text> {label}
</Text>
</Stack>
);
}
interface DriversHeroProps {
driverCount: number;
activeCount: number;
totalWins: number;
totalRaces: number;
onViewLeaderboard: () => void;
}
export function DriversHero({
driverCount,
activeCount,
totalWins,
totalRaces,
onViewLeaderboard,
}: DriversHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(38, 38, 38, 0.8), #0f1115)', borderColor: 'rgba(59, 130, 246, 0.3)' }}>
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="yellow" size="md" position="bottom-left" opacity={5} />
<Stack direction="row" align="center" justify="between" wrap gap={8} style={{ position: 'relative', zIndex: 10 }}>
<Box style={{ maxWidth: '42rem' }}>
<Stack direction="row" align="center" gap={3} mb={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.05))', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
<Icon icon={Users} size={6} color="#3b82f6" />
</Surface>
<Heading level={1}>Drivers</Heading>
</Stack>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
</Text>
{/* Quick Stats */}
<Stack direction="row" gap={6} wrap>
<StatItem label="drivers" value={driverCount} color="#3b82f6" />
<StatItem label="active" value={activeCount} color="#10b981" animate />
<StatItem label="total wins" value={totalWins.toLocaleString()} color="#f59e0b" />
<StatItem label="races" value={totalRaces.toLocaleString()} color="#00f2ff" />
</Stack>
</Box>
{/* CTA */}
<Stack align="center" gap={4}>
<Button
variant="primary"
onClick={onViewLeaderboard}
icon={<Icon icon={Trophy} size={5} />}
>
View Leaderboard
</Button>
<Text size="xs" color="text-gray-500">See full driver rankings</Text>
</Stack>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import React from 'react';
import { Search } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Input } from '@/ui/Input';
interface DriversSearchProps {
query: string;
onChange: (query: string) => void;
}
export function DriversSearch({ query, onChange }: DriversSearchProps) {
return (
<Box mb={8}>
<Box style={{ position: 'relative', maxWidth: '28rem' }}>
<Box style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Search style={{ width: '1.25rem', height: '1.25rem', color: '#6b7280' }} />
</Box>
<Input
type="text"
placeholder="Search drivers by name or nationality..."
value={query}
onChange={(e) => onChange(e.target.value)}
style={{ paddingLeft: '2.75rem' }}
/>
</Box>
</Box>
);
}

View File

@@ -3,8 +3,6 @@
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' },
{ id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' },
@@ -22,7 +20,17 @@ const CATEGORIES = [
];
interface FeaturedDriverCardProps {
driver: DriverLeaderboardItemViewModel;
driver: {
id: string;
name: string;
nationality: string;
avatarUrl?: string;
rating: number;
wins: number;
podiums: number;
skillLevel?: string;
category?: string;
};
position: number;
onClick: () => void;
}

View File

@@ -1,4 +1,4 @@
import Heading from '@/components/ui/Heading';
import Heading from '@/ui/Heading';
import { Trophy, Users } from 'lucide-react';
import Button from '../ui/Button';

View File

@@ -3,10 +3,8 @@
import { useRouter } from 'next/navigation';
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
import Image from 'next/image';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
import { mediaConfig } from '@/lib/config/mediaConfig';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
@@ -24,7 +22,16 @@ const CATEGORIES = [
];
interface LeaderboardPreviewProps {
drivers: DriverLeaderboardItemViewModel[];
drivers: {
id: string;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { Crown } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { Surface } from '@/ui/Surface';
interface PodiumDriver {
id: string;
name: string;
avatarUrl: string;
rating: number;
wins: number;
podiums: number;
}
interface RankingsPodiumProps {
podium: PodiumDriver[];
onDriverClick?: (id: string) => void;
}
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
return (
<Box mb={10}>
<Box style={{ display: 'flex', alignItems: 'end', justifyContent: 'center', gap: '1rem' }}>
{[1, 0, 2].map((index) => {
const driver = podium[index];
if (!driver) return null;
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
const config = {
1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' },
2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' },
3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' },
}[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' };
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }}
>
<Box style={{ position: 'relative', marginBottom: '1rem' }}>
<Box style={{ position: 'relative', width: position === 1 ? '6rem' : '5rem', height: position === 1 ? '6rem' : '5rem', borderRadius: '9999px', overflow: 'hidden', border: `4px solid ${config.crown}`, boxShadow: position === 1 ? '0 0 30px rgba(250, 204, 21, 0.3)' : 'none' }}>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={112}
height={112}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Box style={{ position: 'absolute', bottom: '-0.5rem', left: '50%', transform: 'translateX(-50%)', width: '2rem', height: '2rem', borderRadius: '9999px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.875rem', fontWeight: 'bold', background: `linear-gradient(to bottom right, ${config.color}, transparent)`, border: `2px solid ${config.crown}`, color: config.crown }}>
{position}
</Box>
</Box>
<Text weight="semibold" color="text-white" style={{ fontSize: position === 1 ? '1.125rem' : '1rem', marginBottom: '0.25rem' }}>
{driver.name}
</Text>
<Text font="mono" weight="bold" style={{ fontSize: position === 1 ? '1.25rem' : '1.125rem', color: position === 1 ? '#facc15' : '#3b82f6' }}>
{driver.rating.toString()}
</Text>
<Stack direction="row" align="center" gap={2} style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '0.25rem' }}>
<Stack direction="row" align="center" gap={1}>
<Text color="text-performance-green">🏆</Text>
{driver.wins}
</Stack>
<Text></Text>
<Stack direction="row" align="center" gap={1}>
<Text color="text-warning-amber">🏅</Text>
{driver.podiums}
</Stack>
</Stack>
<Box style={{ marginTop: '1rem', width: position === 1 ? '7rem' : '6rem', height: config.height, borderRadius: '0.5rem 0.5rem 0 0', background: `linear-gradient(to top, ${config.color}, transparent)`, borderTop: `1px solid ${config.borderColor}`, borderLeft: `1px solid ${config.borderColor}`, borderRight: `1px solid ${config.borderColor}`, display: 'flex', alignItems: 'end', justifyContent: 'center', paddingBottom: '1rem' }}>
<Text weight="bold" style={{ fontSize: position === 1 ? '3rem' : '2.25rem', color: config.crown }}>
{position}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import React from 'react';
import { Medal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { Icon } from '@/ui/Icon';
interface Driver {
id: string;
name: string;
avatarUrl: string;
rank: number;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
medalBg?: string;
medalColor?: string;
}
interface RankingsTableProps {
drivers: Driver[];
onDriverClick?: (id: string) => void;
}
export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) {
return (
<Box style={{ borderRadius: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', border: '1px solid #262626', overflow: 'hidden' }}>
{/* Table Header */}
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(12, minmax(0, 1fr))', gap: '1rem', padding: '0.75rem 1rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderBottom: '1px solid #262626', fontSize: '0.75rem', fontWeight: 500, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<Box style={{ gridColumn: 'span 1', textAlign: 'center' }}>Rank</Box>
<Box style={{ gridColumn: 'span 5' }}>Driver</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Races</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Rating</Box>
<Box style={{ gridColumn: 'span 2', textAlign: 'center' }}>Wins</Box>
</Box>
{/* Table Body */}
<Stack gap={0}>
{drivers.map((driver, index) => {
const position = driver.rank;
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, minmax(0, 1fr))',
gap: '1rem',
padding: '1rem',
width: '100%',
textAlign: 'left',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
borderBottom: index < drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none'
}}
>
{/* Position */}
<Box style={{ gridColumn: 'span 1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box style={{ display: 'flex', height: '2.25rem', width: '2.25rem', alignItems: 'center', justifyContent: 'center', borderRadius: '9999px', fontSize: '0.875rem', fontWeight: 'bold', border: '1px solid #262626', backgroundColor: driver.medalBg, color: driver.medalColor }}>
{position <= 3 ? <Icon icon={Medal} size={4} /> : position}
</Box>
</Box>
{/* Driver Info */}
<Box style={{ gridColumn: 'span 5', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Box style={{ position: 'relative', width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: '2px solid #262626' }}>
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text weight="semibold" color="text-white" block truncate>
{driver.name}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Text size="xs" color="text-gray-500">{driver.skillLevel}</Text>
</Stack>
</Box>
</Box>
{/* Races */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text color="text-gray-400">{driver.racesCompleted}</Text>
</Box>
{/* Rating */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text font="mono" weight="semibold" color="text-white">
{driver.rating.toString()}
</Text>
</Box>
{/* Wins */}
<Box style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text font="mono" weight="semibold" color="text-performance-green">
{driver.wins}
</Text>
</Box>
</Box>
);
})}
</Stack>
{/* Empty State */}
{drivers.length === 0 && (
<Box style={{ padding: '4rem 0', textAlign: 'center' }}>
<Text size="4xl" block mb={4}>🔍</Text>
<Text color="text-gray-400" block mb={2}>No drivers found</Text>
<Text size="sm" color="text-gray-500">There are no drivers in the system yet</Text>
</Box>
)}
</Box>
);
}

View File

@@ -3,8 +3,6 @@
import { Activity } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
@@ -22,7 +20,14 @@ const CATEGORIES = [
];
interface RecentActivityProps {
drivers: DriverLeaderboardItemViewModel[];
drivers: {
id: string;
name: string;
avatarUrl?: string;
isActive: boolean;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}

View File

@@ -1,8 +1,6 @@
'use client';
import { BarChart3 } from 'lucide-react';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
@@ -11,7 +9,9 @@ const SKILL_LEVELS = [
];
interface SkillDistributionProps {
drivers: DriverLeaderboardItemViewModel[];
drivers: {
skillLevel?: string;
}[];
}
export function SkillDistribution({ drivers }: SkillDistributionProps) {

View File

@@ -1,5 +1,5 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
export default function FeedEmptyState() {
return (

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import Image from 'next/image';
interface FeedItemData {

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
import FeedList from '@/components/feed/FeedList';
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';

View File

@@ -1,7 +1,7 @@
'use client';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { useParallax } from "@/lib/hooks/useScrollProgress";
import { useRef } from 'react';

View File

@@ -1,7 +1,7 @@
'use client';
import { useRef } from 'react';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
export default function DiscordCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';

View File

@@ -1,10 +1,10 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import Section from '@/components/ui/Section';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
import MockupStack from '@/components/ui/MockupStack';
import Section from '@/ui/Section';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import MockupStack from '@/ui/MockupStack';
import LeagueHomeMockup from '@/components/mockups/LeagueHomeMockup';
import StandingsTableMockup from '@/components/mockups/StandingsTableMockup';
import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';

View File

@@ -1,9 +1,9 @@
'use client';
import { useRef } from 'react';
import Button from '@/components/ui/Button';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
import Button from '@/ui/Button';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { useParallax } from '@/lib/hooks/useScrollProgress';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { Check } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
export function FeatureItem({ text }: { text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', borderColor: 'rgba(51, 65, 85, 0.4)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<Icon icon={Check} size={5} color="#3b82f6" />
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}
export function ResultItem({ text, color }: { text: string, color: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.6)', borderColor: 'rgba(51, 65, 85, 0.4)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: `${color}1A`, border: `1px solid ${color}4D` }}>
<Icon icon={Check} size={5} color={color} />
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}
export function StepItem({ step, text }: { step: number, text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} style={{ backgroundColor: 'rgba(15, 23, 42, 0.7)', borderColor: 'rgba(51, 65, 85, 0.5)' }}>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.4)', width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text weight="bold" size="sm" color="text-primary-blue">{step}</Text>
</Surface>
<Text color="text-slate-200" style={{ lineHeight: 1.625, fontWeight: 300 }}>
{text}
</Text>
</Stack>
</Surface>
);
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
import Image from 'next/image';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { Award, Trophy, Users } from 'lucide-react';
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 { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { DecorativeBlur } from '@/ui/DecorativeBlur';
interface LeaderboardsHeroProps {
onNavigateToDrivers: () => void;
onNavigateToTeams: () => void;
}
export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: LeaderboardsHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={8} style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(to bottom right, rgba(202, 138, 4, 0.2), rgba(38, 38, 38, 0.8), #0f1115)', borderColor: 'rgba(234, 179, 8, 0.2)' }}>
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
<Box style={{ position: 'relative', zIndex: 10 }}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Surface variant="muted" rounded="xl" padding={3} style={{ background: 'linear-gradient(to bottom right, rgba(250, 204, 21, 0.2), rgba(217, 119, 6, 0.1))', border: '1px solid rgba(250, 204, 21, 0.3)' }}>
<Icon icon={Award} size={7} color="#facc15" />
</Surface>
<Box>
<Heading level={1}>Leaderboards</Heading>
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
</Box>
</Stack>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625, maxWidth: '42rem' }}>
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</Text>
<Stack direction="row" gap={3} wrap>
<Button
variant="secondary"
onClick={onNavigateToDrivers}
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
>
Driver Rankings
</Button>
<Button
variant="secondary"
onClick={onNavigateToTeams}
icon={<Icon icon={Users} size={4} color="#a855f7" />}
>
Team Rankings
</Button>
</Stack>
</Box>
</Surface>
);
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Image from 'next/image';
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
import { getMediaUrl } from '@/lib/utilities/media';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
interface BonusPointsCardProps {
bonusSummary: string[];

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
import type { LeagueScoringChampionshipViewModel } from '@/lib/view-models/LeagueScoringChampionshipViewModel';
type PointsPreviewRow = {

View File

@@ -1,10 +1,10 @@
'use client';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import Input from '@/components/ui/Input';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import Heading from '@/ui/Heading';
import Input from '@/ui/Input';
import { useAuth } from '@/lib/auth/AuthContext';
import {
AlertCircle,

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
interface DropRulesExplanationProps {
dropPolicyDescription: string;

View File

@@ -1,7 +1,7 @@
import { Trophy, Sparkles, Search } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Heading from '@/ui/Heading';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
interface EmptyStateProps {
title: string;

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
interface EndRaceModalProps {
raceId: string;

View File

@@ -1,6 +1,6 @@
import { Trophy, Plus } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
import Heading from '@/ui/Heading';
import Button from '@/ui/Button';
interface StatItem {
value: number;

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/components/ui/Input';
import Input from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
interface LeagueBasicsSectionProps {

View File

@@ -14,7 +14,7 @@ import {
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {

View File

@@ -1,5 +1,12 @@
'use client';
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 { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
interface LeagueChampionshipStatsProps {
standings: Array<{
@@ -21,45 +28,45 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh
const totalRaces = Math.max(...standings.map(s => s.racesFinished), 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Grid cols={3} gap={4}>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-yellow-500/10 flex items-center justify-center">
<span className="text-2xl">🏆</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Championship Leader</p>
<p className="font-bold text-white">{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</p>
<p className="text-sm text-yellow-400 font-medium">{leader?.totalPoints || 0} points</p>
</div>
</div>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)' }}>
<Text size="2xl">🏆</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Championship Leader</Text>
<Text weight="bold" color="text-white" block>{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}</Text>
<Text size="sm" color="text-warning-amber" weight="medium">{leader?.totalPoints || 0} points</Text>
</Box>
</Stack>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-blue/10 flex items-center justify-center">
<span className="text-2xl">🏁</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Races Completed</p>
<p className="text-2xl font-bold text-white">{totalRaces}</p>
<p className="text-sm text-gray-400">Season in progress</p>
</div>
</div>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)' }}>
<Text size="2xl">🏁</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Races Completed</Text>
<Text size="2xl" weight="bold" color="text-white" block>{totalRaces}</Text>
<Text size="sm" color="text-gray-400">Season in progress</Text>
</Box>
</Stack>
</Card>
<Card>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-performance-green/10 flex items-center justify-center">
<span className="text-2xl">👥</span>
</div>
<div>
<p className="text-xs text-gray-400 mb-1">Active Drivers</p>
<p className="text-2xl font-bold text-white">{standings.length}</p>
<p className="text-sm text-gray-400">Competing for points</p>
</div>
</div>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="full" padding={3} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)' }}>
<Text size="2xl">👥</Text>
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>Active Drivers</Text>
<Text size="2xl" weight="bold" color="text-white" block>{standings.length}</Text>
<Text size="sm" color="text-gray-400">Competing for points</Text>
</Box>
</Stack>
</Card>
</div>
</Grid>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import {
Move,
RotateCw,

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';

View File

@@ -4,7 +4,7 @@ import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import type * as React from 'react';
import { createPortal } from 'react-dom';
import Input from '@/components/ui/Input';
import Input from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// ============================================================================

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { ArrowRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface LeagueSummaryCardProps {
league: {
id: string;
name: string;
description?: string;
settings: {
maxDrivers: number;
qualifyingFormat: string;
};
};
}
export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) {
return (
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Box style={{ width: '3.5rem', height: '3.5rem', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626', flexShrink: 0 }}>
<Image src={`/media/league-logo/${league.id}`} alt={league.name} width={56} height={56} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={0.5}>League</Text>
<Heading level={3} style={{ fontSize: '1rem' }}>{league.name}</Heading>
</Box>
</Stack>
{league.description && (
<Text size="sm" color="text-gray-400" block mb={4} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{league.description}</Text>
)}
<Box mb={4}>
<Grid cols={2} gap={3}>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Max Drivers</Text>
<Text weight="medium" color="text-white">{league.settings.maxDrivers}</Text>
</Surface>
<Surface variant="dark" rounded="lg" padding={3}>
<Text size="xs" color="text-gray-500" block mb={1}>Format</Text>
<Text weight="medium" color="text-white" style={{ textTransform: 'capitalize' }}>{league.settings.qualifyingFormat}</Text>
</Surface>
</Grid>
</Box>
<Box>
<Link href={`/leagues/${league.id}`} variant="ghost">
<Button variant="secondary" fullWidth icon={<Icon icon={ArrowRight} size={4} />}>
View League
</Button>
</Link>
</Box>
</Box>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
interface Tab {
label: string;
href: string;
exact?: boolean;
}
interface LeagueTabsProps {
tabs: Tab[];
}
export function LeagueTabs({ tabs }: LeagueTabsProps) {
return (
<Box style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" gap={6} style={{ overflowX: 'auto' }}>
{tabs.map((tab) => (
<Link
key={tab.href}
href={tab.href}
variant="ghost"
>
<Box pb={3} px={1}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap' }}>{tab.label}</span>
</Box>
</Link>
))}
</Stack>
</Box>
);
}

View File

@@ -20,8 +20,8 @@ import {
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import Input from '@/components/ui/Input';
import RangeField from '@/components/ui/RangeField';
import Input from '@/ui/Input';
import RangeField from '@/ui/RangeField';
// Common time zones for racing leagues
const TIME_ZONES = [

View File

@@ -19,10 +19,10 @@ import {
Timer,
} from 'lucide-react';
import LeagueCard from '@/components/leagues/LeagueCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
import Input from '@/ui/Input';
import Heading from '@/ui/Heading';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';

View File

@@ -1,6 +1,6 @@
import { Search } from 'lucide-react';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Button from '@/ui/Button';
import Card from '@/ui/Card';
interface NoResultsStateProps {
icon?: React.ElementType;

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
interface PointsBreakdownTableProps {
positionPoints: Array<{ position: number; points: number }>;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
interface PointsTableProps {
title?: string;

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation";
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
export type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties';
interface RulebookTabsProps {
activeSection: RulebookSection;
onSectionChange: (section: RulebookSection) => void;
}
export function RulebookTabs({ activeSection, onSectionChange }: RulebookTabsProps) {
const sections: { id: RulebookSection; label: string }[] = [
{ id: 'scoring', label: 'Scoring' },
{ id: 'conduct', label: 'Conduct' },
{ id: 'protests', label: 'Protests' },
{ id: 'penalties', label: 'Penalties' },
];
return (
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: '#0f1115', border: '1px solid #262626' }}>
<Box style={{ display: 'flex', gap: '0.25rem' }}>
{sections.map((section) => (
<Button
key={section.id}
variant={activeSection === section.id ? 'secondary' : 'ghost'}
onClick={() => onSectionChange(section.id)}
fullWidth
size="sm"
>
{section.label}
</Button>
))}
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface Race {
id: string;
name: string;
track?: string;
car?: string;
scheduledAt: string;
status: string;
sessionType?: string;
isPast?: boolean;
}
interface ScheduleRaceCardProps {
race: Race;
}
export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box style={{ width: '0.75rem', height: '0.75rem', borderRadius: '9999px', backgroundColor: race.isPast ? '#10b981' : '#3b82f6' }} />
<Heading level={3} style={{ fontSize: '1.125rem' }}>{race.name}</Heading>
<Badge variant={race.status === 'completed' ? 'success' : 'primary'}>
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
</Badge>
</Stack>
<Grid cols={4} gap={4}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleDateString()}</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300">{new Date(race.scheduledAt).toLocaleTimeString()}</Text>
</Stack>
{race.track && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={MapPin} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300" truncate>{race.track}</Text>
</Stack>
)}
{race.car && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Car} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-300" truncate>{race.car}</Text>
</Stack>
)}
</Grid>
{race.sessionType && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-400">{race.sessionType} Session</Text>
</Stack>
)}
</Stack>
</Card>
);
}

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
interface ScoringOverviewCardProps {
gameName: string;

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Search, Filter } from 'lucide-react';
import Input from '@/components/ui/Input';
import Button from '@/components/ui/Button';
import Input from '@/ui/Input';
import Button from '@/ui/Button';
interface Category {
id: string;

View File

@@ -0,0 +1,75 @@
'use client';
import React from 'react';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface SponsorshipRequest {
id: string;
sponsorName: string;
status: 'pending' | 'approved' | 'rejected';
requestedAt: string;
slotName: string;
}
interface SponsorshipRequestCardProps {
request: SponsorshipRequest;
}
export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps) {
const statusVariant = {
pending: 'warning' as const,
approved: 'success' as const,
rejected: 'danger' as const,
}[request.status];
const statusIcon = {
pending: AlertCircle,
approved: CheckCircle,
rejected: XCircle,
}[request.status];
const statusColor = {
pending: '#f59e0b',
approved: '#10b981',
rejected: '#ef4444',
}[request.status];
return (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{
backgroundColor: `${statusColor}0D`,
borderColor: statusColor
}}
>
<Stack direction="row" align="start" justify="between">
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={3} mb={2}>
<Icon icon={statusIcon} size={5} color={statusColor} />
<Text weight="semibold" color="text-white">{request.sponsorName}</Text>
<Badge variant={statusVariant}>
{request.status}
</Badge>
</Stack>
<Text size="sm" color="text-gray-300" block mb={2}>
Requested: {request.slotName}
</Text>
<Text size="xs" color="text-gray-400" block>
{new Date(request.requestedAt).toLocaleDateString()}
</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { DollarSign } from 'lucide-react';
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 { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface SponsorshipSlot {
id: string;
name: string;
description: string;
price: number;
currency: string;
isAvailable: boolean;
sponsoredBy?: {
name: string;
};
}
interface SponsorshipSlotCardProps {
slot: SponsorshipSlot;
}
export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
return (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
borderColor: slot.isAvailable ? '#10b981' : '#262626'
}}
>
<Stack gap={3}>
<Stack direction="row" align="start" justify="between">
<Heading level={4}>{slot.name}</Heading>
<Badge variant={slot.isAvailable ? 'success' : 'default'}>
{slot.isAvailable ? 'Available' : 'Taken'}
</Badge>
</Stack>
<Text size="sm" color="text-gray-300" block>{slot.description}</Text>
<Stack direction="row" align="center" gap={2}>
<Icon icon={DollarSign} size={4} color="#9ca3af" />
<Text weight="semibold" color="text-white">
{slot.price} {slot.currency}
</Text>
</Stack>
{!slot.isAvailable && slot.sponsoredBy && (
<Box pt={3} style={{ borderTop: '1px solid #262626' }}>
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>
</Box>
)}
</Stack>
</Surface>
);
}

View File

@@ -3,8 +3,8 @@
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import CountryFlag from '@/components/ui/CountryFlag';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import CountryFlag from '@/ui/CountryFlag';
import PlaceholderImage from '@/ui/PlaceholderImage';
// League role display data
const leagueRoleDisplay = {

View File

@@ -1,91 +1,77 @@
'use client';
import React from 'react';
import {
ArrowDownLeft,
ArrowUpRight,
CheckCircle,
Clock,
CreditCard,
DollarSign,
TrendingUp,
XCircle
} from 'lucide-react';
import { ArrowUpRight, ArrowDownRight, DollarSign, TrendingUp, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface Transaction {
id: string;
amount: number;
type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize';
status: 'completed' | 'pending' | 'failed';
type: string;
description: string;
reference?: string;
formattedDate: string;
formattedAmount: string;
fee: number;
typeColor: string;
status: string;
statusColor: string;
amountColor: string;
}
interface TransactionRowProps {
transaction: Transaction;
}
export default function TransactionRow({ transaction }: TransactionRowProps) {
const isIncoming = transaction.amount > 0;
const typeIcons = {
sponsorship: DollarSign,
membership: CreditCard,
withdrawal: ArrowUpRight,
prize: TrendingUp,
export function TransactionRow({ transaction }: TransactionRowProps) {
const getTransactionIcon = (type: string): LucideIcon => {
switch (type) {
case 'deposit': return ArrowUpRight;
case 'withdrawal': return ArrowDownRight;
case 'sponsorship': return DollarSign;
case 'prize': return TrendingUp;
default: return DollarSign;
}
};
const TypeIcon = typeIcons[transaction.type];
const statusConfig = {
completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle },
pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock },
failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle },
};
const status = statusConfig[transaction.status];
const StatusIcon = status.icon;
return (
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline last:border-b-0 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isIncoming ? 'bg-performance-green/10' : 'bg-iron-gray/50'}`}>
{isIncoming ? (
<ArrowDownLeft className="w-5 h-5 text-performance-green" />
) : (
<ArrowUpRight className="w-5 h-5 text-gray-400" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{transaction.description}</span>
<span className={`px-2 py-0.5 rounded text-xs ${status.bg} ${status.color}`}>
{transaction.status}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
<TypeIcon className="w-3 h-3" />
<span className="capitalize">{transaction.type}</span>
{transaction.reference && (
<>
<span></span>
<span>{transaction.reference}</span>
</>
)}
<span></span>
<span>{transaction.formattedDate}</span>
</div>
</div>
</div>
<div className="text-right">
<div className={`font-semibold ${isIncoming ? 'text-performance-green' : 'text-white'}`}>
{transaction.formattedAmount}
</div>
{transaction.fee > 0 && (
<div className="text-xs text-gray-500">
Fee: ${transaction.fee.toFixed(2)}
</div>
)}
</div>
</div>
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}
>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Box style={{ flexShrink: 0 }}>
<Icon icon={getTransactionIcon(transaction.type)} size={4} color={transaction.typeColor} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" weight="medium" color="text-white" block truncate>
{transaction.description}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{transaction.formattedDate}</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" style={{ color: transaction.typeColor, textTransform: 'capitalize' }}>
{transaction.type}
</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" style={{ color: transaction.statusColor, textTransform: 'capitalize' }}>
{transaction.status}
</Text>
</Stack>
</Box>
</Stack>
<Box style={{ textAlign: 'right' }}>
<Text size="lg" weight="semibold" style={{ color: transaction.amountColor }}>
{transaction.formattedAmount}
</Text>
</Box>
</Stack>
</Surface>
);
}
}

View File

@@ -21,7 +21,7 @@ import {
Zap,
X,
} from 'lucide-react';
import Button from '@/components/ui/Button';
import Button from '@/ui/Button';
interface ModalNotificationProps {
notification: Notification;

View File

@@ -1,7 +1,7 @@
import { useRef, ChangeEvent } from 'react';
import { Camera, Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import Button from '@/ui/Button';
import Heading from '@/ui/Heading';
export type RacingSuitColor =
| 'red'

View File

@@ -0,0 +1,5 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,58 @@
import Button from '@/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,17 +1,17 @@
'use client';
import { useState, FormEvent } from 'react';
import Card from '@/components/ui/Card';
import { OnboardingCardAccent } from '@/components/onboarding/OnboardingCardAccent';
import { OnboardingContainer } from '@/components/onboarding/OnboardingContainer';
import { OnboardingError } from '@/components/onboarding/OnboardingError';
import { OnboardingForm } from '@/components/onboarding/OnboardingForm';
import { OnboardingHeader } from '@/components/onboarding/OnboardingHeader';
import { OnboardingHelpText } from '@/components/onboarding/OnboardingHelpText';
import { OnboardingNavigation } from '@/components/onboarding/OnboardingNavigation';
import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep';
import Card from '@/ui/Card';
import { StepIndicator } from '@/ui/StepIndicator';
import { PersonalInfoStep, PersonalInfo } from '@/ui/onboarding/PersonalInfoStep';
import { AvatarStep, AvatarInfo } from './AvatarStep';
import { OnboardingHeader } from '@/ui/onboarding/OnboardingHeader';
import { OnboardingHelpText } from '@/ui/onboarding/OnboardingHelpText';
import { OnboardingError } from '@/ui/onboarding/OnboardingError';
import { OnboardingNavigation } from '@/ui/onboarding/OnboardingNavigation';
import { OnboardingContainer } from '@/ui/onboarding/OnboardingContainer';
import { OnboardingCardAccent } from '@/ui/onboarding/OnboardingCardAccent';
import { OnboardingForm } from '@/ui/onboarding/OnboardingForm';
import { FormEvent, useState } from 'react';
import { AvatarInfo, AvatarStep } from './AvatarStep';
type OnboardingStep = 1 | 2;

View File

@@ -0,0 +1,151 @@
import { User, Clock, ChevronRight } from 'lucide-react';
import Input from '@/ui/Input';
import Heading from '@/ui/Heading';
import CountrySelect from '@/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>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { Award, Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react';
import { Card } from '@/ui/Card';
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 { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
rarity: string;
earnedAt: Date;
}
interface AchievementGridProps {
achievements: Achievement[];
}
function getAchievementIcon(icon: string) {
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;
default: return Award;
}
}
export function AchievementGrid({ achievements }: AchievementGridProps) {
return (
<Card>
<Box mb={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements
</Heading>
<Text size="sm" color="text-gray-500" weight="normal">{achievements.length} earned</Text>
</Stack>
</Box>
<Grid cols={1} gap={4}>
{achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon);
return (
<Surface
key={achievement.id}
variant="muted"
rounded="xl"
border
padding={4}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={3}>
<Icon icon={AchievementIcon} size={5} color="#facc15" />
</Surface>
<Box>
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text>
<Text size="xs" color="text-gray-500" block mt={2}>
{achievement.earnedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</Text>
</Box>
</Stack>
</Surface>
);
})}
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Grid } from '@/ui/Grid';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface CareerStatsProps {
stats: {
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
};
}
export function CareerStats({ stats }: CareerStatsProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Career Statistics
</Heading>
</Box>
<Grid cols={2} gap={4}>
<StatItem label="Races" value={stats.totalRaces} />
<StatItem label="Wins" value={stats.wins} color="text-performance-green" />
<StatItem label="Podiums" value={stats.podiums} color="text-warning-amber" />
<StatItem label="Consistency" value={`${stats.consistency}%`} color="text-primary-blue" />
</Grid>
</Card>
);
}
function StatItem({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
return (
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
<Text size="3xl" weight="bold" color={color as any} block mb={1}>{value}</Text>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</Text>
</Box>
);
}

View File

@@ -5,7 +5,7 @@ import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import DriverRating from '@/components/profile/DriverRatingPill';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import PlaceholderImage from '@/ui/PlaceholderImage';
export interface DriverSummaryPillProps {
driver: DriverViewModel;

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { Users } from 'lucide-react';
import { Card } from '@/ui/Card';
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 { Image } from '@/ui/Image';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { mediaConfig } from '@/lib/config/mediaConfig';
interface Friend {
id: string;
name: string;
avatarUrl?: string;
country: string;
}
interface FriendsPreviewProps {
friends: Friend[];
}
export function FriendsPreview({ friends }: FriendsPreviewProps) {
return (
<Card>
<Box mb={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Users} size={5} color="#a855f7" />}>
Friends
</Heading>
<Text size="sm" color="text-gray-500" weight="normal">({friends.length})</Text>
</Stack>
</Box>
<Stack direction="row" gap={3} wrap>
{friends.slice(0, 8).map((friend) => (
<Box key={friend.id}>
<Link
href={`/drivers/${friend.id}`}
variant="ghost"
>
<Surface variant="muted" rounded="xl" border padding={2} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', borderColor: '#262626' }}>
<Box style={{ width: '2rem', height: '2rem', borderRadius: '9999px', overflow: 'hidden', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)' }}>
<Image
src={friend.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={friend.name}
width={32}
height={32}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Text size="sm" color="text-white">{friend.name}</Text>
<Text size="lg">{CountryFlagDisplay.fromCountryCode(friend.country).toString()}</Text>
</Surface>
</Link>
</Box>
))}
{friends.length > 8 && (
<Box p={2}>
<Text size="sm" color="text-gray-500">+{friends.length - 8} more</Text>
</Box>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
interface League {
leagueId: string;
name: string;
description: string;
membershipRole?: string;
}
interface LeagueListItemProps {
league: League;
isAdmin?: boolean;
}
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
return (
<Surface
variant="dark"
rounded="lg"
border
padding={4}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderColor: '#262626' }}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="medium" color="text-white" block>{league.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{league.description}
</Text>
{league.membershipRole && (
<Text size="xs" color="text-gray-500" block mt={1}>
Your role:{' '}
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
</Text>
)}
</Box>
<Stack direction="row" align="center" gap={2} style={{ marginLeft: '1rem' }}>
<Link
href={`/leagues/${league.leagueId}`}
variant="ghost"
>
<Text size="sm" color="text-gray-300">View</Text>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link>
)}
</Stack>
</Surface>
);
}

View File

@@ -1,5 +1,5 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import { Car, Download, Trash2, Edit } from 'lucide-react';
interface DriverLiveryItem {

View File

@@ -0,0 +1,113 @@
'use client';
import React from 'react';
import { Activity, TrendingUp, Target, BarChart3 } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon } from '@/ui/Icon';
import { CircularProgress } from '@/components/drivers/CircularProgress';
import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart';
interface PerformanceOverviewProps {
stats: {
wins: number;
podiums: number;
totalRaces: number;
consistency: number | null;
dnfs: number;
bestFinish: number;
avgFinish: number | null;
};
}
export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
return (
<Card>
<Box mb={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
Performance Overview
</Heading>
</Box>
<Grid cols={12} gap={8}>
<GridItem colSpan={12} lgSpan={6}>
<Stack align="center" gap={4}>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.wins}
max={stats.totalRaces}
label="Win Rate"
color="#10b981"
/>
<CircularProgress
value={stats.podiums}
max={stats.totalRaces}
label="Podium Rate"
color="#f59e0b"
/>
</Stack>
<Stack direction="row" gap={6}>
<CircularProgress
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="#3b82f6"
/>
<CircularProgress
value={stats.totalRaces - stats.dnfs}
max={stats.totalRaces}
label="Finish Rate"
color="#00f2ff"
/>
</Stack>
</Stack>
</GridItem>
<GridItem colSpan={12} lgSpan={6}>
<Box mb={4}>
<Heading level={3} icon={<Icon icon={BarChart3} size={4} color="#9ca3af" />}>
Results Breakdown
</Heading>
</Box>
<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}
/>
<Box mt={6}>
<Grid cols={2} gap={4}>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={TrendingUp} size={4} color="#10b981" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Best Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-performance-green">P{stats.bestFinish}</Text>
</Stack>
</Box>
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Target} size={4} color="#3b82f6" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Avg Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-primary-blue">
P{(stats.avgFinish ?? 0).toFixed(1)}
</Text>
</Stack>
</Box>
</Grid>
</Box>
</GridItem>
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import React from 'react';
import { User } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/Box';
interface ProfileBioProps {
bio: string;
}
export function ProfileBio({ bio }: ProfileBioProps) {
return (
<Card>
<Box mb={3}>
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
About
</Heading>
</Box>
<Text color="text-gray-300">{bio}</Text>
</Card>
);
}

View File

@@ -4,8 +4,8 @@ import Image from 'next/image';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import Button from '../ui/Button';
import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import CountryFlag from '@/ui/CountryFlag';
import PlaceholderImage from '@/ui/PlaceholderImage';
interface ProfileHeaderProps {
driver: DriverViewModel;

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import { Star, Trophy, Globe, Calendar, Clock, UserPlus, ExternalLink, LucideIcon } from 'lucide-react';
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 { Button } from '@/ui/Button';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
interface ProfileHeroProps {
driver: {
name: string;
avatarUrl?: string;
country: string;
iracingId: number;
joinedAt: string | Date;
};
stats: {
rating: number;
} | null;
globalRank: number;
timezone: string;
socialHandles: {
platform: string;
handle: string;
url: string;
}[];
onAddFriend: () => void;
friendRequestSent: boolean;
}
function getSocialIcon(platform: string) {
const { Twitter, Youtube, Twitch, MessageCircle } = require('lucide-react');
switch (platform) {
case 'twitter': return Twitter;
case 'youtube': return Youtube;
case 'twitch': return Twitch;
case 'discord': return MessageCircle;
default: return Globe;
}
}
export function ProfileHero({
driver,
stats,
globalRank,
timezone,
socialHandles,
onAddFriend,
friendRequestSent,
}: ProfileHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={6} style={{ background: 'linear-gradient(to bottom right, rgba(38, 38, 38, 0.8), rgba(38, 38, 38, 0.6), #0f1115)', borderColor: '#262626' }}>
<Stack direction="row" align="start" gap={6} wrap>
{/* Avatar */}
<Box style={{ position: 'relative' }}>
<Box style={{ width: '7rem', height: '7rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.25rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
<Box style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Box>
</Box>
{/* Driver Info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={3} wrap mb={2}>
<Heading level={1}>{driver.name}</Heading>
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
</Text>
</Stack>
{/* Rating and Rank */}
<Stack direction="row" align="center" gap={4} wrap mb={4}>
{stats && (
<>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Stack direction="row" align="center" gap={2}>
<Star style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
<Text font="mono" weight="bold" color="text-primary-blue">{stats.rating}</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
</Stack>
</Surface>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Stack direction="row" align="center" gap={2}>
<Trophy style={{ width: '1rem', height: '1rem', color: '#facc15' }} />
<Text font="mono" weight="bold" style={{ color: '#facc15' }}>#{globalRank}</Text>
<Text size="xs" color="text-gray-400">Global</Text>
</Stack>
</Surface>
</>
)}
</Stack>
{/* Meta info */}
<Stack direction="row" align="center" gap={4} wrap style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
<Stack direction="row" align="center" gap={1.5}>
<Globe style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">iRacing: {driver.iracingId}</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Calendar style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">
Joined{' '}
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Clock style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">{timezone}</Text>
</Stack>
</Stack>
</Box>
{/* Action Buttons */}
<Box>
<Button
variant="primary"
onClick={onAddFriend}
disabled={friendRequestSent}
icon={<UserPlus style={{ width: '1rem', height: '1rem' }} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</Box>
</Stack>
{/* Social Handles */}
{socialHandles.length > 0 && (
<Box mt={6} pt={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack direction="row" align="center" gap={2} wrap>
<Text size="sm" color="text-gray-500" style={{ marginRight: '0.5rem' }}>Connect:</Text>
{socialHandles.map((social) => {
const Icon = getSocialIcon(social.platform);
return (
<Box key={social.platform}>
<Link
href={social.url}
target="_blank"
rel="noopener noreferrer"
variant="ghost"
>
<Surface variant="muted" rounded="lg" padding={1} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', paddingLeft: '0.75rem', paddingRight: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', color: '#9ca3af' }}>
<Icon style={{ width: '1rem', height: '1rem' }} />
<Text size="sm">{social.handle}</Text>
<ExternalLink style={{ width: '0.75rem', height: '0.75rem', opacity: 0.5 }} />
</Surface>
</Link>
</Box>
);
})}
</Stack>
</Box>
)}
</Surface>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { Grid } from '@/ui/Grid';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface Stat {
label: string;
value: string | number;
color?: string;
}
interface ProfileStatGridProps {
stats: Stat[];
}
export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
return (
<Grid cols={2} gap={4}>
{stats.map((stat, idx) => (
<Box key={idx} p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
<Text size="3xl" weight="bold" color={stat.color as any} block mb={1}>{stat.value}</Text>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{stat.label}</Text>
</Box>
))}
</Grid>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { User, BarChart3 } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
type ProfileTab = 'overview' | 'stats';
interface ProfileTabsProps {
activeTab: ProfileTab;
onTabChange: (tab: ProfileTab) => void;
}
export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
return (
<Surface variant="muted" rounded="xl" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', width: 'fit-content' }}>
<Box style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Button
variant={activeTab === 'overview' ? 'primary' : 'ghost'}
onClick={() => onTabChange('overview')}
size="sm"
icon={<Icon icon={User} size={4} />}
>
Overview
</Button>
<Button
variant={activeTab === 'stats' ? 'primary' : 'ghost'}
onClick={() => onTabChange('stats')}
size="sm"
icon={<Icon icon={BarChart3} size={4} />}
>
Detailed Stats
</Button>
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import React from 'react';
import { Flag, Users, UserPlus } from 'lucide-react';
import { Card } from '@/ui/Card';
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 { Icon } from '@/ui/Icon';
interface RacingProfileProps {
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export function RacingProfile({
racingStyle,
favoriteTrack,
favoriteCar,
availableHours,
lookingForTeam,
openToRequests,
}: RacingProfileProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#00f2ff" />}>
Racing Profile
</Heading>
</Box>
<Stack gap={4}>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Racing Style</Text>
<Text color="text-white" weight="medium">{racingStyle}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Track</Text>
<Text color="text-white" weight="medium">{favoriteTrack}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Favorite Car</Text>
<Text color="text-white" weight="medium">{favoriteCar}</Text>
</Box>
<Box>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block>Available</Text>
<Text color="text-white" weight="medium">{availableHours}</Text>
</Box>
{/* Status badges */}
<Box mt={4} pt={4} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack gap={2}>
{lookingForTeam && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Users} size={4} color="#10b981" />
<Text size="sm" color="text-performance-green" weight="medium">Looking for Team</Text>
</Stack>
</Surface>
)}
{openToRequests && (
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={UserPlus} size={4} color="#3b82f6" />
<Text size="sm" color="text-primary-blue" weight="medium">Open to Friend Requests</Text>
</Stack>
</Surface>
)}
</Stack>
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import React from 'react';
import { Shield, Users, ChevronRight } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
interface TeamMembership {
team: {
id: string;
name: string;
};
role: string;
joinedAt: Date;
}
interface TeamMembershipGridProps {
memberships: TeamMembership[];
}
export function TeamMembershipGrid({ memberships }: TeamMembershipGridProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Shield style={{ width: '1.25rem', height: '1.25rem', color: '#a855f7' }} />}>
Team Memberships
<Text size="sm" color="text-gray-500" weight="normal" style={{ marginLeft: '0.5rem' }}>({memberships.length})</Text>
</Heading>
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: '1rem' }}>
{memberships.map((membership) => (
<Box key={membership.team.id}>
<Link
href={`/teams/${membership.team.id}`}
variant="ghost"
>
<Surface variant="muted" rounded="xl" border padding={4} style={{ display: 'flex', alignItems: 'center', gap: '1rem', backgroundColor: 'rgba(38, 38, 38, 0.3)', borderColor: '#262626' }}>
<Surface variant="muted" rounded="lg" padding={3} style={{ backgroundColor: 'rgba(147, 51, 234, 0.2)', border: '1px solid rgba(147, 51, 234, 0.3)' }}>
<Users style={{ width: '1.5rem', height: '1.5rem', color: '#a855f7' }} />
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text weight="semibold" color="text-white" block truncate>{membership.team.name}</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Surface variant="muted" rounded="full" padding={1} style={{ paddingLeft: '0.5rem', paddingRight: '0.5rem', backgroundColor: 'rgba(147, 51, 234, 0.2)', color: '#a855f7' }}>
<Text size="xs" weight="medium" style={{ textTransform: 'capitalize' }}>{membership.role}</Text>
</Surface>
<Text size="xs" color="text-gray-500">Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</Text>
</Stack>
</Box>
<ChevronRight style={{ width: '1rem', height: '1rem', color: '#737373' }} />
</Surface>
</Link>
</Box>
))}
</Box>
</Card>
);
}

View File

@@ -1,120 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserPill from './UserPill';
// Mock useAuth to control session state
vi.mock('@/lib/auth/AuthContext', () => {
return {
useAuth: () => mockedAuthValue,
};
});
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => {
return {
useEffectiveDriverId: () => mockedDriverId,
};
});
// Mock the new DI hooks
const mockFindById = vi.fn();
let mockDriverData: any = null;
vi.mock('@/hooks/driver/useFindDriverById', () => ({
useFindDriverById: (driverId: string) => {
return {
data: mockDriverData,
isLoading: false,
isError: false,
isSuccess: !!mockDriverData,
refetch: vi.fn(),
};
},
}));
interface MockSessionUser {
id: string;
}
interface MockSession {
user: MockSessionUser | null;
}
let mockedAuthValue: { session: MockSession | null } = { session: null };
let mockedDriverId: string | null = null;
// Provide global stats helpers used by UserPill's rating/rank computation
// They are UI-level helpers, so a minimal stub is sufficient for these tests.
(globalThis as any).getDriverStats = (driverId: string) => ({
driverId,
rating: 2000,
overallRank: 10,
wins: 5,
});
(globalThis as any).getAllDriverRankings = () => [
{ driverId: 'driver-1', rating: 2100 },
{ driverId: 'driver-2', rating: 2000 },
];
describe('UserPill', () => {
beforeEach(() => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockDriverData = null;
mockFindById.mockReset();
});
it('renders auth links when there is no session', () => {
mockedAuthValue = { session: null };
const { container } = render(<UserPill />);
expect(screen.getByText('Sign In')).toBeInTheDocument();
expect(screen.getByText('Get Started')).toBeInTheDocument();
expect(mockFindById).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('does not load driver when there is no primary driver id', async () => {
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = null;
const { container } = render(<UserPill />);
// Component should still render user pill with session user info
await waitFor(() => {
expect(screen.getByText('User')).toBeInTheDocument();
});
expect(mockFindById).not.toHaveBeenCalled();
});
it('loads driver via driverService and uses driver avatarUrl', async () => {
const driver = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
joinedAt: '2023-01-01',
avatarUrl: '/api/media/avatar/driver-1',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
// Set the mock data that the hook will return
mockDriverData = driver;
render(<UserPill />);
await waitFor(() => {
expect(screen.getByText('Test Driver')).toBeInTheDocument();
});
expect(mockFindById).not.toHaveBeenCalled(); // Hook is mocked, not called directly
});
});

View File

@@ -1,22 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserPill > renders auth links when there is no session 1`] = `
<div>
<div
class="flex items-center gap-2"
>
<a
class="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
href="/auth/login"
>
Sign In
</a>
<a
class="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
href="/auth/signup"
>
Get Started
</a>
</div>
</div>
`;

View File

@@ -1,8 +1,8 @@
'use client';
import { useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Modal from '@/ui/Modal';
import Button from '@/ui/Button';
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
import { useFileProtest } from "@/lib/hooks/race/useFileProtest";
import {

View File

@@ -4,11 +4,10 @@ import { useState } from 'react';
import Button from '../ui/Button';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { ImportResultRowDTO } from '@/lib/services/races/RaceResultsService';
interface ImportResultsFormProps {
raceId: string;
onSuccess: (results: ImportResultRowDTO[]) => void;
onSuccess: (results: any[]) => void;
onError: (error: string) => void;
}
@@ -26,7 +25,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
try {
const content = await file.text();
const results = raceResultsService.parseAndTransformCSV(content, raceId);
const results = (raceResultsService as any).parseAndTransformCSV(content, raceId);
onSuccess(results);
} catch (err) {
const errorMessage =

View File

@@ -1,4 +1,4 @@
import Card from '@/components/ui/Card';
import Card from '@/ui/Card';
type RaceWithResults = {
raceId: string;

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { PlayCircle, ChevronRight } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
interface LiveRacesBannerProps {
liveRaces: RaceViewData[];
onRaceClick: (raceId: string) => void;
}
export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps) {
if (liveRaces.length === 0) return null;
return (
<Box style={{
position: 'relative',
overflow: 'hidden',
borderRadius: '0.75rem',
background: 'linear-gradient(to right, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1), transparent)',
border: '1px solid rgba(16, 185, 129, 0.3)',
padding: '1.5rem'
}}>
<Box style={{ position: 'absolute', top: 0, right: 0, width: '8rem', height: '8rem', backgroundColor: 'rgba(16, 185, 129, 0.2)', borderRadius: '9999px', filter: 'blur(24px)' }} />
<Box style={{ position: 'relative', zIndex: 10 }}>
<Box style={{ marginBottom: '1rem' }}>
<Stack direction="row" align="center" gap={2} style={{ backgroundColor: 'rgba(16, 185, 129, 0.2)', padding: '0.25rem 0.75rem', borderRadius: '9999px', width: 'fit-content' }}>
<Box style={{ width: '0.5rem', height: '0.5rem', backgroundColor: '#10b981', borderRadius: '9999px' }} />
<Text weight="semibold" size="sm" color="text-performance-green">LIVE NOW</Text>
</Stack>
</Box>
<Stack gap={3}>
{liveRaces.map((race) => (
<Box
key={race.id}
onClick={() => onRaceClick(race.id)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1rem',
backgroundColor: 'rgba(15, 17, 21, 0.8)',
borderRadius: '0.5rem',
border: '1px solid rgba(16, 185, 129, 0.2)',
cursor: 'pointer'
}}
>
<Stack direction="row" align="center" gap={4}>
<Box style={{ padding: '0.5rem', backgroundColor: 'rgba(16, 185, 129, 0.2)', borderRadius: '0.5rem' }}>
<PlayCircle style={{ width: '1.25rem', height: '1.25rem', color: '#10b981' }} />
</Box>
<Box>
<Heading level={3}>{race.track}</Heading>
<Text size="sm" color="text-gray-400">{race.leagueName}</Text>
</Box>
</Stack>
<ChevronRight style={{ width: '1.25rem', height: '1.25rem', color: '#9ca3af' }} />
</Box>
))}
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import React from 'react';
import { AlertCircle, AlertTriangle, Video } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface Protest {
id: string;
status: string;
protestingDriverId: string;
accusedDriverId: string;
filedAt: string;
incident: {
lap: number;
description: string;
};
proofVideoUrl?: string;
decisionNotes?: string;
}
interface Driver {
id: string;
name: string;
}
interface ProtestCardProps {
protest: Protest;
protester?: Driver;
accused?: Driver;
isAdmin: boolean;
onReview: (id: string) => void;
formatDate: (date: string) => string;
}
export function ProtestCard({ protest, protester, accused, isAdmin, onReview, formatDate }: ProtestCardProps) {
const daysSinceFiled = Math.floor(
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
);
const isUrgent = daysSinceFiled > 2 && protest.status === 'pending';
const getStatusBadge = (status: string) => {
const variants: Record<string, { bg: string, text: string, label: string }> = {
pending: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' },
under_review: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' },
upheld: { bg: 'rgba(239, 68, 68, 0.2)', text: '#ef4444', label: 'Upheld' },
dismissed: { bg: 'rgba(115, 115, 115, 0.2)', text: '#9ca3af', label: 'Dismissed' },
withdrawn: { bg: 'rgba(59, 130, 246, 0.2)', text: '#3b82f6', label: 'Withdrawn' },
};
const config = variants[status] || variants.pending;
return (
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: config.bg, paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Text size="xs" weight="medium" style={{ color: config.text }}>{config.label}</Text>
</Surface>
);
};
return (
<Card style={{ borderLeft: isUrgent ? '4px solid #ef4444' : undefined }}>
<Stack direction="row" align="start" justify="between" gap={4}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={2} mb={2} wrap>
<Icon icon={AlertCircle} size={4} color="#9ca3af" />
<Link href={`/drivers/${protest.protestingDriverId}`}>
<Text weight="medium" color="text-white">{protester?.name || 'Unknown'}</Text>
</Link>
<Text size="sm" color="text-gray-500">vs</Text>
<Link href={`/drivers/${protest.accusedDriverId}`}>
<Text weight="medium" color="text-white">{accused?.name || 'Unknown'}</Text>
</Link>
{getStatusBadge(protest.status)}
{isUrgent && (
<Surface variant="muted" rounded="full" padding={1} style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', paddingLeft: '0.5rem', paddingRight: '0.5rem' }}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={AlertTriangle} size={3} color="#ef4444" />
<Text size="xs" weight="medium" color="text-error-red">{daysSinceFiled}d old</Text>
</Stack>
</Surface>
)}
</Stack>
<Stack direction="row" align="center" gap={4} mb={2} wrap>
<Text size="sm" color="text-gray-400">Lap {protest.incident.lap}</Text>
<Text size="sm" color="text-gray-400"></Text>
<Text size="sm" color="text-gray-400">Filed {formatDate(protest.filedAt)}</Text>
{protest.proofVideoUrl && (
<>
<Text size="sm" color="text-gray-400"></Text>
<Link href={protest.proofVideoUrl} target="_blank">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Video} size={3.5} color="#3b82f6" />
<Text size="sm" color="text-primary-blue">Video Evidence</Text>
</Stack>
</Link>
</>
)}
</Stack>
<Text size="sm" color="text-gray-300" block>{protest.incident.description}</Text>
{protest.decisionNotes && (
<Box mt={4} p={3} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', borderRadius: '0.5rem', border: '1px solid #262626' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>Steward Decision</Text>
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
</Box>
)}
</Box>
{isAdmin && protest.status === 'pending' && (
<Button
variant="primary"
onClick={() => onReview(protest.id)}
size="sm"
>
Review
</Button>
)}
</Stack>
</Card>
);
}

View File

@@ -1,6 +1,5 @@
import Link from 'next/link';
import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react';
import { formatTime, getRelativeTime } from '@/lib/utilities/time';
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
interface RaceCardProps {
@@ -19,6 +18,7 @@ interface RaceCardProps {
}
export function RaceCard({ race, onClick, className }: RaceCardProps) {
const scheduledAtDate = new Date(race.scheduledAt);
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
border: 'border-charcoal-outline',
bg: 'bg-charcoal-outline',
@@ -41,12 +41,12 @@ export function RaceCard({ race, onClick, className }: RaceCardProps) {
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">
{formatTime(race.scheduledAt)}
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running'
? 'LIVE'
: getRelativeTime(race.scheduledAt)}
: scheduledAtDate.toLocaleDateString()}
</p>
</div>

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import { Flag } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface RaceDetailCardProps {
track: string;
car: string;
sessionType: string;
statusLabel: string;
statusColor: string;
}
export function RaceDetailCard({ track, car, sessionType, statusLabel, statusColor }: RaceDetailCardProps) {
return (
<Card>
<Stack gap={4}>
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#3b82f6" />}>Race Details</Heading>
<Grid cols={2} gap={4}>
<DetailItem label="Track" value={track} />
<DetailItem label="Car" value={car} />
<DetailItem label="Session Type" value={sessionType} capitalize />
<DetailItem label="Status" value={statusLabel} color={statusColor} />
</Grid>
</Stack>
</Card>
);
}
function DetailItem({ label, value, capitalize, color }: { label: string, value: string | number, capitalize?: boolean, color?: string }) {
return (
<Box p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.5rem' }}>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }} block mb={1}>{label}</Text>
<Text weight="medium" color="text-white" style={{ textTransform: capitalize ? 'capitalize' : 'none', color: color || 'white' }}>{value}</Text>
</Box>
);
}

Some files were not shown because too many files have changed in this diff Show More