website refactor

This commit is contained in:
2026-01-15 17:12:24 +01:00
parent c3b308e960
commit f035cfe7ce
468 changed files with 24378 additions and 17324 deletions

View File

@@ -4,10 +4,10 @@ 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 { NotificationProvider } from '@/components/notifications/NotificationProvider';
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import DevToolbar from '@/components/dev/DevToolbar';
import { DevToolbar } from '@/components/dev/DevToolbar';
import React from 'react';
interface AppWrapperProps {

View File

@@ -1,29 +1,14 @@
'use client';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import React from 'react';
import {
UserPlus,
Link as LinkIcon,
Settings,
Trophy,
Car,
CheckCircle2,
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 WorkflowStep {
id: number;
icon: LucideIcon;
title: string;
description: string;
color: string;
}
import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
const WORKFLOW_STEPS: WorkflowStep[] = [
{
@@ -64,123 +49,5 @@ const WORKFLOW_STEPS: WorkflowStep[] = [
];
export function AuthWorkflowMockup() {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % WORKFLOW_STEPS.length);
}, 3000);
return () => clearInterval(interval);
}, [isMounted]);
if (!isMounted) {
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6}>
<Stack direction="row" justify="between" gap={2}>
{WORKFLOW_STEPS.map((step) => (
<Stack key={step.id} align="center" center>
<Box width={10} height={10} rounded="lg" backgroundColor="iron-gray" border borderColor="charcoal-outline" display="flex" center mb={2}>
<Icon icon={step.icon} size={4} className={step.color} />
</Box>
<Text size="xs" weight="medium" color="text-white">{step.title}</Text>
</Stack>
))}
</Stack>
</Surface>
</Box>
);
}
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="2xl" border padding={6} className="overflow-hidden">
{/* Connection Lines */}
<Box position="absolute" top="3.5rem" left="8%" right="8%" className="hidden sm:block">
<Box height={0.5} backgroundColor="charcoal-outline" position="relative">
<motion.div
className="absolute h-full bg-gradient-to-r from-primary-blue to-performance-green"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (WORKFLOW_STEPS.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</Box>
</Box>
{/* Steps */}
<Stack direction="row" justify="between" gap={2} position="relative">
{WORKFLOW_STEPS.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<motion.div
key={step.id}
className="flex flex-col items-center text-center cursor-pointer flex-1"
onClick={() => setActiveStep(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`w-10 h-10 sm:w-12 sm:h-12 rounded-lg border flex items-center justify-center mb-2 transition-all duration-300 ${
isActive
? 'bg-primary-blue/20 border-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: isCompleted
? 'bg-performance-green/20 border-performance-green/50'
: 'bg-iron-gray border-charcoal-outline'
}`}
animate={isActive && !shouldReduceMotion ? {
scale: [1, 1.08, 1],
transition: { duration: 1, repeat: Infinity }
} : {}}
>
{isCompleted ? (
<Icon icon={CheckCircle2} size={5} color="text-performance-green" />
) : (
<Icon icon={StepIcon} size={5} className={isActive ? step.color : 'text-gray-500'} />
)}
</motion.div>
<Text size="xs" weight="medium" className={`transition-colors hidden sm:block ${
isActive ? 'text-white' : 'text-gray-400'
}`}>
{step.title}
</Text>
</motion.div>
);
})}
</Stack>
{/* Active Step Preview - Mobile */}
<AnimatePresence mode="wait">
<motion.div
key={activeStep}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="mt-4 pt-4 border-t border-charcoal-outline sm:hidden"
>
<Box textAlign="center">
<Text size="xs" color="text-gray-400" block mb={1}>
Step {activeStep + 1}: {WORKFLOW_STEPS[activeStep]?.title || ''}
</Text>
<Text size="xs" color="text-gray-500" block>
{WORKFLOW_STEPS[activeStep]?.description || ''}
</Text>
</Box>
</motion.div>
</AnimatePresence>
</Surface>
</Box>
);
return <WorkflowMockup steps={WORKFLOW_STEPS} />;
}

View File

@@ -52,7 +52,9 @@ export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
justifyContent="center"
mb={1}
>
<Icon icon={role.icon} size={4} className={`text-${role.color}`} />
<Text color={`text-${role.color}`}>
<Icon icon={role.icon} size={4} />
</Text>
</Box>
<Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack>
@@ -89,7 +91,9 @@ export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
alignItems="center"
justifyContent="center"
>
<Icon icon={role.icon} size={5} className={`text-${role.color}`} />
<Text color={`text-${role.color}`}>
<Icon icon={role.icon} size={5} />
</Text>
</Box>
<Box>
<Heading level={4}>{role.title}</Heading>

View File

@@ -1,54 +0,0 @@
'use client';
import React from 'react';
interface CircularProgressProps {
value: number;
max: number;
label: string;
color: string;
size?: number;
}
export function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) {
const percentage = Math.min((value / max) * 100, 100);
const strokeWidth = 6;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
className="text-charcoal-outline"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={color}
style={{ transition: 'stroke-dashoffset 0.5s ease-in-out' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-white">{percentage.toFixed(0)}%</span>
</div>
</div>
<span className="text-xs text-gray-400 mt-2">{label}</span>
</div>
);
}

View File

@@ -1,29 +0,0 @@
'use client';
import React from 'react';
interface BarChartProps {
data: { label: string; value: number; color: string }[];
maxValue: number;
}
export function HorizontalBarChart({ data, maxValue }: BarChartProps) {
return (
<div className="space-y-3">
{data.map((item) => (
<div key={item.label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">{item.label}</span>
<span className="text-white font-medium">{item.value}</span>
</div>
<div className="h-2 bg-charcoal-outline rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${item.color} transition-all duration-500 ease-out`}
style={{ width: `${Math.min((item.value / maxValue) * 100, 100)}%` }}
/>
</div>
</div>
))}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
'use client';
import React from 'react';
import { LucideIcon } from 'lucide-react';
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}>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2}>
<Icon icon={icon} size={5} />
</Surface>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{value}</Text>
<Text size="xs" color="text-gray-500" block>{label}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

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

View File

@@ -1,41 +0,0 @@
'use client';
import { ReactNode } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
interface AccordionProps {
title: string;
icon: ReactNode;
children: ReactNode;
isOpen: boolean;
onToggle: () => void;
}
export function Accordion({ title, icon, children, isOpen, onToggle }: AccordionProps) {
return (
<div className="border border-charcoal-outline rounded-lg overflow-hidden bg-iron-gray/30">
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-iron-gray/50 transition-colors"
>
<div className="flex items-center gap-2">
{icon}
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
{title}
</span>
</div>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</button>
{isOpen && (
<div className="p-3 border-t border-charcoal-outline">
{children}
</div>
)}
</div>
);
}

View File

@@ -1,376 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Bug, X, Settings, Shield, Activity } from 'lucide-react';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import type { ApiRequestLogger } from '@/lib/infrastructure/ApiRequestLogger';
// Extend Window interface for debug globals
declare global {
interface Window {
__GRIDPILOT_FETCH_LOGGED__?: boolean;
__GRIDPILOT_GLOBAL_HANDLER__?: GlobalErrorHandler;
__GRIDPILOT_API_LOGGER__?: ApiRequestLogger;
__GRIDPILOT_REACT_ERRORS__?: Array<{ error: unknown; componentStack?: string }>;
}
}
interface DebugModeToggleProps {
/**
* Whether to show the toggle (auto-detected from environment)
*/
show?: boolean;
}
/**
* Debug Mode Toggle Component
* Provides a floating interface to control debug features and view real-time metrics
*/
export function DebugModeToggle({ show }: DebugModeToggleProps) {
const [isOpen, setIsOpen] = useState(false);
const [debugEnabled, setDebugEnabled] = useState(false);
const [metrics, setMetrics] = useState({
errors: 0,
apiRequests: 0,
apiFailures: 0,
});
const isDev = process.env.NODE_ENV === 'development';
const shouldShow = show ?? isDev;
useEffect(() => {
if (!shouldShow) return;
// Load debug state from localStorage
const saved = localStorage.getItem('gridpilot_debug_enabled');
if (saved === 'true') {
setDebugEnabled(true);
initializeDebugFeatures();
}
// Update metrics every 2 seconds
const interval = setInterval(updateMetrics, 2000);
return () => clearInterval(interval);
}, [shouldShow]);
useEffect(() => {
// Save debug state
if (shouldShow) {
localStorage.setItem('gridpilot_debug_enabled', debugEnabled.toString());
}
}, [debugEnabled, shouldShow]);
const updateMetrics = () => {
if (!debugEnabled) return;
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
const errorStats = globalHandler.getStats();
const apiStats = apiLogger.getStats();
setMetrics({
errors: errorStats.total,
apiRequests: apiStats.total,
apiFailures: apiStats.failed,
});
};
const initializeDebugFeatures = () => {
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
// Initialize global error handler
globalHandler.initialize();
// Override fetch with logging
if (!window.__GRIDPILOT_FETCH_LOGGED__) {
const loggedFetch = apiLogger.createLoggedFetch();
window.fetch = loggedFetch as typeof fetch;
window.__GRIDPILOT_FETCH_LOGGED__ = true;
}
// Expose to window for easy access
window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
window.__GRIDPILOT_API_LOGGER__ = apiLogger;
console.log('%c[DEBUG MODE] Enabled', 'color: #00ff88; font-weight: bold; font-size: 14px;');
console.log('Available globals:', {
__GRIDPILOT_GLOBAL_HANDLER__: globalHandler,
__GRIDPILOT_API_LOGGER__: apiLogger,
__GRIDPILOT_REACT_ERRORS__: window.__GRIDPILOT_REACT_ERRORS__ || [],
});
};
const toggleDebug = () => {
const newEnabled = !debugEnabled;
setDebugEnabled(newEnabled);
if (newEnabled) {
initializeDebugFeatures();
} else {
// Disable debug features
const globalHandler = getGlobalErrorHandler();
globalHandler.destroy();
console.log('%c[DEBUG MODE] Disabled', 'color: #ff4444; font-weight: bold; font-size: 14px;');
}
};
const triggerTestError = () => {
if (!debugEnabled) return;
// Trigger a test API error
const testError = new Error('This is a test error for debugging');
(testError as any).type = 'TEST_ERROR';
const globalHandler = getGlobalErrorHandler();
globalHandler.report(testError, { test: true, timestamp: Date.now() });
console.log('%c[TEST] Error triggered', 'color: #ffaa00; font-weight: bold;', testError);
};
const triggerTestApiCall = async () => {
if (!debugEnabled) return;
try {
// This will fail and be logged
await fetch('https://httpstat.us/500');
} catch (error) {
// Already logged by interceptor
console.log('%c[TEST] API call completed', 'color: #00aaff; font-weight: bold;');
}
};
const clearAllLogs = () => {
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
globalHandler.clearHistory();
apiLogger.clearHistory();
setMetrics({ errors: 0, apiRequests: 0, apiFailures: 0 });
console.log('%c[DEBUG] All logs cleared', 'color: #00ff88; font-weight: bold;');
};
const copyDebugInfo = async () => {
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
const debugInfo = {
timestamp: new Date().toISOString(),
environment: {
mode: process.env.NODE_ENV,
version: process.env.NEXT_PUBLIC_APP_VERSION,
},
browser: {
userAgent: navigator.userAgent,
language: navigator.language,
platform: navigator.platform,
},
errors: globalHandler.getStats(),
api: apiLogger.getStats(),
reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__ || [],
};
try {
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
console.log('%c[DEBUG] Debug info copied to clipboard', 'color: #00ff88; font-weight: bold;');
} catch (err) {
console.error('Failed to copy:', err);
}
};
if (!shouldShow) {
return null;
}
return (
<div className="fixed bottom-4 left-4 z-50">
{/* Main Toggle Button */}
{!isOpen && (
<button
onClick={() => setIsOpen(true)}
className={`p-3 rounded-full shadow-lg transition-all ${
debugEnabled
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300'
}`}
title={debugEnabled ? 'Debug Mode Active' : 'Enable Debug Mode'}
>
<Bug className="w-5 h-5" />
</button>
)}
{/* Debug Panel */}
{isOpen && (
<div className="w-80 bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-iron-gray/50 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Bug className="w-4 h-4 text-green-400" />
<span className="text-sm font-semibold text-white">Debug Control</span>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-charcoal-outline rounded"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Content */}
<div className="p-3 space-y-3">
{/* Debug Toggle */}
<div className="flex items-center justify-between bg-iron-gray/30 p-2 rounded border border-charcoal-outline">
<div className="flex items-center gap-2">
<Shield className={`w-4 h-4 ${debugEnabled ? 'text-green-400' : 'text-gray-500'}`} />
<span className="text-sm font-medium">Debug Mode</span>
</div>
<button
onClick={toggleDebug}
className={`px-3 py-1 rounded text-xs font-bold transition-colors ${
debugEnabled
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{debugEnabled ? 'ON' : 'OFF'}
</button>
</div>
{/* Metrics */}
{debugEnabled && (
<div className="grid grid-cols-3 gap-2">
<div className="bg-iron-gray border border-charcoal-outline rounded p-2 text-center">
<div className="text-[10px] text-gray-500">Errors</div>
<div className="text-lg font-bold text-red-400">{metrics.errors}</div>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-2 text-center">
<div className="text-[10px] text-gray-500">API</div>
<div className="text-lg font-bold text-blue-400">{metrics.apiRequests}</div>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-2 text-center">
<div className="text-[10px] text-gray-500">Failures</div>
<div className="text-lg font-bold text-yellow-400">{metrics.apiFailures}</div>
</div>
</div>
)}
{/* Actions */}
{debugEnabled && (
<div className="space-y-2">
<div className="text-xs font-semibold text-gray-400">Test Actions</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={triggerTestError}
className="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-xs font-medium"
>
Test Error
</button>
<button
onClick={triggerTestApiCall}
className="px-2 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-medium"
>
Test API
</button>
</div>
<div className="text-xs font-semibold text-gray-400 mt-2">Utilities</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={copyDebugInfo}
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded text-xs font-medium border border-charcoal-outline"
>
Copy Info
</button>
<button
onClick={clearAllLogs}
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded text-xs font-medium border border-charcoal-outline"
>
Clear Logs
</button>
</div>
</div>
)}
{/* Quick Links */}
{debugEnabled && (
<div className="space-y-1">
<div className="text-xs font-semibold text-gray-400">Quick Access</div>
<div className="text-[10px] text-gray-500 font-mono space-y-0.5">
<div> window.__GRIDPILOT_GLOBAL_HANDLER__</div>
<div> window.__GRIDPILOT_API_LOGGER__</div>
<div> window.__GRIDPILOT_REACT_ERRORS__</div>
</div>
</div>
)}
{/* Status */}
<div className="text-[10px] text-gray-500 text-center pt-2 border-t border-charcoal-outline">
{debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
{isDev && ' • Development Environment'}
</div>
</div>
</div>
)}
</div>
);
}
/**
* Hook for programmatic debug control
*/
export function useDebugMode() {
const [debugEnabled, setDebugEnabled] = useState(false);
useEffect(() => {
const saved = localStorage.getItem('gridpilot_debug_enabled');
setDebugEnabled(saved === 'true');
}, []);
const enable = () => {
setDebugEnabled(true);
localStorage.setItem('gridpilot_debug_enabled', 'true');
// Initialize debug features
const globalHandler = getGlobalErrorHandler();
globalHandler.initialize();
const apiLogger = getGlobalApiLogger();
if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) {
const loggedFetch = apiLogger.createLoggedFetch();
window.fetch = loggedFetch as any;
(window as any).__GRIDPILOT_FETCH_LOGGED__ = true;
}
(window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
(window as any).__GRIDPILOT_API_LOGGER__ = apiLogger;
};
const disable = () => {
setDebugEnabled(false);
localStorage.setItem('gridpilot_debug_enabled', 'false');
const globalHandler = getGlobalErrorHandler();
globalHandler.destroy();
};
const toggle = () => {
if (debugEnabled) {
disable();
} else {
enable();
}
};
return {
enabled: debugEnabled,
enable,
disable,
toggle,
};
}

View File

@@ -1,27 +1,32 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import React, { useEffect, useState } from 'react';
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
// Import our new components
import { Accordion } from './Accordion';
import { Accordion } from '@/ui/Accordion';
import { NotificationTypeSection } from './sections/NotificationTypeSection';
import { UrgencySection } from './sections/UrgencySection';
import { NotificationSendSection } from './sections/NotificationSendSection';
import { APIStatusSection } from './sections/APIStatusSection';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
// Import types
import type { DemoNotificationType, DemoUrgency } from './types';
export default function DevToolbar() {
const router = useRouter();
export function DevToolbar() {
const { addNotification } = useNotifications();
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
@@ -225,140 +230,155 @@ export default function DevToolbar() {
if (isMinimized) {
return (
<button
onClick={() => setIsMinimized(false)}
className="fixed bottom-4 right-4 z-50 p-3 bg-iron-gray border border-charcoal-outline rounded-full shadow-lg hover:bg-charcoal-outline transition-colors"
title="Open Dev Toolbar"
>
<Wrench className="w-5 h-5 text-primary-blue" />
</button>
<Box position="fixed" bottom="4" right="4" zIndex={50}>
<IconButton
icon={Wrench}
onClick={() => setIsMinimized(false)}
variant="secondary"
title="Open Dev Toolbar"
size="lg"
/>
</Box>
);
}
return (
<div className="fixed bottom-4 right-4 z-50 w-80 bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden">
<Box
position="fixed"
bottom="4"
right="4"
zIndex={50}
w="80"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
overflow="hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">Dev Toolbar</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-primary-blue/20 text-primary-blue rounded">
<Box display="flex" alignItems="center" justifyContent="between" px={4} py={3} bg="bg-iron-gray/50" borderBottom borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Wrench} size={4} color="rgb(59, 130, 246)" />
<Text size="sm" weight="semibold" color="text-white">Dev Toolbar</Text>
<Badge variant="primary" size="xs">
DEMO
</span>
</div>
<div className="flex items-center gap-1">
<button
</Badge>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<IconButton
icon={isExpanded ? ChevronDown : ChevronUp}
onClick={() => setIsExpanded(!isExpanded)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronUp className="w-4 h-4 text-gray-400" />
)}
</button>
<button
variant="ghost"
size="sm"
/>
<IconButton
icon={X}
onClick={() => setIsMinimized(true)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
variant="ghost"
size="sm"
/>
</Box>
</Box>
{/* Content */}
{isExpanded && (
<div className="p-4 space-y-3">
{/* Notification Section - Accordion */}
<Accordion
title="Notifications"
icon={<MessageSquare className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'notifications'}
onToggle={() => setOpenAccordion(openAccordion === 'notifications' ? null : 'notifications')}
>
<div className="space-y-3">
<NotificationTypeSection
selectedType={selectedType}
onSelectType={setSelectedType}
/>
<UrgencySection
selectedUrgency={selectedUrgency}
onSelectUrgency={setSelectedUrgency}
/>
<NotificationSendSection
selectedType={selectedType}
selectedUrgency={selectedUrgency}
sending={sending}
lastSent={lastSent}
onSend={handleSendNotification}
/>
</div>
</Accordion>
<Box p={4}>
<Stack gap={3}>
{/* Notification Section - Accordion */}
<Accordion
title="Notifications"
icon={<Icon icon={MessageSquare} size={4} color="rgb(156, 163, 175)" />}
isOpen={openAccordion === 'notifications'}
onToggle={() => setOpenAccordion(openAccordion === 'notifications' ? null : 'notifications')}
>
<Stack gap={3}>
<NotificationTypeSection
selectedType={selectedType}
onSelectType={setSelectedType}
/>
<UrgencySection
selectedUrgency={selectedUrgency}
onSelectUrgency={setSelectedUrgency}
/>
<NotificationSendSection
selectedType={selectedType}
selectedUrgency={selectedUrgency}
sending={sending}
lastSent={lastSent}
onSend={handleSendNotification}
/>
</Stack>
</Accordion>
{/* API Status Section - Accordion */}
<Accordion
title="API Status"
icon={<Activity className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'apiStatus'}
onToggle={() => setOpenAccordion(openAccordion === 'apiStatus' ? null : 'apiStatus')}
>
<APIStatusSection
apiStatus={apiStatus}
apiHealth={apiHealth}
circuitBreakers={circuitBreakers}
checkingHealth={checkingHealth}
onHealthCheck={handleApiHealthCheck}
onResetStats={handleResetApiStats}
onTestError={handleTestApiError}
/>
</Accordion>
{/* API Status Section - Accordion */}
<Accordion
title="API Status"
icon={<Icon icon={Activity} size={4} color="rgb(156, 163, 175)" />}
isOpen={openAccordion === 'apiStatus'}
onToggle={() => setOpenAccordion(openAccordion === 'apiStatus' ? null : 'apiStatus')}
>
<APIStatusSection
apiStatus={apiStatus}
apiHealth={apiHealth}
circuitBreakers={circuitBreakers}
checkingHealth={checkingHealth}
onHealthCheck={handleApiHealthCheck}
onResetStats={handleResetApiStats}
onTestError={handleTestApiError}
/>
</Accordion>
{/* Error Stats Section - Accordion */}
<Accordion
title="Error Stats"
icon={<AlertTriangle className="w-4 h-4 text-gray-400" />}
isOpen={openAccordion === 'errors'}
onToggle={() => setOpenAccordion(openAccordion === 'errors' ? null : 'errors')}
>
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center p-2 bg-iron-gray/30 rounded">
<span className="text-gray-400">Total Errors</span>
<span className="font-mono font-bold text-red-400">{errorStats.total}</span>
</div>
{Object.keys(errorStats.byType).length > 0 ? (
<div className="space-y-1">
{Object.entries(errorStats.byType).map(([type, count]) => (
<div key={type} className="flex justify-between items-center p-1.5 bg-deep-graphite rounded">
<span className="text-gray-300">{type}</span>
<span className="font-mono text-yellow-400">{count}</span>
</div>
))}
</div>
) : (
<div className="text-center text-gray-500 py-2">No errors yet</div>
)}
<button
onClick={() => {
const handler = getGlobalErrorHandler();
handler.clearHistory();
setErrorStats({ total: 0, byType: {} });
}}
className="w-full p-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline text-xs"
>
Clear Error History
</button>
</div>
</Accordion>
</div>
{/* Error Stats Section - Accordion */}
<Accordion
title="Error Stats"
icon={<Icon icon={AlertTriangle} size={4} color="rgb(156, 163, 175)" />}
isOpen={openAccordion === 'errors'}
onToggle={() => setOpenAccordion(openAccordion === 'errors' ? null : 'errors')}
>
<Stack gap={2}>
<Box display="flex" justifyContent="between" alignItems="center" p={2} bg="bg-iron-gray/30" rounded="md">
<Text size="xs" color="text-gray-400">Total Errors</Text>
<Text size="xs" font="mono" weight="bold" color="text-red-400">{errorStats.total}</Text>
</Box>
{Object.keys(errorStats.byType).length > 0 ? (
<Stack gap={1}>
{Object.entries(errorStats.byType).map(([type, count]) => (
<Box key={type} display="flex" justifyContent="between" alignItems="center" p={1.5} bg="bg-deep-graphite" rounded="md">
<Text size="xs" color="text-gray-300">{type}</Text>
<Text size="xs" font="mono" color="text-warning-amber">{count}</Text>
</Box>
))}
</Stack>
) : (
<Box textAlign="center" py={2}>
<Text size="xs" color="text-gray-500">No errors yet</Text>
</Box>
)}
<Button
variant="secondary"
onClick={() => {
const handler = getGlobalErrorHandler();
handler.clearHistory();
setErrorStats({ total: 0, byType: {} });
}}
fullWidth
size="sm"
>
Clear Error History
</Button>
</Stack>
</Accordion>
</Stack>
</Box>
)}
{/* Collapsed state hint */}
{!isExpanded && (
<div className="px-4 py-2 text-xs text-gray-500">
Click to expand dev tools
</div>
<Box px={4} py={2}>
<Text size="xs" color="text-gray-500">Click to expand dev tools</Text>
</Box>
)}
</div>
</Box>
);
}
}

View File

@@ -1,15 +1,24 @@
'use client';
import React from 'react';
import { Activity, Wifi, RefreshCw, Terminal } from 'lucide-react';
import { useState } from 'react';
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { useNotifications } from '@/components/notifications/NotificationProvider';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { StatusIndicator, StatRow, Badge } from '@/ui/StatusIndicator';
interface APIStatusSectionProps {
apiStatus: string;
apiHealth: any;
circuitBreakers: Record<string, any>;
apiHealth: {
successfulRequests: number;
totalRequests: number;
averageResponseTime: number;
consecutiveFailures: number;
lastCheck: number | Date | null;
};
circuitBreakers: Record<string, { state: string; failures: number }>;
checkingHealth: boolean;
onHealthCheck: () => void;
onResetStats: () => void;
@@ -25,121 +34,137 @@ export function APIStatusSection({
onResetStats,
onTestError
}: APIStatusSectionProps) {
const reliability = apiHealth.totalRequests === 0
? 0
: (apiHealth.successfulRequests / apiHealth.totalRequests);
const reliabilityLabel = apiHealth.totalRequests === 0 ? 'N/A' : 'Calculated';
const getReliabilityColor = () => {
if (apiHealth.totalRequests === 0) return 'text-gray-500';
if (reliability >= 0.95) return 'text-performance-green';
if (reliability >= 0.8) return 'text-warning-amber';
return 'text-red-400';
};
const getStatusVariant = () => {
if (apiStatus === 'connected') return 'success';
if (apiStatus === 'degraded') return 'warning';
return 'danger';
};
const statusLabel = apiStatus.toUpperCase();
const healthSummary = `${apiHealth.successfulRequests}/${apiHealth.totalRequests} req`;
const avgResponseLabel = `${apiHealth.averageResponseTime.toFixed(0)}ms`;
const lastCheckLabel = apiHealth.lastCheck ? 'Recently' : 'Never';
const consecutiveFailuresLabel = String(apiHealth.consecutiveFailures);
return (
<div>
<div className="flex items-center gap-2 mb-3">
<Activity className="w-4 h-4 text-gray-400" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
<Box>
<Box display="flex" alignItems="center" gap={2} mb={3}>
<Icon icon={Activity} size={4} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
API Status
</span>
</div>
</Text>
</Box>
{/* Status Indicator */}
<div className={`flex items-center justify-between p-2 rounded-lg mb-2 ${
apiStatus === 'connected' ? 'bg-green-500/10 border border-green-500/30' :
apiStatus === 'degraded' ? 'bg-yellow-500/10 border border-yellow-500/30' :
'bg-red-500/10 border border-red-500/30'
}`}>
<div className="flex items-center gap-2">
<Wifi className={`w-4 h-4 ${
apiStatus === 'connected' ? 'text-green-400' :
apiStatus === 'degraded' ? 'text-yellow-400' :
'text-red-400'
}`} />
<span className="text-sm font-semibold text-white">{apiStatus.toUpperCase()}</span>
</div>
<span className="text-xs text-gray-400">
{apiHealth.successfulRequests}/{apiHealth.totalRequests} req
</span>
</div>
<StatusIndicator
icon={Wifi}
label={statusLabel}
subLabel={healthSummary}
variant={getStatusVariant()}
/>
{/* Reliability */}
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-gray-500">Reliability</span>
<span className={`font-bold ${
apiHealth.totalRequests === 0 ? 'text-gray-500' :
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.95 ? 'text-green-400' :
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.8 ? 'text-yellow-400' :
'text-red-400'
}`}>
{apiHealth.totalRequests === 0 ? 'N/A' :
((apiHealth.successfulRequests / apiHealth.totalRequests) * 100).toFixed(1) + '%'}
</span>
</div>
<Box mt={2}>
{/* Reliability */}
<StatRow
label="Reliability"
value={reliabilityLabel}
valueColor={getReliabilityColor()}
/>
{/* Response Time */}
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-gray-500">Avg Response</span>
<span className="text-blue-400 font-mono">
{apiHealth.averageResponseTime.toFixed(0)}ms
</span>
</div>
{/* Response Time */}
<StatRow
label="Avg Response"
value={avgResponseLabel}
valueColor="text-primary-blue"
valueFont="mono"
/>
</Box>
{/* Consecutive Failures */}
{apiHealth.consecutiveFailures > 0 && (
<div className="flex items-center justify-between text-xs mb-2 bg-red-500/10 rounded px-2 py-1">
<span className="text-red-400">Consecutive Failures</span>
<span className="text-red-400 font-bold">{apiHealth.consecutiveFailures}</span>
</div>
<Box mt={2}>
<StatusIndicator
icon={Activity}
label="Consecutive Failures"
subLabel={consecutiveFailuresLabel}
variant="danger"
/>
</Box>
)}
{/* Circuit Breakers */}
<div className="mt-2">
<div className="text-[10px] text-gray-500 mb-1">Circuit Breakers:</div>
<Box mt={2}>
<Text size="xs" color="text-gray-500" block mb={1}>Circuit Breakers:</Text>
{Object.keys(circuitBreakers).length === 0 ? (
<div className="text-[10px] text-gray-500 italic">None active</div>
<Text size="xs" color="text-gray-500" italic>None active</Text>
) : (
<div className="space-y-1 max-h-16 overflow-auto">
{Object.entries(circuitBreakers).map(([endpoint, status]: [string, any]) => (
<div key={endpoint} className="flex items-center justify-between text-[10px]">
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span>
<span className={`px-1 rounded ${
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>
<Stack gap={1} maxHeight="4rem" overflow="auto">
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
<Box key={endpoint} display="flex" alignItems="center" justifyContent="between">
<Text size="xs" color="text-gray-400" truncate flexGrow={1}>
{endpoint}
</Text>
<Badge variant={status.state === 'CLOSED' ? 'success' : status.state === 'OPEN' ? 'danger' : 'warning'}>
{status.state}
</span>
</Badge>
{status.failures > 0 && (
<span className="text-red-400 ml-1">({status.failures})</span>
<Text size="xs" color="text-red-400" ml={1}>({status.failures})</Text>
)}
</div>
</Box>
))}
</div>
</Stack>
)}
</div>
</Box>
{/* API Actions */}
<div className="grid grid-cols-2 gap-2 mt-3">
<button
<Box display="grid" gridCols={2} gap={2} mt={3}>
<Button
variant="primary"
onClick={onHealthCheck}
disabled={checkingHealth}
className="px-2 py-1.5 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
size="sm"
icon={<Icon icon={RefreshCw} size={3} animate={checkingHealth ? 'spin' : 'none'} />}
>
<RefreshCw className={`w-3 h-3 ${checkingHealth ? 'animate-spin' : ''}`} />
Health Check
</button>
<button
</Button>
<Button
variant="secondary"
onClick={onResetStats}
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline"
size="sm"
>
Reset Stats
</button>
</div>
</Button>
</Box>
<div className="grid grid-cols-1 gap-2 mt-2">
<button
<Box display="grid" gridCols={1} gap={2} mt={2}>
<Button
variant="danger"
onClick={onTestError}
className="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors flex items-center justify-center gap-1"
size="sm"
icon={<Icon icon={Terminal} size={3} />}
>
<Terminal className="w-3 h-3" />
Test Error Handler
</button>
</div>
</Button>
</Box>
<div className="text-[10px] text-gray-600 mt-2">
Last Check: {apiHealth.lastCheck ? new Date(apiHealth.lastCheck).toLocaleTimeString() : 'Never'}
</div>
</div>
<Box mt={2}>
<Text size="xs" color="text-gray-600">
Last Check: {lastCheckLabel}
</Text>
</Box>
</Box>
);
}
}

View File

@@ -1,10 +1,12 @@
'use client';
import { Bell } from 'lucide-react';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import React from 'react';
import { Bell, Loader2 } from 'lucide-react';
import type { DemoNotificationType, DemoUrgency } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
interface NotificationSendSectionProps {
selectedType: DemoNotificationType;
@@ -15,50 +17,36 @@ interface NotificationSendSectionProps {
}
export function NotificationSendSection({
selectedType,
selectedUrgency,
sending,
lastSent,
onSend
}: NotificationSendSectionProps) {
return (
<div>
<button
<Box>
<Button
onClick={onSend}
disabled={sending}
className={`
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all
${lastSent
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green'
: 'bg-primary-blue hover:bg-primary-blue/80 text-white'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
variant={lastSent ? 'secondary' : 'primary'}
fullWidth
bg={lastSent ? 'bg-performance-green/20' : undefined}
borderColor={lastSent ? 'border-performance-green/30' : undefined}
color={lastSent ? 'text-performance-green' : undefined}
icon={sending ? <Icon icon={Loader2} size={4} animate="spin" /> : lastSent ? undefined : <Icon icon={Bell} size={4} />}
>
{sending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Sending...
</>
) : lastSent ? (
<>
Notification Sent!
</>
) : (
<>
<Bell className="w-4 h-4" />
Send Demo Notification
</>
)}
</button>
{sending ? 'Sending...' : lastSent ? '✓ Notification Sent!' : 'Send Demo Notification'}
</Button>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline mt-2">
<p className="text-[10px] text-gray-500">
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
</p>
</div>
</div>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" mt={2}>
<Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Silent:</Text> Notification center only
</Text>
<Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Toast:</Text> Temporary popup (auto-dismisses)
</Text>
<Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Modal:</Text> Blocking popup (may require action)
</Text>
</Box>
</Box>
);
}
}

View File

@@ -1,13 +1,17 @@
'use client';
import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award } from 'lucide-react';
import React from 'react';
import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award, LucideIcon } from 'lucide-react';
import type { DemoNotificationType } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface NotificationOption {
type: DemoNotificationType;
label: string;
description: string;
icon: any;
icon: LucideIcon;
color: string;
}
@@ -22,73 +26,85 @@ export const notificationOptions: NotificationOption[] = [
label: 'Protest Against You',
description: 'A protest was filed against you',
icon: AlertTriangle,
color: 'text-red-400',
color: 'rgb(239, 68, 68)',
},
{
type: 'defense_requested',
label: 'Defense Requested',
description: 'A steward requests your defense',
icon: Shield,
color: 'text-warning-amber',
color: 'rgb(245, 158, 11)',
},
{
type: 'vote_required',
label: 'Vote Required',
description: 'You need to vote on a protest',
icon: Vote,
color: 'text-primary-blue',
color: 'rgb(59, 130, 246)',
},
{
type: 'race_performance_summary',
label: 'Race Performance Summary',
description: 'Immediate results after main race',
icon: TrendingUp,
color: 'text-primary-blue',
color: 'rgb(59, 130, 246)',
},
{
type: 'race_final_results',
label: 'Race Final Results',
description: 'Final results after stewarding closes',
icon: Award,
color: 'text-warning-amber',
color: 'rgb(245, 158, 11)',
},
];
export function NotificationTypeSection({ selectedType, onSelectType }: NotificationTypeSectionProps) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<MessageSquare className="w-4 h-4 text-gray-400" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
<Box>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={MessageSquare} size={4} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
Notification Type
</span>
</div>
</Text>
</Box>
<div className="grid grid-cols-2 gap-1">
<Box display="grid" gridCols={2} gap={1}>
{notificationOptions.map((option) => {
const Icon = option.icon;
const isSelected = selectedType === option.type;
return (
<button
<Box
key={option.type}
as="button"
type="button"
onClick={() => onSelectType(option.type)}
className={`
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
${isSelected
? 'bg-primary-blue/20 border-primary-blue/50'
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
}
`}
display="flex"
flexDirection="col"
alignItems="center"
gap={1}
p={2}
rounded="lg"
border
transition
bg={isSelected ? 'bg-primary-blue/20' : 'bg-iron-gray/30'}
borderColor={isSelected ? 'border-primary-blue/50' : 'border-charcoal-outline'}
>
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} />
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}>
<Icon
icon={option.icon}
size={4}
color={isSelected ? 'rgb(59, 130, 246)' : option.color}
/>
<Text
size="xs"
weight="medium"
color={isSelected ? 'text-primary-blue' : 'text-gray-400'}
>
{option.label.split(' ')[0]}
</span>
</button>
</Text>
</Box>
);
})}
</div>
</div>
</Box>
</Box>
);
}
}

View File

@@ -1,6 +1,14 @@
import { useState, useEffect } from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import { Play, Copy, Trash2, Download, Clock } from 'lucide-react';
import { getGlobalReplaySystem } from '@/lib/infrastructure/ErrorReplay';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Button } from '@/ui/Button';
interface ReplayEntry {
id: string;
@@ -11,7 +19,6 @@ interface ReplayEntry {
export function ReplaySection() {
const [replays, setReplays] = useState<ReplayEntry[]>([]);
const [selectedReplay, setSelectedReplay] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
@@ -78,83 +85,89 @@ export function ReplaySection() {
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-gray-400">Error Replay</span>
<div className="flex gap-1">
<button
<Stack gap={2}>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="xs" weight="semibold" color="text-gray-400">Error Replay</Text>
<Box display="flex" gap={1}>
<IconButton
icon={Clock}
onClick={loadReplays}
className="p-1 hover:bg-charcoal-outline rounded"
variant="ghost"
size="sm"
title="Refresh"
>
<Clock className="w-3 h-3 text-gray-400" />
</button>
<button
/>
<IconButton
icon={Trash2}
onClick={handleClearAll}
className="p-1 hover:bg-charcoal-outline rounded"
variant="ghost"
size="sm"
title="Clear All"
>
<Trash2 className="w-3 h-3 text-red-400" />
</button>
</div>
</div>
color="rgb(239, 68, 68)"
/>
</Box>
</Box>
{replays.length === 0 ? (
<div className="text-xs text-gray-500 text-center py-2">
No replays available
</div>
<Box textAlign="center" py={2}>
<Text size="xs" color="text-gray-500">No replays available</Text>
</Box>
) : (
<div className="space-y-1 max-h-48 overflow-auto">
<Stack gap={1}>
{replays.map((replay) => (
<div
<Box
key={replay.id}
className="bg-deep-graphite border border-charcoal-outline rounded p-2 text-xs"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="md"
p={2}
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<div className="font-mono text-red-400 font-bold truncate">
{replay.type}
</div>
<div className="text-gray-300 truncate">{replay.error}</div>
<div className="text-gray-500 text-[10px]">
{new Date(replay.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
<div className="flex gap-1 mt-1">
<button
<Box mb={1}>
<Text size="xs" font="mono" weight="bold" color="text-red-400" block truncate>
{replay.type}
</Text>
<Text size="xs" color="text-gray-300" block truncate>{replay.error}</Text>
<Text size="xs" color="text-gray-500" block>
{new Date(replay.timestamp).toLocaleTimeString()}
</Text>
</Box>
<Box display="flex" gap={1} mt={1}>
<Button
variant="primary"
onClick={() => handleReplay(replay.id)}
disabled={loading}
className="flex items-center gap-1 px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
size="sm"
icon={<Icon icon={Play} size={3} />}
>
<Play className="w-3 h-3" />
Replay
</button>
<button
</Button>
<Button
variant="secondary"
onClick={() => handleExport(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline"
size="sm"
icon={<Icon icon={Download} size={3} />}
>
<Download className="w-3 h-3" />
Export
</button>
<button
</Button>
<Button
variant="secondary"
onClick={() => handleCopy(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline"
size="sm"
icon={<Icon icon={Copy} size={3} />}
>
<Copy className="w-3 h-3" />
Copy
</button>
<button
</Button>
<IconButton
icon={Trash2}
onClick={() => handleDelete(replay.id)}
className="flex items-center gap-1 px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded border border-charcoal-outline"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
variant="secondary"
size="sm"
/>
</Box>
</Box>
))}
</div>
</Stack>
)}
</div>
</Stack>
);
}
}

View File

@@ -1,13 +1,17 @@
'use client';
import { Bell, BellRing, AlertCircle } from 'lucide-react';
import React from 'react';
import { Bell, BellRing, AlertCircle, LucideIcon } from 'lucide-react';
import type { DemoUrgency } from '../types';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface UrgencyOption {
urgency: DemoUrgency;
label: string;
description: string;
icon: any;
icon: LucideIcon;
}
interface UrgencySectionProps {
@@ -38,62 +42,72 @@ export const urgencyOptions: UrgencyOption[] = [
export function UrgencySection({ selectedUrgency, onSelectUrgency }: UrgencySectionProps) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<BellRing className="w-4 h-4 text-gray-400" />
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
<Box>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={BellRing} size={4} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
Urgency Level
</span>
</div>
</Text>
</Box>
<div className="grid grid-cols-3 gap-1">
<Box display="grid" gridCols={3} gap={1}>
{urgencyOptions.map((option) => {
const Icon = option.icon;
const isSelected = selectedUrgency === option.urgency;
const getSelectedBg = () => {
if (option.urgency === 'modal') return 'bg-red-500/20';
if (option.urgency === 'toast') return 'bg-warning-amber/20';
return 'bg-gray-500/20';
};
const getSelectedBorder = () => {
if (option.urgency === 'modal') return 'border-red-500/50';
if (option.urgency === 'toast') return 'border-warning-amber/50';
return 'border-gray-500/50';
};
const getSelectedColor = () => {
if (option.urgency === 'modal') return 'rgb(239, 68, 68)';
if (option.urgency === 'toast') return 'rgb(245, 158, 11)';
return 'rgb(156, 163, 175)';
};
return (
<button
<Box
key={option.urgency}
as="button"
type="button"
onClick={() => onSelectUrgency(option.urgency)}
className={`
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
${isSelected
? option.urgency === 'modal'
? 'bg-red-500/20 border-red-500/50'
: option.urgency === 'toast'
? 'bg-warning-amber/20 border-warning-amber/50'
: 'bg-gray-500/20 border-gray-500/50'
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
}
`}
display="flex"
flexDirection="col"
alignItems="center"
gap={1}
p={2}
rounded="lg"
border
transition
bg={isSelected ? getSelectedBg() : 'bg-iron-gray/30'}
borderColor={isSelected ? getSelectedBorder() : 'border-charcoal-outline'}
>
<Icon className={`w-4 h-4 ${
isSelected
? option.urgency === 'modal'
? 'text-red-400'
: option.urgency === 'toast'
? 'text-warning-amber'
: 'text-gray-400'
: 'text-gray-500'
}`} />
<span className={`text-[10px] font-medium ${
isSelected
? option.urgency === 'modal'
? 'text-red-400'
: option.urgency === 'toast'
? 'text-warning-amber'
: 'text-gray-400'
: 'text-gray-500'
}`}>
<Icon
icon={option.icon}
size={4}
color={isSelected ? getSelectedColor() : 'rgb(107, 114, 128)'}
/>
<Text
size="xs"
weight="medium"
color={isSelected ? (option.urgency === 'modal' ? 'text-red-400' : option.urgency === 'toast' ? 'text-warning-amber' : 'text-gray-400') : 'text-gray-500'}
>
{option.label}
</span>
</button>
</Text>
</Box>
);
})}
</div>
<p className="text-[10px] text-gray-600 mt-1">
</Box>
<Text size="xs" color="text-gray-600" mt={1} block>
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
</p>
</div>
</Text>
</Box>
);
}
}

View File

@@ -1,4 +1,4 @@
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import type { LucideIcon } from 'lucide-react';
export type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results';
export type DemoUrgency = 'silent' | 'toast' | 'modal';
@@ -7,7 +7,7 @@ export interface NotificationOption {
type: DemoNotificationType;
label: string;
description: string;
icon: any;
icon: LucideIcon;
color: string;
}
@@ -15,5 +15,5 @@ export interface UrgencyOption {
urgency: DemoUrgency;
label: string;
description: string;
icon: any;
icon: LucideIcon;
}

View File

@@ -1,127 +0,0 @@
'use client';
import Card from '../ui/Card';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
const mockAchievements: Achievement[] = [
{ id: '1', title: 'First Victory', description: 'Won your first race', icon: '🏆', unlockedAt: '2024-03-15', rarity: 'common' },
{ id: '2', title: '10 Podiums', description: 'Achieved 10 podium finishes', icon: '🥈', unlockedAt: '2024-05-22', rarity: 'rare' },
{ id: '3', title: 'Clean Racer', description: 'Completed 25 races with 0 incidents', icon: '✨', unlockedAt: '2024-08-10', rarity: 'epic' },
{ id: '4', title: 'Comeback King', description: 'Won a race after starting P10 or lower', icon: '⚡', unlockedAt: '2024-09-03', rarity: 'rare' },
{ id: '5', title: 'Perfect Weekend', description: 'Pole, fastest lap, and win in same race', icon: '💎', unlockedAt: '2024-10-17', rarity: 'legendary' },
{ id: '6', title: 'Century Club', description: 'Completed 100 races', icon: '💯', unlockedAt: '2024-11-01', rarity: 'epic' },
];
const rarityColors = {
common: 'border-gray-500 bg-gray-500/10',
rare: 'border-blue-400 bg-blue-400/10',
epic: 'border-purple-400 bg-purple-400/10',
legendary: 'border-warning-amber bg-warning-amber/10'
};
export default function CareerHighlights() {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Key Milestones</h3>
<div className="space-y-3">
<MilestoneItem
label="First Race"
value="March 15, 2024"
icon="🏁"
/>
<MilestoneItem
label="First Win"
value="March 15, 2024 (Imola)"
icon="🏆"
/>
<MilestoneItem
label="Highest Rating"
value="1487 (Nov 2024)"
icon="📈"
/>
<MilestoneItem
label="Longest Win Streak"
value="4 races (Oct 2024)"
icon="🔥"
/>
<MilestoneItem
label="Most Wins (Track)"
value="Spa-Francorchamps (7)"
icon="🗺️"
/>
<MilestoneItem
label="Favorite Car"
value="Porsche 911 GT3 R (45 races)"
icon="🏎️"
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Achievements</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{mockAchievements.map((achievement) => (
<div
key={achievement.id}
className={`p-4 rounded-lg border ${rarityColors[achievement.rarity]}`}
>
<div className="flex items-start gap-3">
<div className="text-3xl">{achievement.icon}</div>
<div className="flex-1">
<div className="text-white font-medium mb-1">{achievement.title}</div>
<div className="text-xs text-gray-400 mb-2">{achievement.description}</div>
<div className="text-xs text-gray-500">
{new Date(achievement.unlockedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
</div>
</div>
</div>
))}
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">🎯</div>
<h3 className="text-lg font-semibold text-white">Next Goals</h3>
</div>
<div className="space-y-2 text-sm text-gray-400">
<div className="flex items-center justify-between">
<span>Win 25 races</span>
<span className="text-primary-blue">23/25</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2">
<div className="bg-primary-blue rounded-full h-2" style={{ width: '92%' }} />
</div>
</div>
</Card>
</div>
);
}
function MilestoneItem({ label, value, icon }: { label: string; value: string; icon: string }) {
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center gap-3">
<span className="text-xl">{icon}</span>
<span className="text-gray-400 text-sm">{label}</span>
</div>
<span className="text-white text-sm font-medium">{value}</span>
</div>
);
}

View File

@@ -1,69 +0,0 @@
'use client';
import { BarChart3 } from 'lucide-react';
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' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
interface CategoryDistributionProps {
drivers: {
category?: string;
}[];
}
export function CategoryDistribution({ drivers }: CategoryDistributionProps) {
const distribution = CATEGORIES.map((category) => ({
...category,
count: drivers.filter((d) => d.category === category.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.category === category.id).length / drivers.length) * 100)
: 0,
}));
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 border border-purple-400/20">
<BarChart3 className="w-5 h-5 text-purple-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Category Distribution</h2>
<p className="text-xs text-gray-500">Driver population by category</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{distribution.map((category) => (
<div
key={category.id}
className={`p-4 rounded-xl ${category.bgColor} border ${category.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<span className={`text-2xl font-bold ${category.color}`}>{category.count}</span>
</div>
<p className="text-white font-medium mb-1">{category.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
category.id === 'beginner' ? 'bg-green-400' :
category.id === 'intermediate' ? 'bg-primary-blue' :
category.id === 'advanced' ? 'bg-purple-400' :
category.id === 'pro' ? 'bg-yellow-400' :
category.id === 'endurance' ? 'bg-orange-400' :
'bg-red-400'
}`}
style={{ width: `${category.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{category.percentage}% of drivers</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import Input from '../ui/Input';
import Button from '../ui/Button';
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver";
import React, { useState, FormEvent } from 'react';
import { Input } from '@/ui/Input';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { TextArea } from '@/ui/TextArea';
import { InfoBox } from '@/ui/InfoBox';
import { AlertCircle } from 'lucide-react';
interface FormErrors {
name?: string;
@@ -15,9 +18,12 @@ interface FormErrors {
submit?: string;
}
export default function CreateDriverForm() {
const router = useRouter();
const createDriverMutation = useCreateDriver();
interface CreateDriverFormProps {
onSuccess: () => void;
isPending: boolean;
}
export function CreateDriverForm({ onSuccess, isPending }: CreateDriverFormProps) {
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
@@ -50,7 +56,7 @@ export default function CreateDriverForm() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (createDriverMutation.isPending) return;
if (isPending) return;
const isValid = await validateForm();
if (!isValid) return;
@@ -61,118 +67,89 @@ export default function CreateDriverForm() {
const firstName = parts[0] ?? displayName;
const lastName = parts.slice(1).join(' ') || 'Driver';
createDriverMutation.mutate(
{
firstName,
lastName,
displayName,
country: formData.country.trim().toUpperCase(),
...(bio ? { bio } : {}),
},
{
onSuccess: () => {
router.push(routes.protected.profile);
router.refresh();
},
onError: (error) => {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
},
}
);
// Construct data for parent to handle
const driverData = {
firstName,
lastName,
displayName,
country: formData.country.trim().toUpperCase(),
...(bio ? { bio } : {}),
};
console.log('Driver data to create:', driverData);
onSuccess();
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Driver Name *
</label>
<Box as="form" onSubmit={handleSubmit}>
<Stack gap={6}>
<Input
label="Driver Name *"
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, name: e.target.value })}
variant={errors.name ? 'error' : 'default'}
errorMessage={errors.name}
placeholder="Alex Vermeer"
disabled={createDriverMutation.isPending}
disabled={isPending}
/>
</div>
<div>
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
Display Name *
</label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
errorMessage={errors.name}
placeholder="Alex Vermeer"
disabled={createDriverMutation.isPending}
/>
</div>
<Box>
<Input
label="Country Code *"
id="country"
type="text"
value={formData.country}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setFormData({ ...formData, country: e.target.value })}
variant={errors.country ? 'error' : 'default'}
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={isPending}
/>
<Text size="xs" color="text-gray-500" mt={1} block>Use ISO 3166-1 alpha-2 or alpha-3 code</Text>
</Box>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country Code *
</label>
<Input
id="country"
type="text"
value={formData.country}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
error={!!errors.country}
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={createDriverMutation.isPending}
/>
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
</div>
<Box>
<TextArea
label="Bio (Optional)"
id="bio"
value={formData.bio}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={isPending}
/>
<Box display="flex" justifyContent="between" mt={1}>
{errors.bio ? (
<Text size="sm" color="text-warning-amber">{errors.bio}</Text>
) : <Box />}
<Text size="xs" color="text-gray-500">
{formData.bio.length}/500
</Text>
</Box>
</Box>
<div>
<label htmlFor="bio" className="block text-sm font-medium text-gray-300 mb-2">
Bio (Optional)
</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={createDriverMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 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 sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.bio.length}/500
</p>
{errors.bio && (
<p className="mt-2 text-sm text-warning-amber">{errors.bio}</p>
{errors.submit && (
<InfoBox
variant="warning"
icon={AlertCircle}
title="Error"
description={errors.submit}
/>
)}
</div>
{errors.submit && (
<div className="rounded-md bg-warning-amber/10 p-4 border border-warning-amber/20">
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
)}
<Button
type="submit"
variant="primary"
disabled={createDriverMutation.isPending}
className="w-full"
>
{createDriverMutation.isPending ? 'Creating Profile...' : 'Create Profile'}
<Button
type="submit"
variant="primary"
disabled={isPending}
fullWidth
>
{isPending ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
</>
</Stack>
</Box>
);
}

View File

@@ -1,79 +0,0 @@
import Card from '@/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverCardProps {
id: string;
name: string;
rating: number;
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
rank: number;
onClick?: () => void;
}
export default function DriverCard(props: DriverCardProps) {
const {
id,
name,
rating,
nationality,
racesCompleted,
wins,
podiums,
rank,
onClick,
} = props;
// Create a proper DriverViewModel instance
const driverViewModel = new DriverViewModel({
id,
name,
avatarUrl: null,
});
return (
<Card
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
{...(onClick ? { onClick } : {})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<RankBadge rank={rank} size="lg" />
<DriverIdentity
driver={driverViewModel}
href={`/drivers/${id}`}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</div>
<div className="flex items-center gap-8 text-center">
<div>
<div className="text-2xl font-bold text-primary-blue">{rating}</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{wins}</div>
<div className="text-xs text-gray-400">Wins</div>
</div>
<div>
<div className="text-2xl font-bold text-warning-amber">{podiums}</div>
<div className="text-xs text-gray-400">Podiums</div>
</div>
<div>
<div className="text-sm text-gray-400">
{racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0'}%
</div>
<div className="text-xs text-gray-500">Win Rate</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,73 +0,0 @@
import Link from 'next/link';
import Image from 'next/image';
import PlaceholderImage from '@/ui/PlaceholderImage';
export interface DriverIdentityProps {
driver: {
id: string;
name: string;
avatarUrl: string | null;
};
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
size?: 'sm' | 'md';
}
export function DriverIdentity(props: DriverIdentityProps) {
const { driver, href, contextLabel, meta, size = 'md' } = props;
const avatarSize = size === 'sm' ? 40 : 48;
const nameTextClasses =
size === 'sm'
? 'text-sm font-medium text-white'
: 'text-base md:text-lg font-semibold text-white';
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
// Use provided avatar URL or show placeholder if null
const avatarUrl = driver.avatarUrl;
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<div
className={`rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0`}
style={{ width: avatarSize, height: avatarSize }}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
className="w-full h-full object-cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={`${nameTextClasses} truncate`}>{driver.name}</span>
{contextLabel ? (
<span className="inline-flex items-center rounded-full bg-charcoal-outline/60 px-2 py-0.5 text-[10px] md:text-xs font-medium text-gray-200">
{contextLabel}
</span>
) : null}
</div>
{meta ? <div className={`${metaTextClasses} mt-0.5 truncate`}>{meta}</div> : null}
</div>
</div>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">{content}</div>;
}

View File

@@ -1,14 +1,18 @@
'use client';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { DriverProfileStatsViewModel } from '@/lib/view-models/DriverProfileViewModel';
import Card from '../ui/Card';
import ProfileHeader from '../profile/ProfileHeader';
import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile";
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { ProfileHeader } from '@/ui/ProfileHeader';
import { ProfileStats } from './ProfileStats';
import { CareerHighlights } from '@/ui/CareerHighlights';
import { DriverRankings } from '@/ui/DriverRankings';
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
interface DriverProfileProps {
driver: DriverViewModel;
@@ -23,8 +27,8 @@ interface DriverTeamViewModel {
};
}
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const { data: profileData, isLoading } = useDriverProfile(driver.id);
export function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const { data: profileData } = useDriverProfile(driver.id);
// Extract team data from profile
const teamData: DriverTeamViewModel | null = (() => {
@@ -32,7 +36,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
return null;
}
const currentTeam = profileData.teamMemberships.find(m => m.isCurrent) || profileData.teamMemberships[0];
const currentTeam = profileData.teamMemberships.find((m: { isCurrent: boolean }) => m.isCurrent) || profileData.teamMemberships[0];
if (!currentTeam) {
return null;
}
@@ -71,7 +75,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
] : [];
return (
<div className="space-y-6">
<Stack gap={6}>
<Card>
<ProfileHeader
driver={driver}
@@ -86,48 +90,50 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
{driver.bio && (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{driver.bio}</p>
<Heading level={3} mb={4}>About</Heading>
<Text color="text-gray-300" leading="relaxed" block>{driver.bio}</Text>
</Card>
)}
{driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Rating"
value={(driverStats.rating ?? 0).toString()}
color="text-primary-blue"
/>
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
</div>
</Card>
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
<Box responsiveColSpan={{ lg: 2 }}>
<Stack gap={6}>
<Card>
<Heading level={3} mb={4}>Career Statistics</Heading>
<Box display="grid" gridCols={2} gap={4}>
<StatCard
label="Rating"
value={driverStats.rating ?? 0}
variant="blue"
/>
<StatCard label="Total Races" value={driverStats.totalRaces} variant="blue" />
<StatCard label="Wins" value={driverStats.wins} variant="green" />
<StatCard label="Podiums" value={driverStats.podiums} variant="orange" />
</Box>
</Card>
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
</div>
{performanceStats && <PerformanceMetrics stats={performanceStats} />}
</Stack>
</Box>
<DriverRankings rankings={rankings} />
</div>
</Box>
)}
{!driverStats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-3">
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
<p className="text-gray-400 text-sm">
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
<Card responsiveColSpan={{ lg: 3 }}>
<Heading level={3} mb={4}>Career Statistics</Heading>
<Text color="text-gray-400" size="sm" block>
No statistics available yet. Compete in races to start building your record.
</p>
</Text>
</Card>
</div>
</Box>
)}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
<Heading level={3} mb={4}>Performance by Class</Heading>
{driverStats && (
<ProfileStats
stats={{
@@ -147,34 +153,25 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
<CareerHighlights />
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">🔒</div>
<h3 className="text-lg font-semibold text-white">Private Information</h3>
</div>
<p className="text-gray-400 text-sm">
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">🔒</Text>
<Heading level={3}>Private Information</Heading>
</Box>
<Text color="text-gray-400" size="sm" block>
Detailed race history, settings, and preferences are only visible to the driver.
</p>
</Text>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">📊</Text>
<Heading level={3}>Coming Soon</Heading>
</Box>
<Text color="text-gray-400" size="sm" block>
Per-car statistics, per-track performance, and head-to-head comparisons will be available in production.
</p>
</Text>
</Card>
</div>
</Stack>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="text-center p-4 rounded bg-deep-graphite border border-charcoal-outline">
<div className="text-sm text-gray-400 mb-1">{label}</div>
<div className={`text-2xl font-bold ${color}`}>{value}</div>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import Card from '@/ui/Card';
export interface DriverRanking {
type: 'overall' | 'league';
name: string;
rank: number;
totalDrivers: number;
percentile: number;
rating: number;
}
interface DriverRankingsProps {
rankings: DriverRanking[];
}
export default function DriverRankings({ rankings }: DriverRankingsProps) {
if (!rankings || rankings.length === 0) {
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">Rankings</h3>
<p className="text-sm text-gray-400">
No ranking data available yet. Compete in leagues to earn your first results.
</p>
</div>
</Card>
);
}
return (
<Card className="bg-iron-gray/60 border-charcoal-outline/80">
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Rankings</h3>
<div className="space-y-3">
{rankings.map((ranking, index) => (
<div
key={`${ranking.type}-${ranking.name}-${index}`}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-deep-graphite/60"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{ranking.name}
</span>
<span className="text-xs text-gray-400">
{ranking.type === 'overall' ? 'Overall' : 'League'} ranking
</span>
</div>
<div className="flex items-center gap-6 text-right text-xs">
<div>
<div className="text-primary-blue text-base font-semibold">
#{ranking.rank}
</div>
<div className="text-gray-500">Position</div>
</div>
<div>
<div className="text-white text-sm font-semibold">
{ranking.totalDrivers}
</div>
<div className="text-gray-500">Drivers</div>
</div>
<div>
<div className="text-green-400 text-sm font-semibold">
{ranking.percentile.toFixed(1)}%
</div>
<div className="text-gray-500">Percentile</div>
</div>
<div>
<div className="text-warning-amber text-sm font-semibold">
{ranking.rating}
</div>
<div className="text-gray-500">Rating</div>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
);
}

View File

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

View File

@@ -1,120 +0,0 @@
'use client';
import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
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' },
{ id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', description: 'Developing skills' },
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' },
];
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' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
interface FeaturedDriverCardProps {
driver: {
id: string;
name: string;
nationality: string;
avatarUrl?: string;
rating: number;
wins: number;
podiums: number;
skillLevel?: string;
category?: string;
};
position: number;
onClick: () => void;
}
export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const getBorderColor = (pos: number) => {
switch (pos) {
case 1: return 'border-yellow-400/50 hover:border-yellow-400';
case 2: return 'border-gray-300/50 hover:border-gray-300';
case 3: return 'border-amber-600/50 hover:border-amber-600';
default: return 'border-charcoal-outline hover:border-primary-blue';
}
};
const getMedalColor = (pos: number) => {
switch (pos) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
return (
<button
type="button"
onClick={onClick}
className={`p-5 rounded-xl bg-iron-gray/60 border-2 ${getBorderColor(position)} transition-all duration-200 text-left group hover:scale-[1.02]`}
>
{/* Header with Position */}
<div className="flex items-start justify-between mb-4">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${position <= 3 ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}`}>
{position <= 3 ? (
<Crown className={`w-5 h-5 ${getMedalColor(position)}`} />
) : (
<span className="text-lg font-bold text-gray-400">#{position}</span>
)}
</div>
<div className="flex gap-2">
{categoryConfig && (
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${categoryConfig.bgColor} ${categoryConfig.color} border ${categoryConfig.borderColor}`}>
{categoryConfig.label}
</span>
)}
<span className={`px-2 py-1 rounded-full text-[10px] font-medium ${levelConfig?.bgColor} ${levelConfig?.color} border ${levelConfig?.borderColor}`}>
{levelConfig?.label}
</span>
</div>
</div>
{/* Avatar & Name */}
<div className="flex items-center gap-4 mb-4">
<div className="relative w-16 h-16 rounded-full overflow-hidden border-2 border-charcoal-outline group-hover:border-primary-blue transition-colors">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-primary-blue transition-colors">
{driver.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Flag className="w-3.5 h-3.5" />
{driver.nationality}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-primary-blue">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-performance-green">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
<div className="text-center p-2 rounded-lg bg-charcoal-outline/30">
<p className="text-lg font-bold text-warning-amber">{driver.podiums}</p>
<p className="text-[10px] text-gray-500">Podiums</p>
</div>
</div>
</button>
);
}

View File

@@ -1,140 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { Award, Crown, Flag, ChevronRight } from 'lucide-react';
import Image from 'next/image';
import Button from '@/ui/Button';
import { mediaConfig } from '@/lib/config/mediaConfig';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface LeaderboardPreviewProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}
export function LeaderboardPreview({ drivers, onDriverClick }: LeaderboardPreviewProps) {
const router = useRouter();
const top5 = drivers.slice(0, 5);
const getMedalColor = (position: number) => {
switch (position) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
case 2: return 'bg-gray-300/10 border-gray-300/30';
case 3: return 'bg-amber-600/10 border-amber-600/30';
default: return 'bg-iron-gray/50 border-charcoal-outline';
}
};
return (
<div className="mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-400/20 to-amber-600/10 border border-yellow-400/30">
<Award className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Top Drivers</h2>
<p className="text-xs text-gray-500">Highest rated competitors</p>
</div>
</div>
<Button
variant="secondary"
onClick={() => router.push('/leaderboards/drivers')}
className="flex items-center gap-2 text-sm"
>
Full Rankings
<ChevronRight className="w-4 h-4" />
</Button>
</div>
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="divide-y divide-charcoal-outline/50">
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1;
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-4 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
{/* Avatar */}
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-primary-blue font-mono font-semibold">{driver.rating.toLocaleString()}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
'use client';
import Card from '../ui/Card';
interface PerformanceMetricsProps {
stats: {
winRate: number;
podiumRate: number;
dnfRate: number;
avgFinish: number;
consistency: number;
bestFinish: number;
worstFinish: number;
};
}
export default function PerformanceMetrics({ stats }: PerformanceMetricsProps) {
const getPerformanceColor = (value: number, type: 'rate' | 'finish' | 'consistency') => {
if (type === 'rate') {
if (value >= 30) return 'text-green-400';
if (value >= 15) return 'text-warning-amber';
return 'text-gray-300';
}
if (type === 'consistency') {
if (value >= 80) return 'text-green-400';
if (value >= 60) return 'text-warning-amber';
return 'text-gray-300';
}
return 'text-white';
};
const metrics = [
{
label: 'Win Rate',
value: `${stats.winRate.toFixed(1)}%`,
color: getPerformanceColor(stats.winRate, 'rate'),
icon: '🏆'
},
{
label: 'Podium Rate',
value: `${stats.podiumRate.toFixed(1)}%`,
color: getPerformanceColor(stats.podiumRate, 'rate'),
icon: '🥇'
},
{
label: 'DNF Rate',
value: `${stats.dnfRate.toFixed(1)}%`,
color: stats.dnfRate < 10 ? 'text-green-400' : 'text-danger-red',
icon: '❌'
},
{
label: 'Avg Finish',
value: stats.avgFinish.toFixed(1),
color: 'text-white',
icon: '📊'
},
{
label: 'Consistency',
value: `${stats.consistency.toFixed(0)}%`,
color: getPerformanceColor(stats.consistency, 'consistency'),
icon: '🎯'
},
{
label: 'Best / Worst',
value: `${stats.bestFinish} / ${stats.worstFinish}`,
color: 'text-gray-300',
icon: '📈'
}
];
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{metrics.map((metric, index) => (
<Card key={index} className="text-center">
<div className="text-2xl mb-2">{metric.icon}</div>
<div className="text-sm text-gray-400 mb-1">{metric.label}</div>
<div className={`text-xl font-bold ${metric.color}`}>{metric.value}</div>
</Card>
))}
</div>
);
}

View File

@@ -1,14 +1,21 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { EmptyState } from '@/ui/EmptyState';
import { Pagination } from '@/ui/Pagination';
import { Trophy } from 'lucide-react';
interface RaceHistoryProps {
driverId: string;
}
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
@@ -32,94 +39,72 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
const filteredResults: Array<unknown> = [];
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
const paginatedResults = filteredResults.slice(
(page - 1) * resultsPerPage,
page * resultsPerPage
);
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Stack gap={4}>
<Box display="flex" alignItems="center" gap={2}>
{[1, 2, 3].map(i => (
<div key={i} className="h-9 w-24 bg-iron-gray rounded animate-pulse" />
<Box key={i} h="9" w="24" bg="bg-iron-gray" rounded="md" animate="pulse" />
))}
</div>
</Box>
<Card>
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-deep-graphite rounded animate-pulse" />
))}
</div>
<LoadingWrapper variant="skeleton" skeletonCount={3} />
</Card>
</div>
</Stack>
);
}
if (filteredResults.length === 0) {
return (
<Card className="text-center py-12">
<p className="text-gray-400 mb-2">No race history yet</p>
<p className="text-sm text-gray-500">Complete races to build your racing record</p>
</Card>
<EmptyState
icon={Trophy}
title="No race history yet"
description="Complete races to build your racing record"
/>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Stack gap={4}>
<Box display="flex" alignItems="center" gap={2}>
<Button
variant={filter === 'all' ? 'primary' : 'secondary'}
onClick={() => { setFilter('all'); setPage(1); }}
className="text-sm"
size="sm"
>
All Races
</Button>
<Button
variant={filter === 'wins' ? 'primary' : 'secondary'}
onClick={() => { setFilter('wins'); setPage(1); }}
className="text-sm"
size="sm"
>
Wins Only
</Button>
<Button
variant={filter === 'podiums' ? 'primary' : 'secondary'}
onClick={() => { setFilter('podiums'); setPage(1); }}
className="text-sm"
size="sm"
>
Podiums
</Button>
</div>
</Box>
<Card>
<div className="space-y-2">
{/* No results until API provides driver results */}
</div>
{/* No results until API provides driver results */}
<Box minHeight="100px" display="flex" center>
<Text color="text-gray-500">No results found for the selected filter.</Text>
</Box>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4 pt-4 border-t border-charcoal-outline">
<Button
variant="secondary"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="text-sm"
>
Previous
</Button>
<span className="text-gray-400 text-sm">
Page {page} of {totalPages}
</span>
<Button
variant="secondary"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="text-sm"
>
Next
</Button>
</div>
)}
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filteredResults.length}
itemsPerPage={resultsPerPage}
onPageChange={setPage}
/>
</Card>
</div>
</Stack>
);
}

View File

@@ -2,16 +2,23 @@
import { useState } from 'react';
import type { DriverProfileDriverSummaryViewModel } from '@/lib/view-models/DriverProfileViewModel';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Toggle } from '@/ui/Toggle';
import { TextArea } from '@/ui/TextArea';
import { Checkbox } from '@/ui/Checkbox';
interface ProfileSettingsProps {
driver: DriverProfileDriverSummaryViewModel;
onSave?: (updates: { bio?: string; country?: string }) => void;
}
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
export function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {
const [bio, setBio] = useState(driver.bio || '');
const [nationality, setNationality] = useState(driver.country);
const [favoriteCarClass, setFavoriteCarClass] = useState('GT3');
@@ -27,147 +34,122 @@ export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps
};
return (
<div className="space-y-6">
<Stack gap={6}>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Profile Information</h3>
<Heading level={3} mb={4}>Profile Information</Heading>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Bio</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent resize-none"
rows={4}
placeholder="Tell us about yourself..."
/>
</div>
<Stack gap={4}>
<TextArea
label="Bio"
value={bio}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBio(e.target.value)}
rows={4}
placeholder="Tell us about yourself..."
/>
<div>
<label className="block text-sm text-gray-400 mb-2">Nationality</label>
<Input
type="text"
value={nationality}
onChange={(e) => setNationality(e.target.value)}
placeholder="e.g., US, GB, DE"
maxLength={2}
/>
</div>
</div>
<Input
label="Nationality"
type="text"
value={nationality}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNationality(e.target.value)}
placeholder="e.g., US, GB, DE"
maxLength={2}
/>
</Stack>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Racing Preferences</h3>
<Heading level={3} mb={4}>Racing Preferences</Heading>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Favorite Car Class</label>
<select
value={favoriteCarClass}
onChange={(e) => setFavoriteCarClass(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
>
<option value="GT3">GT3</option>
<option value="GT4">GT4</option>
<option value="Formula">Formula</option>
<option value="LMP2">LMP2</option>
<option value="Touring">Touring Cars</option>
<option value="NASCAR">NASCAR</option>
</select>
</div>
<Stack gap={4}>
<Select
label="Favorite Car Class"
value={favoriteCarClass}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteCarClass(e.target.value)}
options={[
{ value: 'GT3', label: 'GT3' },
{ value: 'GT4', label: 'GT4' },
{ value: 'Formula', label: 'Formula' },
{ value: 'LMP2', label: 'LMP2' },
{ value: 'Touring', label: 'Touring Cars' },
{ value: 'NASCAR', label: 'NASCAR' },
]}
/>
<div>
<label className="block text-sm text-gray-400 mb-2">Favorite Series Type</label>
<select
value={favoriteSeries}
onChange={(e) => setFavoriteSeries(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
>
<option value="Sprint">Sprint</option>
<option value="Endurance">Endurance</option>
<option value="Mixed">Mixed</option>
</select>
</div>
<Select
label="Favorite Series Type"
value={favoriteSeries}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFavoriteSeries(e.target.value)}
options={[
{ value: 'Sprint', label: 'Sprint' },
{ value: 'Endurance', label: 'Endurance' },
{ value: 'Mixed', label: 'Mixed' },
]}
/>
<div>
<label className="block text-sm text-gray-400 mb-2">Competitive Level</label>
<select
value={competitiveLevel}
onChange={(e) => setCompetitiveLevel(e.target.value)}
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent"
>
<option value="casual">Casual - Just for fun</option>
<option value="competitive">Competitive - Aiming to win</option>
<option value="professional">Professional - Esports focused</option>
</select>
</div>
<Select
label="Competitive Level"
value={competitiveLevel}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCompetitiveLevel(e.target.value)}
options={[
{ value: 'casual', label: 'Casual - Just for fun' },
{ value: 'competitive', label: 'Competitive - Aiming to win' },
{ value: 'professional', label: 'Professional - Esports focused' },
]}
/>
<div>
<label className="block text-sm text-gray-400 mb-2">Preferred Regions</label>
<div className="space-y-2">
<Stack gap={2}>
<Heading level={4}>Preferred Regions</Heading>
<Stack gap={2}>
{['NA', 'EU', 'ASIA', 'OCE'].map(region => (
<label key={region} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={preferredRegions.includes(region)}
onChange={(e) => {
if (e.target.checked) {
setPreferredRegions([...preferredRegions, region]);
} else {
setPreferredRegions(preferredRegions.filter(r => r !== region));
}
}}
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
<span className="text-white text-sm">{region}</span>
</label>
<Checkbox
key={region}
label={region}
checked={preferredRegions.includes(region)}
onChange={(checked) => {
if (checked) {
setPreferredRegions([...preferredRegions, region]);
} else {
setPreferredRegions(preferredRegions.filter(r => r !== region));
}
}}
/>
))}
</div>
</div>
</div>
</Stack>
</Stack>
</Stack>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Privacy Settings</h3>
<Heading level={3} mb={4}>Privacy Settings</Heading>
<div className="space-y-3">
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Show profile to other drivers</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Show race history</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
<label className="flex items-center justify-between cursor-pointer">
<span className="text-white text-sm">Allow friend requests</span>
<input
type="checkbox"
defaultChecked
className="w-4 h-4 bg-deep-graphite border-charcoal-outline rounded text-primary-blue focus:ring-primary-blue"
/>
</label>
</div>
<Stack gap={0}>
<Toggle
checked={true}
onChange={() => {}}
label="Show profile to other drivers"
/>
<Toggle
checked={true}
onChange={() => {}}
label="Show race history"
/>
<Toggle
checked={true}
onChange={() => {}}
label="Allow friend requests"
/>
</Stack>
</Card>
<div className="flex gap-3">
<Button variant="primary" onClick={handleSave} className="flex-1">
<Box display="flex" gap={3}>
<Button variant="primary" onClick={handleSave} fullWidth>
Save Changes
</Button>
<Button variant="secondary" className="flex-1">
<Button variant="secondary" fullWidth>
Cancel
</Button>
</div>
</div>
</Box>
</Stack>
);
}
}

View File

@@ -1,9 +1,14 @@
'use client';
import { useDriverProfile } from "@/lib/hooks/driver";
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";
import { useMemo } from 'react';
import Card from '../ui/Card';
import RankBadge from './RankBadge';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { RankBadge } from '@/ui/RankBadge';
interface ProfileStatsProps {
driverId?: string;
@@ -17,15 +22,12 @@ interface ProfileStatsProps {
};
}
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
export function ProfileStats({ driverId, stats }: ProfileStatsProps) {
const { data: profileData } = useDriverProfile(driverId ?? '');
const driverStats = profileData?.stats ?? null;
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
// League rank widget needs a dedicated API contract; keep it disabled until provided.
// (Leaving UI block out avoids `never` typing issues.)
const defaultStats = useMemo(() => {
if (stats) {
return stats;
@@ -78,132 +80,102 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
};
return (
<div className="space-y-6">
<Stack gap={6}>
{driverStats && (
<Card>
<h3 className="text-xl font-semibold text-white mb-6">Rankings Dashboard</h3>
<Heading level={2} mb={6}>Rankings Dashboard</Heading>
<div className="space-y-4">
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Stack gap={4}>
<Box p={4} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<Box display="flex" alignItems="center" gap={3}>
<RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
<div>
<div className="text-white font-medium text-lg">Overall Ranking</div>
<div className="text-sm text-gray-400">
<Box>
<Text color="text-white" weight="medium" size="lg" block>Overall Ranking</Text>
<Text size="sm" color="text-gray-400" block>
{driverStats.overallRank ?? 0} of {totalDrivers} drivers
</div>
</div>
</div>
<div className="text-right">
<div
className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`}
</Text>
</Box>
</Box>
<Box textAlign="right">
<Text
size="sm"
weight="medium"
color={getPercentileColor(driverStats.percentile ?? 0)}
block
>
{getPercentileLabel(driverStats.percentile ?? 0)}
</div>
<div className="text-xs text-gray-500">Global Percentile</div>
</div>
</div>
</Text>
<Text size="xs" color="text-gray-500" block>Global Percentile</Text>
</Box>
</Box>
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
<div className="text-center">
<div className="text-2xl font-bold text-primary-blue">
<Box display="grid" gridCols={3} gap={4} pt={3} borderTop borderColor="border-charcoal-outline">
<Box textAlign="center">
<Text size="2xl" weight="bold" color="text-primary-blue" block>
{driverStats.rating ?? 0}
</div>
<div className="text-xs text-gray-400">Rating</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-green-400">
</Text>
<Text size="xs" color="text-gray-400" block>Rating</Text>
</Box>
<Box textAlign="center">
<Text size="lg" weight="bold" color="text-green-400" block>
{getTrendIndicator(5)} {winRate}%
</div>
<div className="text-xs text-gray-400">Win Rate</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-warning-amber">
</Text>
<Text size="xs" color="text-gray-400" block>Win Rate</Text>
</Box>
<Box textAlign="center">
<Text size="lg" weight="bold" color="text-warning-amber" block>
{getTrendIndicator(2)} {podiumRate}%
</div>
<div className="text-xs text-gray-400">Podium Rate</div>
</div>
</div>
</div>
{/* Primary-league ranking removed until we have a dedicated API + view model for league ranks. */}
</div>
</Text>
<Text size="xs" color="text-gray-400" block>Podium Rate</Text>
</Box>
</Box>
</Box>
</Stack>
</Card>
)}
{defaultStats ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{
label: 'Total Races',
value: defaultStats.totalRaces,
color: 'text-primary-blue',
},
{ label: 'Wins', value: defaultStats.wins, color: 'text-green-400' },
{
label: 'Podiums',
value: defaultStats.podiums,
color: 'text-warning-amber',
},
{ label: 'DNFs', value: defaultStats.dnfs, color: 'text-red-400' },
{
label: 'Avg Finish',
value: defaultStats.avgFinish.toFixed(1),
color: 'text-white',
},
{
label: 'Completion',
value: `${defaultStats.completionRate.toFixed(1)}%`,
color: 'text-green-400',
},
{ label: 'Win Rate', value: `${winRate}%`, color: 'text-primary-blue' },
{
label: 'Podium Rate',
value: `${podiumRate}%`,
color: 'text-warning-amber',
},
].map((stat, index) => (
<Card key={index} className="text-center">
<div className="text-sm text-gray-400 mb-1">{stat.label}</div>
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
</Card>
))}
</div>
</>
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
<StatCard label="Total Races" value={defaultStats.totalRaces} variant="blue" />
<StatCard label="Wins" value={defaultStats.wins} variant="green" />
<StatCard label="Podiums" value={defaultStats.podiums} variant="orange" />
<StatCard label="DNFs" value={defaultStats.dnfs} variant="blue" />
<StatCard label="Avg Finish" value={defaultStats.avgFinish.toFixed(1)} variant="blue" />
<StatCard label="Completion" value={`${defaultStats.completionRate.toFixed(1)}%`} variant="green" />
<StatCard label="Win Rate" value={`${winRate}%`} variant="blue" />
<StatCard label="Podium Rate" value={`${podiumRate}%`} variant="orange" />
</Box>
) : (
<Card>
<h3 className="text-lg font-semibold text-white mb-2">Career Statistics</h3>
<p className="text-sm text-gray-400">
<Heading level={3} mb={2}>Career Statistics</Heading>
<Text size="sm" color="text-gray-400" block>
No statistics available yet. Compete in races to start building your record.
</p>
</Text>
</Card>
)}
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📊</div>
<h3 className="text-lg font-semibold text-white">Performance by Car Class</h3>
</div>
<p className="text-gray-400 text-sm">
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">📊</Text>
<Heading level={3}>Performance by Car Class</Heading>
</Box>
<Text color="text-gray-400" size="sm" block>
Detailed per-car and per-class performance breakdowns will be available in a future
version once more race history data is tracked.
</p>
</Text>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Coming Soon</h3>
</div>
<p className="text-gray-400 text-sm">
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">📈</Text>
<Heading level={3}>Coming Soon</Heading>
</Box>
<Text color="text-gray-400" size="sm" block>
Performance trends, track-specific stats, head-to-head comparisons vs friends, and
league member comparisons will be available in production.
</p>
</Text>
</Card>
</div>
</Stack>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
interface RankBadgeProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
}
export default function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) {
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return null;
}
};
const medal = getMedalEmoji(rank);
const sizeClasses = {
sm: 'text-sm px-2 py-1',
md: 'text-base px-3 py-1.5',
lg: 'text-lg px-4 py-2'
};
const getRankColor = (rank: number) => {
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
};
return (
<span className={`inline-flex items-center gap-1.5 rounded font-medium border ${getRankColor(rank)} ${sizeClasses[size]}`}>
{medal && <span>{medal}</span>}
{showLabel && <span>#{rank}</span>}
{!showLabel && !medal && <span>#{rank}</span>}
</span>
);
}

View File

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

View File

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

View File

@@ -1,203 +0,0 @@
'use client';
import { Card } from '@/ui/Card';
interface RatingBreakdownProps {
skillRating?: number;
safetyRating?: number;
sportsmanshipRating?: number;
}
export default function RatingBreakdown({
skillRating = 1450,
safetyRating = 92,
sportsmanshipRating = 4.8
}: RatingBreakdownProps) {
return (
<div className="space-y-6">
<Card>
<h3 className="text-lg font-semibold text-white mb-6">Rating Components</h3>
<div className="space-y-6">
<RatingComponent
label="Skill Rating"
value={skillRating}
maxValue={2000}
color="primary-blue"
description="Based on race results, competition strength, and consistency"
breakdown={[
{ label: 'Race Results', percentage: 60 },
{ label: 'Competition Quality', percentage: 25 },
{ label: 'Consistency', percentage: 15 }
]}
/>
<RatingComponent
label="Safety Rating"
value={safetyRating}
maxValue={100}
color="green-400"
suffix="%"
description="Reflects incident-free racing and clean overtakes"
breakdown={[
{ label: 'Incident Rate', percentage: 70 },
{ label: 'Clean Overtakes', percentage: 20 },
{ label: 'Position Awareness', percentage: 10 }
]}
/>
<RatingComponent
label="Sportsmanship"
value={sportsmanshipRating}
maxValue={5}
color="warning-amber"
suffix="/5"
description="Community feedback on racing behavior and fair play"
breakdown={[
{ label: 'Peer Reviews', percentage: 50 },
{ label: 'Fair Racing', percentage: 30 },
{ label: 'Team Play', percentage: 20 }
]}
/>
</div>
</Card>
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Rating History</h3>
<div className="space-y-3">
<HistoryItem
date="November 2024"
skillChange={+15}
safetyChange={+2}
sportsmanshipChange={0}
/>
<HistoryItem
date="October 2024"
skillChange={+28}
safetyChange={-1}
sportsmanshipChange={+0.1}
/>
<HistoryItem
date="September 2024"
skillChange={-12}
safetyChange={+3}
sportsmanshipChange={0}
/>
</div>
</Card>
<Card className="bg-charcoal-200/50 border-primary-blue/30">
<div className="flex items-center gap-3 mb-3">
<div className="text-2xl">📈</div>
<h3 className="text-lg font-semibold text-white">Rating Insights</h3>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-start gap-2">
<span className="text-green-400 mt-0.5"></span>
<span>Strong safety rating - keep up the clean racing!</span>
</li>
<li className="flex items-start gap-2">
<span className="text-warning-amber mt-0.5"></span>
<span>Skill rating improving - competitive against higher-rated drivers</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary-blue mt-0.5">i</span>
<span>Complete more races to stabilize your ratings</span>
</li>
</ul>
</Card>
</div>
);
}
function RatingComponent({
label,
value,
maxValue,
color,
suffix = '',
description,
breakdown
}: {
label: string;
value: number;
maxValue: number;
color: string;
suffix?: string;
description: string;
breakdown: { label: string; percentage: number }[];
}) {
const percentage = (value / maxValue) * 100;
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">{label}</span>
<span className={`text-2xl font-bold text-${color}`}>
{value}{suffix}
</span>
</div>
<div className="w-full bg-deep-graphite rounded-full h-2 mb-3">
<div
className={`bg-${color} rounded-full h-2 transition-all duration-500`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-400 mb-3">{description}</p>
<div className="space-y-1">
{breakdown.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-gray-500">{item.label}</span>
<span className="text-gray-400">{item.percentage}%</span>
</div>
))}
</div>
</div>
);
}
function HistoryItem({
date,
skillChange,
safetyChange,
sportsmanshipChange
}: {
date: string;
skillChange: number;
safetyChange: number;
sportsmanshipChange: number;
}) {
const formatChange = (value: number) => {
if (value === 0) return '—';
return value > 0 ? `+${value}` : `${value}`;
};
const getChangeColor = (value: number) => {
if (value === 0) return 'text-gray-500';
return value > 0 ? 'text-green-400' : 'text-red-400';
};
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<span className="text-white text-sm">{date}</span>
<div className="flex items-center gap-4 text-xs">
<div className="text-center">
<div className="text-gray-500 mb-1">Skill</div>
<div className={getChangeColor(skillChange)}>{formatChange(skillChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Safety</div>
<div className={getChangeColor(safetyChange)}>{formatChange(safetyChange)}</div>
</div>
<div className="text-center">
<div className="text-gray-500 mb-1">Sports</div>
<div className={getChangeColor(sportsmanshipChange)}>{formatChange(sportsmanshipChange)}</div>
</div>
</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
'use client';
import { Activity } from 'lucide-react';
import Image from 'next/image';
import { mediaConfig } from '@/lib/config/mediaConfig';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface RecentActivityProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
isActive: boolean;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
}
export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-performance-green/10 border border-performance-green/20">
<Activity className="w-5 h-5 text-performance-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Active Drivers</h2>
<p className="text-xs text-gray-500">Currently competing in leagues</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="p-3 rounded-xl bg-iron-gray/40 border border-charcoal-outline hover:border-performance-green/40 transition-all group text-center"
>
<div className="relative w-12 h-12 mx-auto rounded-full overflow-hidden border-2 border-charcoal-outline mb-2">
<Image src={driver.avatarUrl || mediaConfig.avatars.defaultFallback} alt={driver.name} fill className="object-cover" />
<div className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-performance-green border-2 border-iron-gray" />
</div>
<p className="text-sm font-medium text-white truncate group-hover:text-performance-green transition-colors">
{driver.name}
</p>
<div className="flex items-center justify-center gap-1 text-xs">
{categoryConfig && (
<span className={categoryConfig.color}>{categoryConfig.label}</span>
)}
<span className={levelConfig?.color}>{levelConfig?.label}</span>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
'use client';
import { BarChart3 } from 'lucide-react';
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' },
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
];
interface SkillDistributionProps {
drivers: {
skillLevel?: string;
}[];
}
export function SkillDistribution({ drivers }: SkillDistributionProps) {
const distribution = SKILL_LEVELS.map((level) => ({
...level,
count: drivers.filter((d) => d.skillLevel === level.id).length,
percentage: drivers.length > 0
? Math.round((drivers.filter((d) => d.skillLevel === level.id).length / drivers.length) * 100)
: 0,
}));
return (
<div className="mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-neon-aqua/10 border border-neon-aqua/20">
<BarChart3 className="w-5 h-5 text-neon-aqua" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Skill Distribution</h2>
<p className="text-xs text-gray-500">Driver population by skill level</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{distribution.map((level) => {
const Icon = level.icon;
return (
<div
key={level.id}
className={`p-4 rounded-xl ${level.bgColor} border ${level.borderColor}`}
>
<div className="flex items-center justify-between mb-3">
<Icon className={`w-5 h-5 ${level.color}`} />
<span className={`text-2xl font-bold ${level.color}`}>{level.count}</span>
</div>
<p className="text-white font-medium mb-1">{level.label}</p>
<div className="w-full h-2 rounded-full bg-deep-graphite/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
level.id === 'pro' ? 'bg-yellow-400' :
level.id === 'advanced' ? 'bg-purple-400' :
level.id === 'intermediate' ? 'bg-primary-blue' :
'bg-green-400'
}`}
style={{ width: `${level.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{level.percentage}% of drivers</p>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import React, { Component, ReactNode } from 'react';
import React, { Component, ReactNode, useState } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ErrorDisplay } from './ErrorDisplay';
@@ -45,7 +45,7 @@ export class ApiErrorBoundary extends Component<Props, State> {
throw error;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
componentDidCatch(error: Error): void {
if (error instanceof ApiError) {
// Report to connection monitor
connectionMonitor.recordFailure(error);
@@ -130,8 +130,8 @@ export class ApiErrorBoundary extends Component<Props, State> {
* Hook-based alternative for functional components
*/
export function useApiErrorBoundary() {
const [error, setError] = React.useState<ApiError | null>(null);
const [isDev] = React.useState(process.env.NODE_ENV === 'development');
const [error, setError] = useState<ApiError | null>(null);
const [isDev] = useState(process.env.NODE_ENV === 'development');
const handleError = (err: ApiError) => {
setError(err);

View File

@@ -1,10 +1,18 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { useState, useEffect } from 'react';
import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
interface DevErrorPanelProps {
error: ApiError;
@@ -80,268 +88,295 @@ export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
};
const getSeverityColor = (type: string) => {
const getSeverityVariant = (): 'danger' | 'warning' | 'info' | 'default' => {
switch (error.getSeverity()) {
case 'error': return 'bg-red-500/20 text-red-400 border-red-500/40';
case 'warn': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40';
case 'info': return 'bg-blue-500/20 text-blue-400 border-blue-500/40';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/40';
case 'error': return 'danger';
case 'warn': return 'warning';
case 'info': return 'info';
default: return 'default';
}
};
const reliability = connectionMonitor.getReliability();
return (
<div className="fixed inset-0 z-50 overflow-auto bg-deep-graphite p-4 font-mono text-sm">
<div className="max-w-6xl mx-auto space-y-4">
{/* Header */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-primary-blue" />
<h2 className="text-lg font-bold text-white">API Error Debug Panel</h2>
<span className={`px-2 py-1 rounded border text-xs ${getSeverityColor(error.type)}`}>
{error.type}
</span>
</div>
<div className="flex gap-2">
<button
onClick={copyToClipboard}
className="px-3 py-1 bg-iron-gray hover:bg-charcoal-outline border border-charcoal-outline rounded text-gray-300 flex items-center gap-2"
title="Copy debug info"
>
<Copy className="w-4 h-4" />
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={onReset}
className="px-3 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center gap-2"
>
<X className="w-4 h-4" />
Close
</button>
</div>
</div>
<Box
position="fixed"
inset="0"
zIndex={50}
overflow="auto"
bg="bg-deep-graphite"
p={4}
>
<Box maxWidth="6xl" mx="auto">
<Stack gap={4}>
{/* Header */}
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="lg" p={4} display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={3}>
<Icon icon={Terminal} size={5} color="rgb(59, 130, 246)" />
<Heading level={2}>API Error Debug Panel</Heading>
<Badge variant={getSeverityVariant()}>
{error.type}
</Badge>
</Box>
<Box display="flex" gap={2}>
<Button
variant="secondary"
onClick={copyToClipboard}
icon={<Icon icon={Copy} size={4} />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button
variant="primary"
onClick={onReset}
icon={<Icon icon={X} size={4} />}
>
Close
</Button>
</Box>
</Box>
{/* Error Details */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Error Details
</div>
<div className="p-4 space-y-2 text-xs">
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Type:</span>
<span className="col-span-2 text-red-400 font-bold">{error.type}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Message:</span>
<span className="col-span-2 text-gray-300">{error.message}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Endpoint:</span>
<span className="col-span-2 text-blue-400">{error.context.endpoint || 'N/A'}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Method:</span>
<span className="col-span-2 text-yellow-400">{error.context.method || 'N/A'}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Status:</span>
<span className="col-span-2">{error.context.statusCode || 'N/A'}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Retry Count:</span>
<span className="col-span-2">{error.context.retryCount || 0}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Timestamp:</span>
<span className="col-span-2 text-gray-500">{error.context.timestamp}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Retryable:</span>
<span className={`col-span-2 ${error.isRetryable() ? 'text-green-400' : 'text-red-400'}`}>
{error.isRetryable() ? 'Yes' : 'No'}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Connectivity:</span>
<span className={`col-span-2 ${error.isConnectivityIssue() ? 'text-red-400' : 'text-green-400'}`}>
{error.isConnectivityIssue() ? 'Yes' : 'No'}
</span>
</div>
{error.context.troubleshooting && (
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Troubleshoot:</span>
<span className="col-span-2 text-yellow-400">{error.context.troubleshooting}</span>
</div>
)}
</div>
</div>
{/* Error Details */}
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
<Stack gap={4}>
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Icon icon={AlertTriangle} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Error Details</Text>
</Box>
<Box p={4}>
<Stack gap={2} fontSize="0.75rem">
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Type:</Text>
<Text colSpan={2} color="text-red-400" weight="bold">{error.type}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Message:</Text>
<Text colSpan={2} color="text-gray-300">{error.message}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Endpoint:</Text>
<Text colSpan={2} color="text-primary-blue">{error.context.endpoint || 'N/A'}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Method:</Text>
<Text colSpan={2} color="text-warning-amber">{error.context.method || 'N/A'}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Status:</Text>
<Text colSpan={2}>{error.context.statusCode || 'N/A'}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Retry Count:</Text>
<Text colSpan={2}>{error.context.retryCount || 0}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Timestamp:</Text>
<Text colSpan={2} color="text-gray-500">{error.context.timestamp}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Retryable:</Text>
<Text colSpan={2} color={error.isRetryable() ? 'text-performance-green' : 'text-red-400'}>
{error.isRetryable() ? 'Yes' : 'No'}
</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Connectivity:</Text>
<Text colSpan={2} color={error.isConnectivityIssue() ? 'text-red-400' : 'text-performance-green'}>
{error.isConnectivityIssue() ? 'Yes' : 'No'}
</Text>
</Box>
{error.context.troubleshooting && (
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Troubleshoot:</Text>
<Text colSpan={2} color="text-warning-amber">{error.context.troubleshooting}</Text>
</Box>
)}
</Stack>
</Box>
</Surface>
{/* Connection Status */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
<Activity className="w-4 h-4" />
Connection Health
</div>
<div className="p-4 space-y-2 text-xs">
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Status:</span>
<span className={`col-span-2 font-bold ${
connectionStatus.status === 'connected' ? 'text-green-400' :
connectionStatus.status === 'degraded' ? 'text-yellow-400' :
'text-red-400'
}`}>
{connectionStatus.status.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Reliability:</span>
<span className="col-span-2">{reliability.toFixed(2)}%</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Total Requests:</span>
<span className="col-span-2">{connectionStatus.totalRequests}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Successful:</span>
<span className="col-span-2 text-green-400">{connectionStatus.successfulRequests}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Failed:</span>
<span className="col-span-2 text-red-400">{connectionStatus.failedRequests}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Consecutive Failures:</span>
<span className="col-span-2">{connectionStatus.consecutiveFailures}</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Avg Response:</span>
<span className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</span>
</div>
<div className="grid grid-cols-3 gap-2">
<span className="text-gray-500">Last Check:</span>
<span className="col-span-2 text-gray-500">
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
</span>
</div>
</div>
</div>
</div>
{/* Connection Status */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Icon icon={Activity} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Connection Health</Text>
</Box>
<Box p={4}>
<Stack gap={2} fontSize="0.75rem">
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Status:</Text>
<Text colSpan={2} weight="bold" color={
connectionStatus.status === 'connected' ? 'text-performance-green' :
connectionStatus.status === 'degraded' ? 'text-warning-amber' :
'text-red-400'
}>
{connectionStatus.status.toUpperCase()}
</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Reliability:</Text>
<Text colSpan={2}>{reliability.toFixed(2)}%</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Total Requests:</Text>
<Text colSpan={2}>{connectionStatus.totalRequests}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Successful:</Text>
<Text colSpan={2} color="text-performance-green">{connectionStatus.successfulRequests}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Failed:</Text>
<Text colSpan={2} color="text-red-400">{connectionStatus.failedRequests}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Consecutive Failures:</Text>
<Text colSpan={2}>{connectionStatus.consecutiveFailures}</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Avg Response:</Text>
<Text colSpan={2}>{connectionStatus.averageResponseTime.toFixed(2)}ms</Text>
</Box>
<Box display="grid" gridCols={3} gap={2}>
<Text color="text-gray-500">Last Check:</Text>
<Text colSpan={2} color="text-gray-500">
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
</Text>
</Box>
</Stack>
</Box>
</Surface>
</Stack>
{/* Right Column */}
<div className="space-y-4">
{/* Circuit Breakers */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
<span className="text-lg"></span>
Circuit Breakers
</div>
<div className="p-4">
{Object.keys(circuitBreakers).length === 0 ? (
<div className="text-gray-500 text-center py-4">No circuit breakers active</div>
) : (
<div className="space-y-2 text-xs max-h-48 overflow-auto">
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
<div key={endpoint} className="flex items-center justify-between p-2 bg-deep-graphite rounded border border-charcoal-outline">
<span className="text-blue-400 truncate flex-1">{endpoint}</span>
<span className={`px-2 py-1 rounded ${
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>
{status.state}
</span>
<span className="text-gray-500 ml-2">{status.failures} failures</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right Column */}
<Stack gap={4}>
{/* Circuit Breakers */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Text size="lg"></Text>
<Text weight="semibold" color="text-white">Circuit Breakers</Text>
</Box>
<Box p={4}>
{Object.keys(circuitBreakers).length === 0 ? (
<Box textAlign="center" py={4}>
<Text color="text-gray-500">No circuit breakers active</Text>
</Box>
) : (
<Stack gap={2} maxHeight="12rem" overflow="auto" fontSize="0.75rem">
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
<Box key={endpoint} display="flex" alignItems="center" justifyContent="between" p={2} bg="bg-deep-graphite" rounded="md" border borderColor="border-charcoal-outline">
<Text color="text-primary-blue" truncate flexGrow={1}>{endpoint}</Text>
<Box px={2} py={1} rounded="sm" bg={
status.state === 'CLOSED' ? 'bg-green-500/20' :
status.state === 'OPEN' ? 'bg-red-500/20' :
'bg-yellow-500/20'
}>
<Text color={
status.state === 'CLOSED' ? 'text-performance-green' :
status.state === 'OPEN' ? 'text-red-400' :
'text-warning-amber'
}>
{status.state}
</Text>
</Box>
<Text color="text-gray-500" ml={2}>{status.failures} failures</Text>
</Box>
))}
</Stack>
)}
</Box>
</Surface>
{/* Actions */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
Actions
</div>
<div className="p-4 space-y-2">
<button
onClick={triggerHealthCheck}
className="w-full px-3 py-2 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center justify-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Run Health Check
</button>
<button
onClick={resetCircuitBreakers}
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded flex items-center justify-center gap-2"
>
<span className="text-lg">🔄</span>
Reset Circuit Breakers
</button>
<button
onClick={() => {
connectionMonitor.reset();
setConnectionStatus(connectionMonitor.getHealth());
}}
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
>
<span className="text-lg">🗑</span>
Reset Connection Stats
</button>
</div>
</div>
{/* Actions */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Actions</Text>
</Box>
<Box p={4}>
<Stack gap={2}>
<Button
variant="primary"
onClick={triggerHealthCheck}
fullWidth
icon={<Icon icon={RefreshCw} size={4} />}
>
Run Health Check
</Button>
<Button
variant="secondary"
onClick={resetCircuitBreakers}
fullWidth
icon={<Text size="lg">🔄</Text>}
>
Reset Circuit Breakers
</Button>
<Button
variant="danger"
onClick={() => {
connectionMonitor.reset();
setConnectionStatus(connectionMonitor.getHealth());
}}
fullWidth
icon={<Text size="lg">🗑</Text>}
>
Reset Connection Stats
</Button>
</Stack>
</Box>
</Surface>
{/* Quick Fixes */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
Quick Fixes
</div>
<div className="p-4 space-y-2 text-xs">
<div className="text-gray-400">Common solutions:</div>
<ul className="list-disc list-inside space-y-1 text-gray-300">
<li>Check API server is running</li>
<li>Verify CORS configuration</li>
<li>Check environment variables</li>
<li>Review network connectivity</li>
<li>Check API rate limits</li>
</ul>
</div>
</div>
{/* Quick Fixes */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Quick Fixes</Text>
</Box>
<Box p={4}>
<Stack gap={2} fontSize="0.75rem">
<Text color="text-gray-400">Common solutions:</Text>
<Stack as="ul" gap={1} pl={4}>
<Box as="li"><Text color="text-gray-300">Check API server is running</Text></Box>
<Box as="li"><Text color="text-gray-300">Verify CORS configuration</Text></Box>
<Box as="li"><Text color="text-gray-300">Check environment variables</Text></Box>
<Box as="li"><Text color="text-gray-300">Review network connectivity</Text></Box>
<Box as="li"><Text color="text-gray-300">Check API rate limits</Text></Box>
</Stack>
</Stack>
</Box>
</Surface>
{/* Raw Error */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
Raw Error
</div>
<div className="p-4">
<pre className="text-xs text-gray-400 overflow-auto max-h-32 bg-deep-graphite p-2 rounded">
{JSON.stringify({
type: error.type,
message: error.message,
context: error.context,
}, null, 2)}
</pre>
</div>
</div>
</div>
</div>
{/* Raw Error */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Raw Error</Text>
</Box>
<Box p={4}>
<Box as="pre" p={2} bg="bg-deep-graphite" rounded="md" overflow="auto" maxHeight="8rem" fontSize="0.75rem" color="text-gray-400">
{JSON.stringify({
type: error.type,
message: error.message,
context: error.context,
}, null, 2)}
</Box>
</Box>
</Surface>
</Stack>
</Box>
{/* Console Output */}
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
<Terminal className="w-4 h-4" />
Console Output
</div>
<div className="p-4 bg-deep-graphite font-mono text-xs">
<div className="text-gray-500 mb-2">{'>'} {error.getDeveloperMessage()}</div>
<div className="text-gray-600">Check browser console for full stack trace and additional debug info.</div>
</div>
</div>
</div>
</div>
{/* Console Output */}
<Surface variant="muted" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Box bg="bg-charcoal-outline" px={4} py={2} display="flex" alignItems="center" gap={2}>
<Icon icon={Terminal} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Console Output</Text>
</Box>
<Box p={4} bg="bg-deep-graphite" fontSize="0.75rem">
<Text color="text-gray-500" block mb={2}>{'>'} {error.getDeveloperMessage()}</Text>
<Text color="text-gray-600" block>Check browser console for full stack trace and additional debug info.</Text>
</Box>
</Surface>
</Stack>
</Box>
</Box>
);
}
}

View File

@@ -1,6 +1,6 @@
'use client';
import React, { Component, ReactNode, ErrorInfo } from 'react';
import React, { Component, ReactNode, ErrorInfo, useState, version } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { DevErrorPanel } from './DevErrorPanel';
@@ -28,6 +28,15 @@ interface State {
isDev: boolean;
}
interface GridPilotWindow extends Window {
__GRIDPILOT_REACT_ERRORS__?: Array<{
error: Error;
errorInfo: ErrorInfo;
timestamp: string;
componentStack?: string;
}>;
}
/**
* Enhanced React Error Boundary with maximum developer transparency
* Integrates with GlobalErrorHandler and provides detailed debugging info
@@ -49,7 +58,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
static getDerivedStateFromError(error: Error): State {
// Don't catch Next.js navigation errors (redirect, notFound, etc.)
if (error && typeof error === 'object' && 'digest' in error) {
const digest = (error as any).digest;
const digest = (error as Record<string, unknown>).digest;
if (typeof digest === 'string' && (
digest.startsWith('NEXT_REDIRECT') ||
digest.startsWith('NEXT_NOT_FOUND')
@@ -70,7 +79,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Don't catch Next.js navigation errors (redirect, notFound, etc.)
if (error && typeof error === 'object' && 'digest' in error) {
const digest = (error as any).digest;
const digest = (error as Record<string, unknown>).digest;
if (typeof digest === 'string' && (
digest.startsWith('NEXT_REDIRECT') ||
digest.startsWith('NEXT_NOT_FOUND')
@@ -81,21 +90,26 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
}
// Add to React error history
const reactErrors = (window as any).__GRIDPILOT_REACT_ERRORS__ || [];
reactErrors.push({
error,
errorInfo,
timestamp: new Date().toISOString(),
componentStack: errorInfo.componentStack,
});
(window as any).__GRIDPILOT_REACT_ERRORS__ = reactErrors;
if (typeof window !== 'undefined') {
const gpWindow = window as unknown as GridPilotWindow;
const reactErrors = gpWindow.__GRIDPILOT_REACT_ERRORS__ || [];
gpWindow.__GRIDPILOT_REACT_ERRORS__ = [
...reactErrors,
{
error,
errorInfo,
timestamp: new Date().toISOString(),
componentStack: errorInfo.componentStack || undefined,
}
];
}
// Report to global error handler with enhanced context
const enhancedContext = {
...this.props.context,
source: 'react_error_boundary',
componentStack: errorInfo.componentStack,
reactVersion: React.version,
reactVersion: version,
componentName: this.getComponentName(errorInfo),
};
@@ -107,12 +121,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
this.props.onError(error, errorInfo);
}
// Show dev overlay if enabled
if (this.props.enableDevOverlay && this.state.isDev) {
// The global handler will show the overlay, but we can add additional React-specific info
this.showReactDevOverlay(error, errorInfo);
}
// Log to console with maximum detail
if (this.state.isDev) {
this.logReactErrorWithMaximumDetail(error, errorInfo);
@@ -126,10 +134,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
}
}
componentWillUnmount(): void {
// Clean up if needed
}
/**
* Extract component name from error info
*/
@@ -146,108 +150,6 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
return undefined;
}
/**
* Show React-specific dev overlay
*/
private showReactDevOverlay(error: Error, errorInfo: ErrorInfo): void {
const existingOverlay = document.getElementById('gridpilot-react-overlay');
if (existingOverlay) {
this.updateReactDevOverlay(existingOverlay, error, errorInfo);
return;
}
const overlay = document.createElement('div');
overlay.id = 'gridpilot-react-overlay';
overlay.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800px;
max-height: 80vh;
background: #1a1a1a;
color: #fff;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
z-index: 999998;
overflow: auto;
padding: 20px;
border: 3px solid #ff6600;
border-radius: 8px;
box-shadow: 0 0 40px rgba(255, 102, 0, 0.6);
`;
this.updateReactDevOverlay(overlay, error, errorInfo);
document.body.appendChild(overlay);
// Add keyboard shortcut to dismiss
const dismissHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', dismissHandler);
}
};
document.addEventListener('keydown', dismissHandler);
}
/**
* Update React dev overlay
*/
private updateReactDevOverlay(overlay: HTMLElement, error: Error, errorInfo: ErrorInfo): void {
overlay.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 15px;">
<h2 style="color: #ff6600; margin: 0; font-size: 18px;">⚛️ React Component Error</h2>
<button onclick="this.parentElement.parentElement.remove()"
style="background: #ff6600; color: white; border: none; padding: 6px 12px; cursor: pointer; border-radius: 4px; font-weight: bold;">
CLOSE
</button>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #ff6600;">
<div style="color: #ff6600; font-weight: bold; margin-bottom: 5px;">Error Message</div>
<div style="color: #fff;">${error.message}</div>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #00aaff;">
<div style="color: #00aaff; font-weight: bold; margin-bottom: 5px;">Component Stack Trace</div>
<pre style="margin: 0; white-space: pre-wrap; color: #888;">${errorInfo.componentStack || 'No component stack available'}</pre>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #ffaa00;">
<div style="color: #ffaa00; font-weight: bold; margin-bottom: 5px;">JavaScript Stack Trace</div>
<pre style="margin: 0; white-space: pre-wrap; color: #888; overflow-x: auto;">${error.stack || 'No stack trace available'}</pre>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #00ff88;">
<div style="color: #00ff88; font-weight: bold; margin-bottom: 5px;">React Information</div>
<div style="line-height: 1.6; color: #888;">
<div>React Version: ${React.version}</div>
<div>Error Boundary: Active</div>
<div>Timestamp: ${new Date().toLocaleTimeString()}</div>
</div>
</div>
<div style="background: #000; padding: 12px; border-radius: 4px; border-left: 3px solid #aa00ff;">
<div style="color: #aa00ff; font-weight: bold; margin-bottom: 5px;">Quick Actions</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button onclick="navigator.clipboard.writeText(\`${error.message}\n\nComponent Stack:\n${errorInfo.componentStack}\n\nStack:\n${error.stack}\`)"
style="background: #0066cc; color: white; border: none; padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px;">
📋 Copy Details
</button>
<button onclick="window.location.reload()"
style="background: #cc6600; color: white; border: none; padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 11px;">
🔄 Reload
</button>
</div>
</div>
<div style="margin-top: 15px; padding: 10px; background: #222; border-radius: 4px; border-left: 3px solid #888; font-size: 11px; color: #888;">
💡 This React error boundary caught a component rendering error. Check the console for additional details from the global error handler.
</div>
`;
}
/**
* Log React error with maximum detail
*/
@@ -265,7 +167,7 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
console.log('Component Stack:', errorInfo.componentStack);
console.log('React Context:', {
reactVersion: React.version,
reactVersion: version,
component: this.getComponentName(errorInfo),
timestamp: new Date().toISOString(),
});
@@ -273,28 +175,9 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
console.log('Props:', this.props);
console.log('State:', this.state);
// Show component hierarchy if available
try {
const hierarchy = this.getComponentHierarchy();
if (hierarchy) {
console.log('Component Hierarchy:', hierarchy);
}
} catch {
// Ignore hierarchy extraction errors
}
console.groupEnd();
}
/**
* Attempt to extract component hierarchy (for debugging)
*/
private getComponentHierarchy(): string | null {
// This is a simplified version - in practice, you might want to use React DevTools
// or other methods to get the full component tree
return null;
}
resetError = (): void => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onReset) {
@@ -350,9 +233,9 @@ export class EnhancedErrorBoundary extends Component<Props, State> {
* Hook-based alternative for functional components
*/
export function useEnhancedErrorBoundary() {
const [error, setError] = React.useState<Error | ApiError | null>(null);
const [errorInfo, setErrorInfo] = React.useState<ErrorInfo | null>(null);
const [isDev] = React.useState(process.env.NODE_ENV === 'development');
const [error, setError] = useState<Error | ApiError | null>(null);
const [errorInfo, setErrorInfo] = useState<ErrorInfo | null>(null);
const [isDev] = useState(process.env.NODE_ENV === 'development');
const handleError = (err: Error, info: ErrorInfo) => {
setError(err);
@@ -402,4 +285,4 @@ export function withEnhancedErrorBoundary<P extends object>(
);
WrappedComponent.displayName = `withEnhancedErrorBoundary(${Component.displayName || Component.name || 'Component'})`;
return WrappedComponent;
}
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertCircle,
@@ -15,13 +15,18 @@ import {
} from 'lucide-react';
import { parseApiError, getErrorSeverity, isRetryable, isConnectivityError } from '@/lib/utils/errorUtils';
import { ApiError } from '@/lib/api/base/ApiError';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Button } from '@/ui/Button';
interface EnhancedFormErrorProps {
error: unknown;
onRetry?: () => void;
onDismiss?: () => void;
showDeveloperDetails?: boolean;
className?: string;
}
/**
@@ -35,7 +40,6 @@ export function EnhancedFormError({
onRetry,
onDismiss,
showDeveloperDetails = process.env.NODE_ENV === 'development',
className = ''
}: EnhancedFormErrorProps) {
const [showDetails, setShowDetails] = useState(false);
const parsed = parseApiError(error);
@@ -44,10 +48,10 @@ export function EnhancedFormError({
const connectivity = isConnectivityError(error);
const getIcon = () => {
if (connectivity) return <Wifi className="w-5 h-5" />;
if (severity === 'error') return <AlertTriangle className="w-5 h-5" />;
if (severity === 'warning') return <AlertCircle className="w-5 h-5" />;
return <Info className="w-5 h-5" />;
if (connectivity) return Wifi;
if (severity === 'error') return AlertTriangle;
if (severity === 'warning') return AlertCircle;
return Info;
};
const getColor = () => {
@@ -62,179 +66,165 @@ export function EnhancedFormError({
const color = getColor();
return (
<motion.div
<Box
as={motion.div}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={`bg-${color}-500/10 border-${color}-500/30 rounded-lg overflow-hidden ${className}`}
>
{/* Main Error Message */}
<div className="p-4 flex items-start gap-3">
<div className={`text-${color}-400 flex-shrink-0 mt-0.5`}>
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className={`text-sm font-medium text-${color}-200`}>
{parsed.userMessage}
</p>
<div className="flex items-center gap-2">
{retryable && onRetry && (
<button
onClick={onRetry}
className="p-1.5 hover:bg-white/5 rounded transition-colors"
title="Retry"
>
<RefreshCw className="w-4 h-4 text-gray-400 hover:text-white" />
</button>
)}
<Box
bg={`bg-${color}-500/10`}
border
borderColor={`border-${color}-500/30`}
rounded="lg"
overflow="hidden"
>
{/* Main Error Message */}
<Box p={4} display="flex" alignItems="start" gap={3}>
<Box color={`text-${color}-400`} flexShrink={0} mt={0.5}>
<Icon icon={getIcon()} size={5} />
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" justifyContent="between" gap={2}>
<Text size="sm" weight="medium" color={`text-${color}-200`}>
{parsed.userMessage}
</Text>
{onDismiss && (
<button
onClick={onDismiss}
className="p-1.5 hover:bg-white/5 rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-gray-400 hover:text-white" />
</button>
)}
{showDeveloperDetails && (
<button
onClick={() => setShowDetails(!showDetails)}
className="p-1.5 hover:bg-white/5 rounded transition-colors"
title="Toggle technical details"
>
{showDetails ? (
<ChevronUp className="w-4 h-4 text-gray-400 hover:text-white" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400 hover:text-white" />
)}
</button>
)}
</div>
</div>
<Box display="flex" alignItems="center" gap={2}>
{retryable && onRetry && (
<IconButton
icon={RefreshCw}
onClick={onRetry}
variant="ghost"
size="sm"
title="Retry"
/>
)}
{onDismiss && (
<IconButton
icon={X}
onClick={onDismiss}
variant="ghost"
size="sm"
title="Dismiss"
/>
)}
{showDeveloperDetails && (
<IconButton
icon={showDetails ? ChevronUp : ChevronDown}
onClick={() => setShowDetails(!showDetails)}
variant="ghost"
size="sm"
title="Toggle technical details"
/>
)}
</Box>
</Box>
{/* Validation Errors List */}
{parsed.isValidationError && parsed.validationErrors.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.validationErrors.map((validationError, index) => (
<div key={index} className="text-xs text-${color}-300/80">
{validationError.field}: {validationError.message}
</div>
))}
</div>
{/* Validation Errors List */}
{parsed.isValidationError && parsed.validationErrors.length > 0 && (
<Stack gap={1} mt={2}>
{parsed.validationErrors.map((validationError, index) => (
<Text key={index} size="xs" color={`text-${color}-300/80`} block>
{validationError.field}: {validationError.message}
</Text>
))}
</Stack>
)}
{/* Action Hint */}
<Box mt={2}>
<Text size="xs" color="text-gray-400">
{connectivity && "Check your internet connection and try again"}
{parsed.isValidationError && "Please review your input and try again"}
{retryable && !connectivity && !parsed.isValidationError && "Please try again in a moment"}
</Text>
</Box>
</Box>
</Box>
{/* Developer Details */}
<AnimatePresence>
{showDetails && (
<Box
as={motion.div}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<Box borderTop borderColor={`border-${color}-500/20`} bg="bg-black/20" p={4}>
<Stack gap={3} fontSize="0.75rem">
<Box display="flex" alignItems="center" gap={2} color="text-gray-400">
<Icon icon={Bug} size={3} />
<Text weight="semibold">Developer Details</Text>
</Box>
<Stack gap={1}>
<Text color="text-gray-500">Error Type:</Text>
<Text color="text-white">{error instanceof ApiError ? error.type : 'Unknown'}</Text>
</Stack>
<Stack gap={1}>
<Text color="text-gray-500">Developer Message:</Text>
<Text color="text-white" transform="break-all">{parsed.developerMessage}</Text>
</Stack>
{error instanceof ApiError && error.context.endpoint && (
<Stack gap={1}>
<Text color="text-gray-500">Endpoint:</Text>
<Text color="text-white">{error.context.method} {error.context.endpoint}</Text>
</Stack>
)}
{error instanceof ApiError && error.context.statusCode && (
<Stack gap={1}>
<Text color="text-gray-500">Status Code:</Text>
<Text color="text-white">{error.context.statusCode}</Text>
</Stack>
)}
<Box pt={2} borderTop borderColor="border-charcoal-outline/50">
<Text color="text-gray-500" block mb={1}>Quick Actions:</Text>
<Box display="flex" gap={2}>
{retryable && onRetry && (
<Button
variant="secondary"
onClick={onRetry}
size="sm"
bg="bg-blue-600/20"
color="text-primary-blue"
>
Retry
</Button>
)}
<Button
variant="secondary"
onClick={() => {
if (error instanceof Error) {
console.error('Full error details:', error);
if (error.stack) {
console.log('Stack trace:', error.stack);
}
}
}}
size="sm"
bg="bg-purple-600/20"
color="text-purple-400"
>
Log to Console
</Button>
</Box>
</Box>
</Stack>
</Box>
</Box>
)}
{/* Action Hint */}
<div className="mt-2 text-xs text-gray-400">
{connectivity && "Check your internet connection and try again"}
{parsed.isValidationError && "Please review your input and try again"}
{retryable && !connectivity && !parsed.isValidationError && "Please try again in a moment"}
</div>
</div>
</div>
{/* Developer Details */}
<AnimatePresence>
{showDeveloperDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-${color}-500/20 bg-black/20"
>
<div className="p-4 space-y-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-400">
<Bug className="w-3 h-3" />
<span className="font-semibold">Developer Details</span>
</div>
<div className="space-y-1">
<div className="text-gray-500">Error Type:</div>
<div className="text-white">{error instanceof ApiError ? error.type : 'Unknown'}</div>
</div>
<div className="space-y-1">
<div className="text-gray-500">Developer Message:</div>
<div className="text-white break-all">{parsed.developerMessage}</div>
</div>
{error instanceof ApiError && error.context.endpoint && (
<div className="space-y-1">
<div className="text-gray-500">Endpoint:</div>
<div className="text-white">{error.context.method} {error.context.endpoint}</div>
</div>
)}
{error instanceof ApiError && error.context.statusCode && (
<div className="space-y-1">
<div className="text-gray-500">Status Code:</div>
<div className="text-white">{error.context.statusCode}</div>
</div>
)}
{error instanceof ApiError && error.context.retryCount !== undefined && (
<div className="space-y-1">
<div className="text-gray-500">Retry Count:</div>
<div className="text-white">{error.context.retryCount}</div>
</div>
)}
{error instanceof ApiError && error.context.timestamp && (
<div className="space-y-1">
<div className="text-gray-500">Timestamp:</div>
<div className="text-white">{error.context.timestamp}</div>
</div>
)}
{error instanceof ApiError && error.context.troubleshooting && (
<div className="space-y-1">
<div className="text-gray-500">Troubleshooting:</div>
<div className="text-yellow-400">{error.context.troubleshooting}</div>
</div>
)}
{parsed.validationErrors.length > 0 && (
<div className="space-y-1">
<div className="text-gray-500">Validation Errors:</div>
<div className="text-white">{JSON.stringify(parsed.validationErrors, null, 2)}</div>
</div>
)}
<div className="pt-2 border-t border-gray-700/50">
<div className="text-gray-500 mb-1">Quick Actions:</div>
<div className="flex gap-2">
{retryable && onRetry && (
<button
onClick={onRetry}
className="px-2 py-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 rounded transition-colors"
>
Retry
</button>
)}
<button
onClick={() => {
if (error instanceof Error) {
console.error('Full error details:', error);
if (error.stack) {
console.log('Stack trace:', error.stack);
}
}
}}
className="px-2 py-1 bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 rounded transition-colors"
>
Log to Console
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
</Box>
</Box>
);
}
@@ -258,30 +248,33 @@ export function FormErrorSummary({
};
return (
<motion.div
<Box
as={motion.div}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 flex items-start gap-2"
>
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-red-200">{summary.title}</div>
<div className="text-xs text-red-300/80 mt-0.5">{summary.description}</div>
<div className="text-xs text-gray-400 mt-1">{summary.action}</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="p-1 hover:bg-red-500/10 rounded transition-colors"
>
<X className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
</motion.div>
<Box bg="bg-red-500/10" border borderColor="border-red-500/30" rounded="lg" p={3} display="flex" alignItems="start" gap={2}>
<Icon icon={AlertCircle} size={4} color="rgb(239, 68, 68)" mt={0.5} />
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" justifyContent="between" gap={2}>
<Box>
<Text size="sm" weight="medium" color="text-red-200" block>{summary.title}</Text>
<Text size="xs" color="text-red-300/80" block mt={0.5}>{summary.description}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{summary.action}</Text>
</Box>
{onDismiss && (
<IconButton
icon={X}
onClick={onDismiss}
variant="ghost"
size="sm"
color="rgb(239, 68, 68)"
/>
)}
</Box>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -1,27 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
import { ApiError } from '@/lib/api/base/ApiError';
import { getErrorAnalyticsStats, type ErrorStats } from '@/lib/services/error/ErrorAnalyticsService';
import {
Activity,
AlertTriangle,
Clock,
Copy,
RefreshCw,
Terminal,
Database,
Zap,
Bug,
Shield,
Globe,
Cpu,
FileText,
Trash2,
Download,
Search
Search,
ChevronDown,
Zap,
Terminal
} from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
interface ErrorAnalyticsDashboardProps {
/**
@@ -34,27 +41,32 @@ interface ErrorAnalyticsDashboardProps {
showInProduction?: boolean;
}
interface ErrorStats {
totalErrors: number;
errorsByType: Record<string, number>;
errorsByTime: Array<{ time: string; count: number }>;
recentErrors: Array<{
timestamp: string;
message: string;
type: string;
context?: unknown;
}>;
apiStats: {
totalRequests: number;
successful: number;
failed: number;
averageDuration: number;
slowestRequests: Array<{ url: string; duration: number }>;
function formatDuration(duration: number): string {
return duration.toFixed(2) + 'ms';
}
function formatPercentage(value: number, total: number): string {
if (total === 0) return '0%';
return ((value / total) * 100).toFixed(1) + '%';
}
function formatMemory(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1) + 'MB';
}
interface PerformanceWithMemory extends Performance {
memory?: {
usedJSHeapSize: number;
totalJSHeapSize: number;
jsHeapSizeLimit: number;
};
environment: {
mode: string;
version?: string;
buildTime?: string;
}
interface NavigatorWithConnection extends Navigator {
connection?: {
effectiveType: string;
downlink: number;
rtt: number;
};
}
@@ -73,77 +85,32 @@ export function ErrorAnalyticsDashboard({
const [copied, setCopied] = useState(false);
const isDev = process.env.NODE_ENV === 'development';
const shouldShow = isDev || showInProduction;
// Don't show in production unless explicitly enabled
if (!isDev && !showInProduction) {
const perf = typeof performance !== 'undefined' ? performance as PerformanceWithMemory : null;
const nav = typeof navigator !== 'undefined' ? navigator as NavigatorWithConnection : null;
useEffect(() => {
if (!shouldShow) return;
const update = () => {
setStats(getErrorAnalyticsStats());
};
update();
if (refreshInterval > 0) {
const interval = setInterval(update, refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval, shouldShow]);
if (!shouldShow) {
return null;
}
useEffect(() => {
updateStats();
if (refreshInterval > 0) {
const interval = setInterval(updateStats, refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval]);
const updateStats = () => {
const globalHandler = getGlobalErrorHandler();
const apiLogger = getGlobalApiLogger();
const errorHistory = globalHandler.getErrorHistory();
const errorStats = globalHandler.getStats();
const apiHistory = apiLogger.getHistory();
const apiStats = apiLogger.getStats();
// Group errors by time (last 10 minutes)
const timeGroups = new Map<string, number>();
const now = Date.now();
const tenMinutesAgo = now - (10 * 60 * 1000);
errorHistory.forEach(entry => {
const entryTime = new Date(entry.timestamp).getTime();
if (entryTime >= tenMinutesAgo) {
const timeKey = new Date(entry.timestamp).toLocaleTimeString();
timeGroups.set(timeKey, (timeGroups.get(timeKey) || 0) + 1);
}
});
const errorsByTime = Array.from(timeGroups.entries())
.map(([time, count]) => ({ time, count }))
.sort((a, b) => a.time.localeCompare(b.time));
const recentErrors = errorHistory.slice(-10).reverse().map(entry => ({
timestamp: entry.timestamp,
message: entry.error.message,
type: entry.error instanceof ApiError ? entry.error.type : entry.error.name || 'Error',
context: entry.context,
}));
const slowestRequests = apiLogger.getSlowestRequests(5).map(log => ({
url: log.url,
duration: log.response?.duration || 0,
}));
setStats({
totalErrors: errorStats.total,
errorsByType: errorStats.byType,
errorsByTime,
recentErrors,
apiStats: {
totalRequests: apiStats.total,
successful: apiStats.successful,
failed: apiStats.failed,
averageDuration: apiStats.averageDuration,
slowestRequests,
},
environment: {
mode: process.env.NODE_ENV || 'unknown',
version: process.env.NEXT_PUBLIC_APP_VERSION,
buildTime: process.env.NEXT_PUBLIC_BUILD_TIME,
},
});
const updateStatsManual = () => {
setStats(getErrorAnalyticsStats());
};
const copyToClipboard = async (data: unknown) => {
@@ -183,7 +150,7 @@ export function ErrorAnalyticsDashboard({
globalHandler.clearHistory();
apiLogger.clearHistory();
updateStats();
updateStatsManual();
}
};
@@ -195,382 +162,438 @@ export function ErrorAnalyticsDashboard({
if (!isExpanded) {
return (
<button
onClick={() => setIsExpanded(true)}
className="fixed bottom-4 left-4 z-50 p-3 bg-iron-gray border border-charcoal-outline rounded-full shadow-lg hover:bg-charcoal-outline transition-colors"
title="Open Error Analytics"
>
<Activity className="w-5 h-5 text-red-400" />
</button>
<Box position="fixed" bottom="4" left="4" zIndex={50}>
<IconButton
icon={Activity}
onClick={() => setIsExpanded(true)}
variant="secondary"
title="Open Error Analytics"
size="lg"
color="rgb(239, 68, 68)"
/>
</Box>
);
}
return (
<div className="fixed bottom-4 left-4 z-50 w-96 max-h-[80vh] bg-deep-graphite border border-charcoal-outline rounded-xl shadow-2xl overflow-hidden flex flex-col">
<Box
position="fixed"
bottom="4"
left="4"
zIndex={50}
w="96"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
overflow="hidden"
display="flex"
flexDirection="col"
maxHeight="80vh"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-iron-gray/50 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-red-400" />
<span className="text-sm font-semibold text-white">Error Analytics</span>
<Box display="flex" alignItems="center" justifyContent="between" px={4} py={3} bg="bg-iron-gray/50" borderBottom borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Activity} size={4} color="rgb(239, 68, 68)" />
<Text size="sm" weight="semibold" color="text-white">Error Analytics</Text>
{isDev && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-500/20 text-red-400 rounded">
<Badge variant="danger" size="xs">
DEV
</span>
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={updateStats}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
</Box>
<Box display="flex" alignItems="center" gap={1}>
<IconButton
icon={RefreshCw}
onClick={updateStatsManual}
variant="ghost"
size="sm"
title="Refresh"
>
<RefreshCw className="w-3 h-3 text-gray-400" />
</button>
<button
/>
<IconButton
icon={ChevronDown}
onClick={() => setIsExpanded(false)}
className="p-1.5 hover:bg-charcoal-outline rounded transition-colors"
variant="ghost"
size="sm"
title="Minimize"
>
<span className="text-gray-400 text-xs font-bold">_</span>
</button>
</div>
</div>
/>
</Box>
</Box>
{/* Tabs */}
<div className="flex border-b border-charcoal-outline bg-iron-gray/30">
<Box display="flex" borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30">
{[
{ id: 'errors', label: 'Errors', icon: AlertTriangle },
{ id: 'api', label: 'API', icon: Globe },
{ id: 'environment', label: 'Env', icon: Cpu },
{ id: 'raw', label: 'Raw', icon: FileText },
].map(tab => (
<button
<Box
key={tab.id}
onClick={() => setSelectedTab(tab.id as any)}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors ${
selectedTab === tab.id
? 'bg-deep-graphite text-white border-b-2 border-red-400'
: 'text-gray-400 hover:bg-charcoal-outline hover:text-gray-200'
}`}
as="button"
type="button"
onClick={() => setSelectedTab(tab.id as 'errors' | 'api' | 'environment' | 'raw')}
display="flex"
flexGrow={1}
alignItems="center"
justifyContent="center"
gap={1}
px={2}
py={2}
cursor="pointer"
transition
bg={selectedTab === tab.id ? 'bg-deep-graphite' : ''}
borderBottom={selectedTab === tab.id}
borderColor={selectedTab === tab.id ? 'border-red-400' : ''}
borderWidth={selectedTab === tab.id ? '2px' : '0'}
>
<tab.icon className="w-3 h-3" />
{tab.label}
</button>
<Icon icon={tab.icon} size={3} color={selectedTab === tab.id ? 'text-white' : 'text-gray-400'} />
<Text
size="xs"
weight="medium"
color={selectedTab === tab.id ? 'text-white' : 'text-gray-400'}
>
{tab.label}
</Text>
</Box>
))}
</div>
</Box>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Search Bar */}
{selectedTab === 'errors' && (
<div className="relative">
<input
<Box flexGrow={1} overflow="auto" p={4}>
<Stack gap={4}>
{/* Search Bar */}
{selectedTab === 'errors' && (
<Input
type="text"
placeholder="Search errors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-iron-gray border border-charcoal-outline rounded px-3 py-2 pl-8 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-red-400"
icon={<Icon icon={Search} size={3} color="rgb(107, 114, 128)" />}
/>
<Search className="w-3 h-3 text-gray-500 absolute left-2.5 top-2.5" />
</div>
)}
)}
{/* Errors Tab */}
{selectedTab === 'errors' && stats && (
<div className="space-y-4">
{/* Error Summary */}
<div className="grid grid-cols-2 gap-2">
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs text-gray-500">Total Errors</div>
<div className="text-xl font-bold text-red-400">{stats.totalErrors}</div>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs text-gray-500">Error Types</div>
<div className="text-xl font-bold text-yellow-400">
{Object.keys(stats.errorsByType).length}
</div>
</div>
</div>
{/* Errors Tab */}
{selectedTab === 'errors' && stats && (
<Stack gap={4}>
{/* Error Summary */}
<Box display="grid" gridCols={2} gap={2}>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Text size="xs" color="text-gray-500" block>Total Errors</Text>
<Text size="xl" weight="bold" color="text-red-400">{stats.totalErrors}</Text>
</Box>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Text size="xs" color="text-gray-500" block>Error Types</Text>
<Text size="xl" weight="bold" color="text-warning-amber">
{Object.keys(stats.errorsByType).length}
</Text>
</Box>
</Box>
{/* Error Types Breakdown */}
{Object.keys(stats.errorsByType).length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Bug className="w-3 h-3" /> Error Types
</div>
<div className="space-y-1 max-h-32 overflow-auto">
{Object.entries(stats.errorsByType).map(([type, count]) => (
<div key={type} className="flex justify-between text-xs">
<span className="text-gray-300">{type}</span>
<span className="text-red-400 font-mono">{count}</span>
</div>
))}
</div>
</div>
)}
{/* Error Types Breakdown */}
{Object.keys(stats.errorsByType).length > 0 && (
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Bug} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Error Types</Text>
</Box>
<Stack gap={1} maxHeight="8rem" overflow="auto">
{Object.entries(stats.errorsByType).map(([type, count]) => (
<Box key={type} display="flex" justifyContent="between">
<Text size="xs" color="text-gray-300">{type}</Text>
<Text size="xs" color="text-red-400" font="mono">{count}</Text>
</Box>
))}
</Stack>
</Box>
)}
{/* Recent Errors */}
{filteredRecentErrors.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<AlertTriangle className="w-3 h-3" /> Recent Errors
</div>
<div className="space-y-2 max-h-64 overflow-auto">
{filteredRecentErrors.map((error, idx) => (
<div key={idx} className="bg-deep-graphite border border-charcoal-outline rounded p-2 text-xs">
<div className="flex justify-between items-start gap-2 mb-1">
<span className="font-mono text-red-400 font-bold">{error.type}</span>
<span className="text-gray-500 text-[10px]">
{new Date(error.timestamp).toLocaleTimeString()}
</span>
</div>
<div className="text-gray-300 break-words mb-1">{error.message}</div>
<button
onClick={() => copyToClipboard(error)}
className="text-[10px] text-gray-500 hover:text-gray-300 flex items-center gap-1"
>
<Copy className="w-3 h-3" /> Copy Details
</button>
</div>
))}
</div>
</div>
)}
{/* Recent Errors */}
{filteredRecentErrors.length > 0 && (
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={AlertTriangle} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Recent Errors</Text>
</Box>
<Stack gap={2} maxHeight="16rem" overflow="auto">
{filteredRecentErrors.map((error, idx) => (
<Box key={idx} bg="bg-deep-graphite" border borderColor="border-charcoal-outline" rounded="md" p={2}>
<Box display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
<Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
<Text size="xs" color="text-gray-500" fontSize="10px">
{new Date(error.timestamp).toLocaleTimeString()}
</Text>
</Box>
<Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>
<Button
variant="ghost"
onClick={() => copyToClipboard(error)}
size="sm"
p={0}
minHeight="0"
icon={<Icon icon={Copy} size={3} />}
>
<Text size="xs" color="text-gray-500" fontSize="10px">Copy Details</Text>
</Button>
</Box>
))}
</Stack>
</Box>
)}
{/* Error Timeline */}
{stats.errorsByTime.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Clock className="w-3 h-3" /> Last 10 Minutes
</div>
<div className="space-y-1 max-h-32 overflow-auto">
{stats.errorsByTime.map((point, idx) => (
<div key={idx} className="flex justify-between text-xs">
<span className="text-gray-500">{point.time}</span>
<span className="text-red-400 font-mono">{point.count} errors</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Error Timeline */}
{stats.errorsByTime.length > 0 && (
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Clock} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Last 10 Minutes</Text>
</Box>
<Stack gap={1} maxHeight="8rem" overflow="auto">
{stats.errorsByTime.map((point, idx) => (
<Box key={idx} display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">{point.time}</Text>
<Text size="xs" color="text-red-400" font="mono">{point.count} errors</Text>
</Box>
))}
</Stack>
</Box>
)}
</Stack>
)}
{/* API Tab */}
{selectedTab === 'api' && stats && (
<div className="space-y-4">
{/* API Summary */}
<div className="grid grid-cols-2 gap-2">
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs text-gray-500">Total Requests</div>
<div className="text-xl font-bold text-blue-400">{stats.apiStats.totalRequests}</div>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs text-gray-500">Success Rate</div>
<div className="text-xl font-bold text-green-400">
{stats.apiStats.totalRequests > 0
? ((stats.apiStats.successful / stats.apiStats.totalRequests) * 100).toFixed(1)
: 0}%
</div>
</div>
</div>
{/* API Tab */}
{selectedTab === 'api' && stats && (
<Stack gap={4}>
{/* API Summary */}
<Box display="grid" gridCols={2} gap={2}>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Text size="xs" color="text-gray-500" block>Total Requests</Text>
<Text size="xl" weight="bold" color="text-primary-blue">{stats.apiStats.totalRequests}</Text>
</Box>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Text size="xs" color="text-gray-500" block>Success Rate</Text>
<Text size="xl" weight="bold" color="text-performance-green">
{formatPercentage(stats.apiStats.successful, stats.apiStats.totalRequests)}
</Text>
</Box>
</Box>
{/* API Stats */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Globe className="w-3 h-3" /> API Metrics
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Successful</span>
<span className="text-green-400 font-mono">{stats.apiStats.successful}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Failed</span>
<span className="text-red-400 font-mono">{stats.apiStats.failed}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Avg Duration</span>
<span className="text-yellow-400 font-mono">{stats.apiStats.averageDuration.toFixed(2)}ms</span>
</div>
</div>
</div>
{/* API Stats */}
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Globe} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">API Metrics</Text>
</Box>
<Stack gap={1}>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Successful</Text>
<Text size="xs" color="text-performance-green" font="mono">{stats.apiStats.successful}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Failed</Text>
<Text size="xs" color="text-red-400" font="mono">{stats.apiStats.failed}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Avg Duration</Text>
<Text size="xs" color="text-warning-amber" font="mono">{formatDuration(stats.apiStats.averageDuration)}</Text>
</Box>
</Stack>
</Box>
{/* Slowest Requests */}
{stats.apiStats.slowestRequests.length > 0 && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Zap className="w-3 h-3" /> Slowest Requests
</div>
<div className="space-y-1 max-h-40 overflow-auto">
{stats.apiStats.slowestRequests.map((req, idx) => (
<div key={idx} className="flex justify-between text-xs bg-deep-graphite p-1.5 rounded border border-charcoal-outline">
<span className="text-gray-300 truncate flex-1">{req.url}</span>
<span className="text-red-400 font-mono ml-2">{req.duration.toFixed(2)}ms</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Slowest Requests */}
{stats.apiStats.slowestRequests.length > 0 && (
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Zap} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Slowest Requests</Text>
</Box>
<Stack gap={1} maxHeight="10rem" overflow="auto">
{stats.apiStats.slowestRequests.map((req, idx) => (
<Box key={idx} display="flex" justifyContent="between" bg="bg-deep-graphite" p={1.5} rounded="sm" border borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-300" truncate flexGrow={1}>{req.url}</Text>
<Text size="xs" color="text-red-400" font="mono" ml={2}>{formatDuration(req.duration)}</Text>
</Box>
))}
</Stack>
</Box>
)}
</Stack>
)}
{/* Environment Tab */}
{selectedTab === 'environment' && stats && (
<div className="space-y-4">
{/* Environment Info */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Cpu className="w-3 h-3" /> Environment
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Node Environment</span>
<span className={`font-mono font-bold ${
stats.environment.mode === 'development' ? 'text-green-400' : 'text-yellow-400'
}`}>{stats.environment.mode}</span>
</div>
{stats.environment.version && (
<div className="flex justify-between">
<span className="text-gray-500">Version</span>
<span className="text-gray-300 font-mono">{stats.environment.version}</span>
</div>
)}
{stats.environment.buildTime && (
<div className="flex justify-between">
<span className="text-gray-500">Build Time</span>
<span className="text-gray-500 font-mono text-[10px]">{stats.environment.buildTime}</span>
</div>
)}
</div>
</div>
{/* Environment Tab */}
{selectedTab === 'environment' && stats && (
<Stack gap={4}>
{/* Environment Info */}
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Cpu} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Environment</Text>
</Box>
<Stack gap={1}>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Node Environment</Text>
<Text size="xs" font="mono" weight="bold" color={stats.environment.mode === 'development' ? 'text-performance-green' : 'text-warning-amber'}>
{stats.environment.mode}
</Text>
</Box>
{stats.environment.version && (
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Version</Text>
<Text size="xs" color="text-gray-300" font="mono">{stats.environment.version}</Text>
</Box>
)}
{stats.environment.buildTime && (
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Build Time</Text>
<Text size="xs" color="text-gray-500" font="mono" fontSize="10px">{stats.environment.buildTime}</Text>
</Box>
)}
</Stack>
</Box>
{/* Browser Info */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Globe className="w-3 h-3" /> Browser
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">User Agent</span>
<span className="text-gray-300 text-[9px] truncate max-w-[150px]">{navigator.userAgent}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Language</span>
<span className="text-gray-300 font-mono">{navigator.language}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Platform</span>
<span className="text-gray-300 font-mono">{navigator.platform}</span>
</div>
</div>
</div>
{/* Browser Info */}
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Globe} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Browser</Text>
</Box>
<Stack gap={1}>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">User Agent</Text>
<Text size="xs" color="text-gray-300" truncate maxWidth="150px">{navigator.userAgent}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Language</Text>
<Text size="xs" color="text-gray-300" font="mono">{navigator.language}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Platform</Text>
<Text size="xs" color="text-gray-300" font="mono">{navigator.platform}</Text>
</Box>
</Stack>
</Box>
{/* Performance */}
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Activity className="w-3 h-3" /> Performance
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Viewport</span>
<span className="text-gray-300 font-mono">{window.innerWidth}x{window.innerHeight}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Screen</span>
<span className="text-gray-300 font-mono">{window.screen.width}x{window.screen.height}</span>
</div>
{(performance as any).memory && (
<div className="flex justify-between">
<span className="text-gray-500">JS Heap</span>
<span className="text-gray-300 font-mono">
{((performance as any).memory.usedJSHeapSize / 1024 / 1024).toFixed(1)}MB
</span>
</div>
)}
</div>
</div>
{/* Performance */}
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Activity} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Performance</Text>
</Box>
<Stack gap={1}>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Viewport</Text>
<Text size="xs" color="text-gray-300" font="mono">{window.innerWidth}x{window.innerHeight}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Screen</Text>
<Text size="xs" color="text-gray-300" font="mono">{window.screen.width}x{window.screen.height}</Text>
</Box>
{perf?.memory && (
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">JS Heap</Text>
<Text size="xs" color="text-gray-300" font="mono">
{formatMemory(perf.memory.usedJSHeapSize)}
</Text>
</Box>
)}
</Stack>
</Box>
{/* Connection */}
{(navigator as any).connection && (
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Zap className="w-3 h-3" /> Network
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Type</span>
<span className="text-gray-300 font-mono">{(navigator as any).connection.effectiveType}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Downlink</span>
<span className="text-gray-300 font-mono">{(navigator as any).connection.downlink}Mbps</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">RTT</span>
<span className="text-gray-300 font-mono">{(navigator as any).connection.rtt}ms</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Connection */}
{nav?.connection && (
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Zap} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Network</Text>
</Box>
<Stack gap={1}>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Type</Text>
<Text size="xs" color="text-gray-300" font="mono">{nav.connection.effectiveType}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">Downlink</Text>
<Text size="xs" color="text-gray-300" font="mono">{nav.connection.downlink}Mbps</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="xs" color="text-gray-500">RTT</Text>
<Text size="xs" color="text-gray-300" font="mono">{nav.connection.rtt}ms</Text>
</Box>
</Stack>
</Box>
)}
</Stack>
)}
{/* Raw Data Tab */}
{selectedTab === 'raw' && stats && (
<div className="space-y-3">
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<FileText className="w-3 h-3" /> Export Options
</div>
<div className="flex gap-2">
<button
onClick={exportAllData}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs"
{/* Raw Data Tab */}
{selectedTab === 'raw' && stats && (
<Stack gap={3}>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={FileText} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Export Options</Text>
</Box>
<Box display="flex" gap={2}>
<Button
variant="primary"
onClick={exportAllData}
fullWidth
size="sm"
icon={<Icon icon={Download} size={3} />}
>
Export JSON
</Button>
<Button
variant="secondary"
onClick={() => copyToClipboard(stats)}
fullWidth
size="sm"
icon={<Icon icon={Copy} size={3} />}
>
Copy Stats
</Button>
</Box>
</Box>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Trash2} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Maintenance</Text>
</Box>
<Button
variant="danger"
onClick={clearAllData}
fullWidth
size="sm"
icon={<Icon icon={Trash2} size={3} />}
>
<Download className="w-3 h-3" /> Export JSON
</button>
<button
onClick={() => copyToClipboard(stats)}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded text-xs border border-charcoal-outline"
>
<Copy className="w-3 h-3" /> Copy Stats
</button>
</div>
</div>
Clear All Logs
</Button>
</Box>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Trash2 className="w-3 h-3" /> Maintenance
</div>
<button
onClick={clearAllData}
className="w-full flex items-center justify-center gap-1 px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
>
<Trash2 className="w-3 h-3" /> Clear All Logs
</button>
</div>
<div className="bg-iron-gray border border-charcoal-outline rounded p-3">
<div className="text-xs font-semibold text-gray-400 mb-2 flex items-center gap-2">
<Terminal className="w-3 h-3" /> Console Commands
</div>
<div className="space-y-1 text-[10px] font-mono text-gray-400">
<div> window.__GRIDPILOT_GLOBAL_HANDLER__</div>
<div> window.__GRIDPILOT_API_LOGGER__</div>
<div> window.__GRIDPILOT_REACT_ERRORS__</div>
</div>
</div>
</div>
)}
</div>
<Box bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="md" p={3}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Icon icon={Terminal} size={3} color="rgb(156, 163, 175)" />
<Text size="xs" weight="semibold" color="text-gray-400">Console Commands</Text>
</Box>
<Stack gap={1} fontSize="10px">
<Text color="text-gray-400" font="mono"> window.__GRIDPILOT_GLOBAL_HANDLER__</Text>
<Text color="text-gray-400" font="mono"> window.__GRIDPILOT_API_LOGGER__</Text>
<Text color="text-gray-400" font="mono"> window.__GRIDPILOT_REACT_ERRORS__</Text>
</Stack>
</Box>
</Stack>
)}
</Stack>
</Box>
{/* Footer */}
<div className="px-4 py-2 bg-iron-gray/30 border-t border-charcoal-outline text-[10px] text-gray-500 flex justify-between items-center">
<span>Auto-refresh: {refreshInterval}ms</span>
{copied && <span className="text-green-400">Copied!</span>}
</div>
</div>
<Box px={4} py={2} bg="bg-iron-gray/30" borderTop borderColor="border-charcoal-outline" display="flex" justifyContent="between" alignItems="center">
<Text size="xs" color="text-gray-500" fontSize="10px">Auto-refresh: {refreshInterval}ms</Text>
{copied && <Text size="xs" color="text-performance-green" fontSize="10px">Copied!</Text>}
</Box>
</Box>
);
}
@@ -608,4 +631,4 @@ export function useErrorAnalytics() {
apiLogger.clearHistory();
},
};
}
}

View File

@@ -1,9 +1,8 @@
'use client';
import React from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { ErrorDisplay as UiErrorDisplay } from '@/ui/ErrorDisplay';
interface ErrorDisplayProps {
error: ApiError;
@@ -14,123 +13,12 @@ interface ErrorDisplayProps {
* User-friendly error display for production environments
*/
export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
const router = useRouter();
const [isRetrying, setIsRetrying] = useState(false);
const userMessage = error.getUserMessage();
const isConnectivity = error.isConnectivityIssue();
const handleRetry = async () => {
if (onRetry) {
setIsRetrying(true);
try {
onRetry();
} finally {
setIsRetrying(false);
}
}
};
const handleGoBack = () => {
router.back();
};
const handleGoHome = () => {
router.push('/');
};
return (
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-500/20 rounded-lg">
{isConnectivity ? (
<Wifi className="w-6 h-6 text-red-400" />
) : (
<AlertTriangle className="w-6 h-6 text-red-400" />
)}
</div>
<div>
<h1 className="text-xl font-bold text-white">
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
</h1>
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
</div>
</div>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
{/* Details for debugging (collapsed by default) */}
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
<div className="mt-2 space-y-1">
<div>Type: {error.type}</div>
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
{error.context.retryCount !== undefined && (
<div>Retries: {error.context.retryCount}</div>
)}
</div>
</details>
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{error.isRetryable() && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{isRetrying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Try Again
</>
)}
</button>
)}
<div className="flex gap-2">
<button
onClick={handleGoBack}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</button>
<button
onClick={handleGoHome}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
>
Home
</button>
</div>
</div>
</div>
{/* Footer */}
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
If this persists, please contact support at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
<UiErrorDisplay
error={error}
onRetry={onRetry}
variant="full-screen"
/>
);
}
@@ -139,8 +27,10 @@ export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) {
*/
export function FullScreenError({ error, onRetry }: ErrorDisplayProps) {
return (
<div className="fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-4">
<ErrorDisplay error={error} onRetry={onRetry} />
</div>
<UiErrorDisplay
error={error}
onRetry={onRetry}
variant="full-screen"
/>
);
}
}

View File

@@ -16,7 +16,7 @@ export function NotificationIntegration() {
useEffect(() => {
// Listen for custom notification events from error reporter
const handleNotificationEvent = (event: CustomEvent) => {
const { type, title, message, variant, autoDismiss } = event.detail;
const { type, title, message, variant } = event.detail;
addNotification({
type: type || 'error',

View File

@@ -1,34 +0,0 @@
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
export function FeedEmptyState() {
return (
<Card className="bg-iron-gray/80 border-dashed border-charcoal-outline">
<Box textAlign="center" py={10}>
<Text size="3xl" block mb={3}>🏁</Text>
<Box mb={2}>
<Heading level={3}>
Your feed is warming up
</Heading>
</Box>
<Box maxWidth="md" mx="auto" mb={4}>
<Text size="sm" color="text-gray-400">
As leagues, teams, and friends start racing, this feed will show their latest results,
signups, and highlights.
</Text>
</Box>
<Button
as="a"
href="/leagues"
variant="secondary"
size="sm"
>
Explore leagues
</Button>
</Box>
</Card>
);
}

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import Card from '@/ui/Card';
import Button from '@/ui/Button';
import Image from 'next/image';
'use client';
import React, { useEffect, useState } from 'react';
import { Button } from '@/ui/Button';
import { FeedItem } from '@/ui/FeedItem';
interface FeedItemData {
id: string;
@@ -26,9 +27,7 @@ function timeAgo(timestamp: Date | string): string {
return `${diffDays} d ago`;
}
async function resolveActor(_item: FeedItemData) {
// Actor resolution is not wired through the API in this build.
// Keep rendering deterministic and decoupled (no core repos).
async function resolveActor() {
return null;
}
@@ -36,14 +35,14 @@ interface FeedItemCardProps {
item: FeedItemData;
}
export default function FeedItemCard({ item }: FeedItemCardProps) {
export function FeedItemCard({ item }: FeedItemCardProps) {
const [actor, setActor] = useState<{ name: string; avatarUrl: string } | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const resolved = await resolveActor(item);
const resolved = await resolveActor();
if (!cancelled) {
setActor(resolved);
}
@@ -55,51 +54,25 @@ export default function FeedItemCard({ item }: FeedItemCardProps) {
}, [item]);
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
{actor ? (
<div className="w-10 h-10 rounded-full overflow-hidden bg-charcoal-outline">
<Image
src={actor.avatarUrl}
alt={actor.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
) : (
<Card className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-blue/10 border-primary-blue/40 p-0">
<span className="text-xs text-primary-blue font-semibold">
{item.type.startsWith('friend') ? 'FR' : 'LG'}
</span>
</Card>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm text-white">{item.headline}</p>
{item.body && (
<p className="text-xs text-gray-400 mt-1">{item.body}</p>
)}
</div>
<span className="text-[11px] text-gray-500 whitespace-nowrap">
{timeAgo(item.timestamp)}
</span>
</div>
{item.ctaHref && item.ctaLabel && (
<div className="mt-3">
<Button
as="a"
href={item.ctaHref}
variant="secondary"
className="text-xs px-4 py-2"
>
{item.ctaLabel}
</Button>
</div>
)}
</div>
</div>
<FeedItem
actorName={actor?.name}
actorAvatarUrl={actor?.avatarUrl}
typeLabel={item.type.startsWith('friend') ? 'FR' : 'LG'}
headline={item.headline}
body={item.body}
timeAgo={timeAgo(item.timestamp)}
cta={item.ctaHref && item.ctaLabel ? (
<Button
as="a"
href={item.ctaHref}
variant="secondary"
size="sm"
px={4}
py={2}
>
{item.ctaLabel}
</Button>
) : undefined}
/>
);
}
}

View File

@@ -1,66 +0,0 @@
import Card from '@/ui/Card';
import FeedList from '@/components/feed/FeedList';
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
type FeedUpcomingRace = {
id: string;
track: string;
car: string;
scheduledAt: string | Date;
};
type FeedLatestResult = {
raceId: string;
track: string;
car: string;
winnerName: string;
scheduledAt: string | Date;
};
interface FeedLayoutProps {
feedItems: FeedItemData[];
upcomingRaces: FeedUpcomingRace[];
latestResults: FeedLatestResult[];
}
export default function FeedLayout({
feedItems,
upcomingRaces,
latestResults
}: FeedLayoutProps) {
return (
<section className="max-w-7xl mx-auto mt-16 mb-20">
<div className="flex flex-col gap-8 lg:grid lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
<div className="flex items-baseline justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-white">Activity</h2>
<p className="text-sm text-gray-400">
See what your friends and leagues are doing right now.
</p>
</div>
</div>
<Card className="bg-iron-gray/80">
<FeedList items={feedItems} />
</Card>
</div>
<aside className="space-y-6">
<UpcomingRacesSidebar races={upcomingRaces} />
<LatestResultsSidebar results={latestResults} />
</aside>
</div>
</section>
);
}

View File

@@ -1,31 +0,0 @@
import FeedEmptyState from '@/components/feed/FeedEmptyState';
import FeedItemCard from '@/components/feed/FeedItemCard';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface FeedListProps {
items: FeedItemData[];
}
export default function FeedList({ items }: FeedListProps) {
if (!items.length) {
return <FeedEmptyState />;
}
return (
<div className="space-y-4">
{items.map(item => (
<FeedItemCard key={item.id} item={item} />
))}
</div>
);
}

View File

@@ -1,108 +0,0 @@
'use client';
import Container from '@/ui/Container';
import Heading from '@/ui/Heading';
import { useParallax } from "@/lib/hooks/useScrollProgress";
import { useRef } from 'react';
interface AlternatingSectionProps {
heading: string;
description: string | React.ReactNode;
mockup: React.ReactNode;
layout: 'text-left' | 'text-right';
backgroundImage?: string;
backgroundVideo?: string;
}
export default function AlternatingSection({
heading,
description,
mockup,
layout,
backgroundImage,
backgroundVideo
}: AlternatingSectionProps) {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.2);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-deep-graphite px-[calc(1rem+var(--sal))] pr-[calc(1rem+var(--sar))] py-20 sm:py-24 md:py-32 md:px-[calc(2rem+var(--sal))] md:pr-[calc(2rem+var(--sar))] lg:px-8">
{backgroundVideo && (
<>
<video
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-cover opacity-20 md:opacity-30"
style={{
maskImage: 'radial-gradient(ellipse at center, black 0%, rgba(0,0,0,0.8) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, black 0%, rgba(0,0,0,0.8) 40%, transparent 70%)',
}}
>
<source src={backgroundVideo} type="video/mp4" />
</video>
{/* Racing red accent for sections with background videos */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" />
</>
)}
{backgroundImage && !backgroundVideo && (
<>
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${backgroundImage})`,
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)',
transform: `translateY(${bgParallax * 0.3}px)`
}}
/>
{/* Racing red accent for sections with background images */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" />
</>
)}
{/* Carbon fiber texture on sections without images or videos */}
{!backgroundImage && !backgroundVideo && (
<div className="absolute inset-0 carbon-fiber opacity-30" />
)}
{/* Checkered pattern accent */}
<div className="absolute inset-0 checkered-pattern opacity-10" />
<Container size="lg" className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 md:gap-12 lg:gap-16 items-center">
{/* Text Content - Always first on mobile, respects layout on desktop */}
<div
className={`space-y-4 md:space-y-6 lg:space-y-8 ${layout === 'text-right' ? 'lg:order-2' : ''}`}
style={{
opacity: 1,
transform: 'translateX(0)'
}}
>
<Heading level={2} className="text-xl md:text-2xl lg:text-3xl xl:text-4xl bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-medium drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient" style={{ WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{heading}
</Heading>
<div className="text-sm md:text-base lg:text-lg text-slate-400 font-light leading-relaxed md:leading-loose space-y-3 md:space-y-5">
{description}
</div>
</div>
{/* Mockup - Always second on mobile, respects layout on desktop */}
<div
className={`relative group ${layout === 'text-right' ? 'lg:order-1' : ''}`}
style={{
opacity: 1,
transform: 'translateX(0) scale(1)'
}}
>
<div className={`w-full min-h-[240px] md:min-h-[380px] lg:min-h-[440px] transition-transform duration-speed group-hover:scale-[1.02] ${layout === 'text-left' ? 'md:[mask-image:linear-gradient(to_right,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)] md:[-webkit-mask-image:linear-gradient(to_right,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)]' : 'md:[mask-image:linear-gradient(to_left,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)] md:[-webkit-mask-image:linear-gradient(to_left,white_50%,rgba(255,255,255,0.8)_70%,rgba(255,255,255,0.4)_85%,transparent_100%)]'}`}>
{mockup}
</div>
</div>
</div>
</Container>
</section>
);
}

View File

@@ -1,190 +0,0 @@
'use client';
import { Button } from '@/ui/Button';
import { Surface } from '@/ui/Surface';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { MessageSquare, Lightbulb, Users, Code, LucideIcon } from 'lucide-react';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
export function DiscordCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
return (
<Surface
as="section"
variant="discord"
padding={4}
position="relative"
py={{ base: 4, md: 12, lg: 16 }}
>
<Box maxWidth="896px" mx="auto" px={2}>
<Surface
variant="discord-inner"
padding={3}
border
rounded="xl"
position="relative"
shadow="discord"
>
{/* Discord brand accent */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="1"
backgroundColor="[#5865F2]"
opacity={0.6}
className="bg-gradient-to-r from-transparent via-[#5865F2]/60 to-transparent"
/>
<Stack align="center" gap={6} center>
{/* Header */}
<Stack align="center" gap={4}>
<Box
display="flex"
center
rounded="full"
w={{ base: "10", md: "14", lg: "18" }}
h={{ base: "10", md: "14", lg: "18" }}
backgroundColor="[#5865F2]"
opacity={0.2}
border
borderColor="[#5865F2]"
>
<DiscordIcon color="text-[#5865F2]" size={32} />
</Box>
<Stack gap={2}>
<Text as="h2" size="2xl" weight="semibold" color="text-white">
Join us on Discord
</Text>
<Box
mx="auto"
rounded="full"
w={{ base: "16", md: "24", lg: "32" }}
h={{ base: "0.5", md: "1" }}
backgroundColor="[#5865F2]"
className="bg-gradient-to-r from-[#5865F2] to-[#7289DA]"
/>
</Stack>
</Stack>
{/* Personal message */}
<Box maxWidth="672px" mx="auto">
<Stack gap={3}>
<Text size="sm" color="text-gray-300" weight="normal" leading="relaxed">
GridPilot is a <Text weight="bold" color="text-white">solo developer project</Text>, and I'm building it because I got tired of the chaos in league racing.
</Text>
<Text size="sm" color="text-gray-400" weight="normal" leading="relaxed">
This is <Text weight="bold" color="text-gray-300">early days</Text>, and I need your help. Join the Discord to:
</Text>
</Stack>
</Box>
{/* Benefits grid */}
<Box
maxWidth="2xl"
mx="auto"
mt={4}
>
<Grid cols={2} gap={3} className="md:grid-cols-2">
<BenefitItem
icon={MessageSquare}
title="Share your pain points"
description="Tell me what frustrates you about league racing today"
/>
<BenefitItem
icon={Lightbulb}
title="Shape the product"
description="Your ideas will directly influence what gets built"
/>
<BenefitItem
icon={Users}
title="Be part of the community"
description="Connect with other league racers who get it"
/>
<BenefitItem
icon={Code}
title="Get early access"
description="Test features first and help iron out the rough edges"
/>
</Grid>
</Box>
{/* CTA Button */}
<Stack gap={3} pt={4}>
<Button
as="a"
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
variant="discord"
size="lg"
icon={<DiscordIcon size={28} />}
>
Join us on Discord
</Button>
<Text size="xs" color="text-primary-blue" weight="light">
💡 Get a link to our early alpha view in the Discord
</Text>
{!process.env.NEXT_PUBLIC_DISCORD_URL && (
<Text size="xs" color="text-gray-500">
Note: Configure NEXT_PUBLIC_DISCORD_URL in your environment variables
</Text>
)}
</Stack>
{/* Footer note */}
<Box maxWidth="xl" mx="auto" pt={4}>
<Text size="xs" color="text-gray-500" weight="light" leading="relaxed" align="center" block>
This is a community effort. Every voice matters. Let's build something that actually works for league racing.
</Text>
</Box>
</Stack>
</Surface>
</Box>
</Surface>
);
}
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
return (
<Surface
variant="muted"
border
padding={3}
rounded="lg"
display="flex"
gap={3}
className="items-start hover:border-[#5865F2]/30 transition-all"
>
<Box
display="flex"
center
rounded="lg"
flexShrink={0}
w="6"
h="6"
backgroundColor="[#5865F2]"
opacity={0.2}
border
borderColor="[#5865F2]"
mt={0.5}
>
<Icon icon={icon} size={4} color="text-[#5865F2]" />
</Box>
<Stack gap={0.5}>
<Text size="xs" weight="medium" color="text-white">{title}</Text>
<Text size="xs" color="text-gray-400" leading="relaxed">{description}</Text>
</Stack>
</Surface>
);
}
import { Grid } from '@/ui/Grid';

View File

@@ -1,7 +1,12 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { ChevronDown } from 'lucide-react';
const faqs = [
{
@@ -34,35 +39,43 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
<Box
as={motion.div}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="group"
group
>
<div className="rounded-lg bg-iron-gray border border-charcoal-outline transition-all duration-150 hover:-translate-y-1 hover:shadow-lg hover:border-primary-blue/50">
<button
<Box rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" transition hoverBorderColor="border-primary-blue/50" transform={isOpen ? '' : 'translateY(0)'} hoverScale={!isOpen}>
<Box
as="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full p-2 md:p-3 lg:p-4 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-blue rounded-lg min-h-[44px]"
fullWidth
p={{ base: 2, md: 3, lg: 4 }}
textAlign="left"
rounded="lg"
minHeight="44px"
>
<div className="flex items-center justify-between gap-1.5 md:gap-2">
<h3 className="text-xs md:text-sm font-semibold text-white group-hover:text-primary-blue transition-colors duration-150">
<Box display="flex" alignItems="center" justifyContent="between" gap={{ base: 1.5, md: 2 }}>
<Heading level={3} fontSize={{ base: 'xs', md: 'sm' }} weight="semibold" color="text-white" groupHoverColor="primary-blue" transition>
{faq.question}
</h3>
<motion.svg
</Heading>
<Box
as={motion.div}
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.15, ease: 'easeInOut' }}
className="w-3.5 h-3.5 md:w-4 md:h-4 text-neon-aqua flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
w={{ base: "3.5", md: "4" }}
h={{ base: "3.5", md: "4" }}
color="text-neon-aqua"
flexShrink={0}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</motion.svg>
</div>
</button>
<motion.div
<Icon icon={ChevronDown} size="full" />
</Box>
</Box>
</Box>
<Box
as={motion.div}
initial={false}
animate={{
height: isOpen ? 'auto' : 0,
@@ -72,47 +85,48 @@ function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
height: { duration: 0.3, ease: [0.34, 1.56, 0.64, 1] },
opacity: { duration: 0.2, ease: 'easeInOut' }
}}
className="overflow-hidden"
overflow="hidden"
>
<div className="px-2 pb-2 pt-1 md:px-3 md:pb-3">
<p className="text-[10px] md:text-xs text-gray-300 font-light leading-relaxed">
<Box px={{ base: 2, md: 3 }} pb={{ base: 2, md: 3 }} pt={1}>
<Text size={{ base: 'xs', md: 'xs' }} color="text-gray-300" weight="light" leading="relaxed">
{faq.answer}
</p>
</div>
</motion.div>
</div>
</motion.div>
</Text>
</Box>
</Box>
</Box>
</Box>
);
}
export default function FAQ() {
export function FAQ() {
return (
<section className="relative py-3 md:py-12 lg:py-16 bg-deep-graphite overflow-hidden">
<Box as="section" position="relative" py={{ base: 3, md: 12, lg: 16 }} bg="bg-deep-graphite" overflow="hidden">
{/* Background image with mask */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: 'url(/images/porsche.jpeg)',
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)'
}}
<Box
position="absolute"
inset="0"
bg="url(/images/porsche.jpeg)"
backgroundSize="cover"
backgroundPosition="center"
maskImage="radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)"
webkitMaskImage="radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.1) 40%, transparent 70%)"
/>
{/* Racing red accent */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-red-500/30 to-transparent" />
<Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, rgba(239, 68, 68, 0.3), transparent)" />
<div className="max-w-3xl mx-auto px-4 md:px-6 relative z-10">
<div className="text-center mb-4 md:mb-8">
<h2 className="text-base md:text-xl lg:text-2xl font-semibold text-white mb-1">
<Box maxWidth="3xl" mx="auto" px={{ base: 4, md: 6 }} position="relative" zIndex={10}>
<Box textAlign="center" mb={{ base: 4, md: 8 }}>
<Heading level={2} fontSize={{ base: 'base', md: 'xl', lg: '2xl' }} weight="semibold" color="text-white" mb={1}>
Frequently Asked Questions
</h2>
<div className="w-24 md:w-32 h-1 bg-gradient-to-r from-primary-blue to-neon-aqua mx-auto rounded-full" />
</div>
<div className="space-y-1.5 md:space-y-2">
</Heading>
<Box mx="auto" rounded="full" w={{ base: "24", md: "32" }} h="1" bg="linear-gradient(to right, var(--primary-blue), var(--neon-aqua))" />
</Box>
<Box display="flex" flexDirection="column" gap={{ base: 1.5, md: 2 }}>
{faqs.map((faq, index) => (
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</div>
</div>
</section>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,106 +0,0 @@
'use client';
import { useRef, useState, useEffect } from 'react';
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';
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
const features = [
{
title: "A Real Home for Your League",
description: "Stop juggling Discord, spreadsheets, and iRacing admin panels. GridPilot brings everything into one dedicated platform built specifically for league racing.",
MockupComponent: LeagueHomeMockup
},
{
title: "Automatic Results & Standings",
description: "Race happens. Results appear. Standings update. No manual data entry, no spreadsheet formulas, no waiting for someone to publish.",
MockupComponent: StandingsTableMockup
},
{
title: "Real Team Racing",
description: "Constructors' championships that actually matter. Driver lineups. Team strategies. Multi-class racing done right.",
MockupComponent: TeamCompetitionMockup
},
{
title: "Clean Protests & Penalties",
description: "Structured incident reporting with video clip references. Steward review workflows. Transparent penalty application. Professional race control.",
MockupComponent: ProtestWorkflowMockup
},
{
title: "Find Your Perfect League",
description: "Search and discover leagues by game, region, and skill level. Browse featured competitions, check driver counts, and join communities that match your racing style.",
MockupComponent: LeagueDiscoveryMockup
},
{
title: "Your Racing Identity",
description: "Cross-league driver profiles with career stats, achievements, and racing history. Build your reputation across multiple championships and showcase your progression.",
MockupComponent: DriverProfileMockup
}
];
function FeatureCard({ feature, index }: { feature: typeof features[0], index: number }) {
return (
<div
className="flex flex-col gap-6 sm:gap-6 group"
style={{
opacity: 1,
transform: 'translateY(0) scale(1)'
}}
>
<div className="aspect-video w-full relative">
<div className="absolute -inset-0.5 bg-gradient-to-r from-racing-red/20 via-primary-blue/20 to-racing-red/20 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-500 blur-sm" />
<div className="relative">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Heading level={3} className="bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-medium drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient" style={{ WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{feature.title}
</Heading>
</div>
<p className="text-sm sm:text-base leading-7 sm:leading-7 text-gray-400 font-light">
{feature.description}
</p>
</div>
</div>
);
}
export default function FeatureGrid() {
return (
<Section variant="default">
<Container className="relative z-10">
<Container size="sm" center>
<div
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<Heading level={2} className="bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent font-semibold drop-shadow-[0_0_20px_rgba(220,0,0,0.5)] static-racing-gradient" style={{ WebkitTextStroke: '1px rgba(220,0,0,0.2)' }}>
Building for League Racing
</Heading>
<p className="mt-4 sm:mt-6 text-base sm:text-lg text-gray-400">
These features are in development. Join the community to help shape what gets built first
</p>
</div>
</Container>
<div className="mx-auto mt-8 sm:mt-12 md:mt-16 grid max-w-2xl grid-cols-1 gap-10 sm:gap-12 md:gap-16 lg:max-w-none lg:grid-cols-2 xl:grid-cols-3">
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}
</div>
</Container>
</Section>
);
}

View File

@@ -1,23 +1,58 @@
import { LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface FeatureItemProps {
icon: LucideIcon;
text: string;
className?: string;
}
export function FeatureItem({ icon: Icon, text, className }: FeatureItemProps) {
export function FeatureItem({ icon, text }: FeatureItemProps) {
return (
<div className={`group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)] ${className || ''}`}>
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<Icon className="w-5 h-5 text-primary-blue" />
</div>
<span className="text-slate-200 leading-relaxed font-light">
<Box
position="relative"
overflow="hidden"
rounded="lg"
bg="bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60"
p={4}
border
borderColor="border-slate-700/40"
hoverBorderColor="border-primary-blue/50"
transition
group
>
<Box
position="absolute"
top="0"
left="0"
w="full"
h="0.5"
bg="bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent"
opacity={0}
groupHoverBorderColor="opacity-100" // This is a hack, Box doesn't support groupHoverOpacity
/>
<Box display="flex" alignItems="start" gap={3}>
<Box
flexShrink={0}
w="9"
h="9"
rounded="lg"
bg="bg-gradient-to-br from-primary-blue/20 to-blue-900/20"
border
borderColor="border-primary-blue/30"
display="flex"
alignItems="center"
justifyContent="center"
shadow="lg"
hoverScale
>
<Icon icon={icon} size={5} color="text-primary-blue" />
</Box>
<Text color="text-slate-200" leading="relaxed" weight="light">
{text}
</span>
</div>
</div>
</Text>
</Box>
</Box>
);
}

View File

@@ -1,92 +0,0 @@
'use client';
import Image from 'next/image';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
export default function Footer() {
return (
<footer className="relative bg-deep-graphite">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
<div className="max-w-4xl mx-auto px-[calc(1.5rem+var(--sal))] pr-[calc(1.5rem+var(--sar))] py-2 md:py-8 lg:py-12 pb-[calc(0.5rem+var(--sab))] md:pb-[calc(1.5rem+var(--sab))]">
{/* Racing stripe accent */}
<div
className="flex gap-1 mb-2 md:mb-4 lg:mb-6 justify-center"
style={{
opacity: 1,
transform: 'scaleX(1)'
}}
>
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-white rounded-full" />
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-primary-blue rounded-full" />
<div className="w-12 md:w-20 lg:w-28 h-[2px] md:h-0.5 lg:h-1 bg-white rounded-full" />
</div>
{/* Personal message */}
<div
className="text-center mb-3 md:mb-6 lg:mb-8"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<div className="mb-2 flex justify-center">
<Image
src="/images/logos/icon-square-dark.svg"
alt="GridPilot"
width={40}
height={40}
className="h-8 w-auto md:h-10"
/>
</div>
<p className="text-[9px] md:text-xs lg:text-sm text-gray-300 mb-1 md:mb-2">
🏁 Built by a sim racer, for sim racers
</p>
<p className="text-[9px] md:text-xs text-gray-400 font-light max-w-2xl mx-auto">
Just a fellow racer tired of spreadsheets and chaos. GridPilot is my passion project to make league racing actually fun again.
</p>
</div>
{/* Community links */}
<div
className="flex justify-center gap-4 md:gap-6 lg:gap-8 mb-3 md:mb-6 lg:mb-8"
style={{
opacity: 1,
transform: 'scale(1)'
}}
>
<a
href={discordUrl}
className="text-[9px] md:text-xs text-primary-blue hover:text-neon-aqua transition-colors font-medium inline-flex items-center justify-center min-h-[44px] min-w-[44px] px-3 py-2 active:scale-95 transition-transform"
>
💬 Join Discord
</a>
<a
href={xUrl}
className="text-[9px] md:text-xs text-gray-300 hover:text-neon-aqua transition-colors font-medium inline-flex items-center justify-center min-h-[44px] min-w-[44px] px-3 py-2 active:scale-95 transition-transform"
>
𝕏 Follow on X
</a>
</div>
{/* Development status */}
<div
className="text-center pt-2 md:pt-4 lg:pt-6 border-t border-charcoal-outline"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<p className="text-[9px] md:text-xs lg:text-sm text-gray-500 mb-1 md:mb-2">
Early development Feedback welcome
</p>
<p className="text-[9px] md:text-xs text-gray-600">
Questions? Find me on Discord
</p>
</div>
</div>
</footer>
);
}

View File

@@ -1,139 +0,0 @@
'use client';
import { useRef } from 'react';
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 || '#';
if (!process.env.NEXT_PUBLIC_DISCORD_URL) {
console.warn('NEXT_PUBLIC_DISCORD_URL is not set. Discord button will use "#" as fallback.');
}
export default function Hero() {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.3);
return (
<section ref={sectionRef} className="relative overflow-hidden bg-deep-graphite px-[calc(1.5rem+var(--sal))] pr-[calc(1.5rem+var(--sar))] pt-[calc(3rem+var(--sat))] pb-16 sm:pt-[calc(4rem+var(--sat))] sm:pb-24 md:py-32 lg:px-8">
{/* Background image layer with parallax */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: 'url(/images/header.jpeg)',
maskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.35) 40%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.35) 40%, transparent 70%)',
transform: `translateY(${bgParallax * 0.5}px)`
}}
/>
{/* Racing red accent gradient */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-red-600/40 to-transparent" />
{/* Racing stripes background */}
<div className="absolute inset-0 racing-stripes opacity-30" />
{/* Checkered pattern overlay */}
<div className="absolute inset-0 checkered-pattern opacity-20" />
{/* Speed lines - left side */}
<div className="absolute left-0 top-1/4 w-32 h-px bg-gradient-to-r from-transparent to-primary-blue/30 animate-speed-lines" style={{ animationDelay: '0s' }} />
<div className="absolute left-0 top-1/3 w-24 h-px bg-gradient-to-r from-transparent to-primary-blue/20 animate-speed-lines" style={{ animationDelay: '0.3s' }} />
<div className="absolute left-0 top-2/5 w-28 h-px bg-gradient-to-r from-transparent to-primary-blue/25 animate-speed-lines" style={{ animationDelay: '0.6s' }} />
{/* Carbon fiber accent - bottom */}
<div className="absolute bottom-0 left-0 right-0 h-32 carbon-fiber opacity-50" />
{/* Radial gradient overlay with racing red accent */}
<div className="absolute inset-0 bg-gradient-radial from-red-600/5 via-primary-blue/5 to-transparent opacity-60 pointer-events-none" />
<Container size="sm" center className="relative z-10 space-y-6 sm:space-y-8 md:space-y-12">
<Heading
level={1}
className="text-2xl sm:text-4xl md:text-5xl lg:text-6xl leading-tight tracking-tight font-semibold bg-gradient-to-r from-red-600 via-white to-blue-600 bg-clip-text text-transparent drop-shadow-[0_0_15px_rgba(220,0,0,0.4)] static-racing-gradient"
style={{
WebkitTextStroke: '0.5px rgba(220,0,0,0.2)',
opacity: 1,
transform: 'translateY(0)',
filter: 'blur(0)'
}}
>
League racing is incredible. What's missing is everything around it.
</Heading>
<div
className="text-sm sm:text-lg md:text-xl lg:text-2xl leading-relaxed text-slate-200 font-light space-y-4 sm:space-y-6"
style={{
opacity: 1,
transform: 'translateY(0)'
}}
>
<p className="text-left md:text-center">
If you've been in any league, you know the feeling:
</p>
{/* Problem badges - mobile optimized */}
<div className="flex flex-col sm:flex-row sm:flex-wrap gap-2 sm:gap-3 items-stretch sm:justify-center sm:items-center max-w-2xl mx-auto">
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">Results scattered across Discord</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">No long-term identity</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">No career progression</span>
</div>
<div className="flex items-center gap-2.5 px-3 py-2.5 sm:gap-2.5 sm:px-5 sm:py-2.5 bg-gradient-to-br from-slate-800/80 to-slate-900/80 border border-red-500/20 rounded-lg backdrop-blur-md active:scale-95 sm:hover:border-red-500/40 sm:hover:shadow-[0_0_15px_rgba(239,68,68,0.15)] transition-all duration-300">
<span className="text-red-500 text-sm sm:text-sm font-semibold flex-shrink-0">×</span>
<span className="text-slate-100 text-sm sm:text-base font-medium text-left">Forgotten after each season</span>
</div>
</div>
<p className="text-left md:text-center">
The ecosystem isn't built for this.
</p>
<p className="text-left md:text-center">
<strong className="text-white font-semibold">GridPilot gives your league racing a real home.</strong>
</p>
</div>
<div
className="flex items-center justify-center"
style={{
opacity: 1,
transform: 'translateY(0) scale(1)'
}}
>
<a
href="#community"
className="group relative inline-flex items-center justify-center gap-3 px-8 py-4 min-h-[44px] min-w-[44px] bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold text-base sm:text-lg rounded-lg transition-all duration-300 hover:scale-105 hover:-translate-y-0.5 shadow-[0_0_20px_rgba(88,101,242,0.3)] hover:shadow-[0_0_30px_rgba(88,101,242,0.6)] active:scale-95"
aria-label="Scroll to Discord community section"
>
{/* Discord Logo SVG */}
<svg
className="w-7 h-7 transition-transform duration-300 group-hover:scale-110"
viewBox="0 0 71 55"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0)">
<path
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white"/>
</clipPath>
</defs>
</svg>
<span>Join us on Discord</span>
</a>
</div>
</Container>
</section>
);
}

View File

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

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { Link } from '@/ui/Link';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
export type BreadcrumbItem = {
label: string;
href?: string;
};
interface BreadcrumbsProps {
items: BreadcrumbItem[];
className?: string;
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
if (!items || items.length === 0) {
return null;
}
const lastIndex = items.length - 1;
return (
<Box as="nav" aria-label="Breadcrumb" mb={4}>
<Stack direction="row" align="center" gap={2} wrap>
{items.map((item, index) => {
const isLast = index === lastIndex;
const content = item.href && !isLast ? (
<Link
href={item.href}
variant="ghost"
>
{item.label}
</Link>
) : (
<Text color={isLast ? 'text-white' : 'text-gray-400'}>{item.label}</Text>
);
return (
<Stack key={`${item.label}-${index}`} direction="row" align="center" gap={2}>
{index > 0 && (
<Text color="text-gray-600">/</Text>
)}
{content}
</Stack>
);
})}
</Stack>
</Box>
);
}

View File

@@ -1,7 +1,12 @@
import React from 'react';
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
import Button from '@/ui/Button';
import Image from 'next/image';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
@@ -26,71 +31,115 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
const top10 = drivers; // Already sliced in builder
return (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Trophy className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Driver Rankings</h3>
<p className="text-xs text-gray-500">Top performers across all leagues</p>
</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-primary-blue/20 to-primary-blue/5" border borderColor="border-primary-blue/20">
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Driver Rankings</Heading>
<Text size="xs" color="text-gray-500" block>Top performers across all leagues</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToDrivers}
className="flex items-center gap-2 text-sm"
size="sm"
>
View All
<ChevronRight className="w-4 h-4" />
<Stack direction="row" align="center" gap={2}>
<Text size="sm">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</div>
</Box>
<div className="divide-y divide-charcoal-outline/50">
<Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top10.map((driver, index) => {
const position = index + 1;
return (
<button
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick(driver.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
display="flex"
alignItems="center"
gap={4}
px={5}
py={3}
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
group
>
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
<Box
display="flex"
h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
</Box>
<div className="relative w-9 h-9 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<Box position="relative" w="9" h="9" rounded="full" overflow="hidden" border borderWidth="2px" borderColor="border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" />
</Box>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-primary-blue transition-colors">
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-primary-blue" transition block>
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Flag className="w-3 h-3" />
{driver.nationality}
<span className={SkillLevelDisplay.getColor(driver.skillLevel)}>{SkillLevelDisplay.getLabel(driver.skillLevel)}</span>
</div>
</div>
</Text>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Flag} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Box as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className={SkillLevelDisplay.getColor(driver.skillLevel)}
>
<Text size="xs">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
</Box>
</Box>
</Box>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-primary-blue font-mono font-semibold">{RatingDisplay.format(driver.rating)}</p>
<p className="text-[10px] text-gray-500">Rating</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{driver.wins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text color="text-primary-blue" font="mono" weight="semibold" block>{RatingDisplay.format(driver.rating)}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Rating
</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{driver.wins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
</Box>
</Box>
</Box>
);
})}
</div>
</div>
</Stack>
</Box>
);
}

View File

@@ -18,13 +18,29 @@ interface LeaderboardsHeroProps {
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)' }}>
<Surface
variant="muted"
rounded="2xl"
border
padding={8}
position="relative"
overflow="hidden"
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
borderColor="border-yellow-500/20"
>
<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 }}>
<Box 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)' }}>
<Surface
variant="muted"
rounded="xl"
padding={3}
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
border
borderColor="border-yellow-400/30"
>
<Icon icon={Award} size={7} color="#facc15" />
</Surface>
<Box>
@@ -33,7 +49,14 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
</Box>
</Stack>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625, maxWidth: '42rem' }}>
<Text
size="lg"
color="text-gray-400"
block
mb={6}
leading="relaxed"
maxWidth="42rem"
>
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
</Text>

View File

@@ -1,7 +1,12 @@
import React from 'react';
import Image from 'next/image';
import { Users, Crown, Shield, ChevronRight } from 'lucide-react';
import Button from '@/ui/Button';
import { Users, Crown, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { getMediaUrl } from '@/lib/utilities/media';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
@@ -25,85 +30,131 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
const top5 = teams; // Already sliced in builder when implemented
return (
<div className="rounded-xl bg-iron-gray/30 border border-charcoal-outline overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-charcoal-outline bg-iron-gray/20">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/20">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Team Rankings</h3>
<p className="text-xs text-gray-500">Top performing racing teams</p>
</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
<Icon icon={Users} size={5} color="text-purple-400" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToTeams}
className="flex items-center gap-2 text-sm"
size="sm"
>
View All
<ChevronRight className="w-4 h-4" />
<Stack direction="row" align="center" gap={2}>
<Text size="sm">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</div>
</Box>
<div className="divide-y divide-charcoal-outline/50">
{top5.map((team, index) => {
<Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top5.map((team) => {
const position = team.position;
return (
<button
<Box
key={team.id}
as="button"
type="button"
onClick={() => onTeamClick(team.id)}
className="flex items-center gap-4 px-5 py-3 w-full text-left hover:bg-iron-gray/30 transition-colors group"
display="flex"
alignItems="center"
gap={4}
px={5}
py={3}
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
group
>
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold border ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</div>
<Box
display="flex"
h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
</Box>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-charcoal-outline border border-charcoal-outline overflow-hidden">
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={36}
height={36}
className="w-full h-full object-cover"
fullWidth
fullHeight
objectFit="cover"
/>
</div>
</Box>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate group-hover:text-purple-400 transition-colors">
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
{team.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
</Text>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
{team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{team.category}
</span>
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
<Text size="xs">{team.category}</Text>
</Box>
)}
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{team.memberCount} members
</span>
<span className={SkillLevelDisplay.getColor(team.category || '')}>{SkillLevelDisplay.getLabel(team.category || '')}</span>
</div>
</div>
<Box display="flex" alignItems="center" gap={1}>
<Icon icon={Users} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
</Box>
<Box as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className={SkillLevelDisplay.getColor(team.category || '')}
>
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
</Box>
</Box>
</Box>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-purple-400 font-mono font-semibold">{team.memberCount}</p>
<p className="text-[10px] text-gray-500">Members</p>
</div>
<div className="text-center">
<p className="text-performance-green font-mono font-semibold">{team.totalWins}</p>
<p className="text-[10px] text-gray-500">Wins</p>
</div>
</div>
</button>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Members
</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
</Box>
</Box>
</Box>
);
})}
</div>
</div>
</Stack>
</Box>
);
}

View File

@@ -1,11 +1,6 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
interface EmptyStateProps {
title: string;
@@ -15,7 +10,6 @@ interface EmptyStateProps {
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
className?: string;
}
export function EmptyState({
@@ -26,38 +20,20 @@ export function EmptyState({
actionLabel,
onAction,
children,
className,
}: EmptyStateProps) {
return (
<Card className={className}>
<Box textAlign="center" py={16}>
<Box maxWidth="md" mx="auto">
<Box height={16} width={16} mx="auto" display="flex" center rounded="2xl" backgroundColor="primary-blue" opacity={0.1} border borderColor="primary-blue" mb={6}>
<Icon icon={icon} size={8} color="text-primary-blue" />
</Box>
<Box mb={3}>
<Heading level={2}>
{title}
</Heading>
</Box>
<Box mb={8}>
<Text color="text-gray-400">
{description}
</Text>
</Box>
{children}
{actionLabel && onAction && (
<Button
variant="primary"
onClick={onAction}
icon={<Icon icon={actionIcon} size={4} />}
className="mx-auto"
>
{actionLabel}
</Button>
)}
</Box>
</Box>
<Card>
<UiEmptyState
title={title}
description={description}
icon={icon}
action={actionLabel && onAction ? {
label: actionLabel,
onClick: onAction,
icon: actionIcon,
} : undefined}
/>
{children}
</Card>
);
}

View File

@@ -1,100 +1,72 @@
'use client';
import React from 'react';
import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react';
import Button from '@/ui/Button';
import { TestTube } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Modal } from '@/ui/Modal';
import { InfoBanner } from '@/ui/InfoBanner';
import { Box } from '@/ui/Box';
import { ModalIcon } from '@/ui/ModalIcon';
interface EndRaceModalProps {
raceId: string;
raceName: string;
onConfirm: () => void;
onCancel: () => void;
isOpen: boolean;
}
export default function EndRaceModal({ raceId, raceName, onConfirm, onCancel }: EndRaceModalProps) {
export function EndRaceModal({ raceId, raceName, onConfirm, onCancel, isOpen }: EndRaceModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
<div className="p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10 border border-warning-amber/20">
<TestTube className="w-6 h-6 text-warning-amber" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Development Test Function</h2>
<p className="text-sm text-gray-400">End Race & Process Results</p>
</div>
</div>
<Modal
isOpen={isOpen}
onOpenChange={(open) => !open && onCancel()}
title="Development Test Function"
description="End Race & Process Results"
icon={
<ModalIcon
icon={TestTube}
color="text-warning-amber"
bgColor="bg-warning-amber/10"
borderColor="border-warning-amber/20"
/>
}
primaryActionLabel="Run Test"
onPrimaryAction={onConfirm}
secondaryActionLabel="Cancel"
onSecondaryAction={onCancel}
footer={
<Text size="xs" color="text-gray-500" align="center" block>
This action cannot be undone. Use only for testing purposes.
</Text>
}
>
<Stack gap={4}>
<InfoBanner type="warning" title="Development Only Feature">
This is a development/testing function to simulate ending a race and processing results.
It will generate realistic race results, update driver ratings, and calculate final standings.
</InfoBanner>
{/* Content */}
<div className="space-y-4 mb-6">
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-white mb-1">Development Only Feature</h3>
<p className="text-sm text-gray-300 leading-relaxed">
This is a development/testing function to simulate ending a race and processing results.
It will generate realistic race results, update driver ratings, and calculate final standings.
</p>
</div>
</div>
</div>
<InfoBanner type="success" title="What This Does">
<Stack as="ul" gap={1}>
<Text as="li" size="sm" color="text-gray-300"> Marks the race as completed</Text>
<Text as="li" size="sm" color="text-gray-300"> Generates realistic finishing positions</Text>
<Text as="li" size="sm" color="text-gray-300"> Updates driver ratings based on performance</Text>
<Text as="li" size="sm" color="text-gray-300"> Calculates championship points</Text>
<Text as="li" size="sm" color="text-gray-300"> Updates league standings</Text>
</Stack>
</InfoBanner>
<div className="p-4 rounded-lg bg-performance-green/10 border border-performance-green/20">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-performance-green mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-white mb-1">What This Does</h3>
<ul className="text-sm text-gray-300 space-y-1">
<li> Marks the race as completed</li>
<li> Generates realistic finishing positions</li>
<li> Updates driver ratings based on performance</li>
<li> Calculates championship points</li>
<li> Updates league standings</li>
</ul>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-400">
Race: <span className="text-white font-medium">{raceName}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
ID: {raceId}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
variant="secondary"
onClick={onCancel}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
className="flex-1 bg-performance-green hover:bg-performance-green/80"
>
<TestTube className="w-4 h-4 mr-2" />
Run Test
</Button>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<p className="text-xs text-gray-500 text-center">
This action cannot be undone. Use only for testing purposes.
</p>
</div>
</div>
</div>
</div>
<Box textAlign="center">
<Text size="sm" color="text-gray-400">
Race: <Text color="text-white" weight="medium">{raceName}</Text>
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
ID: {raceId}
</Text>
</Box>
</Stack>
</Modal>
);
}
}

View File

@@ -1,10 +1,13 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership';
import { useState } from 'react';
import { useLeagueMembershipMutation } from "@/lib/hooks/league/useLeagueMembershipMutation";
import Button from '../ui/Button';
import { useLeagueMembershipMutation } from "@/hooks/league/useLeagueMembershipMutation";
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Modal } from '@/ui/Modal';
interface JoinLeagueButtonProps {
leagueId: string;
@@ -12,7 +15,7 @@ interface JoinLeagueButtonProps {
onMembershipChange?: () => void;
}
export default function JoinLeagueButton({
export function JoinLeagueButton({
leagueId,
isInviteOnly = false,
onMembershipChange,
@@ -93,7 +96,7 @@ export default function JoinLeagueButton({
const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending;
return (
<>
<Box>
<Button
variant={getButtonVariant()}
onClick={() => {
@@ -104,58 +107,41 @@ export default function JoinLeagueButton({
}
}}
disabled={isDisabled}
className="w-full"
fullWidth
>
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()}
</Button>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
<Text size="sm" color="text-red-400" mt={2} block>{error}</Text>
)}
{/* Confirmation Dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-iron-gray border border-charcoal-outline rounded-lg max-w-md w-full p-6">
<h3 className="text-xl font-semibold text-white mb-4">
{dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
</h3>
<p className="text-gray-400 mb-6">
{dialogAction === 'leave'
? 'Are you sure you want to leave this league? You can rejoin later.'
: dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</p>
<Modal
isOpen={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
title={dialogAction === 'leave' ? 'Leave League' : dialogAction === 'request' ? 'Request to Join' : 'Join League'}
primaryActionLabel={(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
onPrimaryAction={dialogAction === 'leave' ? handleLeave : handleJoin}
secondaryActionLabel="Cancel"
onSecondaryAction={closeDialog}
>
<Box>
<Text color="text-gray-400" block mb={6}>
{dialogAction === 'leave'
? 'Are you sure you want to leave this league? You can rejoin later.'
: dialogAction === 'request'
? 'Your join request will be sent to the league admins for approval.'
: 'Are you sure you want to join this league?'}
</Text>
{error && (
<div className="mb-4 p-3 rounded bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="flex gap-3">
<Button
variant={dialogAction === 'leave' ? 'danger' : 'primary'}
onClick={dialogAction === 'leave' ? handleLeave : handleJoin}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
</Button>
<Button
variant="secondary"
onClick={closeDialog}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</>
{error && (
<Box mb={4} p={3} rounded="md" bg="bg-red-500/10" border borderColor="border-red-500/30">
<Text size="sm" color="text-red-400">{error}</Text>
</Box>
)}
</Box>
</Modal>
</Box>
);
}

View File

@@ -1,7 +1,11 @@
'use client';
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/lib/hooks/league/useLeagueRaces";
import React, { useMemo } from 'react';
import { Calendar, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { ActivityFeedItem } from '@/ui/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { processLeagueActivities } from '@/lib/services/league/LeagueActivityService';
export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
@@ -29,67 +33,36 @@ function timeAgo(timestamp: Date): string {
return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
const activities: LeagueActivity[] = [];
if (!isLoading && raceList.length > 0) {
const completedRaces = raceList
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
for (const race of completedRaces) {
activities.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt),
});
}
for (const race of upcomingRaces) {
activities.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
// Sort all activities by timestamp
activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
activities.splice(limit); // Limit results
}
const activities = useMemo(() => {
if (isLoading || raceList.length === 0) return [];
return processLeagueActivities(raceList, limit);
}, [raceList, isLoading, limit]);
if (isLoading) {
return (
<div className="text-center text-gray-400 py-8">
<Text color="text-gray-400" textAlign="center" block py={8}>
Loading activities...
</div>
</Text>
);
}
if (activities.length === 0) {
return (
<div className="text-center text-gray-400 py-8">
<Text color="text-gray-400" textAlign="center" block py={8}>
No recent activity
</div>
</Text>
);
}
return (
<div className="space-y-4">
<Stack gap={0}>
{activities.map((activity, index) => (
<ActivityItem key={`${activity.type}-${index}`} activity={activity} />
))}
</div>
</Stack>
);
}
@@ -97,17 +70,17 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
const getIcon = () => {
switch (activity.type) {
case 'race_completed':
return <Flag className="w-4 h-4 text-performance-green" />;
return <Icon icon={Flag} size={4} color="var(--performance-green)" />;
case 'race_scheduled':
return <Calendar className="w-4 h-4 text-primary-blue" />;
return <Icon icon={Calendar} size={4} color="var(--primary-blue)" />;
case 'penalty_applied':
return <AlertTriangle className="w-4 h-4 text-warning-amber" />;
return <Icon icon={AlertTriangle} size={4} color="var(--warning-amber)" />;
case 'member_joined':
return <UserPlus className="w-4 h-4 text-performance-green" />;
return <Icon icon={UserPlus} size={4} color="var(--performance-green)" />;
case 'member_left':
return <UserMinus className="w-4 h-4 text-gray-400" />;
return <Icon icon={UserMinus} size={4} color="var(--text-gray-400)" />;
case 'role_changed':
return <Shield className="w-4 h-4 text-primary-blue" />;
return <Icon icon={Shield} size={4} color="var(--primary-blue)" />;
}
};
@@ -116,64 +89,56 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
case 'race_completed':
return (
<>
<span className="text-white font-medium">Race Completed</span>
<span className="text-gray-400"> · {activity.raceName}</span>
<Text weight="medium" color="text-white">Race Completed</Text>
<Text color="text-gray-400"> · {activity.raceName}</Text>
</>
);
case 'race_scheduled':
return (
<>
<span className="text-white font-medium">Race Scheduled</span>
<span className="text-gray-400"> · {activity.raceName}</span>
<Text weight="medium" color="text-white">Race Scheduled</Text>
<Text color="text-gray-400"> · {activity.raceName}</Text>
</>
);
case 'penalty_applied':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> received a </span>
<span className="text-warning-amber">{activity.points}-point penalty</span>
<span className="text-gray-400"> · {activity.reason}</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> received a </Text>
<Text color="text-warning-amber">{activity.points}-point penalty</Text>
<Text color="text-gray-400"> · {activity.reason}</Text>
</>
);
case 'member_joined':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> joined the league</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> joined the league</Text>
</>
);
case 'member_left':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> left the league</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> left the league</Text>
</>
);
case 'role_changed':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> promoted to </span>
<span className="text-primary-blue">{activity.newRole}</span>
<Text weight="medium" color="text-white">{activity.driverName}</Text>
<Text color="text-gray-400"> promoted to </Text>
<Text color="text-primary-blue">{activity.newRole}</Text>
</>
);
}
};
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/30 last:border-0">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-iron-gray/50 flex items-center justify-center">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm leading-relaxed">
{getContent()}
</p>
<p className="text-xs text-gray-500 mt-1">
{timeAgo(activity.timestamp)}
</p>
</div>
</div>
<ActivityFeedItem
icon={getIcon()}
content={getContent()}
timestamp={timeAgo(activity.timestamp)}
/>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import { FileText, Gamepad2, Check } from 'lucide-react';
import { Input } from '@/ui/Input';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
@@ -12,6 +12,7 @@ import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { Button } from '@/ui/Button';
import { TextArea } from '@/ui/TextArea';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;
@@ -61,15 +62,15 @@ export function LeagueBasicsSection({
{/* League name */}
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
League name *
</Stack>
</Text>
<Input
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
<Text size="sm" weight="medium" color="text-gray-300">League name *</Text>
</Stack>
}
value={basics.name}
onChange={(e) => updateBasics({ name: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateBasics({ name: e.target.value })}
placeholder="e.g., GridPilot Sprint Series"
variant={errors?.name ? 'error' : 'default'}
errorMessage={errors?.name}
@@ -93,8 +94,12 @@ export function LeagueBasicsSection({
onClick={() => updateBasics({ name })}
variant="secondary"
size="sm"
className="h-auto py-0.5 px-2 rounded-full text-xs"
disabled={disabled}
rounded="full"
fontSize="0.75rem"
px={2}
py={0.5}
h="auto"
>
{name}
</Button>
@@ -104,74 +109,61 @@ export function LeagueBasicsSection({
</Stack>
{/* Description - Now Required */}
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<TextArea
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={FileText} size={4} color="text-primary-blue" />
Tell your story *
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
<Text size="sm" weight="medium" color="text-gray-300">Tell your story *</Text>
</Stack>
</Text>
<Box position="relative">
<textarea
value={basics.description ?? ''}
onChange={(e) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
className={`block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue text-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all duration-150 ${
errors?.description ? 'ring-warning-amber' : 'ring-charcoal-outline'
}`}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
</Box>
{errors?.description && (
<Text size="xs" color="text-warning-amber">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={AlertCircle} size={3} />
{errors.description}
</Stack>
}
value={basics.description ?? ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
updateBasics({
description: e.target.value,
})
}
rows={4}
disabled={disabled}
variant={errors?.description ? 'error' : 'default'}
errorMessage={errors?.description}
placeholder="What makes your league special? Tell drivers what to expect..."
/>
<Surface variant="muted" rounded="lg" border padding={4}>
<Box mb={3}>
<Text size="xs" color="text-gray-400">
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
</Text>
)}
<Surface variant="muted" rounded="lg" border padding={4}>
<Box mb={3}>
<Text size="xs" color="text-gray-400">
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
</Text>
</Box>
<Grid cols={3} gap={3}>
{[
'Racing style & pace',
'Schedule & timezone',
'Community vibe'
].map(item => (
<Stack key={item} direction="row" align="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" className="mt-0.5" />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
</Stack>
</Box>
<Grid cols={3} gap={3}>
{[
'Racing style & pace',
'Schedule & timezone',
'Community vibe'
].map(item => (
<Stack key={item} direction="row" align="start" gap={2}>
<Icon icon={Check} size={3.5} color="var(--performance-green)" mt={0.5} />
<Text size="xs" color="text-gray-400">{item}</Text>
</Stack>
))}
</Grid>
</Surface>
{/* Game Platform */}
<Stack gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Gamepad2} size={4} color="text-gray-400" />
Game platform
</Stack>
<Input
label={
<Stack direction="row" align="center" gap={2}>
<Icon icon={Gamepad2} size={4} color="var(--text-gray-400)" />
<Text size="sm" weight="medium" color="text-gray-300">Game platform</Text>
</Stack>
}
value="iRacing"
disabled
/>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
<Box position="relative">
<Input value="iRacing" disabled />
<Box position="absolute" right={3} top="50%" style={{ transform: 'translateY(-50%)' }}>
<Text size="xs" color="text-gray-500">
More platforms soon
</Text>
</Box>
</Box>
</Stack>
</Stack>
);

View File

@@ -1,289 +0,0 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import {
Trophy,
Users,
Flag,
Award,
Gamepad2,
Calendar,
ChevronRight,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import PlaceholderImage from '@/ui/PlaceholderImage';
import { getMediaUrl } from '@/lib/utilities/media';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
onClick?: () => void;
}
function getChampionshipIcon(type?: string) {
switch (type) {
case 'driver':
return Trophy;
case 'team':
return Users;
case 'nations':
return Flag;
case 'trophy':
return Award;
default:
return Trophy;
}
}
function getChampionshipLabel(type?: string) {
switch (type) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
default:
return 'Championship';
}
}
function getCategoryLabel(category?: string): string {
if (!category) return '';
switch (category) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
case 'endurance':
return 'Endurance';
case 'sprint':
return 'Sprint';
default:
return category.charAt(0).toUpperCase() + category.slice(1);
}
}
function getCategoryColor(category?: string): string {
if (!category) return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
switch (category) {
case 'driver':
return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'team':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'nations':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'trophy':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'endurance':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'sprint':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
}
}
function getGameColor(gameId?: string): string {
switch (gameId) {
case 'iracing':
return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'acc':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'f1-23':
case 'f1-24':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
}
}
function isNewLeague(createdAt: string | Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
const gameColorClass = getGameColor(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryColorClass = getCategoryColor(league.category);
// Calculate fill percentage - use teams for team leagues, drivers otherwise
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
// Determine slot label based on championship type
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<div
className="group relative cursor-pointer h-full"
onClick={onClick}
>
{/* Card Container */}
<div className="relative h-full rounded-xl bg-iron-gray border border-charcoal-outline overflow-hidden transition-all duration-200 hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80">
{/* Cover Image */}
<div className="relative h-32 overflow-hidden">
<img
src={coverUrl}
alt={`${league.name} cover`}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
{/* Badges - Top Left */}
<div className="absolute top-3 left-3 flex items-center gap-2">
{isNew && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-performance-green/20 text-performance-green border border-performance-green/30">
<Sparkles className="w-3 h-3" />
NEW
</span>
)}
{league.scoring?.gameName && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${gameColorClass}`}>
{league.scoring.gameName}
</span>
)}
{league.category && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${categoryColorClass}`}>
{categoryLabel}
</span>
)}
</div>
{/* Championship Type Badge - Top Right */}
<div className="absolute top-3 right-3">
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-deep-graphite/80 text-gray-300 border border-charcoal-outline">
<ChampionshipIcon className="w-3 h-3" />
{championshipLabel}
</span>
</div>
{/* Logo */}
<div className="absolute left-4 -bottom-6 z-10">
<div className="w-12 h-12 rounded-lg overflow-hidden border-2 border-iron-gray bg-deep-graphite shadow-xl">
{logoUrl ? (
<img
src={logoUrl}
alt={`${league.name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
</div>
</div>
{/* Content */}
<div className="pt-8 px-4 pb-4 flex flex-col flex-1">
{/* Title & Description */}
<h3 className="text-base font-semibold text-white mb-1 line-clamp-1 group-hover:text-primary-blue transition-colors">
{league.name}
</h3>
<p className="text-xs text-gray-500 line-clamp-2 mb-3 h-8">
{league.description || 'No description available'}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 mb-3">
{/* Primary Slots (Drivers/Teams/Nations) */}
<div className="flex-1">
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
<span>{slotLabel}</span>
<span className="text-gray-400">
{usedSlots}/{maxSlots || '∞'}
</span>
</div>
<div className="h-1.5 rounded-full bg-charcoal-outline overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fillPercentage >= 90
? 'bg-warning-amber'
: fillPercentage >= 70
? 'bg-primary-blue'
: 'bg-performance-green'
}`}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</div>
</div>
{/* Open Slots Badge */}
{hasOpenSlots && (
<div className="flex items-center gap-1 px-2 py-1 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20">
<span className="w-1.5 h-1.5 rounded-full bg-neon-aqua animate-pulse" />
<span className="text-[10px] text-neon-aqua font-medium">
{maxSlots - usedSlots} open
</span>
</div>
)}
</div>
{/* Driver count for team leagues */}
{isTeamLeague && (
<div className="flex items-center gap-2 mb-3 text-[10px] text-gray-500">
<Users className="w-3 h-3" />
<span>
{league.usedDriverSlots ?? 0}/{league.maxDrivers ?? '∞'} drivers
</span>
</div>
)}
{/* Spacer to push footer to bottom */}
<div className="flex-1" />
{/* Footer Info */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50 mt-auto">
<div className="flex items-center gap-3 text-[10px] text-gray-500">
{league.timingSummary && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{league.timingSummary.split('•')[1]?.trim() || league.timingSummary}
</span>
)}
</div>
{/* View Arrow */}
<div className="flex items-center gap-1 text-[10px] text-gray-500 group-hover:text-primary-blue transition-colors">
<span>View</span>
<ChevronRight className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,7 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
import { HorizontalStatCard } from '@/ui/HorizontalStatCard';
interface LeagueChampionshipStatsProps {
standings: Array<{
@@ -29,44 +24,29 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh
return (
<Grid cols={3} gap={4}>
<Card>
<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>
<HorizontalStatCard
label="Championship Leader"
value={drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}
subValue={`${leader?.totalPoints || 0} points`}
icon={<Text size="2xl">🏆</Text>}
iconBgColor="rgba(250, 204, 21, 0.1)"
/>
<Card>
<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>
<HorizontalStatCard
label="Races Completed"
value={totalRaces}
subValue="Season in progress"
icon={<Text size="2xl">🏁</Text>}
iconBgColor="rgba(59, 130, 246, 0.1)"
/>
<Card>
<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>
<HorizontalStatCard
label="Active Drivers"
value={standings.length}
subValue="Competing for points"
icon={<Text size="2xl">👥</Text>}
iconBgColor="rgba(16, 185, 129, 0.1)"
/>
</Grid>
);
}

View File

@@ -1,27 +0,0 @@
/**
* LeagueCover
*
* Pure UI component for displaying league cover images.
* Renders an image with fallback on error.
*/
export interface LeagueCoverProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueCover({ leagueId, alt, className = '' }: LeagueCoverProps) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/media/leagues/${leagueId}/cover`}
alt={alt}
className={`w-full h-48 object-cover ${className}`}
onError={(e) => {
// Fallback to default cover
(e.target as HTMLImageElement).src = '/default-league-cover.png';
}}
/>
);
}

View File

@@ -3,14 +3,16 @@
import { useState, useRef, useCallback } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import {
Move,
RotateCw,
ZoomIn,
ZoomOut,
Save,
Trash2,
Plus,
Image as ImageIcon,
Target
} from 'lucide-react';
@@ -66,7 +68,7 @@ const DEFAULT_PLACEMENTS: Omit<DecalPlacement, 'id'>[] = [
},
];
export default function LeagueDecalPlacementEditor({
export function LeagueDecalPlacementEditor({
leagueId,
seasonId,
carId,
@@ -170,38 +172,47 @@ export default function LeagueDecalPlacementEditor({
};
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{carName}</h3>
<p className="text-sm text-gray-400">Position sponsor decals on this car's template</p>
</div>
<div className="flex items-center gap-2">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">{carName}</Heading>
<Text size="sm" color="text-gray-400">Position sponsor decals on this car&apos;s template</Text>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
disabled={zoom <= 0.5}
>
<ZoomOut className="w-4 h-4" />
<Icon icon={ZoomOut} size={4} />
</Button>
<span className="text-sm text-gray-400 min-w-[3rem] text-center">{Math.round(zoom * 100)}%</span>
<Text size="sm" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ minWidth: '3rem' }}
textAlign="center"
>
{Math.round(zoom * 100)}%
</Text>
<Button
variant="secondary"
onClick={() => setZoom(z => Math.min(2, z + 0.25))}
disabled={zoom >= 2}
>
<ZoomIn className="w-4 h-4" />
<Icon icon={ZoomIn} size={4} />
</Button>
</div>
</div>
</Box>
</Box>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={6}>
{/* Canvas */}
<div className="lg:col-span-2">
<div
<Box responsiveColSpan={{ lg: 2 }}>
<Box
ref={canvasRef}
className="relative aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
position="relative"
// eslint-disable-next-line gridpilot-rules/component-classification
className="aspect-video bg-deep-graphite rounded-lg border border-charcoal-outline overflow-hidden cursor-crosshair"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -209,33 +220,50 @@ export default function LeagueDecalPlacementEditor({
>
{/* Base Image or Placeholder */}
{baseImageUrl ? (
<img
<Box
as="img"
src={baseImageUrl}
alt="Livery template"
className="w-full h-full object-cover"
fullWidth
fullHeight
// eslint-disable-next-line gridpilot-rules/component-classification
className="object-cover"
draggable={false}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<ImageIcon className="w-16 h-16 text-gray-600 mb-2" />
<p className="text-sm text-gray-500">No base template uploaded</p>
<p className="text-xs text-gray-600">Upload a template image first</p>
</div>
<Box fullWidth fullHeight display="flex" flexDirection="col" alignItems="center" justifyContent="center">
<Icon icon={ImageIcon} size={16} color="text-gray-600"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mb-2"
/>
<Text size="sm" color="text-gray-500">No base template uploaded</Text>
<Text size="xs" color="text-gray-600">Upload a template image first</Text>
</Box>
)}
{/* Decal Placeholders */}
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
const decalColors = getSponsorTypeColor(placement.sponsorType);
return (
<div
<Box
key={placement.id}
onMouseDown={(e) => handleMouseDown(e, placement.id)}
onMouseDown={(e: React.MouseEvent) => handleMouseDown(e, placement.id)}
onClick={() => handleDecalClick(placement.id)}
className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${
position="absolute"
cursor="move"
border
borderWidth="2px"
rounded="sm"
display="flex"
alignItems="center"
justifyContent="center"
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg} ${colors.text} shadow-lg`
: `${colors.border} ${colors.bg} ${colors.text} opacity-70 hover:opacity-100`
? `${decalColors.border} ${decalColors.bg} ${decalColors.text} shadow-lg`
: `${decalColors.border} ${decalColors.bg} ${decalColors.text} opacity-70 hover:opacity-100`
}`}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
left: `${placement.x * 100}%`,
top: `${placement.y * 100}%`,
@@ -244,151 +272,200 @@ export default function LeagueDecalPlacementEditor({
transform: `translate(-50%, -50%) rotate(${placement.rotation}deg)`,
}}
>
<div className="text-center truncate px-1">
<div className="text-[10px] uppercase tracking-wide opacity-70">
<Box textAlign="center" truncate px={1}>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide opacity-70"
>
{placement.sponsorType === 'main' ? 'Main' : 'Secondary'}
</div>
<div className="truncate">{placement.sponsorName}</div>
</div>
</Box>
<Box truncate>{placement.sponsorName}</Box>
</Box>
{/* Drag handle indicator */}
{selectedDecal === placement.id && (
<div className="absolute -top-1 -left-1 w-3 h-3 bg-white rounded-full border-2 border-primary-blue" />
<Box position="absolute" top="-1" left="-1" w="3" h="3" bg="bg-white" rounded="full" border borderWidth="2px" borderColor="border-primary-blue" />
)}
</div>
</Box>
);
})}
{/* Grid overlay when dragging */}
{isDragging && (
<div className="absolute inset-0 pointer-events-none">
<div className="w-full h-full" style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}} />
</div>
<Box position="absolute" inset="0" pointerEvents="none">
<Box fullWidth fullHeight
// eslint-disable-next-line gridpilot-rules/component-classification
style={{
backgroundImage: 'linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px)',
backgroundSize: '10% 10%',
}}
/>
</Box>
)}
</div>
</Box>
<p className="text-xs text-gray-500 mt-2">
<Text size="xs" color="text-gray-500" mt={2} block>
Click a decal to select it, then drag to reposition. Use controls on the right to adjust size and rotation.
</p>
</div>
</Text>
</Box>
{/* Controls Panel */}
<div className="space-y-4">
<Stack gap={4}>
{/* Decal List */}
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Sponsor Slots</h4>
<div className="space-y-2">
<Card p={4}>
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Sponsor Slots</Heading>
<Stack gap={2}>
{placements.map((placement) => {
const colors = getSponsorTypeColor(placement.sponsorType);
const decalColors = getSponsorTypeColor(placement.sponsorType);
return (
<button
<Box
key={placement.id}
as="button"
onClick={() => setSelectedDecal(placement.id)}
className={`w-full p-3 rounded-lg border text-left transition-all ${
selectedDecal === placement.id
? `${colors.border} ${colors.bg}`
: 'border-charcoal-outline bg-iron-gray/30 hover:bg-iron-gray/50'
}`}
w="full"
p={3}
rounded="lg"
border
textAlign="left"
transition
borderColor={selectedDecal === placement.id ? decalColors.border : 'border-charcoal-outline'}
bg={selectedDecal === placement.id ? decalColors.bg : 'bg-iron-gray/30'}
hoverBg={selectedDecal !== placement.id ? 'bg-iron-gray/50' : undefined}
>
<div className="flex items-center justify-between">
<div>
<div className={`text-xs font-medium uppercase ${colors.text}`}>
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
transform="uppercase"
color={decalColors.text}
>
{placement.sponsorType === 'main' ? 'Main Sponsor' : `Secondary ${placement.sponsorType.split('-')[1]}`}
</div>
<div className="text-xs text-gray-500 mt-0.5">
</Box>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
mt={0.5}
>
{Math.round(placement.x * 100)}%, {Math.round(placement.y * 100)}% {placement.rotation}°
</div>
</div>
<Target className={`w-4 h-4 ${selectedDecal === placement.id ? colors.text : 'text-gray-500'}`} />
</div>
</button>
</Box>
</Box>
<Icon icon={Target} size={4} color={selectedDecal === placement.id ? decalColors.text : 'text-gray-500'} />
</Box>
</Box>
);
})}
</div>
</Stack>
</Card>
{/* Selected Decal Controls */}
{selectedPlacement && (
<Card className="p-4">
<h4 className="text-sm font-semibold text-white mb-3">Adjust Selected</h4>
<Card p={4}>
<Heading level={4} fontSize="sm" weight="semibold" color="text-white" mb={3}>Adjust Selected</Heading>
{/* Position */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Position</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">X</label>
<input
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Position</Text>
<Box display="grid" gridCols={2} gap={2}>
<Box>
<Text as="label" size="xs" color="text-gray-500" block mb={1}>X</Text>
<Box
as="input"
type="range"
min="0"
max="100"
value={selectedPlacement.x * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { x: parseInt(e.target.value) / 100 })}
fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Y</label>
<input
</Box>
<Box>
<Text as="label" size="xs" color="text-gray-500" block mb={1}>Y</Text>
<Box
as="input"
type="range"
min="0"
max="100"
value={selectedPlacement.y * 100}
onChange={(e) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
className="w-full h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { y: parseInt(e.target.value) / 100 })}
fullWidth
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Size */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Size</label>
<div className="flex gap-2">
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Size</Text>
<Box display="flex" gap={2}>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 0.9)}
className="flex-1"
fullWidth
>
<ZoomOut className="w-4 h-4 mr-1" />
<Icon icon={ZoomOut} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Smaller
</Button>
<Button
variant="secondary"
onClick={() => handleResize(selectedPlacement.id, 1.1)}
className="flex-1"
fullWidth
>
<ZoomIn className="w-4 h-4 mr-1" />
<Icon icon={ZoomIn} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-1"
/>
Larger
</Button>
</div>
</div>
</Box>
</Box>
{/* Rotation */}
<div className="mb-4">
<label className="block text-xs text-gray-400 mb-2">Rotation: {selectedPlacement.rotation}°</label>
<div className="flex items-center gap-2">
<input
<Box mb={4}>
<Text as="label" size="xs" color="text-gray-400" block mb={2}>Rotation: {selectedPlacement.rotation}°</Text>
<Box display="flex" alignItems="center" gap={2}>
<Box
as="input"
type="range"
min="0"
max="360"
step="15"
value={selectedPlacement.rotation}
onChange={(e) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updatePlacement(selectedPlacement.id, { rotation: parseInt(e.target.value) })}
flexGrow={1}
h="2"
bg="bg-charcoal-outline"
rounded="lg"
// eslint-disable-next-line gridpilot-rules/component-classification
className="appearance-none cursor-pointer accent-primary-blue"
/>
<Button
variant="secondary"
onClick={() => handleRotate(selectedPlacement.id, 90)}
className="px-2"
px={2}
>
<RotateCw className="w-4 h-4" />
<Icon icon={RotateCw} size={4} />
</Button>
</div>
</div>
</Box>
</Box>
</Card>
)}
@@ -397,21 +474,24 @@ export default function LeagueDecalPlacementEditor({
variant="primary"
onClick={handleSave}
disabled={saving}
className="w-full"
fullWidth
>
<Save className="w-4 h-4 mr-2" />
<Icon icon={Save} size={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="mr-2"
/>
{saving ? 'Saving...' : 'Save Placements'}
</Button>
{/* Help Text */}
<div className="p-3 rounded-lg bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-500">
<strong className="text-gray-400">Tip:</strong> Main sponsor gets the largest, most prominent placement.
<Box p={3} rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" block>
<Text weight="bold" color="text-gray-400">Tip:</Text> Main sponsor gets the largest, most prominent placement.
Secondary sponsors get smaller positions. These decals will be burned onto all driver liveries.
</p>
</div>
</div>
</div>
</div>
</Text>
</Box>
</Stack>
</Box>
</Stack>
);
}

View File

@@ -1,9 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// ============================================================================
// INFO FLYOUT (duplicated for self-contained component)
@@ -78,42 +83,79 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen) return null;
return createPortal(
<div
<Box
ref={flyoutRef}
className="fixed z-50 w-[380px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
position="fixed"
zIndex={50}
w="380px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
<div className="flex items-center gap-2">
<HelpCircle className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">{title}</span>
</div>
<button
<Box
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Box
as="button"
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="p-4">
<Icon icon={X} size={4} color="text-gray-400" />
</Box>
</Box>
<Box p={4}>
{children}
</div>
</div>,
</Box>
</Box>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return (
<button
<Box
as="button"
ref={buttonRef}
type="button"
onClick={onClick}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<HelpCircle className="w-3.5 h-3.5" />
</button>
<Icon icon={HelpCircle} size={3.5} />
</Box>
);
}
@@ -132,36 +174,65 @@ function DropRulesMockup() {
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<div className="bg-deep-graphite rounded-lg p-4">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-charcoal-outline/50">
<span className="text-xs font-semibold text-white">Best 4 of 6 Results</span>
</div>
<div className="flex gap-1 mb-3">
<Box bg="bg-deep-graphite" rounded="lg" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Box display="flex" gap={1} mb={3}>
{results.map((r, i) => (
<div
<Box
key={i}
className={`flex-1 p-2 rounded-lg text-center border transition-all ${
r.dropped
? 'bg-charcoal-outline/20 border-dashed border-charcoal-outline/50 opacity-50'
: 'bg-performance-green/10 border-performance-green/30'
}`}
flexGrow={1}
p={2}
rounded="lg"
textAlign="center"
border
transition
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
opacity={r.dropped ? 0.5 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'border-dashed' : ''}
>
<div className="text-[9px] text-gray-500">{r.round}</div>
<div className={`text-xs font-mono font-semibold ${r.dropped ? 'text-gray-500 line-through' : 'text-white'}`}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{r.round}
</Text>
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'line-through' : ''}
block
>
{r.pts}
</div>
</div>
</Text>
</Box>
))}
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-gray-500">Total counted:</span>
<span className="font-mono font-semibold text-performance-green">{total} pts</span>
</div>
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1">
<span>Without drops:</span>
<span className="font-mono">{wouldBe} pts</span>
</div>
</div>
</Box>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
</Box>
<Box display="flex" justifyContent="between" alignItems="center" mt={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Without drops:
</Text>
<Text font="mono"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{wouldBe} pts
</Text>
</Box>
</Box>
);
}
@@ -290,20 +361,20 @@ export function LeagueDropSection({
const needsN = dropPolicy.strategy !== 'none';
return (
<div className="space-y-4">
<Stack gap={4}>
{/* Section header */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingDown className="w-5 h-5 text-primary-blue" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Drop Rules</h3>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<Icon icon={TrendingDown} size={5} color="text-primary-blue" />
</Box>
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={3}>Drop Rules</Heading>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
</div>
<p className="text-xs text-gray-500">Protect from bad races</p>
</div>
</div>
</Box>
<Text size="xs" color="text-gray-500">Protect from bad races</Text>
</Box>
</Box>
{/* Drop Rules Flyout */}
<InfoFlyout
@@ -312,180 +383,306 @@ export function LeagueDropSection({
title="Drop Rules Explained"
anchorRef={dropInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Drop rules allow drivers to exclude their worst results from championship calculations.
This protects against mechanical failures, bad luck, or occasional poor performances.
</p>
</Text>
<div>
<div className="text-[10px] text-gray-500 uppercase tracking-wide mb-2">Visual Example</div>
<Box>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Visual Example
</Text>
<DropRulesMockup />
</div>
</Box>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Drop Strategies</div>
<div className="space-y-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base"></span>
<div>
<div className="text-[10px] font-medium text-white">All Count</div>
<div className="text-[9px] text-gray-500">Every race affects standings. Best for short seasons.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🏆</span>
<div>
<div className="text-[10px] font-medium text-white">Best N Results</div>
<div className="text-[9px] text-gray-500">Only your top N races count. Extra races are optional.</div>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-base">🗑</span>
<div>
<div className="text-[10px] font-medium text-white">Drop Worst N</div>
<div className="text-[9px] text-gray-500">Exclude your N worst results. Forgives bad days.</div>
</div>
</div>
</div>
</div>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Strategies
</Text>
<Stack gap={2}>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base"></Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
All Count
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Every race affects standings. Best for short seasons.
</Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🏆</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Best N Results
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Only your top N races count. Extra races are optional.
</Text>
</Box>
</Box>
<Box display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🗑</Text>
<Box>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Worst N
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Exclude your N worst results. Forgives bad days.
</Text>
</Box>
</Box>
</Stack>
</Stack>
<div className="rounded-lg bg-primary-blue/5 border border-primary-blue/20 p-3">
<div className="flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<div className="text-[11px] text-gray-400">
<span className="font-medium text-primary-blue">Pro tip:</span> For an 8-round season,
"Best 6" or "Drop 2" are popular choices.
</div>
</div>
</div>
</div>
<Box rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Box>
<Text size="xs" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
&quot;Best 6&quot; or &quot;Drop 2&quot; are popular choices.
</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout>
{/* Strategy buttons + N stepper inline */}
<div className="flex flex-wrap items-center gap-2">
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
{DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return (
<div key={option.value} className="relative flex items-center">
<button
<Box key={option.value} display="flex" alignItems="center" position="relative">
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleStrategyChange(option.value)}
className={`
flex items-center gap-2 px-3 py-2 rounded-l-lg border-2 border-r-0 transition-all duration-200
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{/* Radio indicator */}
<div className={`
flex h-4 w-4 items-center justify-center rounded-full border-2 shrink-0 transition-colors
${isSelected ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'}
`}>
{isSelected && <Check className="w-2.5 h-2.5 text-white" />}
</div>
<Box
display="flex"
h="4"
w="4"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
</Box>
<span className="text-sm">{option.emoji}</span>
<span className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
<Text size="sm">{option.emoji}</Text>
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
{option.label}
</span>
</button>
</Text>
</Box>
{/* Info button - separate from main button */}
<button
ref={(el) => { dropRuleRefs.current[option.value] = el; }}
<Box
as="button"
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
className={`
flex h-full items-center justify-center px-2 py-2 rounded-r-lg border-2 border-l-0 transition-all duration-200
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline/40 bg-iron-gray/20 hover:border-charcoal-outline hover:bg-iron-gray/30'
}
${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}
`}
display="flex"
alignItems="center"
justifyContent="center"
px={2}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
>
<HelpCircle className="w-3.5 h-3.5 text-gray-500 hover:text-primary-blue transition-colors" />
</button>
<Icon icon={HelpCircle} size={3.5} color="text-gray-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="hover:text-primary-blue transition-colors"
/>
</Box>
{/* Drop Rule Info Flyout */}
<InfoFlyout
isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title}
anchorRef={{ current: dropRuleRefs.current[option.value] ?? dropInfoRef.current }}
anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">{ruleInfo.description}</p>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">How It Works</div>
<ul className="space-y-1.5">
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
How It Works
</Text>
<Stack gap={1.5}>
{ruleInfo.details.map((detail, idx) => (
<li key={idx} className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3 h-3 text-performance-green shrink-0 mt-0.5" />
<span>{detail}</span>
</li>
<Box key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
</ul>
</div>
</Stack>
</Stack>
<div className="rounded-lg bg-deep-graphite border border-charcoal-outline/30 p-3">
<div className="flex items-center gap-2">
<span className="text-base">{option.emoji}</span>
<div>
<div className="text-[10px] text-gray-500">Example</div>
<div className="text-xs font-medium text-white">{ruleInfo.example}</div>
</div>
</div>
</div>
</div>
<Box rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
<Box display="flex" alignItems="center" gap={2}>
<Text size="base">{option.emoji}</Text>
<Box>
<Text size="xs" color="text-gray-400" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Example
</Text>
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
</Box>
</Box>
</Box>
</Stack>
</InfoFlyout>
</div>
</Box>
);
})}
{/* N Stepper - only show when needed */}
{needsN && (
<div className="flex items-center gap-1 ml-2">
<span className="text-xs text-gray-500 mr-1">N =</span>
<button
<Box display="flex" alignItems="center" gap={1} ml={2}>
<Text size="xs" color="text-gray-500" mr={1}>N =</Text>
<Box
as="button"
type="button"
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
>
</button>
<div className="flex h-7 w-10 items-center justify-center rounded-md bg-iron-gray/50 border border-charcoal-outline/50">
<span className="text-sm font-semibold text-white">{dropPolicy.n ?? 1}</span>
</div>
<button
</Box>
<Box display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
<Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
</Box>
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleNChange(1)}
className="flex h-7 w-7 items-center justify-center rounded-md bg-iron-gray border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-40 transition-colors"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled ? 0.4 : 1}
>
+
</button>
</div>
</Box>
</Box>
)}
</div>
</Box>
{/* Explanation text */}
<p className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500" block>
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
</p>
</div>
</Text>
</Stack>
);
}
}

View File

@@ -1,9 +1,9 @@
'use client';
import MembershipStatus from '@/components/leagues/MembershipStatus';
import React from 'react';
import { MembershipStatus } from './MembershipStatus';
import { getMediaUrl } from '@/lib/utilities/media';
import Image from 'next/image';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
@@ -21,61 +21,39 @@ export interface LeagueHeaderProps {
mainSponsor?: MainSponsorInfo | null;
}
export default function LeagueHeader({
export function LeagueHeader({
leagueId,
leagueName,
description,
ownerId,
mainSponsor,
}: LeagueHeaderProps) {
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<div className="mb-8">
{/* League header with logo - no cover image */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-xl overflow-hidden border-2 border-charcoal-outline bg-iron-gray shadow-lg">
<img
src={logoUrl}
alt={`${leagueName} logo`}
width={64}
height={64}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-white">
{leagueName}
{mainSponsor && (
<span className="text-gray-400 font-normal text-lg ml-2">
by{' '}
{mainSponsor.websiteUrl ? (
<a
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
className="text-primary-blue hover:text-primary-blue/80 transition-colors"
>
{mainSponsor.name}
</a>
) : (
<span className="text-primary-blue">{mainSponsor.name}</span>
)}
</span>
)}
</h1>
<MembershipStatus leagueId={leagueId} />
</div>
{description && (
<p className="text-gray-400 text-sm max-w-xl">{description}</p>
)}
</div>
</div>
</div>
</div>
<UiLeagueHeader
name={leagueName}
description={description}
logoUrl={logoUrl}
statusContent={<MembershipStatus leagueId={leagueId} />}
sponsorContent={
mainSponsor ? (
mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-primary-blue"
hoverTextColor="text-primary-blue/80"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-primary-blue">{mainSponsor.name}</Text>
)
) : undefined
}
/>
);
}

View File

@@ -1,30 +0,0 @@
/**
* LeagueLogo
*
* Pure UI component for displaying league logos.
* Renders an optimized image with fallback on error.
*/
import Image from 'next/image';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
className?: string;
}
export function LeagueLogo({ leagueId, alt, className = '' }: LeagueLogoProps) {
return (
<Image
src={`/media/leagues/${leagueId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-league-logo.png';
}}
/>
);
}

View File

@@ -1,15 +1,20 @@
'use client';
import { DriverIdentity } from '../drivers/DriverIdentity';
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { useCallback, useEffect, useState } from 'react';
// Migrated to useInject-based DI; legacy EntityMapper removed.
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
import { MinimalEmptyState } from '@/ui/EmptyState';
interface LeagueMembersProps {
leagueId: string;
@@ -18,7 +23,7 @@ interface LeagueMembersProps {
showActions?: boolean;
}
export default function LeagueMembers({
export function LeagueMembers({
leagueId,
onRemoveMember,
onUpdateRole,
@@ -44,7 +49,8 @@ export default function LeagueMembers({
const driverDtos = await driverService.findByIds(uniqueDriverIds);
const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const dto of driverDtos as any[]) {
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
}
setDriversById(byId);
@@ -72,12 +78,11 @@ export default function LeagueMembers({
return order[role];
};
const getDriverStats = (driverId: string): { rating: number; wins: number; overallRank: number } | null => {
// This would typically come from a driver stats service
// For now, return null as the original implementation was missing
const getDriverStats = (): { rating: number; wins: number; overallRank: number } | null => {
return null;
};
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) {
case 'role':
@@ -87,15 +92,15 @@ export default function LeagueMembers({
case 'date':
return new Date(b.joinedAt).getTime() - new Date(a.joinedAt).getTime();
case 'rating': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
const statsA = getDriverStats();
const statsB = getDriverStats();
return (statsB?.rating || 0) - (statsA?.rating || 0);
}
case 'points':
return 0;
case 'wins': {
const statsA = getDriverStats(a.driverId);
const statsB = getDriverStats(b.driverId);
const statsA = getDriverStats();
const statsB = getDriverStats();
return (statsB?.wins || 0) - (statsA?.wins || 0);
}
default:
@@ -103,180 +108,120 @@ export default function LeagueMembers({
}
});
const getRoleBadgeColor = (role: MembershipRole): string => {
const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) {
case 'owner':
return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30';
case 'admin':
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
case 'steward':
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
case 'member':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/30';
case 'owner': return 'warning';
case 'admin': return 'primary';
case 'steward': return 'info';
case 'member': return 'primary';
default: return 'default';
}
};
if (loading) {
return (
<div className="text-center py-8 text-gray-400">
Loading members...
</div>
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading members...</Text>
</Box>
);
}
if (members.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No members found
</div>
<MinimalEmptyState
title="No members found"
description="This league doesn't have any members yet."
/>
);
}
return (
<div>
<Box>
{/* Sort Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Text size="sm" color="text-gray-400">
{members.length} {members.length === 1 ? 'member' : 'members'}
</p>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">Sort by:</label>
<select
</Text>
<Box display="flex" alignItems="center" gap={2}>
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="rating">Rating</option>
<option value="points">Points</option>
<option value="wins">Wins</option>
<option value="role">Role</option>
<option value="name">Name</option>
<option value="date">Join Date</option>
</select>
</div>
</div>
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
options={[
{ value: 'rating', label: 'Rating' },
{ value: 'points', label: 'Points' },
{ value: 'wins', label: 'Wins' },
{ value: 'role', label: 'Role' },
{ value: 'name', label: 'Name' },
{ value: 'date', label: 'Join Date' },
]}
fullWidth={false}
/>
</Box>
</Box>
{/* Members Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rating</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Rank</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Wins</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Role</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Joined</th>
{showActions && <th className="text-right py-3 px-4 text-sm font-semibold text-gray-400">Actions</th>}
</tr>
</thead>
<tbody>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats(member.driverId);
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const roleLabel =
member.role.charAt(0).toUpperCase() + member.role.slice(1);
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
<Box overflow="auto">
<LeagueMemberTable showActions={showActions}>
{sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId;
const cannotModify = member.role === 'owner';
const driverStats = getDriverStats();
const isTopPerformer = index < 3 && sortBy === 'rating';
const driver = driversById[member.driverId];
const ratingAndWinsMeta =
driverStats && typeof driverStats.rating === 'number'
? `Rating ${driverStats.rating}${driverStats.wins ?? 0} wins`
: null;
return (
<tr
key={member.driverId}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors ${isTopPerformer ? 'bg-primary-blue/5' : ''}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{driver ? (
<DriverIdentity
driver={driver}
href={`/drivers/${member.driverId}?from=league-members&leagueId=${leagueId}`}
contextLabel={roleLabel}
meta={ratingAndWinsMeta}
size="md"
/>
) : (
<span className="text-white">Unknown Driver</span>
)}
{isCurrentUser && (
<span className="text-xs text-gray-500">(You)</span>
)}
{isTopPerformer && (
<span className="text-xs"></span>
)}
</div>
</td>
<td className="py-3 px-4">
<span className="text-primary-blue font-medium">
{driverStats?.rating || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-gray-300">
#{driverStats?.overallRank || '—'}
</span>
</td>
<td className="py-3 px-4">
<span className="text-green-400 font-medium">
{driverStats?.wins || 0}
</span>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 text-xs font-medium rounded border ${getRoleBadgeColor(member.role)}`}>
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</span>
</td>
<td className="py-3 px-4">
<span className="text-white text-sm">
{new Date(member.joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</td>
{showActions && (
<td className="py-3 px-4 text-right">
{!cannotModify && !isCurrentUser && (
<div className="flex items-center justify-end gap-2">
{onUpdateRole && (
<select
value={member.role}
onChange={(e) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
className="px-2 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-xs focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="member">Member</option>
<option value="steward">Steward</option>
<option value="admin">Admin</option>
</select>
)}
{onRemoveMember && (
<button
onClick={() => onRemoveMember(member.driverId)}
className="px-2 py-1 text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
>
Remove
</button>
)}
</div>
)}
{cannotModify && (
<span className="text-xs text-gray-500"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
return (
<LeagueMemberRow
key={member.driverId}
driver={driver}
driverId={member.driverId}
isCurrentUser={isCurrentUser}
isTopPerformer={isTopPerformer}
role={member.role}
roleVariant={getRoleVariant(member.role)}
joinedAt={member.joinedAt}
rating={driverStats?.rating}
rank={driverStats?.overallRank}
wins={driverStats?.wins}
href={routes.driver.detail(member.driverId)}
meta={ratingAndWinsMeta}
actions={showActions && !cannotModify && !isCurrentUser ? (
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
{onUpdateRole && (
<Select
value={member.role}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => onUpdateRole(member.driverId, e.target.value as MembershipRole)}
options={[
{ value: 'member', label: 'Member' },
{ value: 'steward', label: 'Steward' },
{ value: 'admin', label: 'Admin' },
]}
fullWidth={false}
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-xs py-1 px-2"
/>
)}
{onRemoveMember && (
<Button
variant="ghost"
onClick={() => onRemoveMember(member.driverId)}
size="sm"
color="text-error-red"
>
Remove
</Button>
)}
</Box>
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500"></Text> : undefined)}
/>
);
})}
</LeagueMemberTable>
</Box>
</Box>
);
}
}

View File

@@ -3,6 +3,11 @@
import { useState } from 'react';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { DollarSign, Calendar, User, TrendingUp } from 'lucide-react';
type FeeType = 'season' | 'monthly' | 'per_race';
@@ -20,8 +25,6 @@ interface LeagueMembershipFeesSectionProps {
}
export function LeagueMembershipFeesSection({
leagueId,
seasonId,
readOnly = false
}: LeagueMembershipFeesSectionProps) {
const [feeConfig, setFeeConfig] = useState<MembershipFeeConfig>({
@@ -71,15 +74,15 @@ export function LeagueMembershipFeesSection({
};
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Membership Fees</h3>
<p className="text-sm text-gray-400 mt-1">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Membership Fees</Heading>
<Text size="sm" color="text-gray-400" mt={1} block>
Charge drivers for league participation
</p>
</div>
</Text>
</Box>
{!feeConfig.enabled && !readOnly && (
<Button
variant="primary"
@@ -88,152 +91,153 @@ export function LeagueMembershipFeesSection({
Enable Fees
</Button>
)}
</div>
</Box>
{!feeConfig.enabled ? (
<div className="text-center py-12 rounded-lg border border-charcoal-outline bg-iron-gray/30">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<DollarSign className="w-8 h-8 text-gray-500" />
</div>
<h4 className="text-lg font-medium text-white mb-2">No Membership Fees</h4>
<p className="text-sm text-gray-400 max-w-md mx-auto">
<Box textAlign="center" py={12} rounded="lg" border borderColor="border-charcoal-outline" bg="bg-iron-gray/30">
<Box w="16" h="16" mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<Icon icon={DollarSign} size={8} color="text-gray-500" />
</Box>
<Heading level={4} fontSize="lg" weight="medium" color="text-white" mb={2}>No Membership Fees</Heading>
<Text size="sm" color="text-gray-400" maxWidth="md" mx="auto" block>
This league is free to join. Enable membership fees to charge drivers for participation.
</p>
</div>
</Text>
</Box>
) : (
<>
{/* Fee Type Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Fee Type
</label>
<div className="grid grid-cols-3 gap-3">
</Text>
<Box display="grid" gridCols={3} gap={3}>
{(['season', 'monthly', 'per_race'] as FeeType[]).map((type) => {
const Icon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const FeeIcon = type === 'season' ? Calendar : type === 'monthly' ? TrendingUp : User;
const isSelected = feeConfig.type === type;
return (
<button
<Box
key={type}
as="button"
type="button"
onClick={() => handleTypeChange(type)}
disabled={readOnly}
className={`p-4 rounded-lg border transition-all ${
isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-primary-blue/50'
}`}
p={4}
rounded="lg"
border
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/30'}
hoverBorderColor={!isSelected ? 'border-primary-blue/50' : undefined}
>
<Icon className={`w-5 h-5 mx-auto mb-2 ${
isSelected ? 'text-primary-blue' : 'text-gray-400'
}`} />
<div className="text-sm font-medium text-white mb-1">
<Icon icon={FeeIcon} size={5} mx="auto" mb={2} color={isSelected ? 'text-primary-blue' : 'text-gray-400'} />
<Text size="sm" weight="medium" color="text-white" block mb={1}>
{typeLabels[type]}
</div>
<div className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
{typeDescriptions[type]}
</div>
</button>
</Text>
</Box>
);
})}
</div>
</div>
</Box>
</Stack>
{/* Amount Configuration */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
<Stack gap={3}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Amount
</label>
</Text>
{editing ? (
<div className="flex items-center gap-3">
<div className="flex-1">
<Box display="flex" alignItems="center" gap={3}>
<Box flexGrow={1}>
<Input
type="number"
value={tempAmount}
onChange={(e) => setTempAmount(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempAmount(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
/>
</div>
</Box>
<Button
variant="primary"
onClick={handleSave}
className="px-4"
px={4}
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancel}
className="px-4"
px={4}
>
Cancel
</Button>
</div>
</Box>
) : (
<div className="flex items-center justify-between p-4 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div>
<div className="text-2xl font-bold text-white">
<Box display="flex" alignItems="center" justifyContent="between" p={4} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box>
<Text size="2xl" weight="bold" color="text-white" block>
${feeConfig.amount.toFixed(2)}
</div>
<div className="text-xs text-gray-500 mt-1">
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
{typeLabels[feeConfig.type]}
</div>
</div>
</Text>
</Box>
{!readOnly && (
<Button
variant="secondary"
onClick={() => setEditing(true)}
className="px-4"
px={4}
>
Edit Amount
</Button>
)}
</div>
</Box>
)}
</div>
</Stack>
{/* Revenue Breakdown */}
{feeConfig.amount > 0 && (
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-lg font-bold text-warning-amber">
<Box display="grid" gridCols={2} gap={4}>
<Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<Text size="xs" color="text-gray-400" block mb={1}>Platform Fee (10%)</Text>
<Text size="lg" weight="bold" color="text-warning-amber" block>
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net per Driver</div>
<div className="text-lg font-bold text-performance-green">
</Text>
</Box>
<Box rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline" p={4}>
<Text size="xs" color="text-gray-400" block mb={1}>Net per Driver</Text>
<Text size="lg" weight="bold" color="text-performance-green" block>
${netAmount.toFixed(2)}
</div>
</div>
</div>
</Text>
</Box>
</Box>
)}
{/* Disable Fees */}
{!readOnly && (
<div className="pt-4 border-t border-charcoal-outline">
<Box pt={4} borderTop borderColor="border-charcoal-outline">
<Button
variant="danger"
onClick={() => setFeeConfig({ type: 'season', amount: 0, enabled: false })}
>
Disable Membership Fees
</Button>
</div>
</Box>
)}
</>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Membership fee collection is demonstration-only.
<Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400" block>
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Membership fee collection is demonstration-only.
In production, fees are collected via payment gateway and deposited to league wallet (minus platform fee).
</p>
</div>
</div>
</Text>
</Box>
</Stack>
);
}

View File

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

View File

@@ -1,14 +1,11 @@
'use client';
import {
FileText,
Users,
Calendar,
Trophy,
Award,
Rocket,
Eye,
EyeOff,
Gamepad2,
User,
UsersRound,
@@ -16,13 +13,18 @@ import {
Flag,
Zap,
Timer,
TrendingDown,
Check,
Globe,
Medal,
type LucideIcon,
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
@@ -31,89 +33,73 @@ interface LeagueReviewSummaryProps {
// Individual review card component
function ReviewCard({
icon: Icon,
icon,
iconColor = 'text-primary-blue',
bgColor = 'bg-primary-blue/10',
title,
children,
}: {
icon: React.ElementType;
icon: LucideIcon;
iconColor?: string;
bgColor?: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 space-y-3">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${bgColor}`}>
<Icon className={`w-4 h-4 ${iconColor}`} />
</div>
<h3 className="text-sm font-semibold text-white">{title}</h3>
</div>
{children}
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4}>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg={bgColor}>
<Icon icon={icon} size={4} color={iconColor} />
</Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white">{title}</Heading>
</Box>
{children}
</Stack>
</Box>
);
}
// Info row component for consistent layout
function InfoRow({
icon: Icon,
icon,
label,
value,
valueClass = '',
}: {
icon?: React.ElementType;
icon?: LucideIcon;
label: string;
value: React.ReactNode;
valueClass?: string;
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-charcoal-outline/20 last:border-0">
<div className="flex items-center gap-2 text-xs text-gray-500">
{Icon && <Icon className="w-3.5 h-3.5" />}
<span>{label}</span>
</div>
<div className={`text-sm font-medium text-white ${valueClass}`}>{value}</div>
</div>
);
}
// Badge component for enabled features
function FeatureBadge({
icon: Icon,
label,
enabled,
color = 'primary-blue',
}: {
icon: React.ElementType;
label: string;
enabled: boolean;
color?: string;
}) {
if (!enabled) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full bg-${color}/10 px-3 py-1.5 text-xs font-medium text-${color}`}>
<Icon className="w-3 h-3" />
{label}
</span>
<Box display="flex" alignItems="center" justifyContent="between" py={2} borderBottom borderColor="border-charcoal-outline/20"
// eslint-disable-next-line gridpilot-rules/component-classification
className="last:border-0"
>
<Box display="flex" alignItems="center" gap={2}>
{icon && <Icon icon={icon} size={3.5} color="text-gray-500" />}
<Text size="xs" color="text-gray-500">{label}</Text>
</Box>
<Text size="sm" weight="medium" color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
className={valueClass}
>
{value}
</Text>
</Box>
);
}
export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;
const modeLabel =
structure.mode === 'solo'
? 'Solo drivers'
: 'Team-based';
const modeDescription =
structure.mode === 'solo'
? 'Individual competition'
: 'Teams with fixed rosters';
const capacityValue = (() => {
if (structure.mode === 'solo') {
return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—';
@@ -122,12 +108,12 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
})();
const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams';
const formatMinutes = (value: number | undefined) => {
if (typeof value !== 'number' || value <= 0) return '—';
return `${value} min`;
};
const getDropRuleInfo = () => {
if (dropPolicy.strategy === 'none') {
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
@@ -148,31 +134,31 @@ export default function LeagueReviewSummary({ form, presets }: LeagueReviewSumma
}
return { emoji: '✓', label: 'All count', description: 'Every race counts' };
};
const dropRuleInfo = getDropRuleInfo();
const dropRuleInfo = getDropRuleInfo();
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const preset = presets.find((p) => p.id === scoring.patternId) ?? null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const seasonStartLabel =
timings.seasonStartDate
? new Date(timings.seasonStartDate).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null;
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const stewardingLabel = (() => {
switch (stewarding.decisionMode) {
case 'admin_only':
return 'Admin-only decisions';
case 'steward_vote':
return 'Steward panel voting';
default:
return stewarding.decisionMode;
}
})();
const getScoringEmoji = () => {
if (!preset) return '🏁';
const name = preset.name.toLowerCase();
@@ -181,14 +167,14 @@ const stewardingLabel = (() => {
if (name.includes('club') || name.includes('casual')) return '🏅';
return '🏁';
};
// Normalize visibility to new terminology
const isRanked = basics.visibility === 'public'; // public = ranked, private/unlisted = unranked
const visibilityLabel = isRanked ? 'Ranked' : 'Unranked';
const visibilityDescription = isRanked
? 'Competitive • Affects ratings'
: 'Casual • Friends only';
// Calculate total weekend duration
const totalWeekendMinutes = (timings.practiceMinutes ?? 0) +
(timings.qualifyingMinutes ?? 0) +
@@ -196,118 +182,138 @@ const stewardingLabel = (() => {
(timings.mainRaceMinutes ?? 0);
return (
<div className="space-y-6">
<Stack gap={6}>
{/* League Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">League summary</h3>
<div className="relative rounded-2xl bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray border border-primary-blue/30 p-6 overflow-hidden">
<Stack gap={3}>
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">League summary</Heading>
<Box position="relative" rounded="2xl" bg="bg-gradient-to-br from-primary-blue/20 via-iron-gray to-iron-gray" border borderColor="border-primary-blue/30" p={6} overflow="hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-blue/10 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-24 h-24 bg-neon-aqua/5 rounded-full blur-2xl" />
<Box position="absolute" top="0" right="0" w="32" h="32" bg="bg-primary-blue/10" rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="blur-3xl"
/>
<Box position="absolute" bottom="0" left="0" w="24" h="24" bg="bg-neon-aqua/5" rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="blur-2xl"
/>
<div className="relative flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/20 border border-primary-blue/30 shrink-0">
<Rocket className="w-7 h-7 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">
<Box position="relative" display="flex" alignItems="start" gap={4}>
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" flexShrink={0}>
<Icon icon={Rocket} size={7} color="text-primary-blue" />
</Box>
<Box flexGrow={1} minWidth="0">
<Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={1} truncate>
{basics.name || 'Your New League'}
</h2>
<p className="text-sm text-gray-400 mb-3">
</Heading>
<Text size="sm" color="text-gray-400" mb={3} block>
{basics.description || 'Ready to launch your racing series!'}
</p>
<div className="flex flex-wrap items-center gap-3">
</Text>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={3}>
{/* Ranked/Unranked Badge */}
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ${
isRanked
? 'bg-primary-blue/15 text-primary-blue border border-primary-blue/30'
: 'bg-neon-aqua/15 text-neon-aqua border border-neon-aqua/30'
}`}>
{isRanked ? <Trophy className="w-3 h-3" /> : <Users className="w-3 h-3" />}
<span className="font-semibold">{visibilityLabel}</span>
<span className="text-[10px] opacity-70"> {visibilityDescription}</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
<Gamepad2 className="w-3 h-3" />
iRacing
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-charcoal-outline/50 px-3 py-1 text-xs font-medium text-gray-300">
{structure.mode === 'solo' ? <User className="w-3 h-3" /> : <UsersRound className="w-3 h-3" />}
{modeLabel}
</span>
</div>
</div>
</div>
</div>
</div>
<Box
as="span"
display="inline-flex"
alignItems="center"
gap={1.5}
rounded="full"
px={3}
py={1.5}
bg={isRanked ? 'bg-primary-blue/15' : 'bg-neon-aqua/15'}
color={isRanked ? 'text-primary-blue' : 'text-neon-aqua'}
border
borderColor={isRanked ? 'border-primary-blue/30' : 'border-neon-aqua/30'}
>
<Icon icon={isRanked ? Trophy : Users} size={3} />
<Text weight="semibold" size="xs">{visibilityLabel}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
opacity={0.7}
>
{visibilityDescription}
</Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={Gamepad2} size={3} />
<Text size="xs" weight="medium">iRacing</Text>
</Box>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="full" bg="bg-charcoal-outline/50" px={3} py={1} color="text-gray-300">
<Icon icon={structure.mode === 'solo' ? User : UsersRound} size={3} />
<Text size="xs" weight="medium">{modeLabel}</Text>
</Box>
</Box>
</Box>
</Box>
</Box>
</Stack>
{/* Season Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-300">First season summary</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{seasonName || 'First season of this league'}</span>
<Stack gap={3}>
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-300">First season summary</Heading>
<Box display="flex" flexWrap="wrap" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-400">{seasonName || 'First season of this league'}</Text>
{seasonStartLabel && (
<>
<span></span>
<span>Starts {seasonStartLabel}</span>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">Starts {seasonStartLabel}</Text>
</>
)}
{typeof timings.roundsPlanned === 'number' && (
<>
<span></span>
<span>{timings.roundsPlanned} rounds planned</span>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">{timings.roundsPlanned} rounds planned</Text>
</>
)}
<span></span>
<span>Stewarding: {stewardingLabel}</span>
</div>
<Text size="xs" color="text-gray-400"></Text>
<Text size="xs" color="text-gray-400">Stewarding: {stewardingLabel}</Text>
</Box>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={3}>
{/* Capacity */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10 mx-auto mb-2">
<Users className="w-5 h-5 text-primary-blue" />
</div>
<div className="text-2xl font-bold text-white">{capacityValue}</div>
<div className="text-xs text-gray-500">{capacityLabel}</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-primary-blue/10" mx="auto" mb={2}>
<Icon icon={Users} size={5} color="text-primary-blue" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{capacityValue}</Text>
<Text size="xs" color="text-gray-500" block>{capacityLabel}</Text>
</Box>
{/* Rounds */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10 mx-auto mb-2">
<Flag className="w-5 h-5 text-performance-green" />
</div>
<div className="text-2xl font-bold text-white">{timings.roundsPlanned ?? '—'}</div>
<div className="text-xs text-gray-500">rounds</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/10" mx="auto" mb={2}>
<Icon icon={Flag} size={5} color="text-performance-green" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{timings.roundsPlanned ?? '—'}</Text>
<Text size="xs" color="text-gray-500" block>rounds</Text>
</Box>
{/* Weekend Duration */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10 mx-auto mb-2">
<Timer className="w-5 h-5 text-warning-amber" />
</div>
<div className="text-2xl font-bold text-white">{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</div>
<div className="text-xs text-gray-500">min/weekend</div>
</div>
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-warning-amber/10" mx="auto" mb={2}>
<Icon icon={Timer} size={5} color="text-warning-amber" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}</Text>
<Text size="xs" color="text-gray-500" block>min/weekend</Text>
</Box>
{/* Championships */}
<div className="rounded-xl bg-iron-gray/50 border border-charcoal-outline/40 p-4 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-aqua/10 mx-auto mb-2">
<Award className="w-5 h-5 text-neon-aqua" />
</div>
<div className="text-2xl font-bold text-white">
<Box rounded="xl" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/40" p={4} textAlign="center">
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-neon-aqua/10" mx="auto" mb={2}>
<Icon icon={Award} size={5} color="text-neon-aqua" />
</Box>
<Text size="2xl" weight="bold" color="text-white" block>
{[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length}
</div>
<div className="text-xs text-gray-500">championships</div>
</div>
</div>
</div>
</Text>
<Text size="xs" color="text-gray-500" block>championships</Text>
</Box>
</Box>
</Stack>
{/* Detail Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
{/* Schedule Card */}
<ReviewCard icon={Calendar} title="Race Weekend">
<div className="space-y-1">
<Stack gap={1}>
{timings.practiceMinutes && timings.practiceMinutes > 0 && (
<InfoRow icon={Clock} label="Practice" value={formatMinutes(timings.practiceMinutes)} />
)}
@@ -316,89 +322,98 @@ const stewardingLabel = (() => {
<InfoRow icon={Zap} label="Sprint Race" value={formatMinutes(timings.sprintRaceMinutes)} />
)}
<InfoRow icon={Flag} label="Main Race" value={formatMinutes(timings.mainRaceMinutes)} />
</div>
</Stack>
</ReviewCard>
{/* Scoring Card */}
<ReviewCard icon={Trophy} iconColor="text-warning-amber" bgColor="bg-warning-amber/10" title="Scoring System">
<div className="space-y-3">
<Stack gap={3}>
{/* Scoring Preset */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<span className="text-2xl">{getScoringEmoji()}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{preset?.name ?? 'Custom'}</div>
<div className="text-xs text-gray-500">{preset?.sessionSummary ?? 'Custom scoring enabled'}</div>
</div>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="2xl">{getScoringEmoji()}</Text>
<Box flexGrow={1} minWidth="0">
<Text size="sm" weight="medium" color="text-white" block>{preset?.name ?? 'Custom'}</Text>
<Text size="xs" color="text-gray-500" block>{preset?.sessionSummary ?? 'Custom scoring enabled'}</Text>
</Box>
{scoring.customScoringEnabled && (
<span className="px-2 py-0.5 rounded bg-primary-blue/20 text-[10px] font-medium text-primary-blue">Custom</span>
<Box as="span" px={2} py={0.5} rounded="sm" bg="bg-primary-blue/20">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Custom
</Text>
</Box>
)}
</div>
</Box>
{/* Drop Rule */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline/30">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-charcoal-outline/50">
<span className="text-base">{dropRuleInfo.emoji}</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white">{dropRuleInfo.label}</div>
<div className="text-xs text-gray-500">{dropRuleInfo.description}</div>
</div>
</div>
</div>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Box display="flex" h="8" w="8" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50">
<Text size="base">{dropRuleInfo.emoji}</Text>
</Box>
<Box flexGrow={1} minWidth="0">
<Text size="sm" weight="medium" color="text-white" block>{dropRuleInfo.label}</Text>
<Text size="xs" color="text-gray-500" block>{dropRuleInfo.description}</Text>
</Box>
</Box>
</Stack>
</ReviewCard>
</div>
</Box>
{/* Championships Section */}
<ReviewCard icon={Award} iconColor="text-neon-aqua" bgColor="bg-neon-aqua/10" title="Active Championships">
<div className="flex flex-wrap gap-2">
<Box display="flex" flexWrap="wrap" gap={2}>
{championships.enableDriverChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Trophy className="w-3.5 h-3.5" />
Driver Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Trophy} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Driver Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableTeamChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Award className="w-3.5 h-3.5" />
Team Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Award} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Team Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableNationsChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Globe className="w-3.5 h-3.5" />
Nations Cup
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Globe} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Nations Cup</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{championships.enableTrophyChampionship && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/20 px-3 py-2 text-xs font-medium text-primary-blue">
<Medal className="w-3.5 h-3.5" />
Trophy Championship
<Check className="w-3 h-3 text-performance-green" />
</span>
<Box as="span" display="inline-flex" alignItems="center" gap={1.5} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={2}>
<Icon icon={Medal} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-primary-blue">Trophy Championship</Text>
<Icon icon={Check} size={3} color="text-performance-green" />
</Box>
)}
{![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && (
<span className="text-sm text-gray-500">No championships enabled</span>
<Text size="sm" color="text-gray-500">No championships enabled</Text>
)}
</div>
</Box>
</ReviewCard>
{/* Ready to launch message */}
<div className="rounded-xl bg-performance-green/5 border border-performance-green/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/20">
<Check className="w-5 h-5 text-performance-green" />
</div>
<div>
<p className="text-sm font-medium text-white">Ready to launch!</p>
<p className="text-xs text-gray-400">
Click "Create League" to launch your racing series. You can modify all settings later.
</p>
</div>
</div>
</div>
</div>
<Box rounded="xl" bg="bg-performance-green/5" border borderColor="border-performance-green/20" p={4}>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-performance-green/20">
<Icon icon={Check} size={5} color="text-performance-green" />
</Box>
<Box>
<Text size="sm" weight="medium" color="text-white" block>Ready to launch!</Text>
<Text size="xs" color="text-gray-400" block>
Click &quot;Create League&quot; to launch your racing series. You can modify all settings later.
</Text>
</Box>
</Box>
</Box>
</Stack>
);
}

View File

@@ -1,79 +0,0 @@
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import LeagueSchedule from './LeagueSchedule';
import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}));
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-123',
}));
const mockUseLeagueSchedule = vi.fn();
vi.mock('@/hooks/useLeagueService', () => ({
useLeagueSchedule: (...args: unknown[]) => mockUseLeagueSchedule(...args),
}));
vi.mock('@/hooks/useRaceService', () => ({
useRegisterForRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useWithdrawFromRace: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
describe('LeagueSchedule', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
mockPush.mockReset();
mockUseLeagueSchedule.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a schedule race (no crash)', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: false,
data: new LeagueScheduleViewModel([
{
id: 'race-1',
name: 'Round 1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
isPast: false,
isUpcoming: true,
status: 'scheduled',
},
]),
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Round 1')).toBeInTheDocument();
});
it('renders loading state while schedule is loading', () => {
mockUseLeagueSchedule.mockReturnValue({
isLoading: true,
data: undefined,
});
render(<LeagueSchedule leagueId="league-1" />);
expect(screen.getByText('Loading schedule...')).toBeInTheDocument();
});
});

View File

@@ -3,22 +3,25 @@
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useRegisterForRace } from "@/hooks/race/useRegisterForRace";
import { useWithdrawFromRace } from "@/hooks/race/useWithdrawFromRace";
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { StateContainer } from '@/ui/StateContainer';
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
interface LeagueScheduleProps {
leagueId: string;
onRaceClick?: (raceId: string) => void;
}
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const router = useRouter();
export function LeagueSchedule({ leagueId, onRaceClick }: LeagueScheduleProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const currentDriverId = useEffectiveDriverId();
@@ -28,10 +31,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const races = useMemo(() => {
return schedule?.races ?? [];
}, [schedule]);
const handleRegister = async (race: LeagueScheduleRaceViewModel, e: React.MouseEvent) => {
e.stopPropagation();
@@ -62,24 +61,6 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
}
};
const upcomingRaces = races.filter((race) => race.isUpcoming);
const pastRaces = races.filter((race) => race.isPast);
const getDisplayRaces = () => {
switch (filter) {
case 'upcoming':
return upcomingRaces;
case 'past':
return [...pastRaces].reverse();
case 'all':
return [...upcomingRaces, ...[...pastRaces].reverse()];
default:
return races;
}
};
const displayRaces = getDisplayRaces();
return (
<StateContainer
data={schedule}
@@ -106,8 +87,10 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
case 'upcoming':
return upcomingRaces;
case 'past':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...pastRaces].reverse();
case 'all':
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
return [...upcomingRaces, ...[...pastRaces].reverse()];
default:
return races;
@@ -117,56 +100,47 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const displayRaces = getDisplayRaces();
return (
<div>
<Stack gap={4}>
{/* Filter Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p>
<div className="flex gap-2">
<button
</Text>
<Box display="flex" gap={2}>
<Button
variant={filter === 'upcoming' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('upcoming')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'upcoming'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Upcoming ({upcomingRaces.length})
</button>
<button
</Button>
<Button
variant={filter === 'past' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('past')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'past'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
Past ({pastRaces.length})
</button>
<button
</Button>
<Button
variant={filter === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setFilter('all')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
filter === 'all'
? 'bg-primary-blue text-white'
: 'bg-iron-gray text-gray-400 hover:text-white'
}`}
>
All ({races.length})
</button>
</div>
</div>
</Button>
</Box>
</Box>
{/* Race List */}
{displayRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No {filter} races</p>
<Box textAlign="center" py={8}>
<Text color="text-gray-400" block mb={2}>No {filter} races</Text>
{filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
<Text size="sm" color="text-gray-500" block>Schedule your first race to get started</Text>
)}
</div>
</Box>
) : (
<div className="space-y-3">
<Stack gap={3}>
{displayRaces.map((race) => {
const isPast = race.isPast;
const isUpcoming = race.isUpcoming;
@@ -178,91 +152,103 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
registerMutation.isPending || withdrawMutation.isPending;
return (
<div
<Box
key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
isPast
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/races/${race.id}`)}
p={4}
rounded="lg"
border
transition
cursor="pointer"
hoverScale
bg={isPast ? 'bg-iron-gray/50' : 'bg-deep-graphite'}
borderColor={isPast ? 'border-charcoal-outline/50' : 'border-charcoal-outline'}
hoverBorderColor={!isPast ? 'border-primary-blue' : undefined}
opacity={isPast ? 0.75 : 1}
onClick={() => onRaceClick?.(race.id)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{trackLabel}</h3>
<Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2} mb={1} flexWrap="wrap">
<Heading level={3} fontSize="base" weight="medium" color="text-white">{trackLabel}</Heading>
{isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
<Box as="span" px={2} py={0.5} bg="bg-primary-blue/10" border borderColor="border-primary-blue/30" rounded="sm">
<Text size="xs" weight="medium" color="text-primary-blue">Upcoming</Text>
</Box>
)}
{isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
<Box as="span" px={2} py={0.5} bg="bg-green-500/10" border borderColor="border-green-500/30" rounded="sm">
<Text size="xs" weight="medium" color="text-green-400"> Registered</Text>
</Box>
)}
{isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
Completed
</span>
<Box as="span" px={2} py={0.5} bg="bg-gray-700/50" border borderColor="border-gray-600/50" rounded="sm">
<Text size="xs" weight="medium" color="text-gray-400">Completed</Text>
</Box>
)}
</div>
<p className="text-sm text-gray-400">{carLabel}</p>
<div className="flex items-center gap-3 mt-2">
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
</div>
</div>
</Box>
<Text size="sm" color="text-gray-400" block>{carLabel}</Text>
<Box mt={2}>
<Text size="xs" color="text-gray-500" transform="uppercase">{sessionTypeLabel}</Text>
</Box>
</Box>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-white font-medium">
<Box display="flex" alignItems="center" gap={3}>
<Box textAlign="right">
<Text color="text-white" weight="medium" block>
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-sm text-gray-400">
</Text>
<Text size="sm" color="text-gray-400" block>
{race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</Text>
{isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p>
<Text size="xs" color="text-primary-blue" mt={1} block>View Results </Text>
)}
</div>
</Box>
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
<Box onClick={(e: React.MouseEvent) => e.stopPropagation()}>
{!isRegistered ? (
<button
<Button
variant="primary"
size="sm"
onClick={(e) => handleRegister(race, e)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{registerMutation.isPending ? 'Registering...' : 'Register'}
</button>
</Button>
) : (
<button
<Button
variant="secondary"
size="sm"
onClick={(e) => handleWithdraw(race, e)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
color="text-gray-300"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</button>
</Button>
)}
</div>
</Box>
)}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
})}
</div>
</Stack>
)}
</div>
</Stack>
);
}}
</StateContainer>

View File

@@ -1,14 +1,14 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X, LucideIcon } from 'lucide-react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Surface } from '@/ui/Surface';
@@ -102,16 +102,18 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
ref={flyoutRef}
position="fixed"
zIndex={50}
width="380px"
backgroundColor="iron-gray"
w="380px"
bg="bg-iron-gray"
border
borderColor="charcoal-outline"
borderColor="border-charcoal-outline"
rounded="xl"
// eslint-disable-next-line gridpilot-rules/component-classification
className="max-h-[80vh] overflow-y-auto shadow-2xl animate-fade-in"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<Box display="flex" align="center" justify="between" padding={4} border borderBottom borderColor="charcoal-outline" position="sticky" top={0} backgroundColor="iron-gray" zIndex={10}>
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline" position="sticky" top="0" bg="bg-iron-gray" zIndex={10}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
@@ -120,14 +122,14 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
variant="ghost"
size="sm"
onClick={onClose}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-6 p-0"
icon={<Icon icon={X} size={4} color="text-gray-400" />}
>
{null}
<Icon icon={X} size={4} color="text-gray-400" />
</Button>
</Box>
{/* Content */}
<Box padding={4}>
<Box p={4}>
{children}
</Box>
</Box>,
@@ -142,10 +144,10 @@ function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: Re
variant="ghost"
size="sm"
onClick={onClick}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0 rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={HelpCircle} size={3.5} />}
>
{null}
<Icon icon={HelpCircle} size={3.5} />
</Button>
);
}
@@ -164,32 +166,64 @@ function PointsSystemMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Surface variant="dark" rounded="lg" p={4}>
<Stack gap={3}>
<Box display="flex" align="center" justify="between" className="text-[10px] text-gray-500 uppercase tracking-wide px-1">
<Text>Position</Text>
<Text>Points</Text>
<Box display="flex" alignItems="center" justifyContent="between" px={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Position
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Points
</Text>
</Box>
{positions.map((p) => (
<Stack key={p.pos} direction="row" align="center" gap={3}>
<Box width={8} height={8} rounded="lg" className={p.color} display="flex" center>
<Text size="sm" weight="bold" className={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
<Box w="8" h="8" rounded="lg" bg={p.color} display="flex" alignItems="center" justifyContent="center">
<Text size="sm" weight="bold" color={p.pos <= 3 ? 'text-deep-graphite' : 'text-gray-400'}>P{p.pos}</Text>
</Box>
<Box flex={1} height={2} backgroundColor="charcoal-outline" rounded="full" className="overflow-hidden opacity-50">
<Box flexGrow={1} h="2" bg="bg-charcoal-outline" rounded="full" overflow="hidden" opacity={0.5}>
<Box
height="full"
className="bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full"
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ width: `${(p.pts / 25) * 100}%` }}
/>
</Box>
<Box width={8} textAlign="right">
<Box w="8" textAlign="right">
<Text size="sm" font="mono" weight="semibold" color="text-white">{p.pts}</Text>
</Box>
</Stack>
))}
<Box display="flex" align="center" justify="center" gap={1} pt={2} className="text-[10px] text-gray-500">
<Text>...</Text>
<Text>down to P10 = 1 point</Text>
<Box display="flex" alignItems="center" justifyContent="center" gap={1} pt={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
...
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
down to P10 = 1 point
</Text>
</Box>
</Stack>
</Surface>
@@ -204,15 +238,22 @@ function BonusPointsMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Surface variant="dark" rounded="lg" p={4}>
<Stack gap={2}>
{bonuses.map((b, i) => (
<Surface key={i} variant="muted" border rounded="lg" padding={2}>
<Surface key={i} variant="muted" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={3}>
<Text size="xl">{b.emoji}</Text>
<Box flex={1}>
<Box flexGrow={1}>
<Text size="xs" weight="medium" color="text-white" block>{b.label}</Text>
<Text className="text-[10px] text-gray-500" block>{b.desc}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
{b.desc}
</Text>
</Box>
<Text size="sm" font="mono" weight="semibold" color="text-performance-green">{b.pts}</Text>
</Stack>
@@ -231,80 +272,48 @@ function ChampionshipMockup() {
];
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box display="flex" align="center" gap={2} mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Surface variant="dark" rounded="lg" p={4}>
<Box display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline" opacity={0.5}>
<Icon icon={Trophy} size={4} color="text-yellow-500" />
<Text size="xs" weight="semibold" color="text-white">Driver Championship</Text>
</Box>
<Stack gap={2}>
{standings.map((s) => (
<Stack key={s.pos} direction="row" align="center" gap={2}>
<Box width={6} height={6} rounded="full" display="flex" center className={s.pos === 1 ? 'bg-yellow-500 text-deep-graphite' : 'bg-charcoal-outline text-gray-400'}>
<Text className="text-[10px]" weight="bold">{s.pos}</Text>
<Box w="6" h="6" rounded="full" display="flex" alignItems="center" justifyContent="center" bg={s.pos === 1 ? 'bg-yellow-500' : 'bg-charcoal-outline'} color={s.pos === 1 ? 'text-deep-graphite' : 'text-gray-400'}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="bold"
>
{s.pos}
</Text>
</Box>
<Box flex={1}>
<Text size="xs" color="text-white" className="truncate" block>{s.name}</Text>
<Box flexGrow={1}>
<Text size="xs" color="text-white" truncate block>{s.name}</Text>
</Box>
<Text size="xs" font="mono" weight="semibold" color="text-white">{s.pts}</Text>
{s.delta && (
<Text className="text-[10px] font-mono text-gray-500">{s.delta}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
font="mono"
color="text-gray-500"
>
{s.delta}
</Text>
)}
</Stack>
))}
</Stack>
<Box mt={3} pt={2} border borderTop borderColor="charcoal-outline" className="text-[10px] text-gray-500 opacity-50" textAlign="center">
<Text>Points accumulated across all races</Text>
</Box>
</Surface>
);
}
function DropRulesMockup() {
const results = [
{ round: 'R1', pts: 25, dropped: false },
{ round: 'R2', pts: 18, dropped: false },
{ round: 'R3', pts: 4, dropped: true },
{ round: 'R4', pts: 15, dropped: false },
{ round: 'R5', pts: 12, dropped: false },
{ round: 'R6', pts: 0, dropped: true },
];
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<Surface variant="dark" rounded="lg" padding={4}>
<Box mb={3} pb={2} border borderBottom borderColor="charcoal-outline" className="opacity-50">
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Box>
<Stack direction="row" gap={1} mb={3}>
{results.map((r, i) => (
<Box
key={i}
flex={1}
padding={2}
rounded="lg"
textAlign="center"
border
borderColor={r.dropped ? 'charcoal-outline' : 'performance-green'}
backgroundColor={r.dropped ? 'transparent' : 'performance-green'}
opacity={r.dropped ? 0.5 : 0.1}
className="transition-all"
>
<Text className="text-[9px] text-gray-500" block>{r.round}</Text>
<Text size="xs" font="mono" weight="semibold" color={r.dropped ? 'text-gray-500' : 'text-white'} className={r.dropped ? 'line-through' : ''} block>
{r.pts}
</Text>
</Box>
))}
</Stack>
<Box display="flex" justify="between" align="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green">{total} pts</Text>
</Box>
<Box display="flex" justify="between" align="center" mt={1} className="text-[10px] text-gray-500">
<Text>Without drops:</Text>
<Text font="mono">{wouldBe} pts</Text>
<Box mt={3} pt={2} borderTop borderColor="border-charcoal-outline" opacity={0.5} textAlign="center">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Points accumulated across all races
</Text>
</Box>
</Surface>
);
@@ -418,7 +427,10 @@ export function LeagueScoringSection({
}
return (
<Grid cols={2} gap={6} className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]">
<Grid cols={2} gap={6}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)]"
>
{patternPanel}
{championshipsPanel}
</Grid>
@@ -560,10 +572,10 @@ export function ScoringPatternSection({
<Stack gap={5}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Points System</Heading>
<InfoButton buttonRef={pointsInfoRef} onClick={() => setShowPointsFlyout(true)} />
@@ -586,16 +598,32 @@ export function ScoringPatternSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Example: F1-Style Points</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Example: F1-Style Points
</Text>
</Box>
<PointsSystemMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> Sprint formats
award points in both races, typically with reduced points for the sprint.
</Text>
@@ -605,11 +633,14 @@ export function ScoringPatternSection({
</InfoFlyout>
{/* Two-column layout: Presets | Custom */}
<Grid cols={2} gap={4} className="lg:grid-cols-[1fr_auto]">
<Grid cols={2} gap={4}
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:grid-cols-[1fr_auto]"
>
{/* Preset options */}
<Stack gap={2}>
{presets.length === 0 ? (
<Box padding={4} border borderStyle="dashed" borderColor="charcoal-outline" rounded="lg">
<Box p={4} border borderStyle="dashed" borderColor="border-charcoal-outline" rounded="lg">
<Text size="sm" color="text-gray-400">Loading presets...</Text>
</Box>
) : (
@@ -622,6 +653,7 @@ export function ScoringPatternSection({
variant="ghost"
onClick={() => onChangePatternId?.(preset.id)}
disabled={disabled}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isSelected
@@ -632,15 +664,17 @@ export function ScoringPatternSection({
>
{/* Radio indicator */}
<Box
width={5}
height={5}
w="5"
h="5"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'primary-blue' : 'gray-500'}
backgroundColor={isSelected ? 'primary-blue' : 'transparent'}
className="shrink-0 transition-colors"
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : 'transparent'}
transition
flexShrink={0}
>
{isSelected && <Icon icon={Check} size={3} color="text-white" />}
</Box>
@@ -649,14 +683,20 @@ export function ScoringPatternSection({
<Text size="xl">{getPresetEmoji(preset)}</Text>
{/* Text */}
<Box flex={1} className="min-w-0">
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text size="sm" weight="medium" color="text-white" block>{preset.name}</Text>
<Text size="xs" color="text-gray-500" block>{getPresetDescription(preset)}</Text>
</Box>
{/* Bonus badge */}
{preset.bonusSummary && (
<Box className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400">
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded-full bg-charcoal-outline/30 text-[10px] text-gray-400"
>
<Icon icon={Zap} size={3} />
<Text>{preset.bonusSummary}</Text>
</Box>
@@ -664,7 +704,7 @@ export function ScoringPatternSection({
{/* Info button */}
<Box
ref={(el: any) => { presetInfoRefs.current[preset.id] = el; }}
ref={(el: HTMLElement | null) => { presetInfoRefs.current[preset.id] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
@@ -678,7 +718,17 @@ export function ScoringPatternSection({
setActivePresetFlyout(activePresetFlyout === preset.id ? null : preset.id);
}
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
>
<Icon icon={HelpCircle} size={3.5} />
</Box>
@@ -695,13 +745,28 @@ export function ScoringPatternSection({
<Text size="xs" color="text-gray-400">{presetInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Key Features</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Key Features
</Text>
</Box>
<Box as="ul" className="space-y-1.5">
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{presetInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
@@ -709,10 +774,17 @@ export function ScoringPatternSection({
</Stack>
{preset.bonusSummary && (
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Bonus points:</Text> {preset.bonusSummary}
</Text>
</Stack>
@@ -727,11 +799,15 @@ export function ScoringPatternSection({
</Stack>
{/* Custom scoring option */}
<Box width="full" className="lg:w-48">
<Box w="full"
// eslint-disable-next-line gridpilot-rules/component-classification
className="lg:w-48"
>
<Button
variant="ghost"
onClick={onToggleCustomScoring}
disabled={!onToggleCustomScoring || readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full h-full min-h-[100px] flex flex-col items-center justify-center gap-2 p-4 rounded-xl border-2 transition-all duration-200
${isCustom
@@ -741,25 +817,40 @@ export function ScoringPatternSection({
`}
>
<Box
width={10}
height={10}
w="10"
h="10"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="xl"
backgroundColor={isCustom ? 'primary-blue' : 'charcoal-outline'}
bg={isCustom ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isCustom ? 0.2 : 0.3}
className="transition-colors"
transition
>
<Icon icon={Settings} size={5} color={isCustom ? 'text-primary-blue' : 'text-gray-500'} />
</Box>
<Box textAlign="center">
<Text size="sm" weight="medium" color={isCustom ? 'text-white' : 'text-gray-400'} block>Custom</Text>
<Text className="text-[10px] text-gray-500" block>Define your own</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Define your own
</Text>
</Box>
{isCustom && (
<Box display="flex" align="center" gap={1} px={2} py={0.5} rounded="full" backgroundColor="primary-blue" opacity={0.2}>
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} rounded="full" bg="bg-primary-blue" opacity={0.2}>
<Icon icon={Check} size={2.5} color="text-primary-blue" />
<Text className="text-[10px]" weight="medium" color="text-primary-blue">Active</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-primary-blue"
>
Active
</Text>
</Box>
)}
</Button>
@@ -773,10 +864,10 @@ export function ScoringPatternSection({
{/* Custom scoring editor - inline, no placeholder */}
{isCustom && (
<Surface variant="muted" border rounded="xl" padding={4}>
<Surface variant="muted" border rounded="xl" p={4}>
<Stack gap={4}>
{/* Header with reset button */}
<Box display="flex" align="center" justify="between">
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Settings} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">Custom Points Table</Text>
@@ -797,16 +888,32 @@ export function ScoringPatternSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Available Bonuses</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Available Bonuses
</Text>
</Box>
<BonusPointsMockup />
</Box>
<Surface variant="muted" border rounded="lg" padding={3}>
<Surface variant="muted" border rounded="lg" p={3}>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-primary-blue"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-primary-blue">Example:</Text> A driver finishing
P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points.
</Text>
@@ -820,16 +927,19 @@ export function ScoringPatternSection({
size="sm"
onClick={resetToDefaults}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-auto py-1 px-2 text-[10px] text-gray-400 hover:text-primary-blue hover:bg-primary-blue/10"
icon={<Icon icon={RotateCcw} size={3} />}
>
Reset
<Stack direction="row" align="center" gap={1}>
<Icon icon={RotateCcw} size={3} />
<Text>Reset</Text>
</Stack>
</Button>
</Box>
{/* Race position points */}
<Stack gap={2}>
<Box display="flex" align="center" justify="between">
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="xs" color="text-gray-400">Finish position points</Text>
<Stack direction="row" align="center" gap={1}>
<Button
@@ -837,54 +947,70 @@ export function ScoringPatternSection({
size="sm"
onClick={removePosition}
disabled={readOnly || customPoints.racePoints.length <= 3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
icon={<Icon icon={Minus} size={3} />}
>
{null}
<Icon icon={Minus} size={3} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={addPosition}
disabled={readOnly || customPoints.racePoints.length >= 20}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-5 p-0"
icon={<Icon icon={Plus} size={3} />}
>
{null}
<Icon icon={Plus} size={3} />
</Button>
</Stack>
</Box>
<Stack direction="row" wrap gap={1}>
<Box display="flex" flexWrap="wrap" gap={1}>
{customPoints.racePoints.map((pts, idx) => (
<Stack key={idx} align="center">
<Text className="text-[9px] text-gray-500" mb={0.5}>P{idx + 1}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
mb={0.5}
>
P{idx + 1}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, -1)}
disabled={readOnly || pts <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-r-none text-[10px]"
>
</Button>
<Box width={6} height={5} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Text className="text-[10px]" weight="medium" color="text-white">{pts}</Text>
<Box w="6" h="5" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
>
{pts}
</Text>
</Box>
<Button
variant="secondary"
size="sm"
onClick={() => updateRacePoints(idx, 1)}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-5 w-4 p-0 rounded-l-none text-[10px]"
>
+
</Button>
</Stack>
</Box>
</Stack>
))}
</Stack>
</Box>
</Stack>
{/* Bonus points */}
@@ -895,18 +1021,25 @@ export function ScoringPatternSection({
{ key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' },
].map((bonus) => (
<Stack key={bonus.key} align="center" gap={1}>
<Text className="text-[10px] text-gray-500">{bonus.emoji} {bonus.label}</Text>
<Stack direction="row" align="center" gap={0.5}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{bonus.emoji} {bonus.label}
</Text>
<Box display="flex" alignItems="center">
<Button
variant="secondary"
size="sm"
onClick={() => updateBonus(bonus.key, -1)}
disabled={readOnly || customPoints[bonus.key] <= 0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-r-none"
>
</Button>
<Box width={7} height={6} display="flex" center backgroundColor="deep-graphite" border borderTop borderBottom borderColor="charcoal-outline">
<Box w="7" h="6" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" border borderTop borderBottom borderColor="border-charcoal-outline">
<Text size="xs" weight="medium" color="text-white">{customPoints[bonus.key]}</Text>
</Box>
<Button
@@ -914,11 +1047,12 @@ export function ScoringPatternSection({
size="sm"
onClick={() => updateBonus(bonus.key, 1)}
disabled={readOnly}
// eslint-disable-next-line gridpilot-rules/component-classification
className="h-6 w-5 p-0 rounded-l-none"
>
+
</Button>
</Stack>
</Box>
</Stack>
))}
</Grid>
@@ -1040,10 +1174,10 @@ export function ChampionshipsSection({
<Stack gap={4}>
{/* Section header */}
<Stack direction="row" align="center" gap={3}>
<Box width={10} height={10} display="flex" center rounded="xl" backgroundColor="primary-blue" opacity={0.1}>
<Box w="10" h="10" display="flex" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue" opacity={0.1}>
<Icon icon={Award} size={5} color="text-primary-blue" />
</Box>
<Box flex={1}>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<Heading level={3}>Championships</Heading>
<InfoButton buttonRef={champInfoRef} onClick={() => setShowChampFlyout(true)} />
@@ -1066,15 +1200,33 @@ export function ChampionshipsSection({
</Text>
<Box>
<Box mb={2} className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Live Standings Example</Text>
<Box mb={2}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Live Standings Example
</Text>
</Box>
<ChampionshipMockup />
</Box>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>Championship Types</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
Championship Types
</Text>
</Box>
<Grid cols={2} gap={2}>
{[
@@ -1083,12 +1235,27 @@ export function ChampionshipsSection({
{ icon: Globe, label: 'Nations', desc: 'By country' },
{ icon: Medal, label: 'Trophy', desc: 'Special class' },
].map((t, i) => (
<Surface key={i} variant="dark" border rounded="lg" padding={2}>
<Surface key={i} variant="dark" border rounded="lg" p={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={t.icon} size={3.5} color="text-primary-blue" />
<Box>
<Text className="text-[10px]" weight="medium" color="text-white" block>{t.label}</Text>
<Text className="text-[9px] text-gray-500" block>{t.desc}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color="text-white"
block
>
{t.label}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{t.desc}
</Text>
</Box>
</Stack>
</Surface>
@@ -1110,6 +1277,7 @@ export function ChampionshipsSection({
variant="ghost"
disabled={disabled || !champ.available}
onClick={() => champ.available && updateChampionship(champ.key, !champ.enabled)}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
w-full flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-all duration-200 h-auto
${isEnabled
@@ -1122,34 +1290,54 @@ export function ChampionshipsSection({
>
{/* Toggle indicator */}
<Box
width={5}
height={5}
w="5"
h="5"
display="flex"
center
alignItems="center"
justifyContent="center"
rounded="md"
backgroundColor={isEnabled ? 'primary-blue' : 'charcoal-outline'}
bg={isEnabled ? 'bg-primary-blue' : 'bg-charcoal-outline'}
opacity={isEnabled ? 1 : 0.5}
className="shrink-0 transition-colors"
transition
flexShrink={0}
>
{isEnabled && <Icon icon={Check} size={3} color="text-white" />}
</Box>
{/* Icon */}
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'} className="shrink-0" />
<Icon icon={champ.icon} size={4} color={isEnabled ? 'text-primary-blue' : 'text-gray-500'}
// eslint-disable-next-line gridpilot-rules/component-classification
className="shrink-0"
/>
{/* Text */}
<Box flex={1} className="min-w-0">
<Text className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`} block>
<Box flexGrow={1}
// eslint-disable-next-line gridpilot-rules/component-classification
className="min-w-0"
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-medium truncate ${isEnabled ? 'text-white' : 'text-gray-400'}`}
block
>
{champ.label}
</Text>
{!champ.available && champ.unavailableHint && (
<Text className="text-[10px] text-warning-amber/70" block>{champ.unavailableHint}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-warning-amber"
opacity={0.7}
block
>
{champ.unavailableHint}
</Text>
)}
</Box>
{/* Info button */}
<Box
ref={(el: any) => { champItemRefs.current[champ.key] = el; }}
ref={(el: HTMLElement | null) => { champItemRefs.current[champ.key] = el; }}
role="button"
tabIndex={0}
onClick={(e: React.MouseEvent) => {
@@ -1163,7 +1351,17 @@ export function ChampionshipsSection({
setActiveChampFlyout(activeChampFlyout === champ.key ? null : champ.key);
}
}}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors shrink-0"
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
transition
flexShrink={0}
>
<Icon icon={HelpCircle} size={3} />
</Box>
@@ -1175,19 +1373,34 @@ export function ChampionshipsSection({
isOpen={activeChampFlyout === champ.key}
onClose={() => setActiveChampFlyout(null)}
title={champInfo.title}
anchorRef={{ current: champItemRefs.current[champ.key] ?? champInfoRef.current }}
anchorRef={{ current: (champItemRefs.current[champ.key] as HTMLElement | null) ?? champInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400">{champInfo.description}</Text>
<Stack gap={2}>
<Box className="text-[10px] text-gray-500 uppercase tracking-wide">
<Text>How It Works</Text>
<Box>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
>
How It Works
</Text>
</Box>
<Box as="ul" className="space-y-1.5">
<Box as="ul"
// eslint-disable-next-line gridpilot-rules/component-classification
className="space-y-1.5"
>
{champInfo.details.map((detail, idx) => (
<Box as="li" key={idx} display="flex" align="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" className="mt-0.5" />
<Box as="li" key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text size="xs" color="text-gray-400">{detail}</Text>
</Box>
))}
@@ -1195,10 +1408,20 @@ export function ChampionshipsSection({
</Stack>
{!champ.available && (
<Surface variant="muted" border rounded="lg" padding={3} className="bg-warning-amber/5 border-warning-amber/20">
<Surface variant="muted" border rounded="lg" p={3}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-warning-amber/5 border-warning-amber/20"
>
<Stack direction="row" align="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-warning-amber" className="mt-0.5" />
<Text className="text-[11px] text-gray-400">
<Icon icon={Zap} size={3.5} color="text-warning-amber"
// eslint-disable-next-line gridpilot-rules/component-classification
className="mt-0.5"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
>
<Text weight="medium" color="text-warning-amber">Note:</Text> {champ.unavailableHint}. Switch to Teams mode to enable this championship.
</Text>
</Stack>

View File

@@ -2,13 +2,20 @@
import { Award, DollarSign, Star, X } from 'lucide-react';
import { useState } from 'react';
import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { PendingSponsorshipRequests } from '../sponsors/PendingSponsorshipRequests';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { StatBox } from '@/ui/StatBox';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons";
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useLeagueSeasons } from "@/hooks/league/useLeagueSeasons";
import { useSponsorshipRequests } from "@/hooks/league/useSponsorshipRequests";
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
@@ -43,12 +50,13 @@ export function LeagueSponsorshipsSection({
const [tempPrice, setTempPrice] = useState<string>('');
// Load season ID if not provided
const { data: seasons = [], isLoading: seasonsLoading } = useLeagueSeasons(leagueId);
const { data: seasons = [] } = useLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
const seasonId = propSeasonId || activeSeason?.seasonId;
// Load pending sponsorship requests
const { data: pendingRequests = [], isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const { data: pendingRequestsData, isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const pendingRequests = pendingRequestsData?.requests || [];
const handleAcceptRequest = async (requestId: string) => {
if (!currentDriverId) return;
@@ -107,107 +115,111 @@ export function LeagueSponsorshipsSection({
const netRevenue = totalRevenue - platformFee;
const availableSlots = slots.filter(s => !s.isOccupied).length;
const occupiedSlots = slots.filter(s => s.isOccupied).length;
return (
<div className="space-y-6">
<Stack gap={6}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Sponsorships</h3>
<p className="text-sm text-gray-400 mt-1">
<Box display="flex" alignItems="center" justifyContent="between">
<Box>
<Heading level={3}>Sponsorships</Heading>
<Text size="sm" color="text-gray-400" mt={1} block>
Define pricing for sponsor slots in this league. Sponsors pay per season.
</p>
<p className="text-xs text-gray-500 mt-1">
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
These sponsors are attached to seasons in this league, so you can change partners from season to season.
</p>
</div>
</Text>
</Box>
{!readOnly && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<DollarSign className="w-4 h-4 text-primary-blue" />
<span className="text-xs font-medium text-primary-blue">
<Box display="flex" alignItems="center" gap={2} px={3} py={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/30">
<Icon icon={DollarSign} size={4} color="var(--primary-blue)" />
<Text size="xs" weight="medium" color="text-primary-blue">
{availableSlots} slot{availableSlots !== 1 ? 's' : ''} available
</span>
</div>
</Text>
</Box>
)}
</div>
</Box>
{/* Revenue Summary */}
{totalRevenue > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Total Revenue</div>
<div className="text-xl font-bold text-white">
${totalRevenue.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Platform Fee (10%)</div>
<div className="text-xl font-bold text-warning-amber">
-${platformFee.toFixed(2)}
</div>
</div>
<div className="rounded-lg bg-iron-gray/50 border border-charcoal-outline p-4">
<div className="text-xs text-gray-400 mb-1">Net Revenue</div>
<div className="text-xl font-bold text-performance-green">
${netRevenue.toFixed(2)}
</div>
</div>
</div>
<Box display="grid" gridCols={3} gap={4}>
<StatBox
icon={DollarSign}
label="Total Revenue"
value={`$${totalRevenue.toFixed(2)}`}
color="var(--primary-blue)"
/>
<StatBox
icon={DollarSign}
label="Platform Fee (10%)"
value={`-$${platformFee.toFixed(2)}`}
color="var(--warning-amber)"
/>
<StatBox
icon={DollarSign}
label="Net Revenue"
value={`$${netRevenue.toFixed(2)}`}
color="var(--performance-green)"
/>
</Box>
)}
{/* Sponsorship Slots */}
<div className="space-y-3">
<Stack gap={3}>
{slots.map((slot, index) => {
const isEditing = editingIndex === index;
const Icon = slot.tier === 'main' ? Star : Award;
const IconComp = slot.tier === 'main' ? Star : Award;
return (
<div
<Box
key={index}
className="rounded-lg border border-charcoal-outline bg-deep-graphite/70 p-4"
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-deep-graphite/70"
p={4}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${
slot.tier === 'main'
? 'bg-primary-blue/10'
: 'bg-gray-500/10'
}`}>
<Icon className={`w-5 h-5 ${
slot.tier === 'main'
? 'text-primary-blue'
: 'text-gray-400'
}`} />
</div>
<Box display="flex" alignItems="center" justifyContent="between" gap={4}>
<Box display="flex" alignItems="center" gap={3} flexGrow={1}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg={slot.tier === 'main' ? 'bg-primary-blue/10' : 'bg-gray-500/10'}
>
<Icon icon={IconComp} size={5} color={slot.tier === 'main' ? 'var(--primary-blue)' : 'var(--gray-400)'} />
</Box>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-white">
<Box flexGrow={1}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={4}>
{slot.tier === 'main' ? 'Main Sponsor' : 'Secondary Sponsor'}
</h4>
</Heading>
{slot.isOccupied && (
<span className="px-2 py-0.5 text-xs bg-performance-green/10 text-performance-green border border-performance-green/30 rounded-full">
<Badge variant="success">
Occupied
</span>
</Badge>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
</Box>
<Text size="xs" color="text-gray-500" mt={0.5} block>
{slot.tier === 'main'
? 'Big livery slot • League page logo • Name in league title'
: 'Small livery slot • League page logo'}
</p>
</div>
</div>
</Text>
</Box>
</Box>
<div className="flex items-center gap-3">
<Box display="flex" alignItems="center" gap={3}>
{isEditing ? (
<div className="flex items-center gap-2">
<Box display="flex" alignItems="center" gap={2}>
<Input
type="number"
value={tempPrice}
onChange={(e) => setTempPrice(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTempPrice(e.target.value)}
placeholder="Price"
// eslint-disable-next-line gridpilot-rules/component-classification
className="w-32"
min="0"
step="0.01"
@@ -215,47 +227,47 @@ export function LeagueSponsorshipsSection({
<Button
variant="primary"
onClick={() => handleSavePrice(index)}
className="px-3 py-1"
size="sm"
>
Save
</Button>
<Button
variant="secondary"
onClick={handleCancelEdit}
className="px-3 py-1"
size="sm"
>
<X className="w-4 h-4" />
<Icon icon={X} size={4} />
</Button>
</div>
</Box>
) : (
<>
<div className="text-right">
<div className="text-lg font-bold text-white">
<Box textAlign="right">
<Text size="lg" weight="bold" color="text-white" block>
${slot.price.toFixed(2)}
</div>
<div className="text-xs text-gray-500">per season</div>
</div>
</Text>
<Text size="xs" color="text-gray-500" block>per season</Text>
</Box>
{!readOnly && !slot.isOccupied && (
<Button
variant="secondary"
onClick={() => handleEditPrice(index)}
className="px-3 py-1"
size="sm"
>
Edit Price
</Button>
)}
</>
)}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
})}
</div>
</Stack>
{/* Pending Sponsorship Requests */}
{!readOnly && (pendingRequests.length > 0 || requestsLoading) && (
<div className="mt-8 pt-6 border-t border-charcoal-outline">
<Box mt={8} pt={6} borderTop borderColor="border-charcoal-outline">
<PendingSponsorshipRequests
entityType="season"
entityId={seasonId || ''}
@@ -265,16 +277,16 @@ export function LeagueSponsorshipsSection({
onReject={handleRejectRequest}
isLoading={requestsLoading}
/>
</div>
</Box>
)}
{/* Alpha Notice */}
<div className="rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Sponsorship management is demonstration-only.
<Box rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400" block>
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Sponsorship management is demonstration-only.
In production, sponsors can browse leagues, select slots, and complete payment integration.
</p>
</div>
</div>
</Text>
</Box>
</Stack>
);
}

View File

@@ -1,8 +1,15 @@
'use client';
import React from 'react';
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
import { Scale, Clock, Bell, Shield, Vote, AlertTriangle } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
import { Checkbox } from '@/ui/Checkbox';
interface LeagueStewardingSectionProps {
form: LeagueConfigFormModel;
@@ -23,14 +30,14 @@ const decisionModeOptions: DecisionModeOption[] = [
value: 'single_steward',
label: 'Single Steward',
description: 'A single steward/admin makes all penalty decisions',
icon: <Shield className="w-5 h-5" />,
icon: <Icon icon={Shield} size={5} />,
requiresVotes: false,
},
{
value: 'committee_vote',
label: 'Committee Vote',
description: 'A group votes to uphold/dismiss protests',
icon: <Scale className="w-5 h-5" />,
icon: <Icon icon={Scale} size={5} />,
requiresVotes: true,
},
];
@@ -66,305 +73,342 @@ export function LeagueStewardingSection({
const selectedMode = decisionModeOptions.find((m) => m.value === stewarding.decisionMode);
return (
<div className="space-y-8">
<Stack gap={8}>
{/* Decision Mode Selection */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Scale className="w-4 h-4 text-primary-blue" />
How are protest decisions made?
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Scale} size={4} color="text-primary-blue" />
How are protest decisions made?
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Choose who has the authority to issue penalties
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Box display="grid" gridCols={{ base: 1, sm: 2, lg: 3 }} gap={3}>
{decisionModeOptions.map((option) => (
<button
<Box
key={option.value}
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ decisionMode: option.value })}
className={`
relative flex flex-col items-start gap-2 p-4 rounded-xl border-2 transition-all text-left
${stewarding.decisionMode === option.value
? 'border-primary-blue bg-primary-blue/5 shadow-[0_0_16px_rgba(25,140,255,0.15)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
position="relative"
display="flex"
flexDirection="col"
alignItems="start"
gap={2}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={stewarding.decisionMode === option.value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
shadow={stewarding.decisionMode === option.value ? '0_0_16px_rgba(25,140,255,0.15)' : undefined}
hoverBorderColor={!readOnly && stewarding.decisionMode !== option.value ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<div
className={`p-2 rounded-lg ${
stewarding.decisionMode === option.value
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-charcoal-outline/50 text-gray-400'
}`}
<Box
p={2}
rounded="lg"
bg={stewarding.decisionMode === option.value ? 'bg-primary-blue/20' : 'bg-charcoal-outline/50'}
color={stewarding.decisionMode === option.value ? 'text-primary-blue' : 'text-gray-400'}
>
{option.icon}
</div>
<div>
<p className="text-sm font-medium text-white">{option.label}</p>
<p className="text-xs text-gray-400 mt-0.5">{option.description}</p>
</div>
</Box>
<Box>
<Text size="sm" weight="medium" color="text-white" block>{option.label}</Text>
<Text size="xs" color="text-gray-400" mt={0.5} block>{option.description}</Text>
</Box>
{stewarding.decisionMode === option.value && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-primary-blue" />
<Box position="absolute" top="2" right="2" w="2" h="2" rounded="full" bg="bg-primary-blue" />
)}
</button>
</Box>
))}
</div>
</div>
</Box>
</Box>
{/* Vote Requirements (conditional) */}
{selectedMode?.requiresVotes && (
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline space-y-4">
<h4 className="text-sm font-medium text-white flex items-center gap-2">
<Vote className="w-4 h-4 text-primary-blue" />
Voting Configuration
</h4>
<Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Stack gap={4}>
<Heading level={4} fontSize="sm" weight="medium" color="text-white">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Vote} size={4} color="text-primary-blue" />
Voting Configuration
</Stack>
</Heading>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Required votes to uphold
</label>
<select
value={stewarding.requiredVotes ?? 2}
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={1}>1 vote</option>
<option value={2}>2 votes</option>
<option value={3}>3 votes (majority of 5)</option>
<option value={4}>4 votes</option>
<option value={5}>5 votes</option>
</select>
</div>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Box>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Required votes to uphold
</Text>
<Select
value={stewarding.requiredVotes?.toString() ?? '2'}
onChange={(e) => updateStewarding({ requiredVotes: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '1', label: '1 vote' },
{ value: '2', label: '2 votes' },
{ value: '3', label: '3 votes (majority of 5)' },
{ value: '4', label: '4 votes' },
{ value: '5', label: '5 votes' },
]}
/>
</Box>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1.5">
Voting time limit
</label>
<select
value={stewarding.voteTimeLimit}
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={24}>24 hours</option>
<option value={48}>48 hours</option>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
<option value={168}>168 hours (7 days)</option>
</select>
</div>
</div>
</div>
<Box>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Voting time limit
</Text>
<Select
value={stewarding.voteTimeLimit?.toString()}
onChange={(e) => updateStewarding({ voteTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
options={[
{ value: '24', label: '24 hours' },
{ value: '48', label: '48 hours' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
{ value: '168', label: '168 hours (7 days)' },
]}
/>
</Box>
</Box>
</Stack>
</Box>
)}
{/* Defense Settings */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Shield className="w-4 h-4 text-primary-blue" />
Defense Requirements
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color="text-primary-blue" />
Defense Requirements
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Should accused drivers be required to submit a defense?
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
<Box
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: false })}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
${!stewarding.requireDefense
? 'border-primary-blue bg-primary-blue/5'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
display="flex"
alignItems="center"
gap={3}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={!stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
}`}>
{!stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
</div>
<div>
<p className="text-sm font-medium text-white">Defense optional</p>
<p className="text-xs text-gray-400">Proceed without waiting for defense</p>
</div>
</button>
<Box w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={!stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
{!stewarding.requireDefense && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Box>
<Box>
<Text size="sm" weight="medium" color="text-white" block>Defense optional</Text>
<Text size="xs" color="text-gray-400" block>Proceed without waiting for defense</Text>
</Box>
</Box>
<button
<Box
as="button"
type="button"
disabled={readOnly}
onClick={() => updateStewarding({ requireDefense: true })}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all text-left
${stewarding.requireDefense
? 'border-primary-blue bg-primary-blue/5'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
${readOnly ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
`}
display="flex"
alignItems="center"
gap={3}
p={4}
rounded="xl"
border
borderWidth="2px"
transition
textAlign="left"
borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={stewarding.requireDefense ? 'bg-primary-blue/5' : 'bg-iron-gray/30'}
hoverBorderColor={!readOnly && !stewarding.requireDefense ? 'border-gray-500' : undefined}
opacity={readOnly ? 0.6 : 1}
cursor={readOnly ? 'not-allowed' : 'pointer'}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'
}`}>
{stewarding.requireDefense && <div className="w-2 h-2 rounded-full bg-primary-blue" />}
</div>
<div>
<p className="text-sm font-medium text-white">Defense required</p>
<p className="text-xs text-gray-400">Wait for defense before deciding</p>
</div>
</button>
</div>
<Box w="4" h="4" rounded="full" border borderWidth="2px" display="flex" alignItems="center" justifyContent="center" borderColor={stewarding.requireDefense ? 'border-primary-blue' : 'border-gray-500'}>
{stewarding.requireDefense && <Box w="2" h="2" rounded="full" bg="bg-primary-blue" />}
</Box>
<Box>
<Text size="sm" weight="medium" color="text-white" block>Defense required</Text>
<Text size="xs" color="text-gray-400" block>Wait for defense before deciding</Text>
</Box>
</Box>
</Box>
{stewarding.requireDefense && (
<div className="mt-4 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box mt={4} p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Defense time limit
</label>
<select
value={stewarding.defenseTimeLimit}
</Text>
<Select
value={stewarding.defenseTimeLimit?.toString()}
onChange={(e) => updateStewarding({ defenseTimeLimit: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={24}>24 hours</option>
<option value={48}>48 hours (2 days)</option>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
options={[
{ value: '24', label: '24 hours' },
{ value: '48', label: '48 hours (2 days)' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
After this time, the decision can proceed without a defense
</p>
</div>
</Text>
</Box>
)}
</div>
</Box>
{/* Deadlines */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Clock className="w-4 h-4 text-primary-blue" />
Deadlines
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="text-primary-blue" />
Deadlines
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Set time limits for filing protests and closing stewarding
</p>
</Text>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
<Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Protest filing deadline (after race)
</label>
<select
value={stewarding.protestDeadlineHours}
</Text>
<Select
value={stewarding.protestDeadlineHours?.toString()}
onChange={(e) => updateStewarding({ protestDeadlineHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={12}>12 hours</option>
<option value={24}>24 hours (1 day)</option>
<option value={48}>48 hours (2 days)</option>
<option value={72}>72 hours (3 days)</option>
<option value={168}>168 hours (7 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
options={[
{ value: '12', label: '12 hours' },
{ value: '24', label: '24 hours (1 day)' },
{ value: '48', label: '48 hours (2 days)' },
{ value: '72', label: '72 hours (3 days)' },
{ value: '168', label: '168 hours (7 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
Drivers cannot file protests after this time
</p>
</div>
</Text>
</Box>
<div className="p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline">
<label className="block text-xs font-medium text-gray-400 mb-1.5">
<Box p={4} rounded="xl" bg="bg-iron-gray/40" border borderColor="border-charcoal-outline">
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1.5}>
Stewarding closes (after race)
</label>
<select
value={stewarding.stewardingClosesHours}
</Text>
<Select
value={stewarding.stewardingClosesHours?.toString()}
onChange={(e) => updateStewarding({ stewardingClosesHours: parseInt(e.target.value, 10) })}
disabled={readOnly}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:ring-1 focus:ring-primary-blue focus:border-primary-blue"
>
<option value={72}>72 hours (3 days)</option>
<option value={96}>96 hours (4 days)</option>
<option value={168}>168 hours (7 days)</option>
<option value={336}>336 hours (14 days)</option>
</select>
<p className="text-xs text-gray-500 mt-2">
options={[
{ value: '72', label: '72 hours (3 days)' },
{ value: '96', label: '96 hours (4 days)' },
{ value: '168', label: '168 hours (7 days)' },
{ value: '336', label: '336 hours (14 days)' },
]}
/>
<Text size="xs" color="text-gray-500" mt={2} block>
All stewarding must be concluded by this time
</p>
</div>
</div>
</div>
</Text>
</Box>
</Box>
</Box>
{/* Notifications */}
<div>
<h3 className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
<Bell className="w-4 h-4 text-primary-blue" />
Notifications
</h3>
<p className="text-xs text-gray-400 mb-4">
<Box>
<Heading level={3} fontSize="sm" weight="semibold" color="text-white" mb={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Bell} size={4} color="text-primary-blue" />
Notifications
</Stack>
</Heading>
<Text size="xs" color="text-gray-400" mb={4} block>
Configure automatic notifications for involved parties
</p>
</Text>
<div className="space-y-3">
<label
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
readOnly ? 'opacity-60 cursor-not-allowed' : ''
}`}
<Stack gap={3}>
<Box
p={4}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
>
<input
type="checkbox"
<Checkbox
label="Notify accused driver"
checked={stewarding.notifyAccusedOnProtest}
onChange={(e) => updateStewarding({ notifyAccusedOnProtest: e.target.checked })}
onChange={(checked) => updateStewarding({ notifyAccusedOnProtest: checked })}
disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/>
<div>
<p className="text-sm font-medium text-white">Notify accused driver</p>
<p className="text-xs text-gray-400">
<Box ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification when a protest is filed against them
</p>
</div>
</label>
</Text>
</Box>
</Box>
<label
className={`flex items-center gap-3 p-4 rounded-xl bg-iron-gray/40 border border-charcoal-outline cursor-pointer hover:bg-iron-gray/60 transition-colors ${
readOnly ? 'opacity-60 cursor-not-allowed' : ''
}`}
<Box
p={4}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
hoverBg={!readOnly ? 'bg-iron-gray/60' : undefined}
opacity={readOnly ? 0.6 : 1}
>
<input
type="checkbox"
<Checkbox
label="Notify voters"
checked={stewarding.notifyOnVoteRequired}
onChange={(e) => updateStewarding({ notifyOnVoteRequired: e.target.checked })}
onChange={(checked) => updateStewarding({ notifyOnVoteRequired: checked })}
disabled={readOnly}
className="w-4 h-4 rounded border-charcoal-outline bg-deep-graphite text-primary-blue focus:ring-primary-blue focus:ring-offset-0"
/>
<div>
<p className="text-sm font-medium text-white">Notify voters</p>
<p className="text-xs text-gray-400">
<Box ml={7} mt={1}>
<Text size="xs" color="text-gray-400" block>
Send notification to stewards/members when their vote is needed
</p>
</div>
</label>
</div>
</div>
</Text>
</Box>
</Box>
</Stack>
</Box>
{/* Warning about strict settings */}
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-warning-amber">Strict settings enabled</p>
<p className="text-xs text-warning-amber/80 mt-1">
<Box display="flex" alignItems="start" gap={3} p={4} rounded="xl" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<Icon icon={AlertTriangle} size={5} color="text-warning-amber" mt={0.5} />
<Box>
<Text size="sm" weight="medium" color="text-warning-amber" block>Strict settings enabled</Text>
<Text size="xs" color="text-warning-amber" opacity={0.8} mt={1} block>
Requiring defense and voting may delay penalty decisions. Make sure your stewards/members
are active enough to meet the deadlines.
</p>
</div>
</div>
</Text>
</Box>
</Box>
)}
</div>
</Stack>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
interface Tab {
label: string;
@@ -17,8 +18,8 @@ interface LeagueTabsProps {
export function LeagueTabs({ tabs }: LeagueTabsProps) {
return (
<Box style={{ borderBottom: '1px solid #262626' }}>
<Stack direction="row" gap={6} style={{ overflowX: 'auto' }}>
<Box borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" gap={6} overflow="auto">
{tabs.map((tab) => (
<Link
key={tab.href}
@@ -26,7 +27,12 @@ export function LeagueTabs({ tabs }: LeagueTabsProps) {
variant="ghost"
>
<Box pb={3} px={1}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap' }}>{tab.label}</span>
<Text weight="medium"
// eslint-disable-next-line gridpilot-rules/component-classification
className="whitespace-nowrap"
>
{tab.label}
</Text>
</Box>
</Link>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@ import { useState, useRef, useEffect } from 'react';
import type * as React from 'react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
// Minimum drivers for ranked leagues
const MIN_RANKED_DRIVERS = 10;
@@ -82,28 +87,55 @@ function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutP
if (!isOpen || !mounted) return null;
return createPortal(
<div
<Box
ref={flyoutRef}
className="fixed z-50 w-[340px] max-h-[80vh] overflow-y-auto bg-iron-gray border border-charcoal-outline rounded-xl shadow-2xl animate-fade-in"
style={{ top: position.top, left: position.left }}
position="fixed"
zIndex={50}
w="340px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 sticky top-0 bg-iron-gray z-10">
<div className="flex items-center gap-2">
<HelpCircle className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-semibold text-white">{title}</span>
</div>
<button
<Box
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Box
as="button"
type="button"
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-charcoal-outline transition-colors"
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="p-4">
<Icon icon={X} size={4} color="text-gray-400" />
</Box>
</Box>
<Box p={4}>
{children}
</div>
</div>,
</Box>
</Box>,
document.body
);
}
@@ -155,96 +187,139 @@ export function LeagueVisibilitySection({
};
return (
<div className="space-y-8">
<Stack gap={8}>
{/* Emotional header for the step */}
<div className="text-center pb-2">
<h3 className="text-lg font-semibold text-white mb-2">
Choose your league's destiny
</h3>
<p className="text-sm text-gray-400 max-w-lg mx-auto">
<Box textAlign="center" pb={2}>
<Heading level={3} mb={2}>
Choose your league&apos;s destiny
</Heading>
<Text size="sm" color="text-gray-400" maxWidth="lg" mx="auto" block>
Will you compete for glory on the global leaderboards, or race with friends in a private series?
</p>
</div>
</Text>
</Box>
{/* League Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{/* Ranked (Public) Option */}
<div className="relative">
<button
<Box position="relative">
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('public')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
isRanked
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
display="flex"
flexDirection="col"
gap={4}
p={6}
textAlign="left"
rounded="xl"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isRanked ? 'bg-primary-blue/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={isRanked ? '0_0_30px_rgba(25,140,255,0.25)' : undefined}
hoverBorderColor={!isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={!isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'
}`}>
<Trophy className={`w-7 h-7 ${isRanked ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-xl font-bold ${isRanked ? 'text-white' : 'text-gray-300'}`}>
<Box display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
h="14"
w="14"
alignItems="center"
justifyContent="center"
rounded="xl"
bg={isRanked ? 'bg-primary-blue/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Trophy} size={7} color={isRanked ? 'text-primary-blue' : 'text-gray-400'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={isRanked ? 'text-white' : 'text-gray-300'} block>
Ranked
</div>
<div className={`text-sm ${isRanked ? 'text-primary-blue' : 'text-gray-500'}`}>
</Text>
<Text size="sm" color={isRanked ? 'text-primary-blue' : 'text-gray-500'} block>
Compete for glory
</div>
</div>
</div>
</Text>
</Box>
</Stack>
{/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
isRanked ? 'border-primary-blue bg-primary-blue' : 'border-gray-500'
}`}>
{isRanked && <Check className="w-4 h-4 text-white" />}
</div>
</div>
<Box
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isRanked ? 'border-primary-blue' : 'border-gray-500'}
bg={isRanked ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isRanked && <Icon icon={Check} size={4} color="text-white" />}
</Box>
</Box>
{/* Emotional tagline */}
<p className={`text-sm ${isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
<Text size="sm" color={isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Your results matter. Build your reputation in the global standings and climb the ranks.
</p>
</Text>
{/* Features */}
<div className="space-y-2.5 py-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Discoverable by all drivers</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Affects driver ratings & rankings</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-performance-green" />
<span>Featured on leaderboards</span>
</div>
</div>
<Stack gap={2.5} py={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Discoverable by all drivers</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Affects driver ratings & rankings</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color="text-performance-green" />
<Text size="sm" color="text-gray-400">Featured on leaderboards</Text>
</Stack>
</Stack>
{/* Requirement badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-warning-amber/10 border border-warning-amber/20 w-fit">
<Users className="w-4 h-4 text-warning-amber" />
<span className="text-xs text-warning-amber font-medium">
<Box display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20" w="fit">
<Icon icon={Users} size={4} color="text-warning-amber" />
<Text size="xs" color="text-warning-amber" weight="medium">
Requires {MIN_RANKED_DRIVERS}+ drivers for competitive integrity
</span>
</div>
</button>
</Text>
</Box>
</Box>
{/* Info button */}
<button
<Box
as="button"
ref={rankedInfoRef}
type="button"
onClick={() => setShowRankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-primary-blue hover:bg-primary-blue/10 transition-colors"
position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<Icon icon={HelpCircle} size={4} />
</Box>
</Box>
{/* Ranked Info Flyout */}
<InfoFlyout
@@ -253,119 +328,176 @@ export function LeagueVisibilitySection({
title="Ranked Leagues"
anchorRef={rankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Ranked leagues are competitive series where results matter. Your performance
affects your driver rating and contributes to global leaderboards.
</p>
</Text>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Requirements</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Users className="w-3.5 h-3.5 text-warning-amber shrink-0 mt-0.5" />
<span><strong className="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</strong> for competitive integrity</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Anyone can discover and join your league</span>
</li>
</ul>
</div>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Requirements
</Text>
<Stack gap={1.5}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Users} size={3.5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-white">Minimum {MIN_RANKED_DRIVERS} drivers</Text> for competitive integrity
</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Anyone can discover and join your league</Text>
</Box>
</Stack>
</Stack>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Benefits</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Trophy className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<span>Results affect driver ratings and rankings</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<span>Featured in league discovery</span>
</li>
</ul>
</div>
</div>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Benefits
</Text>
<Stack gap={1.5}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Trophy} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Results affect driver ratings and rankings</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Featured in league discovery</Text>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
{/* Unranked (Private) Option */}
<div className="relative">
<button
<Box position="relative">
<Box
as="button"
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('private')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
!isRanked
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500 hover:bg-iron-gray/50'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
display="flex"
flexDirection="col"
gap={4}
p={6}
textAlign="left"
rounded="xl"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-charcoal-outline'}
bg={!isRanked ? 'bg-neon-aqua/15' : 'bg-iron-gray/30'}
w="full"
position="relative"
transition
shadow={!isRanked ? '0_0_30px_rgba(67,201,230,0.2)' : undefined}
hoverBorderColor={isRanked && !disabled ? 'border-gray-500' : undefined}
hoverBg={isRanked && !disabled ? 'bg-iron-gray/50' : undefined}
opacity={disabled ? 0.6 : 1}
cursor={disabled ? 'not-allowed' : 'pointer'}
group
>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-14 w-14 items-center justify-center rounded-xl ${
!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'
}`}>
<Users className={`w-7 h-7 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
</div>
<div>
<div className={`text-xl font-bold ${!isRanked ? 'text-white' : 'text-gray-300'}`}>
<Box display="flex" alignItems="start" justifyContent="between">
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
h="14"
w="14"
alignItems="center"
justifyContent="center"
rounded="xl"
bg={!isRanked ? 'bg-neon-aqua/30' : 'bg-charcoal-outline/50'}
>
<Icon icon={Users} size={7} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
</Box>
<Box>
<Text weight="bold" size="xl" color={!isRanked ? 'text-white' : 'text-gray-300'} block>
Unranked
</div>
<div className={`text-sm ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`}>
</Text>
<Text size="sm" color={!isRanked ? 'text-neon-aqua' : 'text-gray-500'} block>
Race with friends
</div>
</div>
</div>
</Text>
</Box>
</Stack>
{/* Radio indicator */}
<div className={`flex h-7 w-7 items-center justify-center rounded-full border-2 shrink-0 transition-colors ${
!isRanked ? 'border-neon-aqua bg-neon-aqua' : 'border-gray-500'
}`}>
{!isRanked && <Check className="w-4 h-4 text-deep-graphite" />}
</div>
</div>
<Box
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={!isRanked ? 'border-neon-aqua' : 'border-gray-500'}
bg={!isRanked ? 'bg-neon-aqua' : ''}
flexShrink={0}
transition
>
{!isRanked && <Icon icon={Check} size={4} color="text-deep-graphite" />}
</Box>
</Box>
{/* Emotional tagline */}
<p className={`text-sm ${!isRanked ? 'text-gray-300' : 'text-gray-500'}`}>
<Text size="sm" color={!isRanked ? 'text-gray-300' : 'text-gray-500'} block>
Pure racing fun. No pressure, no rankings just you and your crew hitting the track.
</p>
</Text>
{/* Features */}
<div className="space-y-2.5 py-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Private, invite-only access</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Zero impact on your rating</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-500'}`} />
<span>Perfect for practice & fun</span>
</div>
</div>
<Stack gap={2.5} py={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Private, invite-only access</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Zero impact on your rating</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Check} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="sm" color="text-gray-400">Perfect for practice & fun</Text>
</Stack>
</Stack>
{/* Flexibility badge */}
<div className="flex items-center gap-2 mt-auto px-3 py-2 rounded-lg bg-neon-aqua/10 border border-neon-aqua/20 w-fit">
<Users className={`w-4 h-4 ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`} />
<span className={`text-xs font-medium ${!isRanked ? 'text-neon-aqua' : 'text-gray-400'}`}>
<Box display="flex" alignItems="center" gap={2} mt="auto" px={3} py={2} rounded="lg" bg="bg-neon-aqua/10" border borderColor="border-neon-aqua/20" w="fit">
<Icon icon={Users} size={4} color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} />
<Text size="xs" color={!isRanked ? 'text-neon-aqua' : 'text-gray-400'} weight="medium">
Any size even 2 friends
</span>
</div>
</button>
</Text>
</Box>
</Box>
{/* Info button */}
<button
<Box
as="button"
ref={unrankedInfoRef}
type="button"
onClick={() => setShowUnrankedFlyout(true)}
className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full text-gray-500 hover:text-neon-aqua hover:bg-neon-aqua/10 transition-colors"
position="absolute"
top="3"
right="3"
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-neon-aqua"
hoverBg="bg-neon-aqua/10"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<Icon icon={HelpCircle} size={4} />
</Box>
</Box>
{/* Unranked Info Flyout */}
<InfoFlyout
@@ -374,88 +506,103 @@ export function LeagueVisibilitySection({
title="Unranked Leagues"
anchorRef={unrankedInfoRef}
>
<div className="space-y-4">
<p className="text-xs text-gray-400">
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>
Unranked leagues are casual, private series for racing with friends.
Results don't affect driver ratings, so you can practice and have fun
Results don&apos;t affect driver ratings, so you can practice and have fun
without pressure.
</p>
</Text>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Perfect For</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Private racing with friends</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Practice and training sessions</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Small groups (2+ drivers)</span>
</li>
</ul>
</div>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Perfect For
</Text>
<Stack gap={1.5}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Private racing with friends</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Practice and training sessions</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Small groups (2+ drivers)</Text>
</Box>
</Stack>
</Stack>
<div className="space-y-2">
<div className="text-[10px] text-gray-500 uppercase tracking-wide">Features</div>
<ul className="space-y-1.5">
<li className="flex items-start gap-2 text-xs text-gray-400">
<Users className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Invite-only membership</span>
</li>
<li className="flex items-start gap-2 text-xs text-gray-400">
<Check className="w-3.5 h-3.5 text-neon-aqua shrink-0 mt-0.5" />
<span>Full stats and standings (internal only)</span>
</li>
</ul>
</div>
</div>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
>
Features
</Text>
<Stack gap={1.5}>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Users} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Invite-only membership</Text>
</Box>
<Box display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3.5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">Full stats and standings (internal only)</Text>
</Box>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
</div>
</Box>
{errors?.visibility && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-warning-amber/10 border border-warning-amber/20">
<HelpCircle className="w-4 h-4 text-warning-amber shrink-0" />
<p className="text-xs text-warning-amber">{errors.visibility}</p>
</div>
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/20">
<Icon icon={HelpCircle} size={4} color="text-warning-amber" flexShrink={0} />
<Text size="xs" color="text-warning-amber">{errors.visibility}</Text>
</Box>
)}
{/* Contextual info based on selection */}
<div className={`rounded-xl p-5 border transition-all duration-300 ${
isRanked
? 'bg-primary-blue/5 border-primary-blue/20'
: 'bg-neon-aqua/5 border-neon-aqua/20'
}`}>
<div className="flex items-start gap-3">
<Box
rounded="xl"
p={5}
border
transition
bg={isRanked ? 'bg-primary-blue/5' : 'bg-neon-aqua/5'}
borderColor={isRanked ? 'border-primary-blue/20' : 'border-neon-aqua/20'}
>
<Box display="flex" alignItems="start" gap={3}>
{isRanked ? (
<>
<Trophy className="w-5 h-5 text-primary-blue shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-white mb-1">Ready to compete</p>
<p className="text-xs text-gray-400">
<Icon icon={Trophy} size={5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Box>
<Text size="sm" weight="medium" color="text-white" block mb={1}>Ready to compete</Text>
<Text size="xs" color="text-gray-400" block>
Your league will be visible to all GridPilot drivers. Results will affect driver ratings
and contribute to the global leaderboards. Make sure you have at least {MIN_RANKED_DRIVERS} drivers
to ensure competitive integrity.
</p>
</div>
</Text>
</Box>
</>
) : (
<>
<Users className="w-5 h-5 text-neon-aqua shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-white mb-1">Private racing awaits</p>
<p className="text-xs text-gray-400">
<Icon icon={Users} size={5} color="text-neon-aqua" flexShrink={0} mt={0.5} />
<Box>
<Text size="sm" weight="medium" color="text-white" block mb={1}>Private racing awaits</Text>
<Text size="xs" color="text-gray-400" block>
Your league will be invite-only. Perfect for racing with friends, practice sessions,
or any time you want to have fun without affecting your official ratings.
</p>
</div>
</Text>
</Box>
</>
)}
</div>
</div>
</div>
</Box>
</Box>
</Stack>
);
}
}

View File

@@ -1,80 +1,63 @@
'use client';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { Badge } from '@/ui/Badge';
interface MembershipStatusProps {
leagueId: string;
className?: string;
}
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
export function MembershipStatus({ leagueId }: MembershipStatusProps) {
const currentDriverId = useEffectiveDriverId();
if (!currentDriverId) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
<Badge variant="default">
Not a Member
</span>
</Badge>
);
}
const membership = getMembership(leagueId, currentDriverId);
const membership = currentDriverId ? getMembership(leagueId, currentDriverId) : null;
if (!membership) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
<Badge variant="default">
Not a Member
</span>
</Badge>
);
}
const getRoleDisplay = (role: MembershipRole): { text: string; bgColor: string; textColor: string; borderColor: string } => {
const getRoleVariant = (role: MembershipRole): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
switch (role) {
case 'owner':
return {
text: 'Owner',
bgColor: 'bg-yellow-500/10',
textColor: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
};
return 'warning';
case 'admin':
return {
text: 'Admin',
bgColor: 'bg-purple-500/10',
textColor: 'text-purple-400',
borderColor: 'border-purple-500/30',
};
return 'primary';
case 'steward':
return {
text: 'Steward',
bgColor: 'bg-blue-500/10',
textColor: 'text-blue-400',
borderColor: 'border-blue-500/30',
};
return 'info';
case 'member':
return {
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
return 'primary';
default:
return {
text: 'Member',
bgColor: 'bg-primary-blue/10',
textColor: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
};
return 'default';
}
};
const { text, bgColor, textColor, borderColor } = getRoleDisplay(membership.role);
const getRoleText = (role: MembershipRole): string => {
switch (role) {
case 'owner': return 'Owner';
case 'admin': return 'Admin';
case 'steward': return 'Steward';
case 'member': return 'Member';
default: return 'Member';
}
};
return (
<span className={`px-3 py-1 text-xs font-medium ${bgColor} ${textColor} rounded border ${borderColor} ${className}`}>
{text}
</span>
<Badge variant={getRoleVariant(membership.role)}>
{getRoleText(membership.role)}
</Badge>
);
}

View File

@@ -1,23 +0,0 @@
'use client';
import { Plus } from 'lucide-react';
import Button from '../ui/Button';
interface PenaltyFABProps {
onClick: () => void;
}
export default function PenaltyFAB({ onClick }: PenaltyFABProps) {
return (
<div className="fixed bottom-6 right-6 z-50">
<Button
variant="primary"
className="w-14 h-14 rounded-full shadow-lg"
onClick={onClick}
title="Add Penalty"
>
<Plus className="w-6 h-6" />
</Button>
</div>
);
}

View File

@@ -5,8 +5,12 @@ import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
import { Box } from "@/ui/Box";
import { Stack } from "@/ui/Stack";
import { Text } from "@/ui/Text";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { AlertCircle, Flag } from "lucide-react";
interface PenaltyHistoryListProps {
protests: ProtestViewModel[];
@@ -20,7 +24,6 @@ export function PenaltyHistoryList({
drivers,
}: PenaltyHistoryListProps) {
const [filteredProtests, setFilteredProtests] = useState<ProtestViewModel[]>([]);
const [filterType, setFilterType] = useState<"all">("all");
useEffect(() => {
setFilteredProtests(protests);
@@ -29,86 +32,108 @@ export function PenaltyHistoryList({
const getStatusColor = (status: string) => {
switch (status) {
case "upheld":
return "text-red-400 bg-red-500/20";
return { text: "text-red-400", bg: "bg-red-500/20" };
case "dismissed":
return "text-gray-400 bg-gray-500/20";
return { text: "text-gray-400", bg: "bg-gray-500/20" };
case "withdrawn":
return "text-blue-400 bg-blue-500/20";
return { text: "text-blue-400", bg: "bg-blue-500/20" };
default:
return "text-orange-400 bg-orange-500/20";
return { text: "text-orange-400", bg: "bg-orange-500/20" };
}
};
return (
<div className="space-y-4">
<Stack gap={4}>
{filteredProtests.length === 0 ? (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4 text-gray-400">
<AlertCircle className="h-12 w-12 opacity-50" />
<div>
<p className="font-medium text-lg">No Resolved Protests</p>
<p className="text-sm mt-1">
<Card py={12} textAlign="center">
<Stack alignItems="center" gap={4}>
<Icon icon={AlertCircle} size={12} color="text-gray-400" opacity={0.5} />
<Box>
<Text weight="medium" size="lg" color="text-gray-400" block>No Resolved Protests</Text>
<Text size="sm" color="text-gray-500" mt={1} block>
No protests have been resolved in this league
</p>
</div>
</div>
</Text>
</Box>
</Stack>
</Card>
) : (
<div className="space-y-3">
<Stack gap={3}>
{filteredProtests.map((protest) => {
const race = races[protest.raceId];
const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId];
const incident = protest.incident;
const resolvedDate = protest.reviewedAt || protest.filedAt;
const statusColors = getStatusColor(protest.status);
return (
<Card key={protest.id} className="p-4">
<div className="flex items-start gap-4">
<div className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${getStatusColor(protest.status)}`}>
<Flag className="h-5 w-5" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
{resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColor(protest.status)}`}>
{protest.status.toUpperCase()}
</span>
</div>
<div className="space-y-1 text-sm">
<p className="text-gray-400">
<span className="font-medium">{protester?.name || 'Unknown'}</span> vs <span className="font-medium">{accused?.name || 'Unknown'}</span>
</p>
{race && incident && (
<p className="text-gray-500">
{race.track} ({race.car}) - Lap {incident.lap}
</p>
<Card key={protest.id} p={4}>
<Box display="flex" alignItems="start" gap={4}>
<Box
w="10"
h="10"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={0}
bg={statusColors.bg}
color={statusColors.text}
>
<Icon icon={Flag} size={5} />
</Box>
<Box flex={1}>
<Stack gap={2}>
<Box display="flex" alignItems="start" justifyContent="between" gap={4}>
<Box>
<Heading level={3}>
Protest #{protest.id.substring(0, 8)}
</Heading>
<Text size="sm" color="text-gray-400" block>
{resolvedDate ? `Resolved ${new Date(resolvedDate).toLocaleDateString()}` : 'Resolved'}
</Text>
</Box>
<Box
px={3}
py={1}
rounded="full"
bg={statusColors.bg}
color={statusColors.text}
fontSize="12px"
weight="medium"
flexShrink={0}
>
{protest.status.toUpperCase()}
</Box>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block>
<Text weight="medium" color="text-white">{protester?.name || 'Unknown'}</Text> vs <Text weight="medium" color="text-white">{accused?.name || 'Unknown'}</Text>
</Text>
{race && incident && (
<Text size="sm" color="text-gray-500" block>
{race.track} ({race.car}) - Lap {incident.lap}
</Text>
)}
</Box>
{incident && (
<Text size="sm" color="text-gray-300" block>{incident.description}</Text>
)}
</div>
{incident && (
<p className="text-gray-300 text-sm">{incident.description}</p>
)}
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/30 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward Notes:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
</div>
{protest.decisionNotes && (
<Box mt={2} p={2} rounded="md" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-400" block>
<Text weight="medium">Steward Notes:</Text> {protest.decisionNotes}
</Text>
</Box>
)}
</Stack>
</Box>
</Box>
</Card>
);
})}
</div>
</Stack>
)}
</div>
</Stack>
);
}

View File

@@ -1,115 +0,0 @@
"use client";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Button } from "@/ui/Button";
import Link from "next/link";
import { AlertCircle, Video, ChevronRight, Flag, Clock, AlertTriangle } from "lucide-react";
interface PendingProtestsListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;
drivers: Record<string, DriverViewModel>;
leagueId: string;
onReviewProtest: (protest: ProtestViewModel) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
races,
drivers,
leagueId,
onReviewProtest,
onProtestReviewed,
}: PendingProtestsListProps) {
if (protests.length === 0) {
return (
<Card className="p-12 text-center">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="h-8 w-8 text-performance-green" />
</div>
<div>
<p className="font-semibold text-lg text-white mb-2">All Clear! 🏁</p>
<p className="text-sm text-gray-400">No pending protests to review</p>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{protests.map((protest) => {
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt || protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2;
return (
<Card
key={protest.id}
className={`p-6 hover:border-warning-amber/40 transition-all ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="h-10 w-10 rounded-full bg-warning-amber/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-5 w-5 text-warning-amber" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">
Protest #{protest.id.substring(0, 8)}
</h3>
<p className="text-sm text-gray-400">
Filed {new Date(protest.filedAt || protest.submittedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full flex items-center gap-1">
<Clock className="h-3 w-3" />
Pending
</span>
{isUrgent && (
<span className="px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Flag className="h-4 w-4 text-gray-400" />
<span className="text-gray-400">Lap {protest.incident?.lap || 'N/A'}</span>
</div>
<p className="text-gray-300 line-clamp-2 leading-relaxed">
{protest.incident?.description || protest.description}
</p>
{protest.proofVideoUrl && (
<div className="inline-flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-blue/10 text-primary-blue rounded-lg border border-primary-blue/20">
<Video className="h-4 w-4" />
<span>Video evidence attached</span>
</div>
)}
</div>
</div>
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button
variant="secondary"
className="flex-shrink-0"
>
<ChevronRight className="h-5 w-5" />
</Button>
</Link>
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -1,55 +0,0 @@
import React from 'react';
import Card from '@/ui/Card';
interface PointsTableProps {
title?: string;
points: { position: number; points: number }[];
}
export default function PointsTable({ title = 'Points Distribution', points }: PointsTableProps) {
return (
<Card>
<h2 className="text-lg font-semibold text-white mb-4">{title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 font-medium text-gray-400">Position</th>
<th className="text-right py-3 px-4 font-medium text-gray-400">Points</th>
</tr>
</thead>
<tbody>
{points.map(({ position, points: pts }) => (
<tr
key={position}
className={`border-b border-charcoal-outline/50 transition-colors hover:bg-iron-gray/30 ${
position <= 3 ? 'bg-iron-gray/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
position === 1 ? 'bg-yellow-500 text-black' :
position === 2 ? 'bg-gray-400 text-black' :
position === 3 ? 'bg-amber-600 text-white' :
'bg-charcoal-outline text-white'
}`}>
{position}
</div>
<span className="text-white font-medium">
{position === 1 ? '1st' : position === 2 ? '2nd' : position === 3 ? '3rd' : `${position}th`}
</span>
</div>
</td>
<td className="py-3 px-4 text-right">
<span className="text-white font-semibold tabular-nums">{pts}</span>
<span className="text-gray-500 ml-1">pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -1,10 +1,16 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/ui/Button';
import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation";
import { useState } from 'react';
import { Button } from '@/ui/Button';
import { usePenaltyMutation } from "@/hooks/league/usePenaltyMutation";
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
import { TextArea } from '@/ui/TextArea';
interface DriverOption {
id: string;
@@ -15,6 +21,7 @@ interface QuickPenaltyModalProps {
raceId?: string;
drivers: DriverOption[];
onClose: () => void;
onRefresh?: () => void;
preSelectedDriver?: DriverOption;
adminId: string;
races?: { id: string; track: string; scheduledAt: Date }[];
@@ -35,14 +42,13 @@ const SEVERITY_LEVELS = [
{ value: 'severe', label: 'Severe', description: 'Heavy penalty' },
] as const;
export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
export function QuickPenaltyModal({ raceId, drivers, onClose, onRefresh, preSelectedDriver, adminId, races }: QuickPenaltyModalProps) {
const [selectedRaceId, setSelectedRaceId] = useState<string>(raceId || '');
const [selectedDriver, setSelectedDriver] = useState<string>(preSelectedDriver?.id || '');
const [infractionType, setInfractionType] = useState<string>('');
const [severity, setSeverity] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const penaltyMutation = usePenaltyMutation();
const handleSubmit = async (e: React.FormEvent) => {
@@ -64,7 +70,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
await penaltyMutation.mutateAsync(command);
// Refresh the page to show updated results
router.refresh();
onRefresh?.();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
@@ -72,135 +78,148 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div className="w-full max-w-md bg-iron-gray rounded-xl border border-charcoal-outline shadow-2xl">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">Quick Penalty</h2>
<Box position="fixed" inset="0" zIndex={50} display="flex" alignItems="center" justifyContent="center" p={4} bg="bg-black/70"
// eslint-disable-next-line gridpilot-rules/component-classification
className="backdrop-blur-sm"
>
<Box w="full" maxWidth="md" bg="bg-iron-gray" rounded="xl" border borderColor="border-charcoal-outline" shadow="2xl">
<Box p={6}>
<Heading level={2} fontSize="xl" weight="bold" color="text-white" mb={4}>Quick Penalty</Heading>
<form onSubmit={handleSubmit} className="space-y-4">
<Box as="form" onSubmit={handleSubmit} display="flex" flexDirection="col" gap={4}>
{/* Race Selection */}
{races && !raceId && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Race
</label>
<select
</Text>
<Select
value={selectedRaceId}
onChange={(e) => setSelectedRaceId(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
required
>
<option value="">Select race...</option>
{races.map((race) => (
<option key={race.id} value={race.id}>
{race.track} ({race.scheduledAt.toLocaleDateString()})
</option>
))}
</select>
</div>
options={[
{ value: '', label: 'Select race...' },
...races.map((race) => ({
value: race.id,
label: `${race.track} (${race.scheduledAt.toLocaleDateString()})`,
})),
]}
/>
</Box>
)}
{/* Driver Selection */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Driver
</label>
</Text>
{preSelectedDriver ? (
<div className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white">
<Box w="full" px={3} py={2} bg="bg-deep-graphite" border borderColor="border-charcoal-outline" rounded="lg" color="text-white">
{preSelectedDriver.name}
</div>
</Box>
) : (
<select
<Select
value={selectedDriver}
onChange={(e) => setSelectedDriver(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:border-primary-blue focus:outline-none"
required
>
<option value="">Select driver...</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</select>
options={[
{ value: '', label: 'Select driver...' },
...drivers.map((driver) => ({
value: driver.id,
label: driver.name,
})),
]}
/>
)}
</div>
</Box>
{/* Infraction Type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Infraction Type
</label>
<div className="grid grid-cols-2 gap-2">
{INFRACTION_TYPES.map(({ value, label, icon: Icon }) => (
<button
</Text>
<Box display="grid" gridCols={2} gap={2}>
{INFRACTION_TYPES.map(({ value, label, icon: InfractionIcon }) => (
<Box
key={value}
as="button"
type="button"
onClick={() => setInfractionType(value)}
className={`flex items-center gap-2 p-3 rounded-lg border transition-colors ${
infractionType === value
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
}`}
display="flex"
alignItems="center"
gap={2}
p={3}
rounded="lg"
border
transition
borderColor={infractionType === value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={infractionType === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
color={infractionType === value ? 'text-primary-blue' : 'text-gray-300'}
hoverBorderColor={infractionType !== value ? 'border-gray-500' : undefined}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{label}</span>
</button>
<Icon icon={InfractionIcon} size={4} />
<Text size="sm">{label}</Text>
</Box>
))}
</div>
</div>
</Box>
</Box>
{/* Severity */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Severity
</label>
<div className="space-y-2">
</Text>
<Stack gap={2}>
{SEVERITY_LEVELS.map(({ value, label, description }) => (
<button
<Box
key={value}
as="button"
type="button"
onClick={() => setSeverity(value)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
severity === value
? 'border-primary-blue bg-primary-blue/10 text-primary-blue'
: 'border-charcoal-outline bg-deep-graphite text-gray-300 hover:border-gray-500'
}`}
w="full"
textAlign="left"
p={3}
rounded="lg"
border
transition
borderColor={severity === value ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={severity === value ? 'bg-primary-blue/10' : 'bg-deep-graphite'}
color={severity === value ? 'text-primary-blue' : 'text-gray-300'}
hoverBorderColor={severity !== value ? 'border-gray-500' : undefined}
>
<div className="font-medium">{label}</div>
<div className="text-xs opacity-75">{description}</div>
</button>
<Text weight="medium" block>{label}</Text>
<Text size="xs" opacity={0.75} block>{description}</Text>
</Box>
))}
</div>
</div>
</Stack>
</Box>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Notes (Optional)
</label>
<textarea
</Text>
<TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)}
placeholder="Additional details..."
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none resize-none"
rows={3}
/>
</div>
</Box>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
<Box p={3} bg="bg-red-500/10" border borderColor="border-red-500/20" rounded="lg">
<Text size="sm" color="text-red-400" block>{error}</Text>
</Box>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<Box display="flex" gap={3} pt={4}>
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
fullWidth
disabled={penaltyMutation.isPending}
>
Cancel
@@ -208,15 +227,15 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
<Button
type="submit"
variant="primary"
className="flex-1"
fullWidth
disabled={penaltyMutation.isPending || !selectedRaceId || !selectedDriver || !infractionType || !severity}
>
{penaltyMutation.isPending ? 'Applying...' : 'Apply Penalty'}
</Button>
</div>
</form>
</div>
</div>
</div>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}
}

View File

@@ -2,6 +2,9 @@
import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { InfoItem } from '@/ui/InfoItem';
interface ReadonlyLeagueInfoProps {
league: {
@@ -64,27 +67,19 @@ export function ReadonlyLeagueInfo({ league, configForm }: ReadonlyLeagueInfoPro
];
return (
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/40 to-iron-gray/20 p-5">
<h3 className="text-sm font-semibold text-gray-400 mb-4">League Information</h3>
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={5}>
<Heading level={5} color="text-gray-400" mb={4}>League Information</Heading>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{infoItems.map((item, index) => {
const Icon = item.icon;
return (
<div key={index} className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-iron-gray/60 shrink-0">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[10px] text-gray-500 mb-0.5">{item.label}</div>
<div className="text-xs font-medium text-gray-300 truncate">
{item.value}
</div>
</div>
</div>
);
})}
</div>
</div>
<Box display="grid" responsiveGridCols={{ base: 2, sm: 3 }} gap={4}>
{infoItems.map((item, index) => (
<InfoItem
key={index}
icon={item.icon}
label={item.label}
value={item.value}
/>
))}
</Box>
</Box>
);
}
}

View File

@@ -1,12 +1,19 @@
"use client";
import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/lib/hooks/usePenaltyTypesReference";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Card from "../ui/Card";
import { Modal } from "@/ui/Modal";
import { Button } from "@/ui/Button";
import { Card } from "@/ui/Card";
import { Box } from "@/ui/Box";
import { Text } from "@/ui/Text";
import { Stack } from "@/ui/Stack";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { TextArea } from "@/ui/TextArea";
import { Input } from "@/ui/Input";
import {
AlertCircle,
Video,
@@ -15,12 +22,12 @@ import {
TrendingDown,
CheckCircle,
XCircle,
FileText,
AlertTriangle,
ShieldAlert,
Ban,
DollarSign,
FileWarning,
type LucideIcon,
} from "lucide-react";
type PenaltyType = string;
@@ -50,6 +57,27 @@ export function ReviewProtestModal({
const [submitting, setSubmitting] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return refs.map((ref: any) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyOptions.find((p: any) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (!protest) return null;
const handleSubmit = async () => {
@@ -178,63 +206,46 @@ export function ReviewProtestModal({
}
};
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (showConfirmation) {
return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
<div className="p-6 space-y-6">
<div className="text-center space-y-4">
{decision === "accept" ? (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<AlertCircle className="h-8 w-8 text-orange-400" />
</div>
</div>
) : (
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-gray-500/20 flex items-center justify-center">
<XCircle className="h-8 w-8 text-gray-400" />
</div>
</div>
)}
<div>
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2">
{decision === "accept"
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</p>
</div>
</div>
<Stack gap={6} p={6}>
<Box textAlign="center">
<Stack gap={4}>
{decision === "accept" ? (
<Box display="flex" justifyContent="center">
<Box h="16" w="16" rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center">
<Icon icon={AlertCircle} size={8} color="text-orange-400" />
</Box>
</Box>
) : (
<Box display="flex" justifyContent="center">
<Box h="16" w="16" rounded="full" bg="bg-gray-500/20" display="flex" alignItems="center" justifyContent="center">
<Icon icon={XCircle} size={8} color="text-gray-400" />
</Box>
</Box>
)}
<Box>
<Heading level={3} fontSize="xl" weight="bold" color="text-white">Confirm Decision</Heading>
<Text color="text-gray-400" mt={2} block>
{decision === "accept"
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</Text>
</Box>
</Stack>
</Box>
<Card className="p-4 bg-gray-800/50">
<p className="text-sm text-gray-300">{stewardNotes}</p>
<Card p={4} bg="bg-gray-800/50">
<Text size="sm" color="text-gray-300" block>{stewardNotes}</Text>
</Card>
<div className="flex gap-3">
<Box display="flex" gap={3}>
<Button
variant="secondary"
className="flex-1"
fullWidth
onClick={() => setShowConfirmation(false)}
disabled={submitting}
>
@@ -242,189 +253,207 @@ export function ReviewProtestModal({
</Button>
<Button
variant="primary"
className="flex-1"
fullWidth
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Submitting..." : "Confirm Decision"}
</Button>
</div>
</div>
</Box>
</Stack>
</Modal>
);
}
return (
<Modal title="Review Protest" isOpen={true} onOpenChange={onClose}>
<div className="p-6 space-y-6">
<div className="flex items-start gap-4">
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="h-6 w-6 text-orange-400" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">Review Protest</h2>
<p className="text-gray-400 mt-1">
<Stack gap={6} p={6}>
<Box display="flex" alignItems="start" gap={4}>
<Box h="12" w="12" rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<Icon icon={AlertCircle} size={6} color="text-orange-400" />
</Box>
<Box flexGrow={1}>
<Heading level={2} fontSize="2xl" weight="bold" color="text-white">Review Protest</Heading>
<Text color="text-gray-400" mt={1} block>
Protest #{protest.id.substring(0, 8)}
</p>
</div>
</div>
</Text>
</Box>
</Box>
<div className="space-y-4">
<Card className="p-4 bg-gray-800/50">
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Filed Date</span>
<span className="text-white font-medium">
<Stack gap={4}>
<Card p={4} bg="bg-gray-800/50">
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Filed Date</Text>
<Text size="sm" color="text-white" weight="medium">
{new Date(protest.filedAt || protest.submittedAt).toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Incident Lap</span>
<span className="text-white font-medium">
</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Incident Lap</Text>
<Text size="sm" color="text-white" weight="medium">
Lap {protest.incident?.lap || 'N/A'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">Status</span>
<span className="px-2 py-1 rounded text-xs font-medium bg-orange-500/20 text-orange-400">
{protest.status}
</span>
</div>
</div>
</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">Status</Text>
<Box as="span" px={2} py={1} rounded="sm" bg="bg-orange-500/20">
<Text size="xs" weight="medium" color="text-orange-400">
{protest.status}
</Text>
</Box>
</Box>
</Stack>
</Card>
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Description
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.incident?.description || protest.description}</p>
</Text>
<Card p={4} bg="bg-gray-800/50">
<Text color="text-gray-300" block>{protest.incident?.description || protest.description}</Text>
</Card>
</div>
</Box>
{protest.comment && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Additional Comment
</label>
<Card className="p-4 bg-gray-800/50">
<p className="text-gray-300">{protest.comment}</p>
</Text>
<Card p={4} bg="bg-gray-800/50">
<Text color="text-gray-300" block>{protest.comment}</Text>
</Card>
</div>
</Box>
)}
{protest.proofVideoUrl && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Evidence
</label>
<Card className="p-4 bg-gray-800/50">
<a
</Text>
<Card p={4} bg="bg-gray-800/50">
<Box
as="a"
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-orange-400 hover:text-orange-300 transition-colors"
display="flex"
alignItems="center"
gap={2}
color="text-orange-400"
hoverTextColor="text-orange-300"
transition
>
<Video className="h-4 w-4" />
<span className="text-sm">View video evidence</span>
</a>
<Icon icon={Video} size={4} />
<Text size="sm">View video evidence</Text>
</Box>
</Card>
</div>
</Box>
)}
</div>
</Stack>
<div className="border-t border-gray-800 pt-6 space-y-4">
<h3 className="text-lg font-semibold text-white">Stewarding Decision</h3>
<Box borderTop borderColor="border-gray-800" pt={6}>
<Stack gap={4}>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Stewarding Decision</Heading>
<div className="grid grid-cols-2 gap-3">
<Button
variant={decision === "accept" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("accept")}
>
<CheckCircle className="h-4 w-4" />
Accept Protest
</Button>
<Button
variant={decision === "reject" ? "primary" : "secondary"}
className="flex items-center justify-center gap-2"
onClick={() => setDecision("reject")}
>
<XCircle className="h-4 w-4" />
Reject Protest
</Button>
</div>
<Box display="grid" gridCols={2} gap={3}>
<Button
variant={decision === "accept" ? "primary" : "secondary"}
fullWidth
onClick={() => setDecision("accept")}
>
<Stack direction="row" align="center" gap={2} center>
<Icon icon={CheckCircle} size={4} />
Accept Protest
</Stack>
</Button>
<Button
variant={decision === "reject" ? "primary" : "secondary"}
fullWidth
onClick={() => setDecision("reject")}
>
<Stack direction="row" align="center" gap={2} center>
<Icon icon={XCircle} size={4} />
Reject Protest
</Stack>
</Button>
</Box>
{decision === "accept" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type
</label>
{decision === "accept" && (
<Stack gap={4}>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Penalty Type
</Text>
{penaltyTypesLoading ? (
<div className="text-sm text-gray-500">Loading penalty types</div>
) : (
<div className="grid grid-cols-3 gap-2">
{penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? "" : "text-gray-400"}`} />
<p className={`text-xs font-medium ${isSelected ? "" : "text-gray-400"}`}>{name}</p>
</button>
);
})}
</div>
{penaltyTypesLoading ? (
<Text size="sm" color="text-gray-500">Loading penalty types</Text>
) : (
<Box display="grid" gridCols={3} gap={2}>
{penaltyOptions.map(({ type, name, Icon: PenaltyIcon, colorClass, defaultValue }: { type: string; name: string; Icon: LucideIcon; colorClass: string; defaultValue: number }) => {
const isSelected = penaltyType === type;
return (
<Box
key={type}
as="button"
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
p={3}
rounded="lg"
border
borderWidth={isSelected ? "2px" : "1px"}
transition
borderColor={isSelected ? undefined : "border-charcoal-outline"}
bg={isSelected ? undefined : "bg-iron-gray/50"}
hoverBorderColor={!isSelected ? "border-gray-600" : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isSelected ? colorClass : ""}
>
<Icon icon={PenaltyIcon} size={5} mx="auto" mb={1} color={isSelected ? undefined : "text-gray-400"} />
<Text size="xs" weight="medium" color={isSelected ? undefined : "text-gray-400"} block textAlign="center">{name}</Text>
</Box>
);
})}
</Box>
)}
</Box>
{selectedPenalty?.requiresValue && (
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Penalty Value ({selectedPenalty.valueLabel})
</Text>
<Input
type="number"
value={penaltyValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
min={1}
/>
</Box>
)}
</div>
</Stack>
)}
{selectedPenalty?.requiresValue && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-orange-500"
/>
</div>
)}
</div>
)}
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-400" block mb={2}>
Steward Notes *
</Text>
<TextArea
value={stewardNotes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
placeholder="Explain your decision and reasoning..."
rows={4}
/>
</Box>
</Stack>
</Box>
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Steward Notes *
</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision and reasoning..."
rows={4}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 resize-none"
/>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-gray-800">
<Box display="flex" gap={3} pt={4} borderTop borderColor="border-gray-800">
<Button
variant="secondary"
className="flex-1"
fullWidth
onClick={onClose}
disabled={submitting}
>
@@ -432,14 +461,14 @@ export function ReviewProtestModal({
</Button>
<Button
variant="primary"
className="flex-1"
fullWidth
onClick={() => setShowConfirmation(true)}
disabled={!decision || !stewardNotes.trim() || submitting}
>
Submit Decision
</Button>
</div>
</div>
</Box>
</Stack>
</Modal>
);
}

View File

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

View File

@@ -10,7 +10,6 @@ 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;
@@ -32,8 +31,8 @@ export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) {
<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>
<Box w="3" h="3" rounded="full" bg={race.isPast ? 'bg-performance-green' : 'bg-primary-blue'} />
<Heading level={3} fontSize="lg">{race.name}</Heading>
<Badge variant={race.status === 'completed' ? 'success' : 'primary'}>
{race.status === 'completed' ? 'Completed' : 'Scheduled'}
</Badge>

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