website refactor
This commit is contained in:
36
apps/website/components/AppWrapper.tsx
Normal file
36
apps/website/components/AppWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
|
||||
|
||||
return (
|
||||
<AdminDashboardTemplate
|
||||
adminDashboardViewData={initialViewData}
|
||||
viewData={initialViewData}
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
|
||||
return (
|
||||
<AdminUsersTemplate
|
||||
adminUsersViewData={initialViewData}
|
||||
viewData={initialViewData}
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={handleSearch}
|
||||
onFilterRole={handleFilterRole}
|
||||
|
||||
92
apps/website/components/admin/UserFilters.tsx
Normal file
92
apps/website/components/admin/UserFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/admin/UserStatsSummary.tsx
Normal file
51
apps/website/components/admin/UserStatsSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/website/components/dashboard/ActivityFeed.tsx
Normal file
62
apps/website/components/dashboard/ActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/website/components/dashboard/ChampionshipStandings.tsx
Normal file
56
apps/website/components/dashboard/ChampionshipStandings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/website/components/dashboard/DashboardHero.tsx
Normal file
101
apps/website/components/dashboard/DashboardHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
apps/website/components/dashboard/FriendsSidebar.tsx
Normal file
83
apps/website/components/dashboard/FriendsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/dashboard/NextRaceCard.tsx
Normal file
74
apps/website/components/dashboard/NextRaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/website/components/dashboard/StatBox.tsx
Normal file
33
apps/website/components/dashboard/StatBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/website/components/dashboard/UpcomingRaces.tsx
Normal file
68
apps/website/components/dashboard/UpcomingRaces.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
export interface DriverRanking {
|
||||
type: 'overall' | 'league';
|
||||
|
||||
87
apps/website/components/drivers/DriversHero.tsx
Normal file
87
apps/website/components/drivers/DriversHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/website/components/drivers/DriversSearch.tsx
Normal file
30
apps/website/components/drivers/DriversSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
94
apps/website/components/drivers/RankingsPodium.tsx
Normal file
94
apps/website/components/drivers/RankingsPodium.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/website/components/drivers/RankingsTable.tsx
Normal file
122
apps/website/components/drivers/RankingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 || '#';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 || '#';
|
||||
|
||||
54
apps/website/components/landing/LandingItems.tsx
Normal file
54
apps/website/components/landing/LandingItems.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
59
apps/website/components/leaderboards/LeaderboardsHero.tsx
Normal file
59
apps/website/components/leaderboards/LeaderboardsHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface BonusPointsCardProps {
|
||||
bonusSummary: string[];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface DropRulesExplanationProps {
|
||||
dropPolicyDescription: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
70
apps/website/components/leagues/LeagueSummaryCard.tsx
Normal file
70
apps/website/components/leagues/LeagueSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/website/components/leagues/LeagueTabs.tsx
Normal file
36
apps/website/components/leagues/LeagueTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface PointsBreakdownTableProps {
|
||||
positionPoints: Array<{ position: number; points: number }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface PointsTableProps {
|
||||
title?: string;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
40
apps/website/components/leagues/RulebookTabs.tsx
Normal file
40
apps/website/components/leagues/RulebookTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/website/components/leagues/ScheduleRaceCard.tsx
Normal file
77
apps/website/components/leagues/ScheduleRaceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
interface ScoringOverviewCardProps {
|
||||
gameName: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
apps/website/components/leagues/SponsorshipRequestCard.tsx
Normal file
75
apps/website/components/leagues/SponsorshipRequestCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/website/components/leagues/SponsorshipSlotCard.tsx
Normal file
67
apps/website/components/leagues/SponsorshipSlotCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
11
apps/website/components/onboarding/OnboardingContainer.tsx
Normal file
11
apps/website/components/onboarding/OnboardingContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/website/components/onboarding/OnboardingError.tsx
Normal file
12
apps/website/components/onboarding/OnboardingError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/website/components/onboarding/OnboardingForm.tsx
Normal file
12
apps/website/components/onboarding/OnboardingForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/website/components/onboarding/OnboardingHeader.tsx
Normal file
17
apps/website/components/onboarding/OnboardingHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
58
apps/website/components/onboarding/OnboardingNavigation.tsx
Normal file
58
apps/website/components/onboarding/OnboardingNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
151
apps/website/components/onboarding/PersonalInfoStep.tsx
Normal file
151
apps/website/components/onboarding/PersonalInfoStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
apps/website/components/profile/AchievementGrid.tsx
Normal file
83
apps/website/components/profile/AchievementGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/website/components/profile/CareerStats.tsx
Normal file
46
apps/website/components/profile/CareerStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
70
apps/website/components/profile/FriendsPreview.tsx
Normal file
70
apps/website/components/profile/FriendsPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/website/components/profile/LeagueListItem.tsx
Normal file
61
apps/website/components/profile/LeagueListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
113
apps/website/components/profile/PerformanceOverview.tsx
Normal file
113
apps/website/components/profile/PerformanceOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/website/components/profile/ProfileBio.tsx
Normal file
26
apps/website/components/profile/ProfileBio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
172
apps/website/components/profile/ProfileHero.tsx
Normal file
172
apps/website/components/profile/ProfileHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/website/components/profile/ProfileStatGrid.tsx
Normal file
29
apps/website/components/profile/ProfileStatGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/website/components/profile/ProfileTabs.tsx
Normal file
40
apps/website/components/profile/ProfileTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/website/components/profile/RacingProfile.tsx
Normal file
79
apps/website/components/profile/RacingProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/website/components/profile/TeamMembershipGrid.tsx
Normal file
63
apps/website/components/profile/TeamMembershipGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Card from '@/ui/Card';
|
||||
|
||||
type RaceWithResults = {
|
||||
raceId: string;
|
||||
|
||||
68
apps/website/components/races/LiveRacesBanner.tsx
Normal file
68
apps/website/components/races/LiveRacesBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
apps/website/components/races/ProtestCard.tsx
Normal file
124
apps/website/components/races/ProtestCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
44
apps/website/components/races/RaceDetailCard.tsx
Normal file
44
apps/website/components/races/RaceDetailCard.tsx
Normal 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
Reference in New Issue
Block a user