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

@@ -0,0 +1,48 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ReactNode } from 'react';
import { Box } from './Box';
import { Icon } from './Icon';
import { Stack } from './Stack';
import { Text } from './Text';
interface AccordionProps {
title: string;
icon: ReactNode;
children: ReactNode;
isOpen: boolean;
onToggle: () => void;
}
export function Accordion({ title, icon, children, isOpen, onToggle }: AccordionProps) {
return (
<Box border={true} borderColor="border-charcoal-outline" rounded="lg" overflow="hidden" bg="bg-iron-gray/30">
<Box
as="button"
onClick={onToggle}
display="flex"
alignItems="center"
justifyContent="between"
px={3}
py={2}
fullWidth
className="hover:bg-iron-gray/50 transition-colors"
>
<Stack direction="row" align="center" gap={2}>
{icon}
<Text size="xs" weight="semibold" color="text-gray-400" uppercase letterSpacing="wide">
{title}
</Text>
</Stack>
<Icon icon={isOpen ? ChevronDown : ChevronUp} size={4} color="text-gray-400" />
</Box>
{isOpen && (
<Box p={3} borderTop={true} borderColor="border-charcoal-outline">
{children}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
interface AchievementCardProps {
title: string;
description: string;
icon: string;
unlockedAt: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
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 function AchievementCard({
title,
description,
icon,
unlockedAt,
rarity,
}: AchievementCardProps) {
return (
<Box
p={4}
rounded="lg"
border
className={rarityColors[rarity]}
>
<Box display="flex" alignItems="start" gap={3}>
<Text size="3xl">{icon}</Text>
<Stack gap={1} flexGrow={1}>
<Text weight="medium" color="text-white">{title}</Text>
<Text size="xs" color="text-gray-400">{description}</Text>
<Text size="xs" color="text-gray-500">
{new Date(unlockedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,86 @@
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
interface Achievement {
id: string;
title: string;
description: string;
icon: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary' | string;
earnedAt: Date;
}
interface AchievementGridProps {
achievements: Achievement[];
}
function getAchievementIcon(icon: string) {
switch (icon) {
case 'trophy': return Trophy;
case 'medal': return Medal;
case 'star': return Star;
case 'crown': return Crown;
case 'target': return Target;
case 'zap': return Zap;
default: return Award;
}
}
export function AchievementGrid({ achievements }: AchievementGridProps) {
return (
<Card>
<Box mb={4}>
<Stack direction="row" align="center" justify="between">
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
Achievements
</Heading>
<Text size="sm" color="text-gray-500" weight="normal">{achievements.length} earned</Text>
</Stack>
</Box>
<Grid cols={1} gap={4}>
{achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon);
const rarity = AchievementDisplay.getRarityColor(achievement.rarity);
return (
<Surface
key={achievement.id}
variant={rarity.surface}
rounded="xl"
border
padding={4}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={3}>
<Icon icon={AchievementIcon} size={5} color={rarity.icon} />
</Surface>
<Box>
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text>
<Stack direction="row" align="center" gap={2} mt={2}>
<Text size="xs" color={rarity.text} weight="medium">
{achievement.rarity.toUpperCase()}
</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">
{AchievementDisplay.formatDate(achievement.earnedAt)}
</Text>
</Stack>
</Box>
</Stack>
</Surface>
);
})}
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,67 @@
import { Box } from './Box';
import { Image } from './Image';
import { Text } from './Text';
interface ActiveDriverCardProps {
name: string;
avatarUrl?: string;
categoryLabel?: string;
categoryColor?: string;
skillLevelLabel?: string;
skillLevelColor?: string;
onClick: () => void;
}
export function ActiveDriverCard({
name,
avatarUrl,
categoryLabel,
categoryColor,
skillLevelLabel,
skillLevelColor,
onClick,
}: ActiveDriverCardProps) {
return (
<Box
as="button"
type="button"
onClick={onClick}
p={3}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
transition
cursor="pointer"
hoverBorderColor="performance-green/40"
group
textAlign="center"
>
<Box position="relative" w="12" h="12" mx="auto" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" mb={2}>
<Image src={avatarUrl || '/default-avatar.png'} alt={name} objectFit="cover" fill />
<Box position="absolute" bottom="0" right="0" w="3" h="3" rounded="full" bg="bg-performance-green" border borderColor="border-iron-gray" style={{ borderWidth: '2px' }} />
</Box>
<Text
size="sm"
weight="medium"
color="text-white"
truncate
block
groupHoverTextColor="performance-green"
transition
>
{name}
</Text>
<Box display="flex" alignItems="center" justifyContent="center" gap={1}>
{categoryLabel && (
<Text size="xs" color={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" color={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Activity } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { ActivityItem } from '@/ui/ActivityItem';
import { Icon } from '@/ui/Icon';
import { ActivityFeedList } from '@/ui/ActivityFeedList';
import { MinimalEmptyState } from '@/ui/EmptyState';
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>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="var(--primary-blue)" />} mb={4}>
Recent Activity
</Heading>
{hasItems ? (
<ActivityFeedList>
{items.slice(0, 5).map((item) => (
<ActivityItem
key={item.id}
headline={item.headline}
body={item.body}
formattedTime={item.formattedTime}
ctaHref={item.ctaHref}
ctaLabel={item.ctaLabel}
/>
))}
</ActivityFeedList>
) : (
<MinimalEmptyState
icon={Activity}
title="No activity yet"
description="Join leagues and add friends to see activity here"
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Surface } from './Surface';
interface ActivityFeedItemProps {
icon: ReactNode;
content: ReactNode;
timestamp: string;
}
export function ActivityFeedItem({
icon,
content,
timestamp,
}: ActivityFeedItemProps) {
return (
<Box
display="flex"
alignItems="start"
gap={3}
py={3}
borderBottom
style={{ borderColor: 'rgba(38, 38, 38, 0.3)' }}
className="last:border-0"
>
<Surface
variant="muted"
w="8"
h="8"
rounded="full"
display="flex"
center
flexShrink={0}
>
{icon}
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" leading="relaxed" block>
{content}
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
{timestamp}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from './Stack';
interface ActivityFeedListProps {
children: ReactNode;
}
export function ActivityFeedList({ children }: ActivityFeedListProps) {
return (
<Stack gap={4}>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,65 @@
import { Box } from './Box';
import { Link } from './Link';
import { Text } from './Text';
interface ActivityItemProps {
headline: string;
body?: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
typeColor?: string;
}
export function ActivityItem({
headline,
body,
formattedTime,
ctaHref,
ctaLabel,
typeColor,
}: ActivityItemProps) {
return (
<Surface
variant="muted"
padding={3}
rounded="lg"
style={{ display: 'flex', alignItems: 'start', gap: '0.75rem' }}
>
{typeColor && (
<Box
style={{
width: '0.5rem',
height: '0.5rem',
borderRadius: '9999px',
marginTop: '0.5rem',
backgroundColor: typeColor,
flexShrink: 0,
}}
/>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text color="text-white" weight="medium" block>
{headline}
</Text>
{body && (
<Text size="sm" color="text-gray-400" block mt={1}>
{body}
</Text>
)}
<Text size="xs" color="text-gray-500" block mt={1}>
{formattedTime}
</Text>
</Box>
{ctaHref && ctaLabel && (
<Box>
<Link href={ctaHref} variant="primary">
<Text size="xs">{ctaLabel}</Text>
</Link>
</Box>
)}
</Surface>
);
}

View File

@@ -0,0 +1,129 @@
import { useParallax } from "@/hooks/useScrollProgress";
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
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 function AlternatingSection({
heading,
description,
mockup,
layout,
backgroundImage,
backgroundVideo
}: AlternatingSectionProps) {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.2);
return (
<Box
as="section"
ref={sectionRef}
position="relative"
overflow="hidden"
bg="bg-deep-graphite"
px={{ base: 'calc(1rem+var(--sal))', lg: 8 }}
py={{ base: 20, sm: 24, md: 32 }}
>
{backgroundVideo && (
<>
<Box
as="video"
autoPlay
loop
muted
playsInline
position="absolute"
inset="0"
fullWidth
fullHeight
objectFit="cover"
opacity={0.2}
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%)"
>
<Box as="source" src={backgroundVideo} type="video/mp4" />
</Box>
{/* Racing red accent for sections with background videos */}
<Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, rgba(239, 68, 68, 0.3), transparent)" />
</>
)}
{backgroundImage && !backgroundVideo && (
<>
<Box
position="absolute"
inset="0"
bg={`url(${backgroundImage})`}
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%)"
transform={`translateY(${bgParallax * 0.3}px)`}
/>
{/* Racing red accent for sections with background images */}
<Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, rgba(239, 68, 68, 0.3), transparent)" />
</>
)}
{/* Carbon fiber texture on sections without images or videos */}
{!backgroundImage && !backgroundVideo && (
<Box position="absolute" inset="0" opacity={0.3} bg="carbon-fiber" />
)}
{/* Checkered pattern accent */}
<Box position="absolute" inset="0" opacity={0.1} bg="checkered-pattern" />
<Container size="lg" position="relative" zIndex={10}>
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={{ base: 8, md: 12, lg: 16 }} alignItems="center">
{/* Text Content - Always first on mobile, respects layout on desktop */}
<Box
display="flex"
flexDirection="column"
gap={{ base: 4, md: 6, lg: 8 }}
order={{ lg: layout === 'text-right' ? 2 : 1 }}
>
<Heading level={2} fontSize={{ base: 'xl', md: '2xl', lg: '3xl', xl: '4xl' }} weight="medium" style={{ background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', filter: 'drop-shadow(0 0 15px rgba(220,0,0,0.4))', WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{heading}
</Heading>
<Box display="flex" flexDirection="column" gap={{ base: 3, md: 5 }}>
<Text size={{ base: 'sm', md: 'base', lg: 'lg' }} color="text-slate-400" weight="light" leading="relaxed">
{description}
</Text>
</Box>
</Box>
{/* Mockup - Always second on mobile, respects layout on desktop */}
<Box
position="relative"
order={{ lg: layout === 'text-right' ? 1 : 2 }}
group
>
<Box
fullWidth
minHeight={{ base: '240px', md: '380px', lg: '440px' }}
transition
hoverScale
maskImage={`linear-gradient(to ${layout === 'text-left' ? 'right' : 'left'}, white 50%, rgba(255,255,255,0.8) 70%, rgba(255,255,255,0.4) 85%, transparent 100%)`}
webkitMaskImage={`linear-gradient(to ${layout === 'text-left' ? 'right' : 'left'}, white 50%, rgba(255,255,255,0.8) 70%, rgba(255,255,255,0.4) 85%, transparent 100%)`}
>
{mockup}
</Box>
</Box>
</Box>
</Container>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
'use client';
import React from 'react';
import { Box } from './Box';

View File

@@ -1,6 +1,5 @@
'use client';
import React from 'react';
import { ErrorBanner } from './ErrorBanner';
interface AuthErrorProps {

View File

@@ -1,10 +1,9 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { LoadingSpinner } from './LoadingSpinner';
import { Stack } from './Stack';
import { Text } from './Text';
import { LoadingSpinner } from './LoadingSpinner';
interface AuthLoadingProps {
message?: string;

View File

@@ -0,0 +1,160 @@
import { CheckCircle2, Clock, Star } from 'lucide-react';
import { Badge } from './Badge';
import { Box } from './Box';
import { Button } from './Button';
import { Card } from './Card';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Link } from './Link';
import { Stack } from './Stack';
interface AvailableLeague {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: { available: boolean; price: number };
secondarySlots: { available: number; total: number; price: number };
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
formattedAvgViews: string;
formattedCpm: string;
}
interface AvailableLeagueCardProps {
league: AvailableLeague;
}
export function AvailableLeagueCard({ league }: AvailableLeagueCardProps) {
const tierConfig = {
premium: { icon: '⭐', label: 'Premium' },
standard: { icon: '🏆', label: 'Standard' },
starter: { icon: '🚀', label: 'Starter' },
};
const statusConfig = {
active: { color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Active Season' },
upcoming: { color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Starting Soon' },
completed: { color: 'text-gray-400', bgColor: 'bg-gray-400/10', label: 'Season Ended' },
};
const config = tierConfig[league.tier];
const status = statusConfig[league.seasonStatus];
return (
<Card>
<Stack gap={4}>
{/* Header */}
<Stack direction="row" align="start" justify="between">
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={2} mb={1} wrap>
<Badge variant="primary">{config.icon} {config.label}</Badge>
<Box px={2} py={0.5} rounded="full" className={status.bgColor}>
<Text size="xs" weight="medium" className={status.color}>{status.label}</Text>
</Box>
</Stack>
<Heading level={3}>{league.name}</Heading>
<Text size="sm" color="text-gray-500" block mt={1}>{league.game}</Text>
</Box>
<Box px={2} py={1} rounded="lg" bg="bg-iron-gray/50">
<Stack direction="row" align="center" gap={1}>
<Icon icon={Star} size={3.5} color="text-yellow-400" />
<Text size="sm" weight="medium" color="text-white">{league.rating}</Text>
</Stack>
</Box>
</Stack>
{/* Description */}
<Text size="sm" color="text-gray-400" block truncate>{league.description}</Text>
{/* Stats Grid */}
<Box display="grid" gridCols={3} gap={2}>
<StatItem label="Drivers" value={league.drivers} />
<StatItem label="Avg Views" value={league.formattedAvgViews} />
<StatItem label="CPM" value={league.formattedCpm} color="text-performance-green" />
</Box>
{/* Next Race */}
{league.nextRace && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={Clock} size={4} color="text-gray-400" />
<Text size="sm" color="text-gray-400">Next:</Text>
<Text size="sm" color="text-white">{league.nextRace}</Text>
</Stack>
)}
{/* Sponsorship Slots */}
<Stack gap={2}>
<SlotRow
label="Main Sponsor"
available={league.mainSponsorSlot.available}
price={`$${league.mainSponsorSlot.price}/season`}
/>
<SlotRow
label="Secondary Slots"
available={league.secondarySlots.available > 0}
price={`${league.secondarySlots.available}/${league.secondarySlots.total} @ $${league.secondarySlots.price}`}
/>
</Stack>
{/* Actions */}
<Stack direction="row" gap={2}>
<Box flexGrow={1}>
<Link href={`/sponsor/leagues/${league.id}`} block>
<Button variant="secondary" fullWidth size="sm">
View Details
</Button>
</Link>
</Box>
{(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && (
<Box flexGrow={1}>
<Link href={`/sponsor/leagues/${league.id}?action=sponsor`} block>
<Button variant="primary" fullWidth size="sm">
Sponsor
</Button>
</Link>
</Box>
)}
</Stack>
</Stack>
</Card>
);
}
function StatItem({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) {
return (
<Box p={2} bg="bg-iron-gray/50" rounded="lg" textAlign="center">
<Text weight="bold" className={color}>{value}</Text>
<Text size="xs" color="text-gray-500" block mt={1}>{label}</Text>
</Box>
);
}
function SlotRow({ label, available, price }: { label: string, available: boolean, price: string }) {
return (
<Box p={2} rounded="lg" bg="bg-iron-gray/30">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Box width="2.5" height="2.5" rounded="full" bg={available ? 'bg-performance-green' : 'bg-error-red'} />
<Text size="sm" color="text-gray-300">{label}</Text>
</Stack>
<Box>
{available ? (
<Text size="sm" weight="semibold" color="text-white">{price}</Text>
) : (
<Stack direction="row" align="center" gap={1}>
<Icon icon={CheckCircle2} size={3} color="text-gray-500" />
<Text size="sm" color="text-gray-500">Filled</Text>
</Stack>
)}
</Box>
</Stack>
</Box>
);
}

View File

@@ -1,15 +1,29 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface BadgeProps {
children: ReactNode;
className?: string;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'xs' | 'sm' | 'md';
icon?: LucideIcon;
style?: React.CSSProperties;
bg?: string;
color?: string;
borderColor?: string;
}
export function Badge({ children, className = '', variant = 'default' }: BadgeProps) {
const baseClasses = 'flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium';
export function Badge({ children, className = '', variant = 'default', size = 'sm', icon, style, bg, color, borderColor }: BadgeProps) {
const baseClasses = 'flex items-center gap-1.5 rounded-full border font-medium';
const sizeClasses = {
xs: 'px-1.5 py-0.5 text-[10px]',
sm: 'px-2.5 py-1 text-xs',
md: 'px-3 py-1.5 text-sm'
};
const variantClasses = {
default: 'bg-gray-500/10 border-gray-500/30 text-gray-400',
primary: 'bg-primary-blue/10 border-primary-blue/30 text-primary-blue',
@@ -19,7 +33,20 @@ export function Badge({ children, className = '', variant = 'default' }: BadgePr
info: 'bg-neon-aqua/10 border-neon-aqua/30 text-neon-aqua'
};
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
const classes = [
baseClasses,
sizeClasses[size],
!bg && !color && !borderColor ? variantClasses[variant] : '',
bg,
color,
borderColor,
className
].filter(Boolean).join(' ');
return <Box className={classes}>{children}</Box>;
return (
<Box className={classes} style={style}>
{icon && <Icon icon={icon} size={3} />}
{children}
</Box>
);
}

View File

@@ -0,0 +1,109 @@
import { motion, useReducedMotion } from 'framer-motion';
import { LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Surface } from './Surface';
import { Text } from './Text';
interface BenefitCardProps {
icon: LucideIcon;
title: string;
description: string;
stats?: {
value: string;
label: string;
};
variant?: 'default' | 'highlight';
delay?: number;
}
export function BenefitCard({
icon,
title,
description,
stats,
variant = 'default',
delay = 0,
}: BenefitCardProps) {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const isHighlight = variant === 'highlight';
const cardContent = (
<Surface
variant="muted"
rounded="xl"
border={true}
padding={6}
className={`relative h-full transition-all duration-300 group ${isHighlight ? 'border-primary-blue/30' : 'border-charcoal-outline hover:border-charcoal-outline/80'}`}
style={isHighlight ? { background: 'linear-gradient(to bottom right, rgba(25, 140, 255, 0.1), rgba(25, 140, 255, 0.05))' } : {}}
>
{/* Icon */}
<Box
width="12"
height="12"
rounded="xl"
display="flex"
center
mb={4}
bg={isHighlight ? 'bg-primary-blue/20' : 'bg-iron-gray'}
border={!isHighlight}
borderColor="border-charcoal-outline"
>
<Icon icon={icon} size={6} className={isHighlight ? 'text-primary-blue' : 'text-gray-400'} />
</Box>
{/* Content */}
<Heading level={3} mb={2}>{title}</Heading>
<Text size="sm" color="text-gray-400" block style={{ lineHeight: 1.625 }}>{description}</Text>
{/* Stats */}
{stats && (
<Box mt={4} pt={4} borderTop={true} borderColor="border-charcoal-outline/50">
<Box display="flex" alignItems="baseline" gap={2}>
<Text size="2xl" weight="bold" color={isHighlight ? 'text-primary-blue' : 'text-white'}>
{stats.value}
</Text>
<Text size="sm" color="text-gray-500">{stats.label}</Text>
</Box>
</Box>
)}
{/* Highlight Glow Effect */}
{isHighlight && (
<Box
position="absolute"
inset="0"
rounded="xl"
className="bg-gradient-to-br from-primary-blue/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
/>
)}
</Surface>
);
if (!isMounted || shouldReduceMotion) {
return <Box fullHeight>{cardContent}</Box>;
}
return (
<Box
as={motion.div}
fullHeight
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
{cardContent}
</Box>
);
}

View File

@@ -0,0 +1,64 @@
import { Badge } from './Badge';
import { Box } from './Box';
import { Text } from './Text';
interface Tab {
id: string;
label: string;
count?: number;
countVariant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
}
interface BorderTabsProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function BorderTabs({ tabs, activeTab, onTabChange }: BorderTabsProps) {
return (
<Box borderBottom borderColor="border-charcoal-outline">
<Box display="flex" gap={4}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Box
key={tab.id}
as="button"
type="button"
onClick={() => onTabChange(tab.id)}
pb={3}
px={1}
cursor="pointer"
transition
borderBottom={isActive}
borderColor={isActive ? 'border-primary-blue' : ''}
style={{
borderBottomWidth: isActive ? '2px' : '0',
marginBottom: '-1px'
}}
>
<Box display="flex" alignItems="center" gap={2}>
<Text
size="sm"
weight="medium"
color={isActive ? 'text-primary-blue' : 'text-gray-400'}
className={!isActive ? 'hover:text-white' : ''}
>
{tab.label}
</Text>
{tab.count !== undefined && tab.count > 0 && (
<Badge variant={tab.countVariant || 'warning'}>
{tab.count}
</Badge>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -18,8 +18,8 @@ export interface BoxProps<T extends ElementType> {
m?: Spacing | ResponsiveSpacing;
mt?: Spacing | ResponsiveSpacing;
mb?: Spacing | ResponsiveSpacing;
ml?: Spacing | ResponsiveSpacing;
mr?: Spacing | ResponsiveSpacing;
ml?: Spacing | 'auto' | ResponsiveSpacing;
mr?: Spacing | 'auto' | ResponsiveSpacing;
mx?: Spacing | 'auto' | ResponsiveSpacing;
my?: Spacing | ResponsiveSpacing;
p?: Spacing | ResponsiveSpacing;
@@ -29,10 +29,10 @@ export interface BoxProps<T extends ElementType> {
pr?: Spacing | ResponsiveSpacing;
px?: Spacing | ResponsiveSpacing;
py?: Spacing | ResponsiveSpacing;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
flexDirection?: 'row' | 'row-reverse' | 'col' | 'col-reverse';
alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
justifyContent?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | ResponsiveValue<'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none'>;
flexDirection?: 'row' | 'row-reverse' | 'col' | 'col-reverse' | ResponsiveValue<'row' | 'row-reverse' | 'col' | 'col-reverse'>;
alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' | ResponsiveValue<'start' | 'center' | 'end' | 'stretch' | 'baseline'>;
justifyContent?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' | ResponsiveValue<'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'>;
flexWrap?: 'wrap' | 'nowrap' | 'wrap-reverse';
flexShrink?: number;
flexGrow?: number;
@@ -42,7 +42,13 @@ export interface BoxProps<T extends ElementType> {
md?: number | string;
lg?: number | string;
};
gap?: Spacing;
gap?: Spacing | ResponsiveSpacing;
colSpan?: number | string;
responsiveColSpan?: {
base?: number | string;
md?: number | string;
lg?: number | string;
};
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
top?: Spacing | string;
bottom?: Spacing | string;
@@ -50,6 +56,9 @@ export interface BoxProps<T extends ElementType> {
right?: Spacing | string;
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto';
maxWidth?: string;
minWidth?: string;
maxHeight?: string;
minHeight?: string;
zIndex?: number;
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
@@ -57,17 +66,59 @@ export interface BoxProps<T extends ElementType> {
height?: string;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
borderTop?: boolean;
borderBottom?: boolean;
borderLeft?: boolean;
borderRight?: boolean;
borderColor?: string;
bg?: string;
color?: string;
shadow?: string;
hoverBorderColor?: string;
transition?: boolean;
cursor?: 'pointer' | 'default' | 'wait' | 'text' | 'move' | 'not-allowed';
lineClamp?: 1 | 2 | 3 | 4 | 5 | 6;
inset?: string;
bgOpacity?: number;
opacity?: number;
blur?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
animate?: 'pulse' | 'bounce' | 'spin' | 'none';
translateX?: string;
textAlign?: 'left' | 'center' | 'right' | 'justify';
hoverScale?: boolean;
group?: boolean;
groupHoverBorderColor?: string;
groupHoverTextColor?: string;
fontSize?: string;
transform?: string;
borderWidth?: string;
hoverTextColor?: string;
hoverBg?: string;
borderStyle?: 'solid' | 'dashed' | 'dotted' | 'none';
ring?: string;
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
aspectRatio?: string;
visibility?: 'visible' | 'hidden' | 'collapse';
pointerEvents?: 'auto' | 'none';
onMouseEnter?: React.MouseEventHandler<T>;
onMouseLeave?: React.MouseEventHandler<T>;
onClick?: React.MouseEventHandler<T>;
onSubmit?: React.FormEventHandler<T>;
style?: React.CSSProperties;
hoverColor?: string;
maskImage?: string;
webkitMaskImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
}
type ResponsiveValue<T> = {
base?: T;
sm?: T;
md?: T;
lg?: T;
xl?: T;
'2xl'?: T;
};
export const Box = forwardRef(<T extends ElementType = 'div'>(
@@ -81,6 +132,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
m, mt, mb, ml, mr, mx, my,
p, pt, pb, pl, pr, px, py,
display,
flexDirection,
alignItems,
justifyContent,
position,
top,
bottom,
@@ -88,21 +142,64 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
right,
overflow,
maxWidth,
minWidth,
maxHeight,
minHeight,
zIndex,
gridCols,
responsiveGridCols,
colSpan,
responsiveColSpan,
gap,
w,
h,
rounded,
border,
borderTop,
borderBottom,
borderLeft,
borderRight,
borderColor,
bg,
color,
shadow,
flexShrink,
flexGrow,
hoverBorderColor,
transition,
cursor,
lineClamp,
inset,
bgOpacity,
opacity,
blur,
animate,
translateX,
textAlign,
hoverScale,
group,
groupHoverBorderColor,
groupHoverTextColor,
fontSize,
transform,
borderWidth,
hoverTextColor,
hoverBg,
borderStyle,
ring,
objectFit,
aspectRatio,
visibility,
pointerEvents,
onMouseEnter,
onMouseLeave,
onClick,
onSubmit,
hoverColor,
maskImage,
webkitMaskImage,
backgroundSize,
backgroundPosition,
...props
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<HTMLElement>
@@ -133,12 +230,62 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(`${prefix}-${value.base}`);
if (value.md !== undefined) classes.push(`md:${prefix}-${value.md}`);
if (value.lg !== undefined) classes.push(`lg:${prefix}-${value.lg}`);
if (value.base !== undefined) classes.push(prefix ? `${prefix}-${value.base}` : value.base);
if (value.sm !== undefined) classes.push(prefix ? `sm:${prefix}-${value.sm}` : `sm:${value.sm}`);
if (value.md !== undefined) classes.push(prefix ? `md:${prefix}-${value.md}` : `md:${value.md}`);
if (value.lg !== undefined) classes.push(prefix ? `lg:${prefix}-${value.lg}` : `lg:${value.lg}`);
if (value.xl !== undefined) classes.push(prefix ? `xl:${prefix}-${value.xl}` : `xl:${value.xl}`);
if (value['2xl'] !== undefined) classes.push(prefix ? `2xl:${prefix}-${value['2xl']}` : `2xl:${value['2xl']}`);
return classes.join(' ');
}
return `${prefix}-${value}`;
return prefix ? `${prefix}-${value}` : value;
};
const getFlexDirectionClass = (value: BoxProps<ElementType>['flexDirection']) => {
if (!value) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base) classes.push(`flex-${value.base}`);
if (value.sm) classes.push(`sm:flex-${value.sm}`);
if (value.md) classes.push(`md:flex-${value.md}`);
if (value.lg) classes.push(`lg:flex-${value.lg}`);
if (value.xl) classes.push(`xl:flex-${value.xl}`);
if (value['2xl']) classes.push(`2xl:flex-${value['2xl']}`);
return classes.join(' ');
}
return `flex-${value}`;
};
const getAlignItemsClass = (value: BoxProps<ElementType>['alignItems']) => {
if (!value) return '';
const map: Record<string, string> = { start: 'items-start', center: 'items-center', end: 'items-end', stretch: 'items-stretch', baseline: 'items-baseline' };
if (typeof value === 'object') {
const classes = [];
if (value.base) classes.push(map[value.base]);
if (value.sm) classes.push(`sm:${map[value.sm]}`);
if (value.md) classes.push(`md:${map[value.md]}`);
if (value.lg) classes.push(`lg:${map[value.lg]}`);
if (value.xl) classes.push(`xl:${map[value.xl]}`);
if (value['2xl']) classes.push(`2xl:${map[value['2xl']]}`);
return classes.join(' ');
}
return map[value];
};
const getJustifyContentClass = (value: BoxProps<ElementType>['justifyContent']) => {
if (!value) return '';
const map: Record<string, string> = { start: 'justify-start', center: 'justify-center', end: 'justify-end', between: 'justify-between', around: 'justify-around', evenly: 'justify-evenly' };
if (typeof value === 'object') {
const classes = [];
if (value.base) classes.push(map[value.base]);
if (value.sm) classes.push(`sm:${map[value.sm]}`);
if (value.md) classes.push(`md:${map[value.md]}`);
if (value.lg) classes.push(`lg:${map[value.lg]}`);
if (value.xl) classes.push(`xl:${map[value.xl]}`);
if (value['2xl']) classes.push(`2xl:${map[value['2xl']]}`);
return classes.join(' ');
}
return map[value];
};
const classes = [
@@ -163,40 +310,92 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
getResponsiveClasses('h', h),
rounded ? `rounded-${rounded}` : '',
border ? 'border' : '',
borderStyle ? `border-${borderStyle}` : '',
borderTop ? 'border-t' : '',
borderBottom ? 'border-b' : '',
borderLeft ? 'border-l' : '',
borderRight ? 'border-r' : '',
borderColor ? borderColor : '',
ring ? ring : '',
bg ? bg : '',
color ? color : '',
hoverColor ? `hover:${hoverColor}` : '',
shadow ? shadow : '',
flexShrink !== undefined ? `flex-shrink-${flexShrink}` : '',
flexGrow !== undefined ? `flex-grow-${flexGrow}` : '',
hoverBorderColor ? `hover:${hoverBorderColor}` : '',
hoverTextColor ? `hover:${hoverTextColor}` : '',
hoverBg ? `hover:${hoverBg}` : '',
transition ? 'transition-all' : '',
display ? display : '',
lineClamp ? `line-clamp-${lineClamp}` : '',
inset ? `inset-${inset}` : '',
bgOpacity !== undefined ? `bg-opacity-${bgOpacity * 100}` : '',
opacity !== undefined ? `opacity-${opacity * 100}` : '',
blur ? `blur-${blur}` : '',
animate ? `animate-${animate}` : '',
translateX ? `translate-x-${translateX}` : '',
hoverScale ? 'hover:scale-[1.02]' : '',
group ? 'group' : '',
groupHoverBorderColor ? `group-hover:border-${groupHoverBorderColor}` : '',
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
getResponsiveClasses('', display),
getFlexDirectionClass(flexDirection),
getAlignItemsClass(alignItems),
getJustifyContentClass(justifyContent),
gridCols ? `grid-cols-${gridCols}` : '',
responsiveGridCols?.base ? `grid-cols-${responsiveGridCols.base}` : '',
responsiveGridCols?.md ? `md:grid-cols-${responsiveGridCols.md}` : '',
responsiveGridCols?.lg ? `lg:grid-cols-${responsiveGridCols.lg}` : '',
gap !== undefined ? `gap-${spacingMap[gap]}` : '',
colSpan ? `col-span-${colSpan}` : '',
responsiveColSpan?.base ? `col-span-${responsiveColSpan.base}` : '',
responsiveColSpan?.md ? `md:col-span-${responsiveColSpan.md}` : '',
responsiveColSpan?.lg ? `lg:col-span-${responsiveColSpan.lg}` : '',
getSpacingClass('gap', gap),
position ? position : '',
top !== undefined && spacingMap[top as any] ? `top-${spacingMap[top as any]}` : '',
bottom !== undefined && spacingMap[bottom as any] ? `bottom-${spacingMap[bottom as any]}` : '',
left !== undefined && spacingMap[left as any] ? `left-${spacingMap[left as any]}` : '',
right !== undefined && spacingMap[right as any] ? `right-${spacingMap[right as any]}` : '',
top !== undefined && spacingMap[top as string | number] ? `top-${spacingMap[top as string | number]}` : '',
bottom !== undefined && spacingMap[bottom as string | number] ? `bottom-${spacingMap[bottom as string | number]}` : '',
left !== undefined && spacingMap[left as string | number] ? `left-${spacingMap[left as string | number]}` : '',
right !== undefined && spacingMap[right as string | number] ? `right-${spacingMap[right as string | number]}` : '',
overflow ? `overflow-${overflow}` : '',
visibility ? visibility : '',
zIndex !== undefined ? `z-${zIndex}` : '',
cursor ? `cursor-${cursor}` : '',
pointerEvents ? `pointer-events-${pointerEvents}` : '',
className
].filter(Boolean).join(' ');
const style: React.CSSProperties = {
...(maxWidth ? { maxWidth } : {}),
...(top !== undefined && !spacingMap[top as any] ? { top } : {}),
...(bottom !== undefined && !spacingMap[bottom as any] ? { bottom } : {}),
...(left !== undefined && !spacingMap[left as any] ? { left } : {}),
...(right !== undefined && !spacingMap[right as any] ? { right } : {}),
...(minWidth ? { minWidth } : {}),
...(maxHeight ? { maxHeight } : {}),
...(minHeight ? { minHeight } : {}),
...(fontSize ? { fontSize } : {}),
...(transform ? { transform } : {}),
...(borderWidth ? { borderWidth } : {}),
...(objectFit ? { objectFit } : {}),
...(aspectRatio ? { aspectRatio } : {}),
...(maskImage ? { maskImage } : {}),
...(webkitMaskImage ? { WebkitMaskImage: webkitMaskImage } : {}),
...(backgroundSize ? { backgroundSize } : {}),
...(backgroundPosition ? { backgroundPosition } : {}),
...(top !== undefined && !spacingMap[top as string | number] ? { top } : {}),
...(bottom !== undefined && !spacingMap[bottom as string | number] ? { bottom } : {}),
...(left !== undefined && !spacingMap[left as string | number] ? { left } : {}),
...(right !== undefined && !spacingMap[right as string | number] ? { right } : {}),
...((props as Record<string, unknown>).style as object || {})
};
return (
<Tag ref={ref as React.ForwardedRef<HTMLElement>} className={classes} {...props} style={style as React.CSSProperties}>
<Tag
ref={ref as React.ForwardedRef<HTMLElement>}
className={classes}
onClick={onClick}
onSubmit={onSubmit}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...props}
style={style as React.CSSProperties}
>
{children}
</Tag>
);

View File

@@ -0,0 +1,52 @@
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,8 @@
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes } from 'react';
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, ElementType } from 'react';
import { Stack } from './Stack';
import { Box, BoxProps } from './Box';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string;
@@ -72,30 +73,34 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
if (as === 'a') {
return (
<a
<Box
as="a"
href={href}
target={target}
rel={rel}
className={classes}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
{...(props as any)}
>
{content}
</a>
</Box>
);
}
return (
<button
ref={ref}
<Box
as="button"
ref={ref as any}
type={type}
className={classes}
onClick={onClick}
onClick={onClick as any}
disabled={disabled}
{...props}
{...(props as any)}
>
{content}
</button>
</Box>
);
});
Button.displayName = 'Button';
export default Button;

View File

@@ -3,7 +3,7 @@ import { Box, BoxProps } from './Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'className'> {
interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'className' | 'onClick'> {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;

View File

@@ -0,0 +1,95 @@
import { AchievementCard } from '@/ui/AchievementCard';
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { GoalCard } from '@/ui/GoalCard';
import { Heading } from '@/ui/Heading';
import { MilestoneItem } from '@/ui/MilestoneItem';
import { Stack } from '@/ui/Stack';
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' },
];
export function CareerHighlights() {
return (
<Stack gap={6}>
<Card>
<Heading level={3} mb={4}>Key Milestones</Heading>
<Stack gap={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="🏎️"
/>
</Stack>
</Card>
<Card>
<Heading level={3} mb={4}>Achievements</Heading>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={3}>
{mockAchievements.map((achievement) => (
<AchievementCard
key={achievement.id}
title={achievement.title}
description={achievement.description}
icon={achievement.icon}
unlockedAt={achievement.unlockedAt}
rarity={achievement.rarity}
/>
))}
</Box>
</Card>
<GoalCard
title="Next Goals"
icon="🎯"
goalLabel="Win 25 races"
currentValue={23}
maxValue={25}
/>
</Stack>
);
}

View File

@@ -0,0 +1,36 @@
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { StatGridItem } from '@/ui/StatGridItem';
import { TrendingUp } from 'lucide-react';
interface CareerStatsProps {
stats: {
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
};
}
export function CareerStats({ stats }: CareerStatsProps) {
return (
<Card>
<Box mb={4}>
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
Career Statistics
</Heading>
</Box>
<Grid cols={2} gap={4}>
<StatGridItem label="Races" value={stats.totalRaces} />
<StatGridItem label="Wins" value={stats.wins} color="text-performance-green" />
<StatGridItem label="Podiums" value={stats.podiums} color="text-warning-amber" />
<StatGridItem label="Consistency" value={`${stats.consistency}%`} color="text-primary-blue" />
</Grid>
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { Box } from '@/ui/Box';
import { CategoryDistributionCard } from '@/ui/CategoryDistributionCard';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
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', progressColor: 'bg-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', progressColor: 'bg-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', progressColor: 'bg-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', progressColor: 'bg-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30', progressColor: 'bg-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30', progressColor: 'bg-red-400' },
];
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 (
<Box mb={10}>
<Box display="flex" alignItems="center" gap={3} mb={4}>
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="xl"
bg="bg-purple-400/10"
border
borderColor="border-purple-400/20"
>
<Icon
icon={BarChart3}
size={5}
color="rgb(192, 132, 252)"
/>
</Box>
<Box>
<Heading level={2}>Category Distribution</Heading>
<Text size="xs" color="text-gray-500">Driver population by category</Text>
</Box>
</Box>
<Grid cols={2} lgCols={3} gap={4}>
{distribution.map((category) => (
<CategoryDistributionCard
key={category.id}
label={category.label}
count={category.count}
percentage={category.percentage}
color={category.color}
bgColor={category.bgColor}
borderColor={category.borderColor}
progressColor={category.progressColor}
/>
))}
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { ProgressBar } from './ProgressBar';
interface CategoryDistributionCardProps {
label: string;
count: number;
percentage: number;
color: string;
bgColor: string;
borderColor: string;
progressColor: string;
}
export function CategoryDistributionCard({
label,
count,
percentage,
color,
bgColor,
borderColor,
progressColor,
}: CategoryDistributionCardProps) {
return (
<Box p={4} rounded="xl" className={`${bgColor} border ${borderColor}`}>
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<Text size="2xl" weight="bold" className={color}>{count}</Text>
</Box>
<Text color="text-white" weight="medium" block mb={1}>{label}</Text>
<ProgressBar value={percentage} max={100} color={progressColor} bg="bg-deep-graphite/50" />
<Text size="xs" color="text-gray-500" block mt={1}>{percentage}% of drivers</Text>
</Box>
);
}

View File

@@ -0,0 +1,56 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Card } from '@/ui/Card';
import { ChampionshipStandingsList } from '@/ui/ChampionshipStandingsList';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { SummaryItem } from '@/ui/SummaryItem';
import { Text } from '@/ui/Text';
import { Award, ChevronRight } from 'lucide-react';
interface Standing {
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}
interface ChampionshipStandingsProps {
standings: Standing[];
}
export function ChampionshipStandings({ standings }: ChampionshipStandingsProps) {
return (
<Card>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={2} icon={<Icon icon={Award} size={5} color="var(--warning-amber)" />}>
Your Championship Standings
</Heading>
<Link href={routes.protected.profileLeagues} variant="primary">
<Stack direction="row" align="center" gap={1}>
<Text size="sm">View all</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Link>
</Stack>
<ChampionshipStandingsList>
{standings.map((summary) => (
<SummaryItem
key={summary.leagueId}
title={summary.leagueName}
subtitle={`Position ${summary.position}${summary.points} points`}
rightContent={
<Text size="xs" color="text-gray-400">
{summary.totalDrivers} drivers
</Text>
}
/>
))}
</ChampionshipStandingsList>
</Card>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from './Stack';
interface ChampionshipStandingsListProps {
children: ReactNode;
}
export function ChampionshipStandingsList({ children }: ChampionshipStandingsListProps) {
return (
<Stack gap={3}>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Checkbox({ label, checked, onChange, disabled }: CheckboxProps) {
return (
<Box as="label" display="flex" alignItems="center" gap={2} cursor={disabled ? 'not-allowed' : 'pointer'}>
<Box
as="input"
type="checkbox"
checked={checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
disabled={disabled}
w="4"
h="4"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="sm"
className="text-primary-blue focus:ring-primary-blue"
/>
<Text size="sm" color={disabled ? 'text-gray-500' : 'text-white'}>{label}</Text>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
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,14 +1,15 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
import { Box, BoxProps } from './Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface ContainerProps extends HTMLAttributes<HTMLElement> {
interface ContainerProps extends BoxProps<'div'> {
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
padding?: boolean;
className?: string;
py?: Spacing;
pb?: Spacing;
}
export function Container({
@@ -17,6 +18,7 @@ export function Container({
padding = true,
className = '',
py,
pb,
...props
}: ContainerProps) {
const sizeClasses = {
@@ -39,6 +41,7 @@ export function Container({
sizeClasses[size],
padding ? 'px-4 sm:px-6 lg:px-8' : '',
py !== undefined ? `py-${spacingMap[py]}` : '',
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
className
].filter(Boolean).join(' ');

View File

@@ -1,6 +1,5 @@
'use client';
import React from 'react';
// ISO 3166-1 alpha-2 country code to full country name mapping
const countryNames: Record<string, string> = {

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { CountryFlag } from './CountryFlag';
export interface Country {

View File

@@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Heading } from './Heading';
import { Card } from './Card';
interface DangerZoneProps {
title: string;
description: string;
children: ReactNode;
}
export function DangerZone({ title, description, children }: DangerZoneProps) {
return (
<Card>
<Heading level={3} mb={4}>Danger Zone</Heading>
<Box p={4} rounded="lg" bg="bg-red-900/10" border={true} borderColor="border-red-900/30">
<Text color="text-white" weight="medium" block mb={2}>{title}</Text>
<Text size="sm" color="text-gray-400" block mb={4}>
{description}
</Text>
{children}
</Box>
</Card>
);
}

View File

@@ -0,0 +1,139 @@
import { ReactNode } from 'react';
import { Badge } from './Badge';
import { Box } from './Box';
import { Heading } from './Heading';
import { Image } from './Image';
import { Stack } from './Stack';
interface DashboardHeroProps {
driverName: string;
avatarUrl: string;
country: string;
rating: string | number;
rank: string | number;
totalRaces: string | number;
actions?: ReactNode;
stats?: ReactNode;
}
export function DashboardHero({
driverName,
avatarUrl,
country,
rating,
rank,
totalRaces,
actions,
stats,
}: DashboardHeroProps) {
return (
<Box as="section" position="relative" overflow="hidden">
{/* Background Pattern */}
<Box
position="absolute"
inset="0"
style={{
background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.1), #0f1115, rgba(147, 51, 234, 0.05))',
}}
/>
<Box
position="relative"
maxWidth="80rem"
mx="auto"
px={6}
py={10}
>
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" wrap gap={8}>
{/* Welcome Message */}
<Stack direction="row" align="start" gap={5}>
<Box position="relative">
<Box
w="20"
h="20"
rounded="xl"
p={0.5}
style={{
background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)',
boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)',
}}
>
<Box
w="full"
h="full"
rounded="lg"
overflow="hidden"
bg="bg-deep-graphite"
>
<Image
src={avatarUrl}
alt={driverName}
width={80}
height={80}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
</Box>
<Box
position="absolute"
bottom="-1"
right="-1"
w="5"
h="5"
rounded="full"
bg="bg-performance-green"
border
style={{ borderColor: '#0f1115', borderWidth: '3px' }}
/>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>
Good morning,
</Text>
<Heading level={1} mb={2}>
{driverName}
<Text size="2xl" ml={3}>
{country}
</Text>
</Heading>
<Stack direction="row" align="center" gap={3} wrap>
<Badge variant="primary">
{rating}
</Badge>
<Badge variant="warning">
#{rank}
</Badge>
<Text size="xs" color="text-gray-500">
{totalRaces} races completed
</Text>
</Stack>
</Box>
</Stack>
{/* Quick Actions */}
{actions && (
<Stack direction="row" gap={3} wrap>
{actions}
</Stack>
)}
</Stack>
{/* Quick Stats Row */}
{stats && (
<Box
display="grid"
gridCols={2}
responsiveGridCols={{ md: 4 }}
gap={4}
>
{stats}
</Box>
)}
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Button } from '@/ui/Button';
import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { StatBox } from '@/ui/StatBox';
import { Flag, Medal, Target, Trophy, User, Users } from 'lucide-react';
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 (
<UiDashboardHero
driverName={currentDriver.name}
avatarUrl={currentDriver.avatarUrl}
country={currentDriver.country}
rating={currentDriver.rating}
rank={currentDriver.rank}
totalRaces={currentDriver.totalRaces}
actions={
<>
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" icon={<Icon icon={Flag} size={4} />}>
Browse Leagues
</Button>
</Link>
<Link href={routes.protected.profile} variant="ghost">
<Button variant="primary" icon={<Icon icon={User} size={4} />}>
View Profile
</Button>
</Link>
</>
}
stats={
<>
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="var(--performance-green)" />
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="var(--warning-amber)" />
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="var(--primary-blue)" />
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="var(--neon-purple)" />
</>
}
/>
);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Calendar } from 'lucide-react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Icon } from './Icon';
interface DateHeaderProps {
label: string;
count?: number;
countLabel?: string;
}
export function DateHeader({ label, count, countLabel = 'races' }: DateHeaderProps) {
return (
<Stack direction="row" align="center" gap={3} px={2}>
<Box p={2} bg="bg-primary-blue/10" rounded="lg">
<Icon icon={Calendar} size={4} color="rgb(59, 130, 246)" />
</Box>
<Text weight="semibold" size="sm" color="text-white">
{label}
</Text>
{count !== undefined && (
<Text size="xs" color="text-gray-500">
{count} {count === 1 ? countLabel.replace(/s$/, '') : countLabel}
</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,385 @@
import type { ApiRequestLogger } from '@/lib/infrastructure/ApiRequestLogger';
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { Bug, Shield, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Box } from './Box';
import { Button } from './Button';
import { Icon } from './Icon';
import { Stack } from './Stack';
import { Text } from './Text';
// 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;
const updateMetrics = useCallback(() => {
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,
});
}, [debugEnabled]);
const initializeDebugFeatures = useCallback(() => {
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;');
}, []);
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, initializeDebugFeatures, updateMetrics]);
useEffect(() => {
// Save debug state
if (shouldShow) {
localStorage.setItem('gridpilot_debug_enabled', debugEnabled.toString());
}
}, [debugEnabled, shouldShow]);
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 (
<Box position="fixed" bottom="4" left="4" zIndex={50}>
{/* Main Toggle Button */}
{!isOpen && (
<Box
as="button"
onClick={() => setIsOpen(true)}
p={3}
rounded="full"
shadow="lg"
bg={debugEnabled ? 'bg-green-600' : 'bg-iron-gray'}
color="text-white"
className="transition-all hover:scale-110"
title={debugEnabled ? 'Debug Mode Active' : 'Enable Debug Mode'}
>
<Icon icon={Bug} size={5} />
</Box>
)}
{/* Debug Panel */}
{isOpen && (
<Box width="80" bg="bg-deep-graphite" border={true} borderColor="border-charcoal-outline" rounded="xl" shadow="2xl" overflow="hidden">
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="between" px={3} py={2} bg="bg-iron-gray/50" borderBottom={true} borderColor="border-charcoal-outline">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Bug} size={4} color="text-green-400" />
<Text size="sm" weight="semibold" color="text-white">Debug Control</Text>
</Stack>
<Box
as="button"
onClick={() => setIsOpen(false)}
p={1}
className="hover:bg-charcoal-outline rounded"
>
<Icon icon={X} size={4} color="text-gray-400" />
</Box>
</Box>
{/* Content */}
<Box p={3}>
<Stack gap={3}>
{/* Debug Toggle */}
<Box display="flex" alignItems="center" justifyContent="between" bg="bg-iron-gray/30" p={2} rounded="md" border={true} borderColor="border-charcoal-outline">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} color={debugEnabled ? 'text-green-400' : 'text-gray-500'} />
<Text size="sm" weight="medium">Debug Mode</Text>
</Stack>
<Button
onClick={toggleDebug}
size="sm"
variant={debugEnabled ? 'primary' : 'secondary'}
className={debugEnabled ? 'bg-green-600 hover:bg-green-700' : ''}
>
{debugEnabled ? 'ON' : 'OFF'}
</Button>
</Box>
{/* Metrics */}
{debugEnabled && (
<Box display="grid" gridCols={3} gap={2}>
<Box bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} textAlign="center">
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Errors</Text>
<Text size="lg" weight="bold" color="text-red-400" block>{metrics.errors}</Text>
</Box>
<Box bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} textAlign="center">
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>API</Text>
<Text size="lg" weight="bold" color="text-blue-400" block>{metrics.apiRequests}</Text>
</Box>
<Box bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} textAlign="center">
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Failures</Text>
<Text size="lg" weight="bold" color="text-yellow-400" block>{metrics.apiFailures}</Text>
</Box>
</Box>
)}
{/* Actions */}
{debugEnabled && (
<Stack gap={2}>
<Text size="xs" weight="semibold" color="text-gray-400">Test Actions</Text>
<Box display="grid" gridCols={2} gap={2}>
<Button
onClick={triggerTestError}
variant="danger"
size="sm"
>
Test Error
</Button>
<Button
onClick={triggerTestApiCall}
size="sm"
>
Test API
</Button>
</Box>
<Text size="xs" weight="semibold" color="text-gray-400" mt={2}>Utilities</Text>
<Box display="grid" gridCols={2} gap={2}>
<Button
onClick={copyDebugInfo}
variant="secondary"
size="sm"
>
Copy Info
</Button>
<Button
onClick={clearAllLogs}
variant="secondary"
size="sm"
>
Clear Logs
</Button>
</Box>
</Stack>
)}
{/* Quick Links */}
{debugEnabled && (
<Stack gap={1}>
<Text size="xs" weight="semibold" color="text-gray-400">Quick Access</Text>
<Box color="text-gray-500" font="mono" style={{ fontSize: '10px' }}>
<Text block> window.__GRIDPILOT_GLOBAL_HANDLER__</Text>
<Text block> window.__GRIDPILOT_API_LOGGER__</Text>
<Text block> window.__GRIDPILOT_REACT_ERRORS__</Text>
</Box>
</Stack>
)}
{/* Status */}
<Box textAlign="center" pt={2} borderTop={true} borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-500" style={{ fontSize: '10px' }}>
{debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
{isDev && ' • Development Environment'}
</Text>
</Box>
</Stack>
</Box>
</Box>
)}
</Box>
);
}
/**
* 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 = useCallback(() => {
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 = useCallback(() => {
setDebugEnabled(false);
localStorage.setItem('gridpilot_debug_enabled', 'false');
const globalHandler = getGlobalErrorHandler();
globalHandler.destroy();
}, []);
const toggle = useCallback(() => {
if (debugEnabled) {
disable();
} else {
enable();
}
}, [debugEnabled, enable, disable]);
return {
enabled: debugEnabled,
enable,
disable,
toggle,
};
}

View File

@@ -0,0 +1,190 @@
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
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

@@ -0,0 +1,71 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { RankBadge } from '@/ui/RankBadge';
import { DriverIdentity } from '@/ui/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Stack } from '@/ui/Stack';
import { DriverStats } from '@/ui/DriverStats';
import { routes } from '@/lib/routing/RouteConfig';
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 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,
});
const winRate = racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0';
return (
<Card
onClick={onClick}
transition
>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4} flexGrow={1}>
<RankBadge rank={rank} size="lg" />
<DriverIdentity
driver={driverViewModel}
href={routes.driver.detail(id)}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</Stack>
<DriverStats
rating={rating}
wins={wins}
podiums={podiums}
winRate={winRate}
/>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,118 @@
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Zap } from 'lucide-react';
import { Badge } from './Badge';
import { Box } from './Box';
import { Icon } from './Icon';
import { Image } from './Image';
import { Stack } from './Stack';
import { Text } from './Text';
interface DriverEntryRowProps {
index: number;
name: string;
avatarUrl: string;
country: string;
rating?: number | null;
isCurrentUser: boolean;
onClick: () => void;
}
export function DriverEntryRow({
index,
name,
avatarUrl,
country,
rating,
isCurrentUser,
onClick,
}: DriverEntryRowProps) {
return (
<Box
onClick={onClick}
display="flex"
alignItems="center"
gap={3}
p={3}
rounded="xl"
cursor="pointer"
transition
bg={isCurrentUser ? 'bg-primary-blue/10' : 'transparent'}
border
borderColor={isCurrentUser ? 'border-primary-blue/30' : 'transparent'}
hoverBorderColor={isCurrentUser ? 'primary-blue/40' : 'charcoal-outline/20'}
>
<Box
display="flex"
alignItems="center"
justifyContent="center"
w="8"
h="8"
rounded="lg"
bg="bg-iron-gray"
color="text-gray-500"
style={{ fontWeight: 'bold', fontSize: '0.875rem' }}
>
{index + 1}
</Box>
<Box position="relative" flexShrink={0}>
<Box
w="10"
h="10"
rounded="full"
overflow="hidden"
border={isCurrentUser}
borderColor={isCurrentUser ? 'border-primary-blue' : ''}
>
<Image
src={avatarUrl}
alt={name}
width={40}
height={40}
objectFit="cover"
fill
/>
</Box>
<Box
position="absolute"
bottom="-0.5"
right="-0.5"
w="5"
h="5"
rounded="full"
bg="bg-deep-graphite"
display="flex"
alignItems="center"
justifyContent="center"
style={{ fontSize: '0.625rem' }}
>
{CountryFlagDisplay.fromCountryCode(country).toString()}
</Box>
</Box>
<Box flexGrow={1} minWidth="0">
<Stack direction="row" align="center" gap={2}>
<Text
weight="semibold"
size="sm"
color={isCurrentUser ? 'text-primary-blue' : 'text-white'}
truncate
>
{name}
</Text>
{isCurrentUser && <Badge variant="primary">You</Badge>}
</Stack>
<Text size="xs" color="text-gray-500">{country}</Text>
</Box>
{rating != null && (
<Badge variant="warning">
<Icon icon={Zap} size={3} />
{rating}
</Badge>
)}
</Box>
);
}

View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import Image from 'next/image';
import { PlaceholderImage } from './PlaceholderImage';
import { Box } from './Box';
import { Text } from './Text';
import { Badge } from './Badge';
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 nameSize = size === 'sm' ? 'sm' : 'base';
const avatarUrl = driver.avatarUrl;
const content = (
<Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">
<Box
rounded="full"
bg="bg-primary-blue/20"
overflow="hidden"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={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} />
)}
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} minWidth="0">
<Text size={nameSize} weight="medium" color="text-white" className="truncate">
{driver.name}
</Text>
{contextLabel && (
<Badge variant="default" className="bg-charcoal-outline/60 text-[10px] md:text-xs">
{contextLabel}
</Badge>
)}
</Box>
{meta && (
<Text size="xs" color="text-gray-400" mt={0.5} className="truncate" block>
{meta}
</Text>
)}
</Box>
</Box>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">{content}</Box>;
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { RankingListItem } from '@/ui/RankingListItem';
import { RankingList } from '@/ui/RankingList';
import { MinimalEmptyState } from '@/ui/EmptyState';
export interface DriverRanking {
type: 'overall' | 'league';
name: string;
rank: number;
totalDrivers: number;
percentile: number;
rating: number;
}
interface DriverRankingsProps {
rankings: DriverRanking[];
}
export function DriverRankings({ rankings }: DriverRankingsProps) {
if (!rankings || rankings.length === 0) {
return (
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
<Heading level={3} mb={2}>Rankings</Heading>
<MinimalEmptyState
title="No ranking data available yet"
description="Compete in leagues to earn your first results."
/>
</Card>
);
}
return (
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
<Heading level={3} mb={4}>Rankings</Heading>
<RankingList>
{rankings.map((ranking, index) => (
<RankingListItem
key={`${ranking.type}-${ranking.name}-${index}`}
name={ranking.name}
type={ranking.type}
rank={ranking.rank}
totalDrivers={ranking.totalDrivers}
percentile={ranking.percentile}
rating={ranking.rating}
/>
))}
</RankingList>
</Card>
);
}

View File

@@ -0,0 +1,31 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Star, Trophy } from 'lucide-react';
interface DriverRatingPillProps {
rating: number | null;
rank: number | null;
}
export function DriverRatingPill({ rating, rank }: DriverRatingPillProps) {
return (
<Box display="flex" alignItems="center" gap={2} mt={0.5} style={{ fontSize: '11px' }}>
<Box display="inline-flex" alignItems="center" gap={1}>
<Icon icon={Star} size={3} color="var(--warning-amber)" />
<Text color="text-amber-300" className="tabular-nums">
{rating !== null ? rating : '—'}
</Text>
</Box>
{rank !== null && (
<Box display="inline-flex" alignItems="center" gap={1}>
<Icon icon={Trophy} size={3} color="var(--primary-blue)" />
<Text color="text-primary-blue" className="tabular-nums">#{rank}</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Stack } from './Stack';
import { Box } from './Box';
import { Text } from './Text';
interface DriverStatsProps {
rating: number;
wins: number;
podiums: number;
winRate: string;
}
export function DriverStats({ rating, wins, podiums, winRate }: DriverStatsProps) {
return (
<Stack direction="row" align="center" gap={8} textAlign="center">
<Box>
<Text size="2xl" weight="bold" color="text-primary-blue" block>{rating}</Text>
<Text size="xs" color="text-gray-400" block>Rating</Text>
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-green-400" block>{wins}</Text>
<Text size="xs" color="text-gray-400" block>Wins</Text>
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-warning-amber" block>{podiums}</Text>
<Text size="xs" color="text-gray-400" block>Podiums</Text>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block>{winRate}%</Text>
<Text size="xs" color="text-gray-500" block>Win Rate</Text>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { Link } from './Link';
import { PlaceholderImage } from './PlaceholderImage';
import { Stack } from './Stack';
import { Text } from './Text';
interface DriverSummaryPillProps {
name: string;
avatarSrc?: string | null;
rating?: number | null;
rank?: number | null;
onClick?: () => void;
href?: string;
ratingComponent?: React.ReactNode;
}
export function DriverSummaryPill({
name,
avatarSrc,
onClick,
href,
ratingComponent,
}: DriverSummaryPillProps) {
const content = (
<>
<Box
w="8"
h="8"
rounded="full"
overflow="hidden"
bg="bg-charcoal-outline"
display="flex"
alignItems="center"
justifyContent="center"
border
borderColor="border-charcoal-outline/80"
>
{avatarSrc ? (
<Image
src={avatarSrc}
alt={name}
width={32}
height={32}
objectFit="cover"
fill
/>
) : (
<PlaceholderImage size={32} />
)}
</Box>
<Stack direction="col" align="start" justify="center">
<Text
size="xs"
weight="semibold"
color="text-white"
truncate
block
style={{ maxWidth: '140px' }}
>
{name}
</Text>
{ratingComponent}
</Stack>
</>
);
if (href) {
return (
<Link
href={href}
block
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
shadow="0 0 18px rgba(0,0,0,0.45)"
transition
hoverBorderColor="primary-blue/60"
className="hover:bg-iron-gray"
>
{content}
</Link>
);
}
if (onClick) {
return (
<Box
as="button"
type="button"
onClick={onClick}
cursor="pointer"
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
shadow="0 0 18px rgba(0,0,0,0.45)"
transition
hoverBorderColor="primary-blue/60"
className="hover:bg-iron-gray"
>
{content}
</Box>
);
}
return (
<Box
display="flex"
alignItems="center"
gap={3}
rounded="full"
bg="bg-iron-gray/70"
px={3}
py={1.5}
border
borderColor="border-charcoal-outline/80"
>
{content}
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { DriverRatingPill } from '@/ui/DriverRatingPill';
import { DriverSummaryPill as UiDriverSummaryPill } from '@/ui/DriverSummaryPill';
export interface DriverSummaryPillProps {
driver: DriverViewModel;
rating: number | null;
rank: number | null;
avatarSrc?: string | null;
onClick?: () => void;
href?: string;
}
export function DriverSummaryPill(props: DriverSummaryPillProps) {
const { driver, rating, rank, avatarSrc, onClick, href } = props;
return (
<UiDriverSummaryPill
name={driver.name}
avatarSrc={avatarSrc}
onClick={onClick}
href={href}
ratingComponent={<DriverRatingPill rating={rating} rank={rank} />}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Search } from 'lucide-react';
interface DriversSearchProps {
query: string;
onChange: (query: string) => void;
}
export function DriversSearch({ query, onChange }: DriversSearchProps) {
return (
<Box mb={8}>
<Box maxWidth="28rem">
<Input
type="text"
placeholder="Search drivers by name or nationality..."
value={query}
onChange={(e) => onChange(e.target.value)}
icon={<Icon icon={Search} size={5} color="#6b7280" />}
/>
</Box>
</Box>
);
}

View File

@@ -1,4 +1,4 @@
'use client';
import Input from '@/ui/Input';

View File

@@ -0,0 +1,329 @@
import { Button } from './Button';
import { EmptyStateProps } from './state-types';
// Illustration components (simple SVG representations)
const Illustrations = {
racing: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
<circle cx="35" cy="65" r="3" fill="currentColor"/>
<circle cx="65" cy="65" r="3" fill="currentColor"/>
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
league: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
team: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
</svg>
),
sponsor: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
</svg>
),
driver: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
<circle cx="40" cy="72" r="3" fill="currentColor"/>
<circle cx="60" cy="72" r="3" fill="currentColor"/>
</svg>
),
} as const;
/**
* EmptyState Component
*
* Provides consistent empty/placeholder states with 3 variants:
* - default: Standard empty state with icon, title, description, and action
* - minimal: Simple version without extra styling
* - full-page: Full page empty state with centered layout
*
* Supports both icons and illustrations for visual appeal.
*/
export function EmptyState({
icon: Icon,
title,
description,
action,
variant = 'default',
className = '',
illustration,
ariaLabel = 'Empty state',
}: EmptyStateProps) {
// Render illustration if provided
const IllustrationComponent = illustration ? Illustrations[illustration] : null;
// Common content
const Content = () => (
<>
{/* Visual - Icon or Illustration */}
<div className="flex justify-center mb-4">
{IllustrationComponent ? (
<div className="text-gray-500">
<IllustrationComponent />
</div>
) : Icon ? (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-8 h-8 text-gray-500" />
</div>
) : null}
</div>
{/* Title */}
<h3 className="text-xl font-semibold text-white mb-2 text-center">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-gray-400 mb-6 text-center leading-relaxed">
{description}
</p>
)}
{/* Action Button */}
{action && (
<div className="flex justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[140px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
</>
);
// Render different variants
switch (variant) {
case 'default':
return (
<div
className={`text-center py-12 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-md mx-auto">
<Content />
</div>
</div>
);
case 'minimal':
return (
<div
className={`text-center py-8 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-sm mx-auto space-y-3">
{/* Minimal icon */}
{Icon && (
<div className="flex justify-center">
<Icon className="w-10 h-10 text-gray-600" />
</div>
)}
<h3 className="text-lg font-medium text-gray-300">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500">
{description}
</p>
)}
{action && (
<button
onClick={action.onClick}
className="text-sm text-primary-blue hover:text-blue-400 font-medium mt-2 inline-flex items-center gap-1"
>
{action.label}
{action.icon && <action.icon className="w-3 h-3" />}
</button>
)}
</div>
</div>
);
case 'full-page':
return (
<div
className={`fixed inset-0 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-lg w-full text-center">
<div className="mb-6">
{IllustrationComponent ? (
<div className="text-gray-500 flex justify-center">
<IllustrationComponent />
</div>
) : Icon ? (
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-10 h-10 text-gray-500" />
</div>
</div>
) : null}
</div>
<h2 className="text-3xl font-bold text-white mb-4">
{title}
</h2>
{description && (
<p className="text-gray-400 text-lg mb-8 leading-relaxed">
{description}
</p>
)}
{action && (
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[160px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
{/* Additional helper text for full-page variant */}
<div className="mt-8 text-sm text-gray-500">
Need help? Contact us at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
);
default:
return null;
}
}
/**
* Convenience component for default empty state
*/
export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="default"
className={className}
illustration={illustration}
/>
);
}
/**
* Convenience component for minimal empty state
*/
export function MinimalEmptyState({ icon, title, description, action, className }: Omit<EmptyStateProps, 'variant'>) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="minimal"
className={className}
/>
);
}
/**
* Convenience component for full-page empty state
*/
export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="full-page"
className={className}
illustration={illustration}
/>
);
}
/**
* Pre-configured empty states for common scenarios
*/
import { Activity, Lock, Search } from 'lucide-react';
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={Activity}
title="No data available"
description="There is nothing to display here at the moment"
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={Search}
title="No results found"
description="Try adjusting your search or filters"
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
return (
<EmptyState
icon={Lock}
title="Access denied"
description="You don't have permission to view this content"
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
variant="full-page"
/>
);
}

View File

@@ -1,9 +1,8 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Surface } from './Surface';
import { Text } from './Text';
export interface ErrorBannerProps {
message: string;

View File

@@ -0,0 +1,248 @@
import { ApiError } from '@/lib/api/base/ApiError';
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
import { Box } from './Box';
import { Button } from './Button';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Stack } from './Stack';
import { ErrorDisplayAction, ErrorDisplayProps } from './state-types';
import { Surface } from './Surface';
import { Text } from './Text';
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
actions = [],
showRetry = true,
showNavigation = true,
hideTechnicalDetails = false,
className = '',
}: ErrorDisplayProps) {
const getErrorInfo = () => {
const isApiError = error instanceof ApiError;
return {
title: isApiError ? 'API Error' : 'Unexpected Error',
message: error.message || 'Something went wrong',
statusCode: isApiError ? error.context.statusCode : undefined,
details: isApiError ? error.context.responseText : undefined,
isApiError,
};
};
const errorInfo = getErrorInfo();
const defaultActions: ErrorDisplayAction[] = [
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
...(showNavigation ? [
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
] : []),
...actions,
];
switch (variant) {
case 'full-screen':
return (
<Box
position="fixed"
inset="0"
zIndex={50}
bg="bg-deep-graphite"
display="flex"
alignItems="center"
justifyContent="center"
p={6}
className={className}
role="alert"
aria-live="assertive"
>
<Box maxWidth="lg" fullWidth textAlign="center">
<Box display="flex" justifyContent="center" mb={6}>
<Box
display="flex"
h="20"
w="20"
alignItems="center"
justifyContent="center"
rounded="3xl"
bg="bg-red-500/10"
border={true}
borderColor="border-red-500/30"
>
<Icon icon={AlertCircle} size={10} color="text-red-500" />
</Box>
</Box>
<Heading level={2} mb={3}>
{errorInfo.title}
</Heading>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Box mb={6} display="inline-flex" alignItems="center" gap={2} px={4} py={2} bg="bg-iron-gray/40" rounded="lg">
<Text size="sm" color="text-gray-300" font="mono">HTTP {errorInfo.statusCode}</Text>
{errorInfo.details && !hideTechnicalDetails && (
<Text size="sm" color="text-gray-500">- {errorInfo.details}</Text>
)}
</Box>
)}
{defaultActions.length > 0 && (
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
icon={action.icon && <Icon icon={action.icon} size={4} />}
className="px-6 py-3"
>
{action.label}
</Button>
))}
</Stack>
)}
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
<Box mt={8} textAlign="left">
<details className="cursor-pointer">
<summary className="text-sm text-gray-500 hover:text-gray-400">
Technical Details
</summary>
<Box as="pre" mt={2} p={4} bg="bg-black/50" rounded="lg" color="text-gray-400" style={{ fontSize: '0.75rem', overflowX: 'auto' }}>
{error.stack}
</Box>
</details>
</Box>
)}
</Box>
</Box>
);
case 'card':
return (
<Surface
variant="muted"
border={true}
borderColor="border-red-500/30"
rounded="xl"
p={6}
className={className}
role="alert"
aria-live="assertive"
>
<Stack direction="row" gap={4} align="start">
<Icon icon={AlertCircle} size={6} color="text-red-500" />
<Box flexGrow={1}>
<Heading level={3} mb={1}>
{errorInfo.title}
</Heading>
<Text size="sm" color="text-gray-400" block mb={3}>
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Text size="xs" font="mono" color="text-gray-500" block mb={3}>
HTTP {errorInfo.statusCode}
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
</Text>
)}
{defaultActions.length > 0 && (
<Stack direction="row" gap={2}>
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
size="sm"
>
{action.label}
</Button>
))}
</Stack>
)}
</Box>
</Stack>
</Surface>
);
case 'inline':
return (
<Box
display="inline-flex"
alignItems="center"
gap={2}
px={3}
py={2}
bg="bg-red-500/10"
border={true}
borderColor="border-red-500/30"
rounded="lg"
className={className}
role="alert"
aria-live="assertive"
>
<Icon icon={AlertCircle} size={4} color="text-red-500" />
<Text size="sm" color="text-red-400">{errorInfo.message}</Text>
{onRetry && showRetry && (
<Button
variant="ghost"
onClick={onRetry}
size="sm"
className="ml-2 text-xs text-red-300 hover:text-red-200 underline p-0 h-auto"
>
Retry
</Button>
)}
</Box>
);
default:
return null;
}
}
export function ApiErrorDisplay({
error,
onRetry,
variant = 'full-screen',
hideTechnicalDetails = false,
}: {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
hideTechnicalDetails?: boolean;
}) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant={variant}
hideTechnicalDetails={hideTechnicalDetails}
/>
);
}
export function NetworkErrorDisplay({
onRetry,
variant = 'full-screen',
}: {
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
}) {
return (
<ErrorDisplay
error={new Error('Network connection failed. Please check your internet connection.')}
onRetry={onRetry}
variant={variant}
/>
);
}

View File

@@ -0,0 +1,102 @@
'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 { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
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 (
<Box
display="flex"
flexDirection="column"
gap={6}
group
>
<Box aspectRatio="video" fullWidth position="relative">
<Box position="absolute" inset="-0.5" bg="linear-gradient(to right, rgba(239, 68, 68, 0.2), rgba(59, 130, 246, 0.2), rgba(239, 68, 68, 0.2))" rounded="lg" opacity={0} groupHoverOpacity={1} transition blur="sm" />
<Box position="relative">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</Box>
</Box>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={3} weight="medium" style={{ background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', filter: 'drop-shadow(0 0 15px rgba(220,0,0,0.4))', WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{feature.title}
</Heading>
</Box>
<Text size={{ base: 'sm', sm: 'base' }} color="text-gray-400" weight="light" leading="relaxed">
{feature.description}
</Text>
</Stack>
</Box>
);
}
export function FeatureGrid() {
return (
<Section variant="default">
<Container position="relative" zIndex={10}>
<Container size="sm" center>
<Box>
<Heading level={2} weight="semibold" style={{ background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', filter: 'drop-shadow(0 0 20px rgba(220,0,0,0.5))', WebkitTextStroke: '1px rgba(220,0,0,0.2)' }}>
Building for League Racing
</Heading>
<Text size={{ base: 'base', sm: 'lg' }} color="text-gray-400" block mt={{ base: 4, sm: 6 }}>
These features are in development. Join the community to help shape what gets built first
</Text>
</Box>
</Container>
<Box mx="auto" mt={{ base: 8, sm: 12, md: 16 }} display="grid" gridCols={{ base: 1, lg: 2, xl: 3 }} gap={{ base: 10, sm: 12, md: 16 }} maxWidth={{ base: '2xl', lg: 'none' }}>
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}
</Box>
</Container>
</Section>
);
}

View File

@@ -0,0 +1,161 @@
import { mediaConfig } from '@/lib/config/mediaConfig';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { MedalBadge } from '@/ui/MedalBadge';
import { MiniStat } from '@/ui/MiniStat';
import { Text } from '@/ui/Text';
import { Flag, Shield, Star, TrendingUp } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: Star, 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';
case 2: return 'border-gray-300/50';
case 3: return 'border-amber-600/50';
default: return 'border-charcoal-outline';
}
};
const getHoverBorderColor = (pos: number) => {
switch (pos) {
case 1: return 'yellow-400';
case 2: return 'gray-300';
case 3: return 'amber-600';
default: return 'primary-blue';
}
};
return (
<Box
as="button"
type="button"
onClick={onClick}
p={5}
rounded="xl"
bg="bg-iron-gray/60"
border
borderColor={getBorderColor(position)}
hoverBorderColor={getHoverBorderColor(position)}
transition
textAlign="left"
cursor="pointer"
hoverScale
group
>
{/* Header with Position */}
<Box display="flex" alignItems="start" justifyContent="between" mb={4}>
<MedalBadge position={position} />
<Box display="flex" gap={2}>
{categoryConfig && (
<Badge
bg={categoryConfig.bgColor}
color={categoryConfig.color}
borderColor={categoryConfig.borderColor}
>
{categoryConfig.label}
</Badge>
)}
{levelConfig && (
<Badge
bg={levelConfig.bgColor}
color={levelConfig.color}
borderColor={levelConfig.borderColor}
>
{levelConfig.label}
</Badge>
)}
</Box>
</Box>
{/* Avatar & Name */}
<Box display="flex" alignItems="center" gap={4} mb={4}>
<Box
position="relative"
w="16"
h="16"
rounded="full"
overflow="hidden"
border
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue"
transition
>
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
objectFit="cover"
fill
/>
</Box>
<Box>
<Heading level={3} groupHoverColor="primary-blue" transition>
{driver.name}
</Heading>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Flag} size={3.5} color="rgb(107, 114, 128)" />
<Text size="sm" color="text-gray-500">{driver.nationality}</Text>
</Box>
</Box>
</Box>
{/* Stats */}
<Box display="grid" gridCols={3} gap={3}>
<MiniStat
label="Rating"
value={driver.rating.toLocaleString()}
color="text-primary-blue"
/>
<MiniStat
label="Wins"
value={driver.wins}
color="text-performance-green"
/>
<MiniStat
label="Podiums"
value={driver.podiums}
color="text-warning-amber"
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
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 bg="bg-iron-gray/80" border={true} borderColor="border-charcoal-outline" className="border-dashed">
<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

@@ -0,0 +1,77 @@
import React, { ReactNode } from 'react';
import Image from 'next/image';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Card } from './Card';
interface FeedItemProps {
actorName?: string;
actorAvatarUrl?: string;
typeLabel: string;
headline: string;
body?: string;
timeAgo: string;
cta?: ReactNode;
}
export function FeedItem({
actorName,
actorAvatarUrl,
typeLabel,
headline,
body,
timeAgo,
cta,
}: FeedItemProps) {
return (
<Box display="flex" gap={4}>
<Box flexShrink={0}>
{actorAvatarUrl ? (
<Box width="10" height="10" rounded="full" overflow="hidden" bg="bg-charcoal-outline">
<Image
src={actorAvatarUrl}
alt={actorName || ''}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</Box>
) : (
<Box
width="10"
height="10"
display="flex"
center
rounded="full"
bg="bg-primary-blue/10"
border={true}
borderColor="border-primary-blue/40"
>
<Text size="xs" color="text-primary-blue" weight="semibold">
{typeLabel}
</Text>
</Box>
)}
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
<Box>
<Text size="sm" color="text-white" block>{headline}</Text>
{body && (
<Text size="xs" color="text-gray-400" block mt={1}>{body}</Text>
)}
</Box>
<Text size="xs" color="text-gray-500" className="whitespace-nowrap" style={{ fontSize: '11px' }}>
{timeAgo}
</Text>
</Box>
{cta && (
<Box mt={3}>
{cta}
</Box>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import { Card } from '@/ui/Card';
import { FeedList } from '@/ui/FeedList';
import { UpcomingRacesSidebar } from '@/ui/UpcomingRacesSidebar';
import { LatestResultsSidebar } from '@/ui/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 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

@@ -0,0 +1,33 @@
import React from 'react';
import { FeedEmptyState } from '@/ui/FeedEmptyState';
import { FeedItemCard } from '@/components/feed/FeedItemCard';
import { Stack } from '@/ui/Stack';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface FeedListProps {
items: FeedItemData[];
}
export function FeedList({ items }: FeedListProps) {
if (!items.length) {
return <FeedEmptyState />;
}
return (
<Stack gap={4}>
{items.map(item => (
<FeedItemCard key={item.id} item={item} />
))}
</Stack>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
interface FilePickerProps {
label?: string;
description?: string;
accept?: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
}
export function FilePicker({ label, description, accept, onChange, disabled }: FilePickerProps) {
return (
<Stack gap={2}>
{label && (
<Text as="label" size="sm" weight="medium" color="text-gray-400">
{label}
</Text>
)}
{description && (
<Text size="xs" color="text-gray-500">
{description}
</Text>
)}
<Box
as="input"
type="file"
accept={accept}
onChange={onChange}
disabled={disabled}
display="block"
fullWidth
size="sm"
color="text-gray-400"
className="file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary-blue file:text-white file:cursor-pointer file:transition-colors hover:file:bg-primary-blue/80 disabled:file:opacity-50 disabled:file:cursor-not-allowed"
/>
</Stack>
);
}

View File

@@ -0,0 +1,46 @@
import { Box } from './Box';
import { Button } from './Button';
import { Stack } from './Stack';
interface FilterOption {
id: string;
label: string;
indicatorColor?: string;
}
interface FilterGroupProps {
options: FilterOption[];
activeId: string;
onSelect: (id: string) => void;
}
export function FilterGroup({ options, activeId, onSelect }: FilterGroupProps) {
return (
<Stack direction="row" align="center" gap={1} bg="bg-deep-graphite" p={1} rounded="lg">
{options.map((option) => (
<Button
key={option.id}
variant={activeId === option.id ? 'primary' : 'ghost'}
onClick={() => onSelect(option.id)}
size="sm"
px={4}
>
{option.indicatorColor && (
<Box
as="span"
w="2"
h="2"
bg={option.indicatorColor}
rounded="full"
mr={2}
animate={option.indicatorColor.includes('green') ? 'pulse' : 'none'}
/>
)}
{option.label}
</Button>
))}
</Stack>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface FinishDistributionProps {
wins: number;
podiums: number;
topTen: number;
total: number;
}
export function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) {
const outsideTopTen = total - topTen;
const podiumsNotWins = podiums - wins;
const topTenNotPodium = topTen - podiums;
const segments = [
{ label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' },
{ label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' },
{ label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' },
{ label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' },
].filter(s => s.value > 0);
return (
<Stack gap={3}>
<Box h="4" rounded="full" overflow="hidden" display="flex" bg="bg-charcoal-outline">
{segments.map((segment) => (
<Box
key={segment.label}
bg={segment.color}
transition
style={{ width: `${(segment.value / total) * 100}%` }}
/>
))}
</Box>
<Box display="flex" flexWrap="wrap" gap={4} justifyContent="center">
{segments.map((segment) => (
<Box key={segment.label} display="flex" alignItems="center" gap={2}>
<Box w="3" h="3" rounded="full" bg={segment.color} />
<Text size="xs" color={segment.textColor}>
{segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%)
</Text>
</Box>
))}
</Box>
</Stack>
);
}

106
apps/website/ui/Footer.tsx Normal file
View File

@@ -0,0 +1,106 @@
'use client';
import { Image } from '@/ui/Image';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
export function Footer() {
return (
<Box as="footer" position="relative" bg="bg-deep-graphite">
<Box position="absolute" top="0" left="0" right="0" h="px" bg="linear-gradient(to right, transparent, var(--primary-blue), transparent)" />
<Box maxWidth="4xl" mx="auto" px={{ base: 'calc(1.5rem+var(--sal))', lg: 8 }} py={{ base: 2, md: 8, lg: 12 }} pb={{ base: 'calc(0.5rem+var(--sab))', md: 'calc(1.5rem+var(--sab))' }}>
{/* Racing stripe accent */}
<Box
display="flex"
gap={1}
mb={{ base: 2, md: 4, lg: 6 }}
justifyContent="center"
>
<Box w={{ base: "12", md: "20", lg: "28" }} h={{ base: "0.5", md: "0.5", lg: "1" }} bg="bg-white" rounded="full" />
<Box w={{ base: "12", md: "20", lg: "28" }} h={{ base: "0.5", md: "0.5", lg: "1" }} bg="bg-primary-blue" rounded="full" />
<Box w={{ base: "12", md: "20", lg: "28" }} h={{ base: "0.5", md: "0.5", lg: "1" }} bg="bg-white" rounded="full" />
</Box>
{/* Personal message */}
<Box
textAlign="center"
mb={{ base: 3, md: 6, lg: 8 }}
>
<Box mb={2} display="flex" justifyContent="center">
<Image
src="/images/logos/icon-square-dark.svg"
alt="GridPilot"
width={40}
height={40}
fullHeight
style={{ width: 'auto' }}
/>
</Box>
<Text size={{ base: 'xs', lg: 'sm' }} color="text-gray-300" block mb={{ base: 1, md: 2 }}>
🏁 Built by a sim racer, for sim racers
</Text>
<Text size={{ base: 'xs', md: 'xs' }} color="text-gray-400" weight="light" maxWidth="2xl" mx="auto" block>
Just a fellow racer tired of spreadsheets and chaos. GridPilot is my passion project to make league racing actually fun again.
</Text>
</Box>
{/* Community links */}
<Box
display="flex"
justifyContent="center"
gap={{ base: 4, md: 6, lg: 8 }}
mb={{ base: 3, md: 6, lg: 8 }}
>
<Link
href={discordUrl}
variant="ghost"
size="xs"
hoverTextColor="text-neon-aqua"
transition
px={3}
py={2}
minHeight="44px"
minWidth="44px"
>
💬 Join Discord
</Link>
<Link
href={xUrl}
variant="ghost"
size="xs"
color="text-gray-300"
hoverTextColor="text-neon-aqua"
transition
px={3}
py={2}
minHeight="44px"
minWidth="44px"
>
𝕏 Follow on X
</Link>
</Box>
{/* Development status */}
<Box
textAlign="center"
pt={{ base: 2, md: 4, lg: 6 }}
borderTop
borderColor="border-charcoal-outline"
>
<Text size={{ base: 'xs', lg: 'sm' }} color="text-gray-500" block mb={{ base: 1, md: 2 }}>
Early development Feedback welcome
</Text>
<Text size={{ base: 'xs', md: 'xs' }} color="text-gray-600" block>
Questions? Find me on Discord
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import React from 'react';
import { Icon } from './Icon';
import { Stack } from './Stack';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';

View File

@@ -0,0 +1,49 @@
import { Box } from './Box';
import { Image } from './Image';
import { Surface } from './Surface';
import { Text } from './Text';
interface FriendItemProps {
name: string;
avatarUrl: string;
country: string;
}
export function FriendItem({ name, avatarUrl, country }: FriendItemProps) {
return (
<Surface
variant="muted"
padding={2}
rounded="lg"
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
>
<Box
w="9"
h="9"
rounded="full"
overflow="hidden"
style={{
background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)',
}}
>
<Image
src={avatarUrl}
alt={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>
{name}
</Text>
<Text size="xs" color="text-gray-500" block>
{country}
</Text>
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from './Stack';
interface FriendsListProps {
children: ReactNode;
}
export function FriendsList({ children }: FriendsListProps) {
return (
<Stack gap={2}>
{children}
</Stack>
);
}

View File

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

View File

@@ -0,0 +1,71 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Card } from '@/ui/Card';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { FriendItem } from '@/ui/FriendItem';
import { FriendsList } from '@/ui/FriendsList';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { UserPlus, Users } from 'lucide-react';
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 direction="row" align="center" justify="between" mb={4}>
<Heading level={3} icon={<Icon icon={Users} size={5} color="var(--neon-purple)" />}>
Friends
</Heading>
<Text size="xs" color="text-gray-500">{friends.length} friends</Text>
</Stack>
{hasFriends ? (
<FriendsList>
{friends.slice(0, 6).map((friend) => (
<FriendItem
key={friend.id}
name={friend.name}
avatarUrl={friend.avatarUrl}
country={friend.country}
/>
))}
{friends.length > 6 && (
<Box py={2}>
<Link
href={routes.protected.profile}
variant="primary"
>
<Text size="sm" block align="center">+{friends.length - 6} more</Text>
</Link>
</Box>
)}
</FriendsList>
) : (
<MinimalEmptyState
icon={UserPlus}
title="No friends yet"
description="Find drivers to follow"
action={{
label: 'Find Drivers',
onClick: () => window.location.href = routes.public.drivers
}}
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Heading } from './Heading';
import { Card } from './Card';
import { ProgressBar } from './ProgressBar';
interface GoalCardProps {
title: string;
icon: string;
goalLabel: string;
currentValue: number;
maxValue: number;
color?: string;
}
export function GoalCard({
title,
icon,
goalLabel,
currentValue,
maxValue,
color = 'text-primary-blue',
}: GoalCardProps) {
return (
<Card bg="bg-charcoal-200/50" borderColor="border-primary-blue/30">
<Box display="flex" alignItems="center" gap={3} mb={3}>
<Text size="2xl">{icon}</Text>
<Heading level={3}>{title}</Heading>
</Box>
<Stack gap={2}>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-gray-400">{goalLabel}</Text>
<Text size="sm" className={color}>{currentValue}/{maxValue}</Text>
</Box>
<ProgressBar value={currentValue} max={maxValue} />
</Stack>
</Card>
);
}

View File

@@ -1,16 +1,19 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
import React, { ReactNode } from 'react';
import { Box, BoxProps } from './Box';
interface GridProps extends HTMLAttributes<HTMLElement> {
interface GridProps extends Omit<BoxProps<'div'>, 'children' | 'display'> {
children: ReactNode;
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
gap?: number;
className?: string;
mdCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
lgCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
gap?: any;
}
export function Grid({
children,
cols = 1,
mdCols,
lgCols,
gap = 4,
className = '',
...props
@@ -40,6 +43,8 @@ export function Grid({
const classes = [
'grid',
colClasses[cols] || 'grid-cols-1',
mdCols ? `md:grid-cols-${mdCols}` : '',
lgCols ? `lg:grid-cols-${lgCols}` : '',
gapClasses[gap] || 'gap-4',
className
].filter(Boolean).join(' ');

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Container from '@/ui/Container';
import { Container } from '@/ui/Container';
interface HeaderProps {
children: React.ReactNode;

View File

@@ -1,16 +1,29 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import React, { ReactNode, ElementType } from 'react';
import { Stack } from './Stack';
import { Box, BoxProps } from './Box';
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: ReactNode;
className?: string;
style?: React.CSSProperties;
icon?: ReactNode;
interface ResponsiveFontSize {
base?: string;
sm?: string;
md?: string;
lg?: string;
xl?: string;
'2xl'?: string;
}
export function Heading({ level, children, className = '', style, icon, ...props }: HeadingProps) {
const Tag = `h${level}` as 'h1';
interface HeadingProps extends Omit<BoxProps<'h1'>, 'children' | 'as' | 'fontSize'> {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: ReactNode;
icon?: ReactNode;
id?: string;
groupHoverColor?: string;
truncate?: boolean;
fontSize?: string | ResponsiveFontSize;
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
}
export function Heading({ level, children, icon, groupHoverColor, truncate, fontSize, weight, ...props }: HeadingProps) {
const Tag = `h${level}` as ElementType;
const levelClasses = {
1: 'text-3xl md:text-4xl font-bold text-white',
@@ -21,7 +34,28 @@ export function Heading({ level, children, className = '', style, icon, ...props
6: 'text-xs font-semibold text-white',
};
const classes = [levelClasses[level], className].filter(Boolean).join(' ');
const weightClasses = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold'
};
const getFontSizeClasses = (value: string | ResponsiveFontSize | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base) classes.push(`text-${value.base}`);
if (value.sm) classes.push(`sm:text-${value.sm}`);
if (value.md) classes.push(`md:text-${value.md}`);
if (value.lg) classes.push(`lg:text-${value.lg}`);
if (value.xl) classes.push(`xl:text-${value.xl}`);
if (value['2xl']) classes.push(`2xl:text-${value['2xl']}`);
return classes.join(' ');
}
return `text-${value}`;
};
const content = icon ? (
<Stack direction="row" align="center" gap={2}>
@@ -30,5 +64,18 @@ export function Heading({ level, children, className = '', style, icon, ...props
</Stack>
) : children;
return <Tag className={classes} style={style} {...props}>{content}</Tag>;
const classes = [
levelClasses[level],
getFontSizeClasses(fontSize),
weight ? weightClasses[weight] : '',
groupHoverColor ? `group-hover:text-${groupHoverColor}` : '',
truncate ? 'truncate' : '',
props.className
].filter(Boolean).join(' ');
return (
<Box as={Tag} {...props} className={classes}>
{content}
</Box>
);
}

View File

@@ -3,28 +3,21 @@ import { Box } from './Box';
interface HeroProps {
children: ReactNode;
className?: string;
variant?: 'default' | 'primary' | 'secondary';
variant?: 'primary' | 'secondary';
}
export function Hero({ children, className = '', variant = 'default' }: HeroProps) {
const baseClasses = 'relative overflow-hidden rounded-2xl border p-8';
const variantClasses = {
default: 'bg-iron-gray border-charcoal-outline',
primary: 'bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border-charcoal-outline',
secondary: 'bg-gradient-to-br from-primary-blue/10 to-purple-600/10 border-primary-blue/20'
};
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
export function Hero({ children, variant = 'primary' }: HeroProps) {
return (
<Box className={classes}>
<Box className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
<Box className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
<Box className="relative z-10">
{children}
</Box>
<Box
position="relative"
rounded="2xl"
overflow="hidden"
p={{ base: 6, md: 8 }}
bg={variant === 'primary' ? 'bg-iron-gray/40' : 'bg-deep-graphite'}
border
borderColor="border-charcoal-outline"
>
{children}
</Box>
);
}

View File

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

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Card } from './Card';
import { Stack } from './Stack';
import { Surface } from './Surface';
import { Text } from './Text';
interface HorizontalStatCardProps {
label: string;
value: string | number;
subValue?: string;
icon: ReactNode;
iconBgColor?: string;
}
export function HorizontalStatCard({
label,
value,
subValue,
icon,
iconBgColor,
}: HorizontalStatCardProps) {
return (
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface
variant="muted"
rounded="full"
padding={3}
style={{ backgroundColor: iconBgColor }}
>
{icon}
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>
{label}
</Text>
<Text size="2xl" weight="bold" color="text-white" block>
{value}
</Text>
{subValue && (
<Text size="sm" color="text-gray-400">
{subValue}
</Text>
)}
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
interface HorizontalStatItemProps {
label: string;
value: string | number;
color?: string;
}
export function HorizontalStatItem({ label, value, color = 'text-white' }: HorizontalStatItemProps) {
return (
<Box display="flex" alignItems="center" justifyContent="between" fullWidth>
<Text size="sm" color="text-gray-400">{label}</Text>
<Text weight="semibold" color={color}>{value}</Text>
</Box>
);
}

View File

@@ -1,12 +1,11 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Box, BoxProps } from './Box';
interface IconProps {
interface IconProps extends Omit<BoxProps<'svg'>, 'children' | 'as'> {
icon: LucideIcon;
size?: number | string;
color?: string;
className?: string;
style?: React.CSSProperties;
}
export function Icon({ icon: LucideIcon, size = 4, color, className = '', style, ...props }: IconProps) {
@@ -20,7 +19,8 @@ export function Icon({ icon: LucideIcon, size = 4, color, className = '', style,
8: 'w-8 h-8',
10: 'w-10 h-10',
12: 'w-12 h-12',
16: 'w-16 h-16'
16: 'w-16 h-16',
'full': 'w-full h-full'
};
const sizeClass = sizeMap[size] || 'w-4 h-4';
@@ -28,8 +28,9 @@ export function Icon({ icon: LucideIcon, size = 4, color, className = '', style,
const combinedStyle = color ? { color, ...style } : style;
return (
<LucideIcon
className={`${sizeClass} ${className}`}
<Box
as={LucideIcon}
className={`${sizeClass} ${className}`}
style={combinedStyle}
{...props}
/>

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from './Button';
import { Icon } from './Icon';
interface IconButtonProps {
icon: LucideIcon;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
title?: string;
disabled?: boolean;
color?: string;
}
export function IconButton({
icon,
onClick,
variant = 'secondary',
size = 'md',
title,
disabled,
color,
}: IconButtonProps) {
const sizeMap = {
sm: { btn: 'w-8 h-8 p-0', icon: 4 },
md: { btn: 'w-10 h-10 p-0', icon: 5 },
lg: { btn: 'w-12 h-12 p-0', icon: 6 },
};
return (
<Button
variant={variant}
onClick={onClick}
title={title}
disabled={disabled}
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0`}
>
<Icon icon={icon} size={sizeMap[size].icon} color={color} />
</Button>
);
}

View File

@@ -6,9 +6,22 @@ interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
width?: number;
height?: number;
className?: string;
fallbackSrc?: string;
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
fill?: boolean;
fullWidth?: boolean;
fullHeight?: boolean;
}
export function Image({ src, alt, width, height, className = '', ...props }: ImageProps) {
export function Image({ src, alt, width, height, className = '', fallbackSrc, objectFit, fill, fullWidth, fullHeight, ...props }: ImageProps) {
const classes = [
objectFit ? `object-${objectFit}` : '',
fill ? 'absolute inset-0 w-full h-full' : '',
fullWidth ? 'w-full' : '',
fullHeight ? 'h-full' : '',
className
].filter(Boolean).join(' ');
return (
// eslint-disable-next-line @next/next/no-img-element
<img
@@ -16,7 +29,12 @@ export function Image({ src, alt, width, height, className = '', ...props }: Ima
alt={alt}
width={width}
height={height}
className={className}
className={classes}
onError={(e) => {
if (fallbackSrc) {
(e.target as HTMLImageElement).src = fallbackSrc;
}
}}
{...props}
/>
);

View File

@@ -1,12 +1,12 @@
'use client';
import { AlertTriangle, CheckCircle, Info, LucideIcon, XCircle } from 'lucide-react';
import React from 'react';
import { Info, AlertTriangle, CheckCircle, XCircle, LucideIcon } from 'lucide-react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Surface } from './Surface';
import { Icon } from './Icon';
import { Stack } from './Stack';
import { Surface } from './Surface';
import { Text } from './Text';
type BannerType = 'info' | 'warning' | 'success' | 'error';

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface InfoItemProps {
icon: LucideIcon;
label: string;
value: string | number;
}
export function InfoItem({ icon, label, value }: InfoItemProps) {
return (
<Box display="flex" alignItems="start" gap={2.5}>
<Box
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-iron-gray/60"
flexShrink={0}
>
<Icon icon={icon} size={3.5} color="text-gray-500" />
</Box>
<Box flexGrow={1} minWidth="0">
<Text size="xs" color="text-gray-500" block mb={0.5} style={{ fontSize: '10px' }}>{label}</Text>
<Text size="xs" weight="medium" color="text-gray-300" block className="truncate">
{value}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,39 @@
import { IconButton } from '@/ui/IconButton';
import { AlertTriangle } from 'lucide-react';
interface DriverDTO {
id: string;
name: string;
}
interface InlinePenaltyButtonProps {
driver: DriverDTO;
onPenaltyClick?: (driver: DriverDTO) => void;
isAdmin: boolean;
}
export function InlinePenaltyButton({
driver,
onPenaltyClick,
isAdmin,
}: InlinePenaltyButtonProps) {
if (!isAdmin) return null;
const handleButtonClick = () => {
if (onPenaltyClick) {
onPenaltyClick(driver);
}
};
return (
<IconButton
variant="danger"
icon={AlertTriangle}
onClick={handleButtonClick}
title={`Issue penalty to ${driver.name}`}
size="sm"
/>
);
}

View File

@@ -1,42 +1,51 @@
import { forwardRef } from 'react';
import React, { forwardRef } from 'react';
import { Text } from './Text';
import { Box } from './Box';
import { Stack } from './Stack';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
errorMessage?: string;
icon?: React.ReactNode;
label?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', variant = 'default', errorMessage, icon, ...props }, ref) => {
({ className = '', variant = 'default', errorMessage, icon, label, ...props }, ref) => {
const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors w-full';
const variantClasses = (variant === 'error' || errorMessage) ? 'border-racing-red' : 'border-charcoal-outline';
const iconClasses = icon ? 'pl-10' : '';
const classes = `${baseClasses} ${variantClasses} ${iconClasses} ${className}`;
return (
<Box fullWidth position="relative">
{icon && (
<Box
position="absolute"
left="3"
top="50%"
style={{ transform: 'translateY(-50%)' }}
zIndex={10}
display="flex"
center
>
{icon}
</Box>
)}
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-error-red" block mt={1}>
{errorMessage}
<Stack gap={1.5} fullWidth>
{label && (
<Text as="label" size="sm" weight="medium" color="text-gray-300">
{label}
</Text>
)}
</Box>
<Box fullWidth position="relative">
{icon && (
<Box
position="absolute"
left="3"
top="50%"
style={{ transform: 'translateY(-50%)' }}
zIndex={10}
display="flex"
center
>
{icon}
</Box>
)}
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-error-red" block mt={1}>
{errorMessage}
</Text>
)}
</Box>
</Stack>
);
}
);

View File

@@ -0,0 +1,76 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
import { Button } from './Button';
interface JoinRequestItemProps {
driverId: string;
requestedAt: string | Date;
onApprove: () => void;
onReject: () => void;
isApproving?: boolean;
isRejecting?: boolean;
}
export function JoinRequestItem({
driverId,
requestedAt,
onApprove,
onReject,
isApproving,
isRejecting,
}: JoinRequestItemProps) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="between"
p={4}
rounded="lg"
bg="bg-deep-graphite"
border={true}
borderColor="border-charcoal-outline"
>
<Stack direction="row" align="center" gap={4} flexGrow={1}>
<Box
width="12"
height="12"
rounded="full"
bg="bg-primary-blue/20"
display="flex"
center
color="text-white"
weight="bold"
style={{ fontSize: '1.125rem' }}
>
{driverId.charAt(0)}
</Box>
<Box flexGrow={1}>
<Text color="text-white" weight="medium" block>{driverId}</Text>
<Text size="sm" color="text-gray-400" block>
Requested {new Date(requestedAt).toLocaleDateString()}
</Text>
</Box>
</Stack>
<Stack direction="row" gap={2}>
<Button
variant="primary"
onClick={onApprove}
disabled={isApproving}
size="sm"
>
{isApproving ? 'Approving...' : 'Approve'}
</Button>
<Button
variant="danger"
onClick={onReject}
disabled={isRejecting}
size="sm"
>
{isRejecting ? 'Rejecting...' : 'Reject'}
</Button>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from './Stack';
interface JoinRequestListProps {
children: ReactNode;
}
export function JoinRequestList({ children }: JoinRequestListProps) {
return (
<Stack gap={3}>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,171 @@
import { useParallax } from '@/hooks/useScrollProgress';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
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 function LandingHero() {
const sectionRef = useRef<HTMLElement>(null);
const bgParallax = useParallax(sectionRef, 0.3);
return (
<Box
as="section"
ref={sectionRef}
position="relative"
overflow="hidden"
bg="bg-deep-graphite"
px={{ base: 'calc(1.5rem+var(--sal))', lg: 8 }}
pt={{ base: 'calc(3rem+var(--sat))', sm: 'calc(4rem+var(--sat))' }}
pb={{ base: 16, sm: 24 }}
py={{ md: 32 }}
>
{/* Background image layer with parallax */}
<Box
position="absolute"
inset="0"
bg="url(/images/header.jpeg)"
backgroundSize="cover"
backgroundPosition="center"
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 */}
<Box position="absolute" top="0" left="0" right="0" h="1" bg="linear-gradient(to right, transparent, rgba(220, 38, 38, 0.4), transparent)" />
{/* Racing stripes background */}
<Box position="absolute" inset="0" opacity={0.3} bg="racing-stripes" />
{/* Checkered pattern overlay */}
<Box position="absolute" inset="0" opacity={0.2} bg="checkered-pattern" />
{/* Speed lines - left side */}
<Box position="absolute" left="0" top="1/4" w="32" h="px" bg="linear-gradient(to right, transparent, rgba(59, 130, 246, 0.3))" className="animate-speed-lines" style={{ animationDelay: '0s' }} />
<Box position="absolute" left="0" top="1/3" w="24" h="px" bg="linear-gradient(to right, transparent, rgba(59, 130, 246, 0.2))" className="animate-speed-lines" style={{ animationDelay: '0.3s' }} />
<Box position="absolute" left="0" top="2/5" w="28" h="px" bg="linear-gradient(to right, transparent, rgba(59, 130, 246, 0.25))" className="animate-speed-lines" style={{ animationDelay: '0.6s' }} />
{/* Carbon fiber accent - bottom */}
<Box position="absolute" bottom="0" left="0" right="0" h="32" opacity={0.5} bg="carbon-fiber" />
{/* Radial gradient overlay with racing red accent */}
<Box position="absolute" inset="0" bg="radial-gradient(circle at center, rgba(220, 38, 38, 0.05), rgba(59, 130, 246, 0.05), transparent)" opacity={0.6} pointerEvents="none" />
<Container size="sm" position="relative" zIndex={10}>
<Stack gap={{ base: 6, sm: 8, md: 12 }}>
<Heading
level={1}
fontSize={{ base: '2xl', sm: '4xl', md: '5xl', lg: '6xl' }}
weight="semibold"
style={{
background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
filter: 'drop-shadow(0 0 15px rgba(220,0,0,0.4))',
WebkitTextStroke: '0.5px rgba(220,0,0,0.2)'
}}
>
League racing is incredible. What's missing is everything around it.
</Heading>
<Box
display="flex"
flexDirection="column"
gap={{ base: 4, sm: 6 }}
>
<Text size={{ base: 'sm', md: 'lg' }} align={{ base: 'left', md: 'center' }} color="text-slate-200" weight="light">
If you've been in any league, you know the feeling:
</Text>
{/* Problem badges - mobile optimized */}
<Box display="flex" flexDirection={{ base: 'col', sm: 'row' }} flexWrap="wrap" gap={{ base: 2, sm: 3 }} alignItems={{ base: 'stretch', sm: 'center' }} justifyContent="center" maxWidth="2xl" mx="auto">
{[
'Results scattered across Discord',
'No long-term identity',
'No career progression',
'Forgotten after each season'
].map((text) => (
<Box
key={text}
display="flex"
alignItems="center"
gap={2.5}
px={{ base: 3, sm: 5 }}
py={2.5}
bg="linear-gradient(to bottom right, rgba(30, 41, 59, 0.8), rgba(15, 23, 42, 0.8))"
border
borderColor="border-red-500/20"
rounded="lg"
transition
hoverBorderColor="border-red-500/40"
hoverScale
>
<Text color="text-red-500" weight="semibold">×</Text>
<Text size={{ base: 'sm', sm: 'base' }} weight="medium" color="text-slate-100">{text}</Text>
</Box>
))}
</Box>
<Text size={{ base: 'sm', md: 'lg' }} align={{ base: 'left', md: 'center' }} color="text-slate-200" weight="light">
The ecosystem isn't built for this.
</Text>
<Text size={{ base: 'sm', md: 'lg' }} align={{ base: 'left', md: 'center' }} color="text-white" weight="semibold">
GridPilot gives your league racing a real home.
</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="center">
<Button
as="a"
href="#community"
variant="primary"
px={8}
py={4}
size="lg"
bg="bg-[#5865F2]"
hoverBg="bg-[#4752C4]"
shadow="0 0 20px rgba(88,101,242,0.3)"
hoverScale
transition
aria-label="Join us on Discord"
>
{/* Discord Logo SVG */}
<Box
as="svg"
w="7"
h="7"
viewBox="0 0 71 55"
fill="none"
xmlns="http://www.w3.org/2000/svg"
transition
groupHoverScale
>
<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>
</Box>
<Text>Join us on Discord</Text>
</Button>
</Box>
</Stack>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { Check } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
export function FeatureItem({ text }: { text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} bg="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} bg="rgba(59, 130, 246, 0.1)" border borderColor="rgba(59, 130, 246, 0.3)">
<Icon icon={Check} size={5} color="#3b82f6" />
</Surface>
<Text color="text-slate-200" leading="relaxed" weight="light">
{text}
</Text>
</Stack>
</Surface>
);
}
export function ResultItem({ text, color }: { text: string, color: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} bg="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} bg={`${color}1A`} border borderColor={`${color}4D`}>
<Icon icon={Check} size={5} color={color} />
</Surface>
<Text color="text-slate-200" leading="relaxed" weight="light">
{text}
</Text>
</Stack>
</Surface>
);
}
export function StepItem({ step, text }: { step: number, text: string }) {
return (
<Surface variant="muted" rounded="lg" border padding={4} bg="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} bg="rgba(59, 130, 246, 0.1)" border borderColor="rgba(59, 130, 246, 0.4)" w="10" h="10" display="flex" center>
<Text weight="bold" size="sm" color="text-primary-blue">{step}</Text>
</Surface>
<Text color="text-slate-200" leading="relaxed" weight="light">
{text}
</Text>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { RaceResultList } from '@/ui/RaceResultList';
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
import { Box } from '@/ui/Box';
type RaceWithResults = {
raceId: string;
track: string;
car: string;
winnerName: string;
scheduledAt: string | Date;
};
interface LatestResultsSidebarProps {
results: RaceWithResults[];
}
export function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
if (!results.length) {
return null;
}
return (
<Card bg="bg-iron-gray/80" p={4}>
<Heading level={3} mb={3}>
Latest results
</Heading>
<RaceResultList>
{results.slice(0, 4).map((result) => {
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
return (
<Box as="li" key={result.raceId}>
<RaceSummaryItem
track={result.track}
meta={`${result.winnerName}${result.car}`}
date={scheduledAt}
/>
</Box>
);
})}
</RaceResultList>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import Image from 'next/image';
import { Crown, Flag } from 'lucide-react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
import { mediaConfig } from '@/lib/config/mediaConfig';
interface LeaderboardItemProps {
position: number;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevelLabel?: string;
skillLevelColor?: string;
categoryLabel?: string;
categoryColor?: string;
onClick: () => void;
}
export function LeaderboardItem({
position,
name,
avatarUrl,
nationality,
rating,
wins,
skillLevelLabel,
skillLevelColor,
categoryLabel,
categoryColor,
onClick,
}: LeaderboardItemProps) {
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';
}
};
const getMedalBg = (pos: number) => {
switch (pos) {
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 (
<Box
as="button"
type="button"
onClick={onClick}
display="flex"
alignItems="center"
gap={4}
px={4}
py={3}
fullWidth
textAlign="left"
className="hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<Box
width="8"
height="8"
display="flex"
center
rounded="full"
border
className={`${getMedalBg(position)} ${getMedalColor(position)} text-xs font-bold`}
>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</Box>
{/* Avatar */}
<Box position="relative" width="9" height="9" rounded="full" overflow="hidden" border={true} borderColor="border-charcoal-outline">
<Image src={avatarUrl || mediaConfig.avatars.defaultFallback} alt={name} fill className="object-cover" />
</Box>
{/* Info */}
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate block className="group-hover:text-primary-blue transition-colors">
{name}
</Text>
<Stack direction="row" align="center" gap={2}>
<Flag className="w-3 h-3 text-gray-500" />
<Text size="xs" color="text-gray-500">{nationality}</Text>
{categoryLabel && (
<Text size="xs" className={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" className={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Stack>
</Box>
{/* Stats */}
<Stack direction="row" align="center" gap={4}>
<Box textAlign="center">
<Text color="text-primary-blue" weight="semibold" font="mono" block>{rating.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Rating</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" weight="semibold" font="mono" block>{wins}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Wins</Text>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,16 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
interface LeaderboardListProps {
children: ReactNode;
}
export function LeaderboardList({ children }: LeaderboardListProps) {
return (
<Box rounded="xl" bg="bg-iron-gray/30" border={true} borderColor="border-charcoal-outline" overflow="hidden">
<div className="divide-y divide-charcoal-outline/50">
{children}
</div>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeaderboardItem } from '@/ui/LeaderboardItem';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Award, ChevronRight } from 'lucide-react';
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;
onNavigate: (href: string) => void;
}
export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) {
const top5 = drivers.slice(0, 5);
return (
<Stack gap={4} mb={10}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
center
w="10"
h="10"
rounded="xl"
bg="bg-gradient-to-br from-yellow-400/20 to-amber-600/10"
border
borderColor="border-yellow-400/30"
>
<Icon icon={Award} size={5} color="rgb(250, 204, 21)" />
</Box>
<Box>
<Heading level={2}>Top Drivers</Heading>
<Text size="xs" color="text-gray-500">Highest rated competitors</Text>
</Box>
</Stack>
<Button
variant="secondary"
onClick={() => onNavigate(routes.leaderboards.drivers)}
icon={<Icon icon={ChevronRight} size={4} />}
>
Full Rankings
</Button>
</Stack>
<LeaderboardList>
{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 (
<LeaderboardItem
key={driver.id}
position={position}
name={driver.name}
avatarUrl={driver.avatarUrl}
nationality={driver.nationality}
rating={driver.rating}
wins={driver.wins}
skillLevelLabel={levelConfig?.label}
skillLevelColor={levelConfig?.color}
categoryLabel={categoryConfig?.label}
categoryColor={categoryConfig?.color}
onClick={() => onDriverClick(driver.id)}
/>
);
})}
</LeaderboardList>
</Stack>
);
}

View File

@@ -0,0 +1,197 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Text } from './Text';
interface LeagueCardProps {
name: string;
description?: string;
coverUrl: string;
logoUrl?: string;
badges?: ReactNode;
championshipBadge?: ReactNode;
slotLabel: string;
usedSlots: number;
maxSlots: number | string;
fillPercentage: number;
hasOpenSlots: boolean;
openSlotsCount: number;
isTeamLeague?: boolean;
usedDriverSlots?: number;
maxDrivers?: number | string;
timingSummary?: string;
onClick?: () => void;
}
export function LeagueCard({
name,
description,
coverUrl,
logoUrl,
badges,
championshipBadge,
slotLabel,
usedSlots,
maxSlots,
fillPercentage,
hasOpenSlots,
openSlotsCount,
isTeamLeague,
usedDriverSlots,
maxDrivers,
timingSummary,
onClick,
}: LeagueCardProps) {
return (
<Box
position="relative"
cursor={onClick ? 'pointer' : 'default'}
h="full"
onClick={onClick}
className="group"
>
{/* Card Container */}
<Box
position="relative"
h="full"
rounded="xl"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
overflow="hidden"
transition
className="hover:border-primary-blue/50 hover:shadow-[0_0_30px_rgba(25,140,255,0.15)] hover:bg-iron-gray/80"
>
{/* Cover Image */}
<Box position="relative" h="32" overflow="hidden">
<img
src={coverUrl}
alt={`${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 */}
<Box position="absolute" inset="0" bg="bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
{/* Badges - Top Left */}
<Box position="absolute" top="3" left="3" display="flex" alignItems="center" gap={2}>
{badges}
</Box>
{/* Championship Type Badge - Top Right */}
<Box position="absolute" top="3" right="3">
{championshipBadge}
</Box>
{/* Logo */}
<Box position="absolute" left="4" bottom="-6" zIndex={10}>
<Box w="12" h="12" rounded="lg" overflow="hidden" border borderColor="border-iron-gray" bg="bg-deep-graphite" shadow="xl">
{logoUrl ? (
<img
src={logoUrl}
alt={`${name} logo`}
width={48}
height={48}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<PlaceholderImage size={48} />
)}
</Box>
</Box>
</Box>
{/* Content */}
<Box pt={8} px={4} pb={4} display="flex" flexDirection="col" fullHeight>
{/* Title & Description */}
<Heading level={3} className="line-clamp-1 group-hover:text-primary-blue transition-colors" mb={1}>
{name}
</Heading>
<Text size="xs" color="text-gray-500" lineClamp={2} mb={3} style={{ height: '2rem' }} block>
{description || 'No description available'}
</Text>
{/* Stats Row */}
<Box display="flex" alignItems="center" gap={3} mb={3}>
{/* Primary Slots (Drivers/Teams/Nations) */}
<Box flexGrow={1}>
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="xs" color="text-gray-500">{slotLabel}</Text>
<Text size="xs" color="text-gray-400">
{usedSlots}/{maxSlots || '∞'}
</Text>
</Box>
<Box h="1.5" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
<Box
h="full"
rounded="full"
transition
bg={
fillPercentage >= 90
? 'bg-warning-amber'
: fillPercentage >= 70
? 'bg-primary-blue'
: 'bg-performance-green'
}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</Box>
</Box>
{/* Open Slots Badge */}
{hasOpenSlots && (
<Box display="flex" alignItems="center" gap={1} px={2} py={1} rounded="lg" bg="bg-neon-aqua/10" border borderColor="border-neon-aqua/20">
<Box w="1.5" h="1.5" rounded="full" bg="bg-neon-aqua" animate="pulse" />
<Text size="xs" color="text-neon-aqua" weight="medium">
{openSlotsCount} open
</Text>
</Box>
)}
</Box>
{/* Driver count for team leagues */}
{isTeamLeague && (
<Box display="flex" alignItems="center" gap={2} mb={3}>
<Icon icon={LucideUsers} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">
{usedDriverSlots ?? 0}/{maxDrivers ?? '∞'} drivers
</Text>
</Box>
)}
{/* Spacer to push footer to bottom */}
<Box flexGrow={1} />
{/* Footer Info */}
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop style={{ borderColor: 'rgba(38, 38, 38, 0.5)' }} mt="auto">
<Box display="flex" alignItems="center" gap={3}>
{timingSummary && (
<Box display="flex" alignItems="center" gap={1}>
<Icon icon={LucideCalendar} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</Box>
)}
</Box>
{/* View Arrow */}
<Box display="flex" alignItems="center" gap={1} className="group-hover:text-primary-blue transition-colors">
<Text size="xs" color="text-gray-500">View</Text>
<Icon icon={LucideChevronRight} size={3} color="text-gray-500" className="transition-transform group-hover:translate-x-0.5" />
</Box>
</Box>
</Box>
</Box>
</Box>
);
}
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight, Users as LucideUsers } from 'lucide-react';

View File

@@ -0,0 +1,178 @@
import React from 'react';
import {
Trophy,
Users,
Flag,
Award,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { Badge } from '@/ui/Badge';
import { LeagueCard as UiLeagueCard } from '@/ui/LeagueCard';
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 getCategoryVariant(category?: string): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (!category) return 'default';
switch (category) {
case 'driver':
return 'primary';
case 'team':
return 'info';
case 'nations':
return 'success';
case 'trophy':
return 'warning';
case 'endurance':
return 'warning';
case 'sprint':
return 'danger';
default:
return 'default';
}
}
function getGameVariant(gameId?: string): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' {
switch (gameId) {
case 'iracing':
return 'warning';
case 'acc':
return 'success';
case 'f1-23':
case 'f1-24':
return 'danger';
default:
return 'primary';
}
}
function isNewLeague(createdAt: string | Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export 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 gameVariant = getGameVariant(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryVariant = getCategoryVariant(league.category);
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;
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<UiLeagueCard
name={league.name}
description={league.description}
coverUrl={coverUrl}
logoUrl={logoUrl || undefined}
slotLabel={slotLabel}
usedSlots={usedSlots}
maxSlots={maxSlots || '∞'}
fillPercentage={fillPercentage}
hasOpenSlots={hasOpenSlots}
openSlotsCount={maxSlots > 0 ? (maxSlots as number) - usedSlots : 0}
isTeamLeague={!!isTeamLeague}
usedDriverSlots={league.usedDriverSlots}
maxDrivers={league.maxDrivers}
timingSummary={league.timingSummary}
onClick={onClick}
badges={
<>
{isNew && (
<Badge variant="success" icon={Sparkles}>
NEW
</Badge>
)}
{league.scoring?.gameName && (
<Badge variant={gameVariant}>
{league.scoring.gameName}
</Badge>
)}
{league.category && (
<Badge variant={categoryVariant}>
{categoryLabel}
</Badge>
)}
</>
}
championshipBadge={
<Badge variant="default" icon={ChampionshipIcon}>
{championshipLabel}
</Badge>
}
/>
);
}

View File

@@ -0,0 +1,20 @@
import { Image } from './Image';
export interface LeagueCoverProps {
leagueId: string;
alt: string;
height?: string;
}
export function LeagueCover({ leagueId, alt, height = '12rem' }: LeagueCoverProps) {
return (
<Image
src={`/media/leagues/${leagueId}/cover`}
alt={alt}
style={{ width: '100%', height, objectFit: 'cover' }}
fallbackSrc="/default-league-cover.png"
/>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { LeagueCover as UiLeagueCover } from '@/ui/LeagueCover';
export interface LeagueCoverProps {
leagueId: string;
alt: string;
}
export function LeagueCover({ leagueId, alt }: LeagueCoverProps) {
return (
<UiLeagueCover
leagueId={leagueId}
alt={alt}
/>
);
}

View File

@@ -0,0 +1,61 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Stack } from './Stack';
import { Text } from './Text';
interface LeagueHeaderProps {
name: string;
description?: string | null;
logoUrl: string;
sponsorContent?: ReactNode;
statusContent?: ReactNode;
}
export function LeagueHeader({
name,
description,
logoUrl,
sponsorContent,
statusContent,
}: LeagueHeaderProps) {
return (
<Box mb={8}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Stack direction="row" align="center" gap={4}>
<Box h="16" w="16" rounded="xl" overflow="hidden" border style={{ borderColor: 'rgba(38, 38, 38, 0.8)', backgroundColor: '#1a1d23' }} shadow="lg">
<img
src={logoUrl}
alt={`${name} logo`}
width={64}
height={64}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</Box>
<Box>
<Box display="flex" alignItems="center" gap={3} mb={1}>
<Heading level={1}>
{name}
{sponsorContent && (
<Text color="text-gray-400" weight="normal" size="lg" ml={2}>
by {sponsorContent}
</Text>
)}
</Heading>
{statusContent}
</Box>
{description && (
<Text color="text-gray-400" size="sm" maxWidth="xl" block>
{description}
</Text>
)}
</Box>
</Stack>
</Box>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,22 @@
import { Image } from './Image';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
size?: number;
}
export function LeagueLogo({ leagueId, alt, size = 100 }: LeagueLogoProps) {
return (
<Image
src={`/media/leagues/${leagueId}/logo`}
alt={alt}
width={size}
height={size}
style={{ objectFit: 'contain' }}
fallbackSrc="/default-league-logo.png"
/>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { LeagueLogo as UiLeagueLogo } from '@/ui/LeagueLogo';
export interface LeagueLogoProps {
leagueId: string;
alt: string;
}
export function LeagueLogo({ leagueId, alt }: LeagueLogoProps) {
return (
<UiLeagueLogo
leagueId={leagueId}
alt={alt}
/>
);
}

View File

@@ -0,0 +1,101 @@
import React, { ReactNode } from 'react';
import { TableRow, TableCell } from './Table';
import { Box } from './Box';
import { Text } from './Text';
import { Badge } from './Badge';
import { DriverIdentity } from './DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface LeagueMemberRowProps {
driver?: DriverViewModel;
driverId: string;
isCurrentUser: boolean;
isTopPerformer: boolean;
role: string;
roleVariant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
joinedAt: string | Date;
rating?: number | string;
rank?: number | string;
wins?: number;
actions?: ReactNode;
href: string;
meta?: string | null;
}
export function LeagueMemberRow({
driver,
driverId,
isCurrentUser,
isTopPerformer,
role,
roleVariant,
joinedAt,
rating,
rank,
wins,
actions,
href,
meta,
}: LeagueMemberRowProps) {
const roleLabel = role.charAt(0).toUpperCase() + role.slice(1);
return (
<TableRow variant={isTopPerformer ? 'highlight' : 'default'}>
<TableCell>
<Box display="flex" alignItems="center" gap={2}>
{driver ? (
<DriverIdentity
driver={driver}
href={href}
contextLabel={roleLabel}
meta={meta}
size="md"
/>
) : (
<Text color="text-white">Unknown Driver</Text>
)}
{isCurrentUser && (
<Text size="xs" color="text-gray-500">(You)</Text>
)}
{isTopPerformer && (
<Text size="xs"></Text>
)}
</Box>
</TableCell>
<TableCell>
<Text color="text-primary-blue" weight="medium">
{rating || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-gray-300">
#{rank || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-green-400" weight="medium">
{wins || 0}
</Text>
</TableCell>
<TableCell>
<Badge variant={roleVariant}>
{roleLabel}
</Badge>
</TableCell>
<TableCell>
<Text color="text-white" size="sm">
{new Date(joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</Text>
</TableCell>
{actions && (
<TableCell align="right">
{actions}
</TableCell>
)}
</TableRow>
);
}

View File

@@ -0,0 +1,28 @@
import React, { ReactNode } from 'react';
import { Table, TableHead, TableBody, TableRow, TableHeader } from './Table';
interface LeagueMemberTableProps {
children: ReactNode;
showActions?: boolean;
}
export function LeagueMemberTable({ children, showActions }: LeagueMemberTableProps) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeader>Driver</TableHeader>
<TableHeader>Rating</TableHeader>
<TableHeader>Rank</TableHeader>
<TableHeader>Wins</TableHeader>
<TableHeader>Role</TableHeader>
<TableHeader>Joined</TableHeader>
{showActions && <TableHeader align="right">Actions</TableHeader>}
</TableRow>
</TableHead>
<TableBody>
{children}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,121 @@
import { ArrowRight } from 'lucide-react';
import { Box } from './Box';
import { Button } from './Button';
import { Card } from './Card';
import { Grid } from './Grid';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Image } from './Image';
import { Link } from './Link';
import { Stack } from './Stack';
import { Surface } from './Surface';
import { Text } from './Text';
interface LeagueSummaryCardProps {
id: string;
name: string;
description?: string;
maxDrivers: number;
qualifyingFormat: string;
href: string;
}
export function LeagueSummaryCard({
id,
name,
description,
maxDrivers,
qualifyingFormat,
href,
}: LeagueSummaryCardProps) {
return (
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Box
w="14"
h="14"
rounded="lg"
overflow="hidden"
bg="bg-deep-graphite"
flexShrink={0}
>
<Image
src={`/media/league-logo/${id}`}
alt={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' }}>
{name}
</Heading>
</Box>
</Stack>
{description && (
<Text
size="sm"
color="text-gray-400"
block
mb={4}
lineClamp={2}
style={{ height: '2.5rem' }}
>
{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">
{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' }}
>
{qualifyingFormat}
</Text>
</Surface>
</Grid>
</Box>
<Box>
<Link href={href}>
<Button
variant="secondary"
fullWidth
icon={<Icon icon={ArrowRight} size={4} />}
>
View League
</Button>
</Link>
</Box>
</Box>
</Card>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { LeagueSummaryCard as UiLeagueSummaryCard } from '@/ui/LeagueSummaryCard';
import { routes } from '@/lib/routing/RouteConfig';
interface LeagueSummaryCardProps {
league: {
id: string;
name: string;
description?: string;
settings: {
maxDrivers: number;
qualifyingFormat: string;
};
};
}
export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) {
return (
<UiLeagueSummaryCard
id={league.id}
name={league.name}
description={league.description}
maxDrivers={league.settings.maxDrivers}
qualifyingFormat={league.settings.qualifyingFormat}
href={routes.league.detail(league.id)}
/>
);
}

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