website refactor
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface ActivityFeedListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ActivityFeedList({ children }: ActivityFeedListProps) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Box } from './Box';
|
||||
import { Link } from './Link';
|
||||
import { Text } from './Text';
|
||||
import { Surface } from './Surface';
|
||||
|
||||
interface ActivityItemProps {
|
||||
headline: string;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface AvailableLeague {
|
||||
id: string;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -334,6 +334,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
blur ? `blur-${blur}` : '',
|
||||
animate ? `animate-${animate}` : '',
|
||||
translateX ? `translate-x-${translateX}` : '',
|
||||
textAlign ? `text-${textAlign}` : '',
|
||||
hoverScale ? 'hover:scale-[1.02]' : '',
|
||||
group ? 'group' : '',
|
||||
groupHoverBorderColor ? `group-hover:border-${groupHoverBorderColor}` : '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, ElementType } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as'
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
children,
|
||||
onClick,
|
||||
className = '',
|
||||
@@ -79,7 +79,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
target={target}
|
||||
rel={rel}
|
||||
className={classes}
|
||||
{...(props as any)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
@@ -89,12 +89,12 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
ref={ref as any}
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={classes}
|
||||
onClick={onClick as any}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...(props as any)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
@@ -102,5 +102,3 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import React, { ReactNode, MouseEventHandler, HTMLAttributes } from 'react';
|
||||
import React, { ReactNode, MouseEventHandler } from 'react';
|
||||
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 ResponsiveSpacing {
|
||||
base?: Spacing;
|
||||
md?: Spacing;
|
||||
lg?: Spacing;
|
||||
}
|
||||
|
||||
interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'className' | 'onClick'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
variant?: 'default' | 'highlight';
|
||||
p?: Spacing | any;
|
||||
px?: Spacing | any;
|
||||
py?: Spacing | any;
|
||||
pt?: Spacing | any;
|
||||
pb?: Spacing | any;
|
||||
pl?: Spacing | any;
|
||||
pr?: Spacing | any;
|
||||
p?: Spacing | ResponsiveSpacing;
|
||||
px?: Spacing | ResponsiveSpacing;
|
||||
py?: Spacing | ResponsiveSpacing;
|
||||
pt?: Spacing | ResponsiveSpacing;
|
||||
pb?: Spacing | ResponsiveSpacing;
|
||||
pl?: Spacing | ResponsiveSpacing;
|
||||
pr?: Spacing | ResponsiveSpacing;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
@@ -22,7 +28,6 @@ export function Card({
|
||||
className = '',
|
||||
onClick,
|
||||
variant = 'default',
|
||||
p, px, py, pt, pb, pl, pr,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const baseClasses = 'rounded-lg shadow-card border duration-200';
|
||||
@@ -32,29 +37,24 @@ export function Card({
|
||||
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 border-blue-500/30'
|
||||
};
|
||||
|
||||
const spacingMap: Record<number, string> = {
|
||||
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
|
||||
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
|
||||
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
|
||||
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
onClick ? 'cursor-pointer hover:scale-[1.02]' : '',
|
||||
p !== undefined ? `p-${spacingMap[p]}` : (px === undefined && py === undefined && pt === undefined && pb === undefined && pl === undefined && pr === undefined ? 'p-6' : ''),
|
||||
px !== undefined ? `px-${spacingMap[px]}` : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
|
||||
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Default padding if none provided
|
||||
const hasPadding = props.p !== undefined || props.px !== undefined || props.py !== undefined ||
|
||||
props.pt !== undefined || props.pb !== undefined || props.pl !== undefined || props.pr !== undefined;
|
||||
|
||||
return (
|
||||
<Box className={classes} onClick={onClick as any} {...props}>
|
||||
<Box
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
p={hasPadding ? undefined : 6}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface ChampionshipStandingsListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ChampionshipStandingsList({ children }: ChampionshipStandingsListProps) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
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;
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
|
||||
|
||||
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CountryFlag } from './CountryFlag';
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const COUNTRIES: Country[] = [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
];
|
||||
|
||||
interface CountrySelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function CountrySelect({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
errorMessage,
|
||||
disabled,
|
||||
placeholder = 'Select country',
|
||||
}: CountrySelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCountry = COUNTRIES.find(c => c.code === value);
|
||||
|
||||
const filteredCountries = COUNTRIES.filter(country =>
|
||||
country.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
country.code.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
onChange(code);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
|
||||
error
|
||||
? 'ring-warning-amber focus:ring-warning-amber'
|
||||
: 'ring-charcoal-outline focus:ring-primary-blue'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-charcoal-outline">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search countries..."
|
||||
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Country List */}
|
||||
<div className="overflow-y-auto max-h-60">
|
||||
{filteredCountries.length > 0 ? (
|
||||
filteredCountries.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(country.code)}
|
||||
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
|
||||
value === country.code
|
||||
? 'bg-primary-blue/20 text-white'
|
||||
: 'text-gray-300 hover:bg-deep-graphite'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
|
||||
<span>{country.name}</span>
|
||||
</span>
|
||||
{value === country.code && (
|
||||
<Check className="w-4 h-4 text-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-gray-500 text-sm">
|
||||
No countries found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && errorMessage && (
|
||||
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Image } from './Image';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface DashboardHeroProps {
|
||||
driverName: string;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
|
||||
|
||||
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)" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { DiscordIcon } from '@/ui/icons/DiscordIcon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
|
||||
|
||||
export function DiscordCTA() {
|
||||
@@ -77,7 +78,7 @@ export function DiscordCTA() {
|
||||
<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.
|
||||
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:
|
||||
@@ -143,7 +144,7 @@ export function DiscordCTA() {
|
||||
{/* 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.
|
||||
This is a community effort. Every voice matters. Let's build something that actually works for league racing.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -186,5 +187,3 @@ function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: st
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
import { Grid } from '@/ui/Grid';
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
|
||||
|
||||
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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import Input from '@/ui/Input';
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
interface DurationFieldProps {
|
||||
label: string;
|
||||
@@ -13,7 +13,7 @@ interface DurationFieldProps {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function DurationField({
|
||||
export function DurationField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
|
||||
|
||||
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,248 +0,0 @@
|
||||
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MockupStack } from '@/ui/MockupStack';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
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';
|
||||
import { Image } from './Image';
|
||||
|
||||
interface FeedItemProps {
|
||||
actorName?: string;
|
||||
@@ -34,7 +32,9 @@ export function FeedItem({
|
||||
alt={actorName || ''}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-full h-full object-cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
'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';
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface GridProps extends Omit<BoxProps<'div'>, 'children' | 'display'> {
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
mdCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
lgCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
gap?: any;
|
||||
gap?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 16;
|
||||
}
|
||||
|
||||
export function Grid({
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Text } from './Text';
|
||||
|
||||
export function HeaderContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link href="/" className="inline-flex items-center">
|
||||
<Image
|
||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||
alt="GridPilot"
|
||||
width={160}
|
||||
height={30}
|
||||
className="h-6 w-auto md:h-8"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-400" className="hidden sm:block font-light">
|
||||
Making league racing less chaotic
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function InfoBanner({
|
||||
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
{title && (
|
||||
<Text weight="medium" color={config.titleColor as any} block mb={1}>{title}</Text>
|
||||
<Text weight="medium" color={config.titleColor} block mb={1}>{title}</Text>
|
||||
)}
|
||||
<Text size="sm" color="text-gray-400" block>{children}</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { Text } from './Text';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
variant?: 'default' | 'error';
|
||||
errorMessage?: string;
|
||||
icon?: React.ReactNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
export function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" border padding={4} 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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
import { Image } from './Image';
|
||||
import { PlaceholderImage } from './PlaceholderImage';
|
||||
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight, Users as LucideUsers } from 'lucide-react';
|
||||
|
||||
interface LeagueCardProps {
|
||||
name: string;
|
||||
@@ -67,12 +70,13 @@ export function LeagueCard({
|
||||
>
|
||||
{/* Cover Image */}
|
||||
<Box position="relative" h="32" overflow="hidden">
|
||||
<img
|
||||
<Image
|
||||
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"
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
className="transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{/* Gradient Overlay */}
|
||||
<Box position="absolute" inset="0" bg="bg-gradient-to-t from-iron-gray via-iron-gray/60 to-transparent" />
|
||||
@@ -91,14 +95,14 @@ export function LeagueCard({
|
||||
<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
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={`${name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={48} />
|
||||
@@ -193,5 +197,3 @@ export function LeagueCard({
|
||||
);
|
||||
}
|
||||
|
||||
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight, Users as LucideUsers } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Image } from './Image';
|
||||
|
||||
interface LeagueHeaderProps {
|
||||
name: string;
|
||||
@@ -26,14 +27,14 @@ export function LeagueHeader({
|
||||
<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
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={`${name} logo`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function LeagueMemberTable({ children, showActions }: LeagueMemberTablePr
|
||||
<TableHeader>Wins</TableHeader>
|
||||
<TableHeader>Role</TableHeader>
|
||||
<TableHeader>Joined</TableHeader>
|
||||
{showActions && <TableHeader align="right">Actions</TableHeader>}
|
||||
{showActions && <TableHeader textAlign="right">Actions</TableHeader>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, AnchorHTMLAttributes, ElementType } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
interface LinkProps extends Omit<BoxProps<'a'>, 'children' | 'className' | 'onClick'> {
|
||||
@@ -70,7 +70,7 @@ export function Link({
|
||||
className={classes}
|
||||
target={target}
|
||||
rel={rel}
|
||||
onClick={onClick as any}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
|
||||
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LiveRaceItem } from '@/ui/LiveRaceItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LiveRacesBannerProps {
|
||||
liveRaces: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps) {
|
||||
if (liveRaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
p={6}
|
||||
border
|
||||
borderColor="border-performance-green/30"
|
||||
bg="linear-gradient(to right, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1), transparent)"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
w="32"
|
||||
h="32"
|
||||
bg="bg-performance-green/20"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
/>
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Box mb={4}>
|
||||
<Stack direction="row" align="center" gap={2} bg="bg-performance-green/20" px={3} py={1} rounded="full" w="fit">
|
||||
<Box w="2" h="2" bg="bg-performance-green" rounded="full" />
|
||||
<Text weight="semibold" size="sm" color="text-performance-green">LIVE NOW</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={3}>
|
||||
{liveRaces.map((race) => (
|
||||
<LiveRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
leagueName={race.leagueName ?? 'Unknown League'}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { LoadingWrapperProps } from './state-types';
|
||||
import { Text } from './Text';
|
||||
|
||||
/**
|
||||
* LoadingWrapper Component
|
||||
*
|
||||
* Provides consistent loading states with multiple variants:
|
||||
* - spinner: Traditional loading spinner (default)
|
||||
* - skeleton: Skeleton screens for better UX
|
||||
* - full-screen: Centered in viewport
|
||||
* - inline: Compact inline loading
|
||||
* - card: Loading card placeholders
|
||||
*
|
||||
* All variants are fully accessible with ARIA labels and keyboard support.
|
||||
*/
|
||||
export function LoadingWrapper({
|
||||
variant = 'spinner',
|
||||
message = 'Loading...',
|
||||
className = '',
|
||||
size = 'md',
|
||||
skeletonCount = 3,
|
||||
cardConfig,
|
||||
ariaLabel = 'Loading content',
|
||||
}: LoadingWrapperProps) {
|
||||
// Size mappings for different variants
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
spinner: 'w-4 h-4 border-2',
|
||||
inline: 'xs' as const,
|
||||
card: 'h-24',
|
||||
},
|
||||
md: {
|
||||
spinner: 'w-10 h-10 border-2',
|
||||
inline: 'sm' as const,
|
||||
card: 'h-32',
|
||||
},
|
||||
lg: {
|
||||
spinner: 'w-16 h-16 border-4',
|
||||
inline: 'base' as const,
|
||||
card: 'h-40',
|
||||
},
|
||||
};
|
||||
|
||||
const spinnerSize = sizeClasses[size].spinner;
|
||||
const inlineSize = sizeClasses[size].inline;
|
||||
const cardHeight = cardConfig?.height || sizeClasses[size].card;
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'spinner':
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="200px"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack align="center" gap={3}>
|
||||
<Box
|
||||
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
|
||||
/>
|
||||
<Text color="text-gray-400" size="sm">{message}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
return (
|
||||
<Stack
|
||||
gap={3}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
fullWidth
|
||||
bg="bg-iron-gray/40"
|
||||
rounded="lg"
|
||||
animate="pulse"
|
||||
style={{ height: cardHeight }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={50}
|
||||
bg="bg-deep-graphite/90"
|
||||
blur="sm"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={4}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Box textAlign="center" maxWidth="md">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<Text color="text-white" size="lg" weight="medium">{message}</Text>
|
||||
<Text color="text-gray-400" size="sm">This may take a moment...</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Box className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
<Text color="text-gray-400" size={inlineSize}>{message}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
const cardCount = cardConfig?.count || 3;
|
||||
const cardClassName = cardConfig?.className || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gap={4}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
{Array.from({ length: cardCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
bg="bg-iron-gray/40"
|
||||
rounded="xl"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={cardClassName}
|
||||
style={{ height: cardHeight }}
|
||||
>
|
||||
<Box h="full" w="full" display="flex" alignItems="center" justifyContent="center">
|
||||
<Box className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-screen loading
|
||||
*/
|
||||
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="full-screen"
|
||||
message={message}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for inline loading
|
||||
*/
|
||||
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="inline"
|
||||
message={message}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for skeleton loading
|
||||
*/
|
||||
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="skeleton"
|
||||
skeletonCount={skeletonCount}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for card loading
|
||||
*/
|
||||
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="card"
|
||||
cardConfig={cardConfig}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
interface MockupStackProps {
|
||||
children: ReactNode;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function MockupStack({ children, index = 0 }: MockupStackProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(true); // Default to mobile (no animations)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const seed = index * 1337;
|
||||
const rotation1 = ((seed * 17) % 80 - 40) / 20;
|
||||
const rotation2 = ((seed * 23) % 80 - 40) / 20;
|
||||
|
||||
// On mobile or before mount, render without animations
|
||||
if (!isMounted || isMobile) {
|
||||
return (
|
||||
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
|
||||
<div
|
||||
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation1}deg`,
|
||||
zIndex: 1,
|
||||
top: '-8px',
|
||||
left: '-8px',
|
||||
right: '-8px',
|
||||
bottom: '-8px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation2}deg`,
|
||||
zIndex: 2,
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: render with animations
|
||||
return (
|
||||
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
|
||||
<motion.div
|
||||
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation1}deg`,
|
||||
zIndex: 1,
|
||||
top: '-8px',
|
||||
left: '-8px',
|
||||
right: '-8px',
|
||||
bottom: '-8px',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 0.5, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
|
||||
style={{
|
||||
rotate: `${rotation2}deg`,
|
||||
zIndex: 2,
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 0.7, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
|
||||
style={{
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? {}
|
||||
: {
|
||||
scale: 1.02,
|
||||
rotateY: 3,
|
||||
rotateX: -2,
|
||||
y: -12,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none rounded-lg"
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? {}
|
||||
: {
|
||||
boxShadow: '0 0 40px rgba(25, 140, 255, 0.4)',
|
||||
transition: { duration: 0.2 },
|
||||
}
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { NextRaceCard as UiNextRaceCard } from '@/ui/NextRaceCard';
|
||||
|
||||
interface NextRaceCardProps {
|
||||
nextRace: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
timeUntil: string;
|
||||
isMyLeague: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function NextRaceCard({ nextRace }: NextRaceCardProps) {
|
||||
return (
|
||||
<UiNextRaceCard
|
||||
track={nextRace.track}
|
||||
car={nextRace.car}
|
||||
formattedDate={nextRace.formattedDate}
|
||||
formattedTime={nextRace.formattedTime}
|
||||
timeUntil={nextRace.timeUntil}
|
||||
isMyLeague={nextRace.isMyLeague}
|
||||
href={routes.race.detail(nextRace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { ErrorDisplay } from '@/ui/ErrorDisplay';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Inbox, List, LucideIcon } from 'lucide-react';
|
||||
|
||||
// ==================== PAGEWRAPPER TYPES ====================
|
||||
|
||||
export interface PageWrapperLoadingConfig {
|
||||
variant?: 'skeleton' | 'full-screen';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PageWrapperErrorConfig {
|
||||
variant?: 'full-screen' | 'card';
|
||||
card?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageWrapperEmptyConfig {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PageWrapperProps<TData> {
|
||||
/** Data to be rendered */
|
||||
data: TData | undefined;
|
||||
/** Loading state (default: false) */
|
||||
isLoading?: boolean;
|
||||
/** Error state (default: null) */
|
||||
error?: Error | null;
|
||||
/** Retry function for errors */
|
||||
retry?: () => void;
|
||||
/** Template component that receives the data */
|
||||
Template: React.ComponentType<{ data: TData }>;
|
||||
/** Loading configuration */
|
||||
loading?: PageWrapperLoadingConfig;
|
||||
/** Error configuration */
|
||||
errorConfig?: PageWrapperErrorConfig;
|
||||
/** Empty configuration */
|
||||
empty?: PageWrapperEmptyConfig;
|
||||
/** Children for flexible content rendering */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageWrapper Component
|
||||
*
|
||||
* A comprehensive wrapper component that handles all page states:
|
||||
* - Loading states (skeleton or full-screen)
|
||||
* - Error states (full-screen or card)
|
||||
* - Empty states (with icon, title, description, and action)
|
||||
* - Success state (renders Template component with data)
|
||||
* - Flexible children support for custom content
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* <PageWrapper
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={retry}
|
||||
* Template={MyTemplateComponent}
|
||||
* loading={{ variant: 'skeleton', message: 'Loading...' }}
|
||||
* error={{ variant: 'full-screen' }}
|
||||
* empty={{
|
||||
* icon: Trophy,
|
||||
* title: 'No data found',
|
||||
* description: 'Try refreshing the page',
|
||||
* action: { label: 'Refresh', onClick: retry }
|
||||
* }}
|
||||
* >
|
||||
* <AdditionalContent />
|
||||
* </PageWrapper>
|
||||
* ```
|
||||
*/
|
||||
export function PageWrapper<TData>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
// Priority order: Loading > Error > Empty > Success
|
||||
|
||||
// 1. Loading State
|
||||
if (isLoading) {
|
||||
const loadingVariant = loading?.variant || 'skeleton';
|
||||
const loadingMessage = loading?.message || 'Loading...';
|
||||
|
||||
if (loadingVariant === 'full-screen') {
|
||||
return (
|
||||
<LoadingWrapper
|
||||
variant="full-screen"
|
||||
message={loadingMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to skeleton
|
||||
return (
|
||||
<Box>
|
||||
<LoadingWrapper
|
||||
variant="skeleton"
|
||||
message={loadingMessage}
|
||||
skeletonCount={3}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Error State
|
||||
if (error) {
|
||||
const errorVariant = errorConfig?.variant || 'full-screen';
|
||||
|
||||
if (errorVariant === 'card') {
|
||||
return (
|
||||
<Box>
|
||||
<ErrorDisplay
|
||||
error={error as ApiError}
|
||||
onRetry={retry}
|
||||
variant="card"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to full-screen
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error as ApiError}
|
||||
onRetry={retry}
|
||||
variant="full-screen"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Empty State
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
if (empty) {
|
||||
const Icon = empty.icon;
|
||||
const hasAction = empty.action && retry;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={Icon || Inbox}
|
||||
title={empty.title || 'No data available'}
|
||||
description={empty.description}
|
||||
action={hasAction ? {
|
||||
label: empty.action!.label,
|
||||
onClick: empty.action!.onClick,
|
||||
} : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If no empty config provided but data is empty, show nothing
|
||||
return (
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Success State - Render Template with data
|
||||
return (
|
||||
<Box>
|
||||
<Template data={data} />
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for list data with automatic empty state handling
|
||||
*/
|
||||
export function ListPageWrapper<TData extends unknown[]>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
const listEmpty = empty || {
|
||||
icon: List,
|
||||
title: 'No items found',
|
||||
description: 'This list is currently empty',
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={errorConfig}
|
||||
empty={listEmpty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for detail pages with enhanced error handling
|
||||
*/
|
||||
export function DetailPageWrapper<TData>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData> & {
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
// Create enhanced error config with additional actions
|
||||
const enhancedErrorConfig: PageWrapperErrorConfig = {
|
||||
...errorConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={enhancedErrorConfig}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
|
||||
|
||||
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
|
||||
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
|
||||
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
|
||||
import { Box } from "@/ui/Box";
|
||||
import { Card } from "@/ui/Card";
|
||||
import { ProtestListItem } from "@/ui/ProtestListItem";
|
||||
import { Stack } from "@/ui/Stack";
|
||||
import { Text } from "@/ui/Text";
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
interface PendingProtestsListProps {
|
||||
protests: ProtestViewModel[];
|
||||
races: Record<string, RaceViewModel>;
|
||||
drivers: Record<string, DriverViewModel>;
|
||||
leagueId: string;
|
||||
onReviewProtest: (protest: ProtestViewModel) => void;
|
||||
onProtestReviewed: () => void;
|
||||
}
|
||||
|
||||
export function PendingProtestsList({
|
||||
protests,
|
||||
leagueId,
|
||||
}: PendingProtestsListProps) {
|
||||
|
||||
if (protests.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<Box p={12} textAlign="center">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box w="16" h="16" rounded="full" bg="bg-performance-green/10" display="flex" alignItems="center" justifyContent="center">
|
||||
<Flag className="h-8 w-8 text-performance-green" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="semibold" size="lg" color="text-white" block mb={2}>All Clear! 🏁</Text>
|
||||
<Text size="sm" color="text-gray-400">No pending protests to review</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{protests.map((protest) => {
|
||||
const filedAt = protest.filedAt || protest.submittedAt;
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(filedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isUrgent = daysSinceFiled > 2;
|
||||
|
||||
return (
|
||||
<ProtestListItem
|
||||
key={protest.id}
|
||||
id={protest.id}
|
||||
filedAt={filedAt}
|
||||
description={protest.incident?.description || protest.description}
|
||||
lap={protest.incident?.lap}
|
||||
hasVideo={!!protest.proofVideoUrl}
|
||||
isUrgent={isUrgent}
|
||||
daysOld={daysSinceFiled}
|
||||
href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function PodiumItem({
|
||||
p={3}
|
||||
>
|
||||
<Box display="flex" center fullWidth>
|
||||
<Text size="3xl" weight="bold" color={positionColor as any}>
|
||||
<Text size="3xl" weight="bold" color={positionColor}>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
import Card from './Card';
|
||||
import { Card } from './Card';
|
||||
|
||||
interface PresetCardStat {
|
||||
label: string;
|
||||
@@ -21,7 +21,7 @@ export interface PresetCardProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function PresetCard({
|
||||
export function PresetCard({
|
||||
title,
|
||||
subtitle,
|
||||
primaryTag,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
|
||||
|
||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CountryFlag } from '@/ui/CountryFlag';
|
||||
import { DriverRatingPill } from '@/ui/DriverRatingPill';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: DriverViewModel;
|
||||
rating?: number | null;
|
||||
rank?: number | null;
|
||||
isOwnProfile?: boolean;
|
||||
onEditClick?: () => void;
|
||||
teamName?: string | null;
|
||||
teamTag?: string | null;
|
||||
}
|
||||
|
||||
export function ProfileHeader({
|
||||
driver,
|
||||
rating,
|
||||
rank,
|
||||
isOwnProfile = false,
|
||||
onEditClick,
|
||||
teamName,
|
||||
teamTag,
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<Box display="flex" alignItems="start" justifyContent="between">
|
||||
<Box display="flex" alignItems="start" gap={4}>
|
||||
<Box
|
||||
w="20"
|
||||
h="20"
|
||||
rounded="full"
|
||||
bg="bg-gradient-to-br from-primary-blue to-purple-600"
|
||||
overflow="hidden"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{driver.avatarUrl ? (
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={80} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={3} mb={2}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
{driver.country && <CountryFlag countryCode={driver.country} size="lg" />}
|
||||
{teamTag && (
|
||||
<Badge variant="primary">
|
||||
{teamTag}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Text size="sm" color="text-gray-400">iRacing ID: {driver.iracingId}</Text>
|
||||
{teamName && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Text size="sm" color="text-primary-blue">
|
||||
{teamTag ? `[${teamTag}] ${teamName}` : teamName}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(typeof rating === 'number' || typeof rank === 'number') && (
|
||||
<Box mt={2}>
|
||||
<DriverRatingPill rating={rating ?? null} rank={rank ?? null} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isOwnProfile && (
|
||||
<Button variant="secondary" onClick={onEditClick}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
|
||||
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
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 { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
|
||||
interface ProfileHeroProps {
|
||||
driver: {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
country: string;
|
||||
iracingId: number;
|
||||
joinedAt: string | Date;
|
||||
};
|
||||
stats: {
|
||||
rating: number;
|
||||
} | null;
|
||||
globalRank: number;
|
||||
timezone: string;
|
||||
socialHandles: {
|
||||
platform: string;
|
||||
handle: string;
|
||||
url: string;
|
||||
}[];
|
||||
onAddFriend: () => void;
|
||||
friendRequestSent: boolean;
|
||||
}
|
||||
|
||||
function getSocialIcon(platform: string) {
|
||||
const { Twitter, Youtube, Twitch, MessageCircle } = require('lucide-react');
|
||||
switch (platform) {
|
||||
case 'twitter': return Twitter;
|
||||
case 'youtube': return Youtube;
|
||||
case 'twitch': return Twitch;
|
||||
case 'discord': return MessageCircle;
|
||||
default: return Globe;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfileHero({
|
||||
driver,
|
||||
stats,
|
||||
globalRank,
|
||||
timezone,
|
||||
socialHandles,
|
||||
onAddFriend,
|
||||
friendRequestSent,
|
||||
}: ProfileHeroProps) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="2xl" border padding={6} style={{ background: 'linear-gradient(to bottom right, rgba(38, 38, 38, 0.8), rgba(38, 38, 38, 0.6), #0f1115)', borderColor: '#262626' }}>
|
||||
<Stack direction="row" align="start" gap={6} wrap>
|
||||
{/* Avatar */}
|
||||
<Box style={{ position: 'relative' }}>
|
||||
<Box style={{ width: '7rem', height: '7rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.25rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
|
||||
<Box style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={144}
|
||||
height={144}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" align="center" gap={3} wrap mb={2}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Rating and Rank */}
|
||||
<Stack direction="row" align="center" gap={4} wrap mb={4}>
|
||||
{stats && (
|
||||
<>
|
||||
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Star style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
|
||||
<Text font="mono" weight="bold" color="text-primary-blue">{stats.rating}</Text>
|
||||
<Text size="xs" color="text-gray-400">Rating</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Trophy style={{ width: '1rem', height: '1rem', color: '#facc15' }} />
|
||||
<Text font="mono" weight="bold" style={{ color: '#facc15' }}>#{globalRank}</Text>
|
||||
<Text size="xs" color="text-gray-400">Global</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Meta info */}
|
||||
<Stack direction="row" align="center" gap={4} wrap style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Globe style={{ width: '1rem', height: '1rem' }} />
|
||||
<Text size="sm">iRacing: {driver.iracingId}</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Calendar style={{ width: '1rem', height: '1rem' }} />
|
||||
<Text size="sm">
|
||||
Joined{' '}
|
||||
{new Date(driver.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Clock style={{ width: '1rem', height: '1rem' }} />
|
||||
<Text size="sm">{timezone}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
icon={<UserPlus style={{ width: '1rem', height: '1rem' }} />}
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Social Handles */}
|
||||
{socialHandles.length > 0 && (
|
||||
<Box mt={6} pt={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
<Text size="sm" color="text-gray-500" style={{ marginRight: '0.5rem' }}>Connect:</Text>
|
||||
{socialHandles.map((social) => {
|
||||
const Icon = getSocialIcon(social.platform);
|
||||
return (
|
||||
<Box key={social.platform}>
|
||||
<Link
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="ghost"
|
||||
>
|
||||
<Surface variant="muted" rounded="lg" padding={1} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', paddingLeft: '0.75rem', paddingRight: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', color: '#9ca3af' }}>
|
||||
<Icon style={{ width: '1rem', height: '1rem' }} />
|
||||
<Text size="sm">{social.handle}</Text>
|
||||
<ExternalLink style={{ width: '0.75rem', height: '0.75rem', opacity: 0.5 }} />
|
||||
</Surface>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
|
||||
<Grid cols={2} gap={4}>
|
||||
{stats.map((stat, idx) => (
|
||||
<Box key={idx} p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
|
||||
<Text size="3xl" weight="bold" color={stat.color as any} block mb={1}>{stat.value}</Text>
|
||||
<Text size="3xl" weight="bold" color={stat.color} block mb={1}>{stat.value}</Text>
|
||||
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{stat.label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ProtestListItem } from '@/ui/ProtestListItem';
|
||||
|
||||
interface Protest {
|
||||
id: string;
|
||||
status: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
filedAt: string;
|
||||
incident: {
|
||||
lap: number;
|
||||
description: string;
|
||||
};
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
}
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProtestCardProps {
|
||||
protest: Protest;
|
||||
protester?: Driver;
|
||||
accused?: Driver;
|
||||
isAdmin: boolean;
|
||||
onReview: (id: string) => void;
|
||||
formatDate: (date: string) => string;
|
||||
}
|
||||
|
||||
export function ProtestCard({ protest, protester, accused, isAdmin, onReview, formatDate }: ProtestCardProps) {
|
||||
const daysSinceFiled = Math.floor(
|
||||
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const isUrgent = daysSinceFiled > 2 && protest.status === 'pending';
|
||||
|
||||
return (
|
||||
<ProtestListItem
|
||||
protesterName={protester?.name || 'Unknown'}
|
||||
protesterHref={routes.driver.detail(protest.protestingDriverId)}
|
||||
accusedName={accused?.name || 'Unknown'}
|
||||
accusedHref={routes.driver.detail(protest.accusedDriverId)}
|
||||
status={protest.status}
|
||||
isUrgent={isUrgent}
|
||||
daysOld={daysSinceFiled}
|
||||
lap={protest.incident.lap}
|
||||
filedAtLabel={formatDate(protest.filedAt)}
|
||||
description={protest.incident.description}
|
||||
proofVideoUrl={protest.proofVideoUrl}
|
||||
decisionNotes={protest.decisionNotes}
|
||||
isAdmin={isAdmin}
|
||||
onReview={() => onReview(protest.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
|
||||
|
||||
import { AlertCircle, AlertTriangle, Video } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Card } from './Card';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface ProtestListItemProps {
|
||||
protesterName: string;
|
||||
protesterHref: string;
|
||||
accusedName: string;
|
||||
accusedHref: string;
|
||||
status: string;
|
||||
isUrgent: boolean;
|
||||
daysOld: number;
|
||||
lap: number;
|
||||
filedAtLabel: string;
|
||||
description: string;
|
||||
proofVideoUrl?: string;
|
||||
decisionNotes?: string;
|
||||
isAdmin: boolean;
|
||||
onReview?: () => void;
|
||||
}
|
||||
|
||||
export function ProtestListItem({
|
||||
protesterName,
|
||||
protesterHref,
|
||||
accusedName,
|
||||
accusedHref,
|
||||
status,
|
||||
isUrgent,
|
||||
daysOld,
|
||||
lap,
|
||||
filedAtLabel,
|
||||
description,
|
||||
proofVideoUrl,
|
||||
decisionNotes,
|
||||
isAdmin,
|
||||
onReview,
|
||||
}: ProtestListItemProps) {
|
||||
const getStatusVariant = (s: string): any => {
|
||||
switch (s) {
|
||||
case 'pending':
|
||||
case 'under_review': return 'warning';
|
||||
case 'upheld': return 'danger';
|
||||
case 'dismissed': return 'default';
|
||||
case 'withdrawn': return 'primary';
|
||||
default: return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
borderLeft={isUrgent}
|
||||
borderColor={isUrgent ? 'border-red-500' : 'border-charcoal-outline'}
|
||||
style={isUrgent ? { borderLeftWidth: '4px' } : undefined}
|
||||
>
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={2} mb={2} wrap>
|
||||
<Icon icon={AlertCircle} size={4} color="rgb(156, 163, 175)" />
|
||||
<Link href={protesterHref}>
|
||||
<Text weight="medium" color="text-white">{protesterName}</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">vs</Text>
|
||||
<Link href={accusedHref}>
|
||||
<Text weight="medium" color="text-white">{accusedName}</Text>
|
||||
</Link>
|
||||
<Badge variant={getStatusVariant(status)}>
|
||||
{status.replace('_', ' ')}
|
||||
</Badge>
|
||||
{isUrgent && (
|
||||
<Badge variant="danger" icon={AlertTriangle}>
|
||||
{daysOld}d old
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={4} mb={2} wrap>
|
||||
<Text size="sm" color="text-gray-400">Lap {lap}</Text>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Text size="sm" color="text-gray-400">Filed {filedAtLabel}</Text>
|
||||
{proofVideoUrl && (
|
||||
<>
|
||||
<Text size="sm" color="text-gray-400">•</Text>
|
||||
<Link href={proofVideoUrl} target="_blank">
|
||||
<Icon icon={Video} size={3.5} mr={1.5} />
|
||||
<Text size="sm">Video Evidence</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-300" block>{description}</Text>
|
||||
|
||||
{decisionNotes && (
|
||||
<Box mt={4} p={3} bg="bg-charcoal-outline/30" rounded="lg" border borderColor="border-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em" block mb={1}>Steward Decision</Text>
|
||||
<Text size="sm" color="text-gray-300">{decisionNotes}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{isAdmin && status === 'pending' && onReview && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReview}
|
||||
size="sm"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Trophy, Users } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { QuickActionItem } from './QuickActionItem';
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3} mb={4}>Quick Actions</Heading>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<QuickActionItem
|
||||
href={routes.public.leagues}
|
||||
label="Browse Leagues"
|
||||
icon={Users}
|
||||
iconVariant="blue"
|
||||
/>
|
||||
<QuickActionItem
|
||||
href={routes.public.leaderboards}
|
||||
label="View Leaderboards"
|
||||
icon={Trophy}
|
||||
iconVariant="amber"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function RaceCard({
|
||||
<Text size="lg" weight="bold" color="text-white" block>
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
<Text size="xs" color={statusConfig.color as any} block>
|
||||
<Text size="xs" color={statusConfig.color} block>
|
||||
{status === 'running' ? 'LIVE' : scheduledAtDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -128,9 +128,9 @@ export function RaceCard({
|
||||
}}
|
||||
>
|
||||
{statusConfig.icon && (
|
||||
<Icon icon={statusConfig.icon} size={3.5} color={statusConfig.color as any} />
|
||||
<Icon icon={statusConfig.icon} size={3.5} color={statusConfig.color} />
|
||||
)}
|
||||
<Text size="xs" weight="medium" color={statusConfig.color as any}>
|
||||
<Text size="xs" weight="medium" color={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
||||
import { RaceCard as UiRaceCard } from '@/ui/RaceCard';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueId?: string;
|
||||
leagueName: string;
|
||||
strengthOfField?: number | null;
|
||||
};
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RaceCard({ race, onClick }: RaceCardProps) {
|
||||
const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || {
|
||||
border: 'border-charcoal-outline',
|
||||
bg: 'bg-charcoal-outline',
|
||||
color: 'text-gray-400',
|
||||
icon: null,
|
||||
label: 'Scheduled',
|
||||
};
|
||||
|
||||
return (
|
||||
<UiRaceCard
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName}
|
||||
leagueId={race.leagueId}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={onClick}
|
||||
statusConfig={{
|
||||
...config,
|
||||
icon: config.icon as LucideIcon | null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { DriverEntryRow } from '@/ui/DriverEntryRow';
|
||||
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 { Users } from 'lucide-react';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating?: number | null;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface RaceEntryListProps {
|
||||
entries: Entry[];
|
||||
onDriverClick: (driverId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceEntryList({ entries, onDriverClick }: RaceEntryListProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={2} icon={<Icon icon={Users} size={5} color="rgb(59, 130, 246)" />}>Entry List</Heading>
|
||||
<Text size="sm" color="text-gray-400">{entries.length} drivers</Text>
|
||||
</Stack>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Stack align="center" py={8} gap={3}>
|
||||
<Surface variant="muted" rounded="full" p={4}>
|
||||
<Icon icon={Users} size={6} color="rgb(82, 82, 82)" />
|
||||
</Surface>
|
||||
<Text color="text-gray-400">No drivers registered yet</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={1}>
|
||||
{entries.map((driver, index) => (
|
||||
<DriverEntryRow
|
||||
key={driver.id}
|
||||
index={index}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl}
|
||||
country={driver.country}
|
||||
rating={driver.rating}
|
||||
isCurrentUser={driver.isCurrentUser}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
|
||||
import type { TimeFilter } from '@/templates/RacesTemplate';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { FilterGroup } from '@/ui/FilterGroup';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface RaceFilterBarProps {
|
||||
timeFilter: TimeFilter;
|
||||
setTimeFilter: (filter: TimeFilter) => void;
|
||||
leagueFilter: string;
|
||||
setLeagueFilter: (filter: string) => void;
|
||||
leagues: Array<{ id: string; name: string }>;
|
||||
onShowMoreFilters: () => void;
|
||||
}
|
||||
|
||||
export function RaceFilterBar({
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
leagueFilter,
|
||||
setLeagueFilter,
|
||||
leagues,
|
||||
onShowMoreFilters,
|
||||
}: RaceFilterBarProps) {
|
||||
const leagueOptions = [
|
||||
{ value: 'all', label: 'All Leagues' },
|
||||
...leagues.map(l => ({ value: l.id, label: l.name }))
|
||||
];
|
||||
|
||||
const timeOptions = [
|
||||
{ id: 'upcoming', label: 'Upcoming' },
|
||||
{ id: 'live', label: 'Live', indicatorColor: 'bg-performance-green' },
|
||||
{ id: 'past', label: 'Past' },
|
||||
{ id: 'all', label: 'All' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card p={4}>
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<FilterGroup
|
||||
options={timeOptions}
|
||||
activeId={timeFilter}
|
||||
onSelect={(id) => setTimeFilter(id as TimeFilter)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={leagueFilter}
|
||||
onChange={(e) => setLeagueFilter(e.target.value)}
|
||||
options={leagueOptions}
|
||||
fullWidth={false}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onShowMoreFilters}
|
||||
>
|
||||
More Filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { DateHeader } from '@/ui/DateHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { RaceListItem } from '@/ui/RaceListItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar, CheckCircle2, Clock, PlayCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface RaceListProps {
|
||||
racesByDate: Array<{
|
||||
dateKey: string;
|
||||
dateLabel: string;
|
||||
races: RaceViewData[];
|
||||
}>;
|
||||
totalCount: number;
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
if (racesByDate.length === 0) {
|
||||
return (
|
||||
<Card py={12} textAlign="center">
|
||||
<Stack align="center" gap={4}>
|
||||
<Box p={4} bg="bg-iron-gray" rounded="full">
|
||||
<Icon icon={Calendar} size={8} color="rgb(115, 115, 115)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block mb={1}>No races found</Text>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{totalCount === 0
|
||||
? 'No races have been scheduled yet'
|
||||
: 'Try adjusting your filters'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{racesByDate.map((group) => (
|
||||
<Stack key={group.dateKey} gap={3}>
|
||||
<DateHeader
|
||||
label={group.dateLabel}
|
||||
count={group.races.length}
|
||||
/>
|
||||
|
||||
<Stack gap={2}>
|
||||
{group.races.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled;
|
||||
|
||||
return (
|
||||
<RaceListItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
timeLabel={race.timeLabel}
|
||||
relativeTimeLabel={race.relativeTimeLabel}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName ?? 'Unknown League'}
|
||||
leagueHref={routes.league.detail(race.leagueId ?? '')}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
statusConfig={config}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
|
||||
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RaceListItemProps {
|
||||
track: string;
|
||||
car: string;
|
||||
timeLabel?: string;
|
||||
relativeTimeLabel?: string;
|
||||
dateLabel?: string;
|
||||
dayLabel?: string;
|
||||
status: string;
|
||||
leagueName?: string | null;
|
||||
leagueHref?: string;
|
||||
strengthOfField?: number | null;
|
||||
onClick: () => void;
|
||||
statusConfig: {
|
||||
icon: LucideIcon;
|
||||
variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceListItem({
|
||||
track,
|
||||
car,
|
||||
timeLabel,
|
||||
relativeTimeLabel,
|
||||
dateLabel,
|
||||
dayLabel,
|
||||
status,
|
||||
leagueName,
|
||||
leagueHref,
|
||||
strengthOfField,
|
||||
onClick,
|
||||
statusConfig,
|
||||
}: RaceListItemProps) {
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverScale
|
||||
group
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{status === 'running' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
style={{ background: 'linear-gradient(to right, #10b981, rgba(16, 185, 129, 0.5), #10b981)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{/* Time/Date Column */}
|
||||
<Box flexShrink={0} textAlign="center" minWidth="60px">
|
||||
{dateLabel && (
|
||||
<Text size="xs" color="text-gray-500" block style={{ textTransform: 'uppercase' }}>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={dayLabel ? "2xl" : "lg"} weight="bold" color="text-white" block>
|
||||
{dayLabel || timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" color={status === 'running' ? 'text-performance-green' : 'text-gray-400'} block>
|
||||
{status === 'running' ? 'LIVE' : relativeTimeLabel || timeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box w="px" h="10" alignSelf="stretch" bg="bg-charcoal-outline" />
|
||||
|
||||
{/* Main Content */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Box minWidth="0">
|
||||
<Heading level={3} truncate>
|
||||
{track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Car} size={3.5} color="rgb(156, 163, 175)" />
|
||||
<Text size="sm" color="text-gray-400">{car}</Text>
|
||||
</Stack>
|
||||
{strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Zap} size={3.5} color="rgb(245, 158, 11)" />
|
||||
<Text size="sm" color="text-gray-400">SOF {strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={statusConfig.variant}>
|
||||
<Icon icon={StatusIcon} size={3.5} />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
{/* League Link */}
|
||||
{leagueName && leagueHref && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline" bgOpacity={0.5}>
|
||||
<Link
|
||||
href={leagueHref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon icon={Trophy} size={3.5} mr={2} />
|
||||
{leagueName}
|
||||
<Icon icon={ArrowRight} size={3} ml={2} />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon icon={ChevronRight} size={5} color="rgb(115, 115, 115)" flexShrink={0} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { RaceListItem as UiRaceListItem } from '@/ui/RaceListItem';
|
||||
import { CheckCircle2, Clock, PlayCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string | null;
|
||||
leagueName?: string | null;
|
||||
strengthOfField?: number | null;
|
||||
}
|
||||
|
||||
interface RaceListItemProps {
|
||||
race: Race;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RaceListItem({ race, onClick }: RaceListItemProps) {
|
||||
const statusConfig = {
|
||||
scheduled: {
|
||||
icon: Clock,
|
||||
variant: 'primary' as const,
|
||||
label: 'Scheduled',
|
||||
},
|
||||
running: {
|
||||
icon: PlayCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'default' as const,
|
||||
label: 'Completed',
|
||||
},
|
||||
cancelled: {
|
||||
icon: XCircle,
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[race.status];
|
||||
|
||||
const formatTime = (date: string) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const date = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<UiRaceListItem
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
dateLabel={date.toLocaleDateString('en-US', { month: 'short' })}
|
||||
dayLabel={date.getDate().toString()}
|
||||
timeLabel={formatTime(race.scheduledAt)}
|
||||
status={race.status}
|
||||
leagueName={race.leagueName}
|
||||
leagueHref={race.leagueId ? routes.league.detail(race.leagueId) : undefined}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={() => onClick(race.id)}
|
||||
statusConfig={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
|
||||
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
|
||||
import { RaceResultCard as UiRaceResultCard } from '@/ui/RaceResultCard';
|
||||
|
||||
interface RaceResultCardProps {
|
||||
race: {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
result: RaceResultViewModel;
|
||||
league?: {
|
||||
name: string;
|
||||
};
|
||||
showLeague?: boolean;
|
||||
}
|
||||
|
||||
export function RaceResultCard({
|
||||
race,
|
||||
result,
|
||||
league,
|
||||
showLeague = true,
|
||||
}: RaceResultCardProps) {
|
||||
return (
|
||||
<UiRaceResultCard
|
||||
raceId={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
position={result.position}
|
||||
startPosition={result.startPosition}
|
||||
incidents={result.incidents}
|
||||
leagueName={league?.name}
|
||||
showLeague={showLeague}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
|
||||
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface ResultEntry {
|
||||
position: number;
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
driverAvatar: string;
|
||||
country: string;
|
||||
car: string;
|
||||
laps: number;
|
||||
time: string;
|
||||
fastestLap: string;
|
||||
points: number;
|
||||
incidents: number;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
interface RaceResultRowProps {
|
||||
result: ResultEntry;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
||||
const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result;
|
||||
|
||||
const getPositionColor = (pos: number) => {
|
||||
if (pos === 1) return { bg: 'bg-yellow-500/20', color: 'text-yellow-400' };
|
||||
if (pos === 2) return { bg: 'bg-gray-400/20', color: 'text-gray-300' };
|
||||
if (pos === 3) return { bg: 'bg-amber-600/20', color: 'text-amber-600' };
|
||||
return { bg: 'bg-iron-gray/50', color: 'text-gray-500' };
|
||||
};
|
||||
|
||||
const posConfig = getPositionColor(position);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant={isCurrentUser ? 'muted' : 'dark'}
|
||||
rounded="xl"
|
||||
border={isCurrentUser}
|
||||
padding={3}
|
||||
className={isCurrentUser ? 'border-primary-blue/40' : ''}
|
||||
style={isCurrentUser ? { background: 'linear-gradient(to right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1), transparent)' } : {}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{/* Position */}
|
||||
<Box
|
||||
width="10"
|
||||
height="10"
|
||||
rounded="lg"
|
||||
display="flex"
|
||||
center
|
||||
className={`${posConfig.bg} ${posConfig.color}`}
|
||||
>
|
||||
<Text weight="bold">{position}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Avatar */}
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Box width="10" height="10" rounded="full" overflow="hidden" border={isCurrentUser} borderColor="border-primary-blue/50" className={isCurrentUser ? 'border-2' : ''}>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} objectFit="cover" />
|
||||
</Box>
|
||||
<Box position="absolute" bottom="-0.5" right="-0.5" width="5" height="5" rounded="full" bg="bg-deep-graphite" display="flex" center style={{ fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<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>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Box px={2} py={0.5} rounded="full" bg="bg-primary-blue">
|
||||
<Text size="xs" weight="bold" color="text-white">YOU</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{car}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Laps: {laps}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Incidents: {incidents}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Times */}
|
||||
<Box textAlign="right" style={{ minWidth: '100px' }}>
|
||||
<Text size="sm" font="mono" color="text-white" block>{time}</Text>
|
||||
<Text size="xs" color="text-performance-green" block mt={1}>FL: {fastestLap}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Points */}
|
||||
<Box p={2} rounded="lg" border={true} borderColor="border-warning-amber/20" bg="bg-warning-amber/10" textAlign="center" style={{ minWidth: '3.5rem' }}>
|
||||
<Text size="xs" color="text-gray-500" block>PTS</Text>
|
||||
<Text size="sm" weight="bold" color="text-warning-amber">{points}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Clock, Trophy, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SidebarRaceItem } from '@/ui/SidebarRaceItem';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceSidebarProps {
|
||||
upcomingRaces: RaceViewData[];
|
||||
recentResults: RaceViewData[];
|
||||
onRaceClick: (raceId: string) => void;
|
||||
}
|
||||
|
||||
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} icon={<Icon icon={Clock} size={4} color="rgb(59, 130, 246)" />}>
|
||||
Next Up
|
||||
</Heading>
|
||||
<Text size="xs" color="text-gray-500">This week</Text>
|
||||
</Stack>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<Box py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No races scheduled this week</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="rgb(245, 158, 11)" />}>
|
||||
Recent Results
|
||||
</Heading>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<Box py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No completed races yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3}>Quick Actions</Heading>
|
||||
<Stack gap={2}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
icon={Users}
|
||||
label="Browse Leagues"
|
||||
/>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leaderboards}
|
||||
icon={Trophy}
|
||||
label="View Leaderboards"
|
||||
iconColor="text-warning-amber"
|
||||
iconBgColor="bg-warning-amber/10"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RaceSummaryItemProps {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface RangeFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
helperText?: string;
|
||||
error?: string | undefined;
|
||||
disabled?: boolean;
|
||||
unitLabel?: string;
|
||||
rangeHint?: string;
|
||||
/** Show large value display above slider */
|
||||
showLargeValue?: boolean;
|
||||
/** Compact mode - single line */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function RangeField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
helperText,
|
||||
error,
|
||||
disabled,
|
||||
unitLabel = 'min',
|
||||
rangeHint,
|
||||
showLargeValue = false,
|
||||
compact = false,
|
||||
}: RangeFieldProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync local value with prop when not dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isDragging]);
|
||||
|
||||
const clampedValue = Number.isFinite(localValue)
|
||||
? Math.min(Math.max(localValue, min), max)
|
||||
: min;
|
||||
|
||||
const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
|
||||
|
||||
const effectiveRangeHint =
|
||||
rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`);
|
||||
|
||||
const calculateValueFromPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!sliderRef.current) return clampedValue;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
||||
const rawValue = min + percent * (max - min);
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
return Math.min(Math.max(steppedValue, min), max);
|
||||
},
|
||||
[min, max, step, clampedValue]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging || disabled) return;
|
||||
const newValue = calculateValueFromPosition(e.clientX);
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue);
|
||||
},
|
||||
[isDragging, disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
setLocalValue(min);
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const clamped = Math.min(Math.max(parsed, min), max);
|
||||
setLocalValue(clamped);
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Ensure value is synced on blur
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
// Quick preset buttons for common values
|
||||
const quickPresets = [
|
||||
Math.round(min + (max - min) * 0.25),
|
||||
Math.round(min + (max - min) * 0.5),
|
||||
Math.round(min + (max - min) * 0.75),
|
||||
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
|
||||
{/* Track fill */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
|
||||
bg-white border-2 border-primary-blue shadow-md
|
||||
transition-transform duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
|
||||
<span className="text-[10px] text-gray-500">{unitLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-gray-300">{label}</label>
|
||||
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
|
||||
</div>
|
||||
|
||||
{showLargeValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
|
||||
<span className="text-sm text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom slider */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
|
||||
|
||||
{/* Track fill with gradient */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
/>
|
||||
|
||||
{/* Tick marks */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
|
||||
{[0, 25, 50, 75, 100].map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className={`w-0.5 h-1 rounded-full transition-colors ${
|
||||
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
|
||||
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
|
||||
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
|
||||
transition-all duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value input and quick presets */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={clampedValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
|
||||
bg-iron-gray border border-charcoal-outline text-white
|
||||
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
|
||||
transition-colors
|
||||
${error ? 'border-warning-amber' : ''}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
|
||||
{quickPresets.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{quickPresets.slice(0, 3).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocalValue(preset);
|
||||
onChange(preset);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
|
||||
{error && <p className="text-xs text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface RankingListProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RankingList({ children }: RankingListProps) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface RankingListItemProps {
|
||||
name: string;
|
||||
type: 'overall' | 'league';
|
||||
rank: number;
|
||||
totalDrivers: number;
|
||||
percentile: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export function RankingListItem({
|
||||
name,
|
||||
type,
|
||||
rank,
|
||||
totalDrivers,
|
||||
percentile,
|
||||
rating,
|
||||
}: RankingListItemProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
py={2}
|
||||
px={3}
|
||||
rounded="lg"
|
||||
bg="bg-deep-graphite/60"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" weight="medium" color="text-white">
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{type === 'overall' ? 'Overall' : 'League'} ranking
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={6} textAlign="right">
|
||||
<Box>
|
||||
<Text color="text-primary-blue" size="base" weight="semibold" block>
|
||||
#{rank}
|
||||
</Text>
|
||||
<Text color="text-gray-500" size="xs" block>Position</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-white" size="sm" weight="semibold" block>
|
||||
{totalDrivers}
|
||||
</Text>
|
||||
<Text color="text-gray-500" size="xs" block>Drivers</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-green-400" size="sm" weight="semibold" block>
|
||||
{percentile.toFixed(1)}%
|
||||
</Text>
|
||||
<Text color="text-gray-500" size="xs" block>Percentile</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-warning-amber" size="sm" weight="semibold" block>
|
||||
{rating}
|
||||
</Text>
|
||||
<Text color="text-gray-500" size="xs" block>Rating</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface RatingHistoryItemProps {
|
||||
date: string;
|
||||
skillChange: number;
|
||||
safetyChange: number;
|
||||
sportsmanshipChange: number;
|
||||
}
|
||||
|
||||
export function RatingHistoryItem({
|
||||
date,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
|
||||
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { ActiveDriverCard } from '@/ui/ActiveDriverCard';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity } 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 RecentActivityProps {
|
||||
drivers: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
isActive: boolean;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
onDriverClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
|
||||
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
|
||||
|
||||
return (
|
||||
<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-performance-green/10"
|
||||
border
|
||||
borderColor="border-performance-green/20"
|
||||
>
|
||||
<Icon icon={Activity} size={5} color="rgb(16, 185, 129)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Active Drivers</Heading>
|
||||
<Text size="xs" color="text-gray-500">Currently competing in leagues</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={3}>
|
||||
{activeDrivers.map((driver) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
return (
|
||||
<ActiveDriverCard
|
||||
key={driver.id}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
categoryLabel={categoryConfig?.label}
|
||||
categoryColor={categoryConfig?.color}
|
||||
skillLevelLabel={levelConfig?.label}
|
||||
skillLevelColor={levelConfig?.color}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface SidebarActionLinkProps {
|
||||
href: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { LucideIcon, ChevronRight, UserPlus } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
|
||||
|
||||
export function SponsorHero({ title, subtitle, children }: SponsorHeroProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: 'easeOut' as const },
|
||||
},
|
||||
};
|
||||
|
||||
const gridStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, #198CFF 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #198CFF 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '40px 40px',
|
||||
};
|
||||
|
||||
if (!isMounted || shouldReduceMotion) {
|
||||
return (
|
||||
<Box position="relative" overflow="hidden">
|
||||
{/* Background Pattern */}
|
||||
<Box position="absolute" inset="0" bg="bg-gradient-to-br from-primary-blue/5 via-transparent to-transparent" />
|
||||
<Box position="absolute" inset="0" bg="bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary-blue/10 via-transparent to-transparent" />
|
||||
|
||||
{/* Grid Pattern */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bgOpacity={0.05}
|
||||
style={gridStyle}
|
||||
/>
|
||||
|
||||
<Box position="relative" maxWidth="5xl" mx="auto" px={4} py={{ base: 16, sm: 24 }}>
|
||||
<Box textAlign="center">
|
||||
<Box display="inline-flex" alignItems="center" gap={2} px={4} py={2} rounded="full" bg="bg-primary-blue/10" border={true} borderColor="border-primary-blue/20" mb={6}>
|
||||
<Icon icon={Building2} size={4} color="text-primary-blue" />
|
||||
<Text size="sm" color="text-primary-blue" weight="medium">Sponsor Portal</Text>
|
||||
</Box>
|
||||
|
||||
<Heading level={1} mb={6} className="tracking-tight">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" color="text-gray-400" maxWidth="2xl" mx="auto" block mb={10}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={motion.div}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Background Pattern */}
|
||||
<Box position="absolute" inset="0" bg="bg-gradient-to-br from-primary-blue/5 via-transparent to-transparent" />
|
||||
<Box position="absolute" inset="0" bg="bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary-blue/10 via-transparent to-transparent" />
|
||||
|
||||
{/* Animated Grid Pattern */}
|
||||
<Box
|
||||
as={motion.div}
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bgOpacity={0.05}
|
||||
style={gridStyle}
|
||||
animate={{
|
||||
backgroundPosition: ['0px 0px', '40px 40px'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: 'linear' as const,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box position="relative" maxWidth="5xl" mx="auto" px={4} py={{ base: 16, sm: 24 }}>
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
as={motion.div}
|
||||
variants={itemVariants}
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={4}
|
||||
py={2}
|
||||
rounded="full"
|
||||
bg="bg-primary-blue/10"
|
||||
border={true}
|
||||
borderColor="border-primary-blue/20"
|
||||
mb={6}
|
||||
>
|
||||
<Icon icon={Building2} size={4} color="text-primary-blue" />
|
||||
<Text size="sm" color="text-primary-blue" weight="medium">Sponsor Portal</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
as={motion.h1}
|
||||
variants={itemVariants}
|
||||
className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight"
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
as={motion.p}
|
||||
variants={itemVariants}
|
||||
className="text-lg sm:text-xl text-gray-400 max-w-2xl mx-auto mb-10"
|
||||
>
|
||||
{subtitle}
|
||||
</Box>
|
||||
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SponsorMetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: LucideIcon;
|
||||
color?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
@@ -30,7 +31,7 @@ export function SponsorMetricCard({
|
||||
borderColor="border-charcoal-outline"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5} className={color} mb={1}>
|
||||
<Icon icon={icon as any} size={4} />
|
||||
<Icon icon={icon} size={4} />
|
||||
<Text size="xs" weight="medium">{label}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="baseline" gap={2}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
interface SponsorSlotCardProps {
|
||||
title: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Building, Check, Clock, DollarSign, MessageCircle, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@@ -11,6 +11,15 @@ interface ResponsiveGap {
|
||||
xl?: number;
|
||||
}
|
||||
|
||||
interface ResponsiveSpacing {
|
||||
base?: Spacing;
|
||||
sm?: Spacing;
|
||||
md?: Spacing;
|
||||
lg?: Spacing;
|
||||
xl?: Spacing;
|
||||
'2xl'?: Spacing;
|
||||
}
|
||||
|
||||
interface StackProps<T extends ElementType> extends Omit<BoxProps<T>, 'children' | 'className' | 'gap'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
@@ -20,18 +29,18 @@ interface StackProps<T extends ElementType> extends Omit<BoxProps<T>, 'children'
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
|
||||
wrap?: boolean;
|
||||
center?: boolean;
|
||||
m?: Spacing | any;
|
||||
mt?: Spacing | any;
|
||||
mb?: Spacing | any;
|
||||
ml?: Spacing | any;
|
||||
mr?: Spacing | any;
|
||||
p?: Spacing | any;
|
||||
pt?: Spacing | any;
|
||||
pb?: Spacing | any;
|
||||
pl?: Spacing | any;
|
||||
pr?: Spacing | any;
|
||||
px?: Spacing | any;
|
||||
py?: Spacing | any;
|
||||
m?: Spacing | ResponsiveSpacing;
|
||||
mt?: Spacing | ResponsiveSpacing;
|
||||
mb?: Spacing | ResponsiveSpacing;
|
||||
ml?: Spacing | ResponsiveSpacing;
|
||||
mr?: Spacing | ResponsiveSpacing;
|
||||
p?: Spacing | ResponsiveSpacing;
|
||||
pt?: Spacing | ResponsiveSpacing;
|
||||
pb?: Spacing | ResponsiveSpacing;
|
||||
pl?: Spacing | ResponsiveSpacing;
|
||||
pr?: Spacing | ResponsiveSpacing;
|
||||
px?: Spacing | ResponsiveSpacing;
|
||||
py?: Spacing | ResponsiveSpacing;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
style?: React.CSSProperties;
|
||||
role?: string;
|
||||
@@ -99,6 +108,21 @@ export function Stack<T extends ElementType = 'div'>({
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
const getSpacingClass = (prefix: string, value: Spacing | ResponsiveSpacing | undefined) => {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
const classes = [];
|
||||
if (value.base !== undefined) classes.push(`${prefix}-${spacingMap[value.base]}`);
|
||||
if (value.sm !== undefined) classes.push(`sm:${prefix}-${spacingMap[value.sm]}`);
|
||||
if (value.md !== undefined) classes.push(`md:${prefix}-${spacingMap[value.md]}`);
|
||||
if (value.lg !== undefined) classes.push(`lg:${prefix}-${spacingMap[value.lg]}`);
|
||||
if (value.xl !== undefined) classes.push(`xl:${prefix}-${spacingMap[value.xl]}`);
|
||||
if (value['2xl'] !== undefined) classes.push(`2xl:${prefix}-${spacingMap[value['2xl']]}`);
|
||||
return classes.join(' ');
|
||||
}
|
||||
return `${prefix}-${spacingMap[value]}`;
|
||||
};
|
||||
|
||||
const classes = [
|
||||
'flex',
|
||||
typeof direction === 'string'
|
||||
@@ -111,18 +135,18 @@ export function Stack<T extends ElementType = 'div'>({
|
||||
getGapClasses(gap) || 'gap-4',
|
||||
center ? 'items-center justify-center' : `items-${align} justify-${justify}`,
|
||||
wrap ? 'flex-wrap' : '',
|
||||
m !== undefined ? `m-${spacingMap[m]}` : '',
|
||||
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
|
||||
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
|
||||
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
|
||||
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
|
||||
p !== undefined ? `p-${spacingMap[p]}` : '',
|
||||
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
|
||||
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
|
||||
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
|
||||
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
|
||||
px !== undefined ? `px-${spacingMap[px]}` : '',
|
||||
py !== undefined ? `py-${spacingMap[py]}` : '',
|
||||
getSpacingClass('m', m),
|
||||
getSpacingClass('mt', mt),
|
||||
getSpacingClass('mb', mb),
|
||||
getSpacingClass('ml', ml),
|
||||
getSpacingClass('mr', mr),
|
||||
getSpacingClass('p', p),
|
||||
getSpacingClass('pt', pt),
|
||||
getSpacingClass('pb', pb),
|
||||
getSpacingClass('pl', pl),
|
||||
getSpacingClass('pr', pr),
|
||||
getSpacingClass('px', px),
|
||||
getSpacingClass('py', py),
|
||||
rounded ? roundedClasses[rounded] : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
@@ -51,13 +51,6 @@ export function StatCard({
|
||||
green: 'text-performance-green',
|
||||
orange: 'text-warning-amber'
|
||||
};
|
||||
|
||||
const iconHexColors = {
|
||||
blue: '#3b82f6',
|
||||
purple: '#a78bfa',
|
||||
green: '#34d399',
|
||||
orange: '#fb923c'
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<Card className={`${variantClasses[variant]} ${className} h-full`} p={5}>
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { StateContainerProps, StateContainerConfig } from '@/ui/state-types';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { ErrorDisplay } from '@/ui/ErrorDisplay';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Inbox, AlertCircle, Grid, List, LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* StateContainer Component
|
||||
*
|
||||
* Combined wrapper that automatically handles all states (loading, error, empty, success)
|
||||
* based on the provided data and state values.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic state detection and rendering
|
||||
* - Customizable configuration for each state
|
||||
* - Custom render functions for advanced use cases
|
||||
* - Consistent behavior across all pages
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* <StateContainer
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={retry}
|
||||
* config={{
|
||||
* loading: { variant: 'skeleton', message: 'Loading...' },
|
||||
* error: { variant: 'full-screen' },
|
||||
* empty: {
|
||||
* icon: Trophy,
|
||||
* title: 'No data found',
|
||||
* description: 'Try refreshing the page',
|
||||
* action: { label: 'Refresh', onClick: retry }
|
||||
* }
|
||||
* }}
|
||||
* >
|
||||
* {(content) => <MyContent data={content} />}
|
||||
* </StateContainer>
|
||||
* ```
|
||||
*/
|
||||
export function StateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
showEmpty = true,
|
||||
isEmpty,
|
||||
}: StateContainerProps<T>) {
|
||||
// Determine if data is empty
|
||||
const isDataEmpty = (data: T | null | undefined): boolean => {
|
||||
if (data === null || data === undefined) return true;
|
||||
if (isEmpty) return isEmpty(data);
|
||||
|
||||
// Default empty checks
|
||||
if (Array.isArray(data)) return data.length === 0;
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
return Object.keys(data).length === 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Priority order: Loading > Error > Empty > Success
|
||||
if (isLoading) {
|
||||
const loadingConfig = config?.loading || {};
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.loading) {
|
||||
return <>{config.customRender.loading()}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<LoadingWrapper
|
||||
variant={loadingConfig.variant || 'spinner'}
|
||||
message={loadingConfig.message || 'Loading...'}
|
||||
size={loadingConfig.size || 'md'}
|
||||
skeletonCount={loadingConfig.skeletonCount}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorConfig = config?.error || {};
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.error) {
|
||||
return <>{config.customRender.error(error)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={retry}
|
||||
variant={errorConfig.variant || 'full-screen'}
|
||||
actions={errorConfig.actions}
|
||||
showRetry={errorConfig.showRetry}
|
||||
showNavigation={errorConfig.showNavigation}
|
||||
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (showEmpty && isDataEmpty(data)) {
|
||||
const emptyConfig = config?.empty;
|
||||
|
||||
// Custom render
|
||||
if (config?.customRender?.empty) {
|
||||
return <>{config.customRender.empty()}</>;
|
||||
}
|
||||
|
||||
// If no empty config provided, show nothing (or could show default empty state)
|
||||
if (!emptyConfig) {
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="No data available"
|
||||
description="There is nothing to display here"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={emptyConfig.icon}
|
||||
title={emptyConfig.title || 'No data available'}
|
||||
description={emptyConfig.description}
|
||||
action={emptyConfig.action}
|
||||
variant="default"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state - render children with data
|
||||
if (data === null || data === undefined) {
|
||||
// This shouldn't happen if we've handled all cases above, but as a fallback
|
||||
return (
|
||||
<Box>
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Unexpected state"
|
||||
description="No data available but no error or loading state"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// At this point, data is guaranteed to be non-null and non-undefined
|
||||
return <>{children(data as T)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ListStateContainer - Specialized for list data
|
||||
* Automatically handles empty arrays with appropriate messaging
|
||||
*/
|
||||
export function ListStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
emptyConfig,
|
||||
}: StateContainerProps<T[]> & {
|
||||
emptyConfig?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const listConfig: StateContainerConfig<T[]> = {
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: List,
|
||||
title: 'No items found',
|
||||
description: 'This list is currently empty',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={listConfig}
|
||||
isEmpty={(arr) => !arr || arr.length === 0}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DetailStateContainer - Specialized for detail pages
|
||||
* Includes back/refresh functionality
|
||||
*/
|
||||
export function DetailStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
onBack,
|
||||
onRefresh,
|
||||
}: StateContainerProps<T> & {
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const detailConfig: StateContainerConfig<T> = {
|
||||
...config,
|
||||
error: {
|
||||
...config?.error,
|
||||
actions: [
|
||||
...(config?.error?.actions || []),
|
||||
...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
|
||||
...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
|
||||
],
|
||||
showNavigation: config?.error?.showNavigation ?? true,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={detailConfig}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PageStateContainer - Full page state management
|
||||
* Wraps content in proper page structure
|
||||
*/
|
||||
export function PageStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
title,
|
||||
description,
|
||||
}: StateContainerProps<T> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const pageConfig: StateContainerConfig<T> = {
|
||||
loading: {
|
||||
variant: 'full-screen',
|
||||
message: title ? `Loading ${title}...` : 'Loading...',
|
||||
...config?.loading,
|
||||
},
|
||||
error: {
|
||||
variant: 'full-screen',
|
||||
...config?.error,
|
||||
},
|
||||
empty: config?.empty,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>;
|
||||
}
|
||||
|
||||
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||
if (config?.empty) {
|
||||
return (
|
||||
<Box bg="bg-deep-graphite" py={12} minHeight="100vh">
|
||||
<Box maxWidth="4xl" mx="auto" px={4}>
|
||||
{title && (
|
||||
<Box mb={8}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400">{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="bg-deep-graphite" py={8} minHeight="100vh">
|
||||
<Box maxWidth="4xl" mx="auto" px={4}>
|
||||
{title && (
|
||||
<Box mb={6}>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400">{description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
|
||||
{children}
|
||||
</StateContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStateContainer - Specialized for grid layouts
|
||||
* Handles card-based empty states
|
||||
*/
|
||||
export function GridStateContainer<T>({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
retry,
|
||||
children,
|
||||
config,
|
||||
emptyConfig,
|
||||
}: StateContainerProps<T[]> & {
|
||||
emptyConfig?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const gridConfig: StateContainerConfig<T[]> = {
|
||||
loading: {
|
||||
variant: 'card',
|
||||
...config?.loading,
|
||||
},
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: Grid,
|
||||
title: 'No items to display',
|
||||
description: 'Try adjusting your filters or search',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={gridConfig}
|
||||
isEmpty={(arr) => !arr || arr.length === 0}
|
||||
>
|
||||
{children}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { PageWrapper, PageWrapperProps } from './PageWrapper';
|
||||
|
||||
/**
|
||||
* Stateful Page Wrapper - CLIENT SIDE ONLY
|
||||
* Adds loading/error state management for client-side fetching
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* 'use client';
|
||||
*
|
||||
* export default function ProfilePage() {
|
||||
* const { data, isLoading, error, refetch } = usePageData(...);
|
||||
*
|
||||
* return (
|
||||
* <StatefulPageWrapper
|
||||
* data={data}
|
||||
* isLoading={isLoading}
|
||||
* error={error}
|
||||
* retry={refetch}
|
||||
* Template={ProfileTemplate}
|
||||
* loading={{ variant: 'skeleton', message: 'Loading profile...' }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function StatefulPageWrapper<TData>({
|
||||
data,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
retry,
|
||||
Template,
|
||||
loading,
|
||||
errorConfig,
|
||||
empty,
|
||||
children,
|
||||
}: PageWrapperProps<TData>) {
|
||||
// Same implementation but with 'use client' for CSR-specific features
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={Template}
|
||||
loading={loading}
|
||||
errorConfig={errorConfig}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';
|
||||
@@ -4,6 +4,7 @@ import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
icon: LucideIcon;
|
||||
|
||||
@@ -80,7 +80,7 @@ export function Surface<T extends ElementType = 'div'>({
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box className={classes} {...props}>
|
||||
<Box as={as} className={classes} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,12 @@ import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: any;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, HTMLAttributes, ElementType } from 'react';
|
||||
import React, { ReactNode, HTMLAttributes } from 'react';
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
interface TableProps extends HTMLAttributes<HTMLTableElement> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UserPlus,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import { Box } from './Box';
|
||||
import { Card } from './Card';
|
||||
@@ -69,7 +70,9 @@ export function TeamCard({
|
||||
alt={name}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={56} />
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
|
||||
|
||||
import { JoinTeamButton } from '@/components/teams/JoinTeamButton';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TeamLogo } from '@/ui/TeamLogo';
|
||||
import { TeamTag } from '@/ui/TeamTag';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface TeamHeroProps {
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string | null;
|
||||
description?: string;
|
||||
category?: string | null;
|
||||
createdAt?: string;
|
||||
leagues: { id: string }[];
|
||||
};
|
||||
memberCount: number;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack direction="row" align="start" justify="between" wrap gap={6}>
|
||||
<Stack direction="row" align="start" gap={6} wrap flexGrow={1}>
|
||||
<Box
|
||||
w="24"
|
||||
h="24"
|
||||
rounded="lg"
|
||||
p={1}
|
||||
overflow="hidden"
|
||||
bg="bg-deep-graphite"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<TeamLogo teamId={team.id} alt={team.name} />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={2}>
|
||||
<Heading level={1}>{team.name}</Heading>
|
||||
{team.tag && <TeamTag tag={team.tag} />}
|
||||
</Stack>
|
||||
|
||||
<Text color="text-gray-300" block mb={4} maxWidth="42rem">{team.description}</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Text size="sm" color="text-gray-400">{memberCount} {memberCount === 1 ? 'member' : 'members'}</Text>
|
||||
{team.category && (
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Box w="2" h="2" rounded="full" bg="bg-purple-500" />
|
||||
<Text size="sm" color="text-purple-400">{team.category}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{team.createdAt && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
)}
|
||||
{team.leagues && team.leagues.length > 0 && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Active in {team.leagues.length} {team.leagues.length === 1 ? 'league' : 'leagues'}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user