website refactor
This commit is contained in:
48
apps/website/ui/Accordion.tsx
Normal file
48
apps/website/ui/Accordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/website/ui/AchievementCard.tsx
Normal file
51
apps/website/ui/AchievementCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/website/ui/AchievementGrid.tsx
Normal file
86
apps/website/ui/AchievementGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/website/ui/ActiveDriverCard.tsx
Normal file
67
apps/website/ui/ActiveDriverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/website/ui/ActivityFeed.tsx
Normal file
52
apps/website/ui/ActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/website/ui/ActivityFeedItem.tsx
Normal file
49
apps/website/ui/ActivityFeedItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/website/ui/ActivityFeedList.tsx
Normal file
14
apps/website/ui/ActivityFeedList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/website/ui/ActivityItem.tsx
Normal file
65
apps/website/ui/ActivityItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
apps/website/ui/AlternatingSection.tsx
Normal file
129
apps/website/ui/AlternatingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorBanner } from './ErrorBanner';
|
||||
|
||||
interface AuthErrorProps {
|
||||
|
||||
@@ -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;
|
||||
|
||||
160
apps/website/ui/AvailableLeagueCard.tsx
Normal file
160
apps/website/ui/AvailableLeagueCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
109
apps/website/ui/BenefitCard.tsx
Normal file
109
apps/website/ui/BenefitCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
apps/website/ui/BorderTabs.tsx
Normal file
64
apps/website/ui/BorderTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
52
apps/website/ui/Breadcrumbs.tsx
Normal file
52
apps/website/ui/Breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
95
apps/website/ui/CareerHighlights.tsx
Normal file
95
apps/website/ui/CareerHighlights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/website/ui/CareerStats.tsx
Normal file
36
apps/website/ui/CareerStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/website/ui/CategoryDistribution.tsx
Normal file
77
apps/website/ui/CategoryDistribution.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/website/ui/CategoryDistributionCard.tsx
Normal file
35
apps/website/ui/CategoryDistributionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/website/ui/ChampionshipStandings.tsx
Normal file
56
apps/website/ui/ChampionshipStandings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/website/ui/ChampionshipStandingsList.tsx
Normal file
14
apps/website/ui/ChampionshipStandingsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/website/ui/Checkbox.tsx
Normal file
34
apps/website/ui/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/website/ui/CircularProgress.tsx
Normal file
53
apps/website/ui/CircularProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(' ');
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
apps/website/ui/DangerZone.tsx
Normal file
26
apps/website/ui/DangerZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/website/ui/DashboardHero.tsx
Normal file
139
apps/website/ui/DashboardHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/website/ui/DashboardHeroWrapper.tsx
Normal file
59
apps/website/ui/DashboardHeroWrapper.tsx
Normal 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)" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/website/ui/DateHeader.tsx
Normal file
30
apps/website/ui/DateHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
apps/website/ui/DebugModeToggle.tsx
Normal file
385
apps/website/ui/DebugModeToggle.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
190
apps/website/ui/DiscordCTA.tsx
Normal file
190
apps/website/ui/DiscordCTA.tsx
Normal 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';
|
||||
71
apps/website/ui/DriverCard.tsx
Normal file
71
apps/website/ui/DriverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
apps/website/ui/DriverEntryRow.tsx
Normal file
118
apps/website/ui/DriverEntryRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
apps/website/ui/DriverIdentity.tsx
Normal file
82
apps/website/ui/DriverIdentity.tsx
Normal 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>;
|
||||
}
|
||||
52
apps/website/ui/DriverRankings.tsx
Normal file
52
apps/website/ui/DriverRankings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/website/ui/DriverRatingPill.tsx
Normal file
31
apps/website/ui/DriverRatingPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/website/ui/DriverStats.tsx
Normal file
34
apps/website/ui/DriverStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/website/ui/DriverSummaryPill.tsx
Normal file
137
apps/website/ui/DriverSummaryPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/website/ui/DriverSummaryPillWrapper.tsx
Normal file
28
apps/website/ui/DriverSummaryPillWrapper.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
apps/website/ui/DriversSearch.tsx
Normal file
27
apps/website/ui/DriversSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import Input from '@/ui/Input';
|
||||
|
||||
|
||||
329
apps/website/ui/EmptyState.tsx
Normal file
329
apps/website/ui/EmptyState.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
248
apps/website/ui/ErrorDisplay.tsx
Normal file
248
apps/website/ui/ErrorDisplay.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
apps/website/ui/FeatureGrid.tsx
Normal file
102
apps/website/ui/FeatureGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/website/ui/FeaturedDriverCard.tsx
Normal file
161
apps/website/ui/FeaturedDriverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/website/ui/FeedEmptyState.tsx
Normal file
35
apps/website/ui/FeedEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/website/ui/FeedItem.tsx
Normal file
77
apps/website/ui/FeedItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/website/ui/FeedLayout.tsx
Normal file
66
apps/website/ui/FeedLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/website/ui/FeedList.tsx
Normal file
33
apps/website/ui/FeedList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/website/ui/FilePicker.tsx
Normal file
43
apps/website/ui/FilePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/website/ui/FilterGroup.tsx
Normal file
46
apps/website/ui/FilterGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/website/ui/FinishDistributionChart.tsx
Normal file
49
apps/website/ui/FinishDistributionChart.tsx
Normal 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
106
apps/website/ui/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
49
apps/website/ui/FriendItem.tsx
Normal file
49
apps/website/ui/FriendItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/website/ui/FriendsList.tsx
Normal file
14
apps/website/ui/FriendsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/website/ui/FriendsPreview.tsx
Normal file
69
apps/website/ui/FriendsPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/website/ui/FriendsSidebar.tsx
Normal file
71
apps/website/ui/FriendsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/website/ui/GoalCard.tsx
Normal file
41
apps/website/ui/GoalCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(' ');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Container from '@/ui/Container';
|
||||
import { Container } from '@/ui/Container';
|
||||
|
||||
interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/website/ui/HorizontalBarChart.tsx
Normal file
28
apps/website/ui/HorizontalBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/website/ui/HorizontalStatCard.tsx
Normal file
52
apps/website/ui/HorizontalStatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/website/ui/HorizontalStatItem.tsx
Normal file
18
apps/website/ui/HorizontalStatItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
44
apps/website/ui/IconButton.tsx
Normal file
44
apps/website/ui/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
36
apps/website/ui/InfoItem.tsx
Normal file
36
apps/website/ui/InfoItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/website/ui/InlinePenaltyButton.tsx
Normal file
39
apps/website/ui/InlinePenaltyButton.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
76
apps/website/ui/JoinRequestItem.tsx
Normal file
76
apps/website/ui/JoinRequestItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/website/ui/JoinRequestList.tsx
Normal file
14
apps/website/ui/JoinRequestList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/website/ui/LandingHero.tsx
Normal file
171
apps/website/ui/LandingHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/website/ui/LandingItems.tsx
Normal file
54
apps/website/ui/LandingItems.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
export function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" border padding={4} 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>
|
||||
);
|
||||
}
|
||||
47
apps/website/ui/LatestResultsSidebar.tsx
Normal file
47
apps/website/ui/LatestResultsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
apps/website/ui/LeaderboardItem.tsx
Normal file
116
apps/website/ui/LeaderboardItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/website/ui/LeaderboardList.tsx
Normal file
16
apps/website/ui/LeaderboardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/website/ui/LeaderboardPreview.tsx
Normal file
105
apps/website/ui/LeaderboardPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/website/ui/LeagueCard.tsx
Normal file
197
apps/website/ui/LeagueCard.tsx
Normal 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';
|
||||
|
||||
178
apps/website/ui/LeagueCardWrapper.tsx
Normal file
178
apps/website/ui/LeagueCardWrapper.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
apps/website/ui/LeagueCover.tsx
Normal file
20
apps/website/ui/LeagueCover.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
apps/website/ui/LeagueCoverWrapper.tsx
Normal file
16
apps/website/ui/LeagueCoverWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
61
apps/website/ui/LeagueHeader.tsx
Normal file
61
apps/website/ui/LeagueHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/website/ui/LeagueListItem.tsx
Normal file
60
apps/website/ui/LeagueListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/LeagueLogo.tsx
Normal file
22
apps/website/ui/LeagueLogo.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
apps/website/ui/LeagueLogoWrapper.tsx
Normal file
16
apps/website/ui/LeagueLogoWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
101
apps/website/ui/LeagueMemberRow.tsx
Normal file
101
apps/website/ui/LeagueMemberRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/website/ui/LeagueMemberTable.tsx
Normal file
28
apps/website/ui/LeagueMemberTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
apps/website/ui/LeagueSummaryCard.tsx
Normal file
121
apps/website/ui/LeagueSummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/website/ui/LeagueSummaryCardWrapper.tsx
Normal file
28
apps/website/ui/LeagueSummaryCardWrapper.tsx
Normal 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
Reference in New Issue
Block a user