website refactor

This commit is contained in:
2026-01-15 19:55:46 +01:00
parent 5ef149b782
commit ce7be39155
154 changed files with 436 additions and 356 deletions

View File

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

View File

@@ -8,7 +8,7 @@ import {
Trophy,
Car,
} from 'lucide-react';
import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
import { WorkflowMockup, WorkflowStep } from '@/components/mockups/WorkflowMockup';
const WORKFLOW_STEPS: WorkflowStep[] = [
{

View File

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

View File

@@ -0,0 +1,29 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Trophy, Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { QuickActionItem } from '@/ui/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>
);
}

View File

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

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { RankBadge } from '@/ui/RankBadge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Stack } from '@/ui/Stack';
import { DriverStats } from '@/ui/DriverStats';
import { routes } from '@/lib/routing/RouteConfig';
export interface DriverCardProps {
id: string;
name: string;
rating: number;
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
rank: number;
onClick?: () => void;
}
export function DriverCard(props: DriverCardProps) {
const {
id,
name,
rating,
nationality,
racesCompleted,
wins,
podiums,
rank,
onClick,
} = props;
// Create a proper DriverViewModel instance
const driverViewModel = new DriverViewModel({
id,
name,
avatarUrl: null,
});
const winRate = racesCompleted > 0 ? ((wins / racesCompleted) * 100).toFixed(0) : '0';
return (
<Card
onClick={onClick}
transition
>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={4} flexGrow={1}>
<RankBadge rank={rank} size="lg" />
<DriverIdentity
driver={driverViewModel}
href={routes.driver.detail(id)}
meta={`${nationality}${racesCompleted} races`}
size="md"
/>
</Stack>
<DriverStats
rating={rating}
wins={wins}
podiums={podiums}
winRate={winRate}
/>
</Stack>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,84 @@
import Link from 'next/link';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { Image } from '@/ui/Image';
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}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} minWidth="0">
<Text size={nameSize} weight="medium" color="text-white" className="truncate">
{driver.name}
</Text>
{contextLabel && (
<Badge variant="default" className="bg-charcoal-outline/60 text-[10px] md:text-xs">
{contextLabel}
</Badge>
)}
</Box>
{meta && (
<Text size="xs" color="text-gray-400" mt={0.5} className="truncate" block>
{meta}
</Text>
)}
</Box>
</Box>
);
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
{content}
</Link>
);
}
return <Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">{content}</Box>;
}

View File

@@ -7,10 +7,10 @@ import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { StatCard } from '@/ui/StatCard';
import { ProfileHeader } from '@/ui/ProfileHeader';
import { ProfileHeader } from '@/components/drivers/ProfileHeader';
import { ProfileStats } from './ProfileStats';
import { CareerHighlights } from '@/ui/CareerHighlights';
import { DriverRankings } from '@/ui/DriverRankings';
import { DriverRankings } from '@/components/drivers/DriverRankings';
import { PerformanceMetrics } from '@/ui/PerformanceMetrics';
import { useDriverProfile } from "@/hooks/driver/useDriverProfile";

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
import { mediaConfig } from '@/lib/config/mediaConfig';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { MedalBadge } from '@/ui/MedalBadge';
import { MiniStat } from '@/ui/MiniStat';
import { Text } from '@/ui/Text';
import { Flag, Shield, Star, TrendingUp } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: Star, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' },
{ id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' },
{ id: 'intermediate', label: 'Intermediate', icon: TrendingUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30', description: 'Developing skills' },
{ id: 'beginner', label: 'Beginner', icon: Shield, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30', description: 'Learning the ropes' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400', bgColor: 'bg-orange-400/10', borderColor: 'border-orange-400/30' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400', bgColor: 'bg-red-400/10', borderColor: 'border-red-400/30' },
];
interface FeaturedDriverCardProps {
driver: {
id: string;
name: string;
nationality: string;
avatarUrl?: string;
rating: number;
wins: number;
podiums: number;
skillLevel?: string;
category?: string;
};
position: number;
onClick: () => void;
}
export function FeaturedDriverCard({ driver, position, onClick }: FeaturedDriverCardProps) {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const getBorderColor = (pos: number) => {
switch (pos) {
case 1: return 'border-yellow-400/50';
case 2: return 'border-gray-300/50';
case 3: return 'border-amber-600/50';
default: return 'border-charcoal-outline';
}
};
const getHoverBorderColor = (pos: number) => {
switch (pos) {
case 1: return 'yellow-400';
case 2: return 'gray-300';
case 3: return 'amber-600';
default: return 'primary-blue';
}
};
return (
<Box
as="button"
type="button"
onClick={onClick}
p={5}
rounded="xl"
bg="bg-iron-gray/60"
border
borderColor={getBorderColor(position)}
hoverBorderColor={getHoverBorderColor(position)}
transition
textAlign="left"
cursor="pointer"
hoverScale
group
>
{/* Header with Position */}
<Box display="flex" alignItems="start" justifyContent="between" mb={4}>
<MedalBadge position={position} />
<Box display="flex" gap={2}>
{categoryConfig && (
<Badge
bg={categoryConfig.bgColor}
color={categoryConfig.color}
borderColor={categoryConfig.borderColor}
>
{categoryConfig.label}
</Badge>
)}
{levelConfig && (
<Badge
bg={levelConfig.bgColor}
color={levelConfig.color}
borderColor={levelConfig.borderColor}
>
{levelConfig.label}
</Badge>
)}
</Box>
</Box>
{/* Avatar & Name */}
<Box display="flex" alignItems="center" gap={4} mb={4}>
<Box
position="relative"
w="16"
h="16"
rounded="full"
overflow="hidden"
border
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue"
transition
>
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
objectFit="cover"
fill
/>
</Box>
<Box>
<Heading level={3} groupHoverColor="primary-blue" transition>
{driver.name}
</Heading>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Flag} size={3.5} color="rgb(107, 114, 128)" />
<Text size="sm" color="text-gray-500">{driver.nationality}</Text>
</Box>
</Box>
</Box>
{/* Stats */}
<Box display="grid" gridCols={3} gap={3}>
<MiniStat
label="Rating"
value={driver.rating.toLocaleString()}
color="text-primary-blue"
/>
<MiniStat
label="Wins"
value={driver.wins}
color="text-performance-green"
/>
<MiniStat
label="Podiums"
value={driver.podiums}
color="text-warning-amber"
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,98 @@
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>
);
}

View File

@@ -0,0 +1,171 @@
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
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>
);
}

View File

@@ -6,8 +6,8 @@ import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { Pagination } from '@/ui/Pagination';
import { Trophy } from 'lucide-react';

View File

@@ -3,7 +3,7 @@
import React, { Component, ReactNode, useState } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ErrorDisplay } from './ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
import { DevErrorPanel } from './DevErrorPanel';
interface Props {

View File

@@ -4,7 +4,7 @@ import React, { Component, ReactNode, ErrorInfo, useState, version } from 'react
import { ApiError } from '@/lib/api/base/ApiError';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { DevErrorPanel } from './DevErrorPanel';
import { ErrorDisplay } from './ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
interface Props {
children: ReactNode;

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { ErrorDisplay as UiErrorDisplay } from '@/ui/ErrorDisplay';
import { ErrorDisplay as UiErrorDisplay } from '@/components/shared/state/ErrorDisplay';
interface ErrorDisplayProps {
error: ApiError;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Activity } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { ActivityItem } from '@/ui/ActivityItem';
import { Icon } from '@/ui/Icon';
import { ActivityFeedList } from '@/components/feed/ActivityFeedList';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
interface FeedItem {
id: string;
headline: string;
body?: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface ActivityFeedProps {
items: FeedItem[];
hasItems: boolean;
}
export function ActivityFeed({ items, hasItems }: ActivityFeedProps) {
return (
<Card>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="var(--primary-blue)" />} mb={4}>
Recent Activity
</Heading>
{hasItems ? (
<ActivityFeedList>
{items.slice(0, 5).map((item) => (
<ActivityItem
key={item.id}
headline={item.headline}
body={item.body}
formattedTime={item.formattedTime}
ctaHref={item.ctaHref}
ctaLabel={item.ctaLabel}
/>
))}
</ActivityFeedList>
) : (
<MinimalEmptyState
icon={Activity}
title="No activity yet"
description="Join leagues and add friends to see activity here"
/>
)}
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
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>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
'use client';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { MockupStack } from '@/components/mockups/MockupStack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { LeagueHomeMockup } from '@/components/mockups/LeagueHomeMockup';
import { StandingsTableMockup } from '@/components/mockups/StandingsTableMockup';
import { TeamCompetitionMockup } from '@/components/mockups/TeamCompetitionMockup';
import { ProtestWorkflowMockup } from '@/components/mockups/ProtestWorkflowMockup';
import { LeagueDiscoveryMockup } from '@/components/mockups/LeagueDiscoveryMockup';
import { DriverProfileMockup } from '@/components/mockups/DriverProfileMockup';
const features = [
{
title: "A Real Home for Your League",
description: "Stop juggling Discord, spreadsheets, and iRacing admin panels. GridPilot brings everything into one dedicated platform built specifically for league racing.",
MockupComponent: LeagueHomeMockup
},
{
title: "Automatic Results & Standings",
description: "Race happens. Results appear. Standings update. No manual data entry, no spreadsheet formulas, no waiting for someone to publish.",
MockupComponent: StandingsTableMockup
},
{
title: "Real Team Racing",
description: "Constructors' championships that actually matter. Driver lineups. Team strategies. Multi-class racing done right.",
MockupComponent: TeamCompetitionMockup
},
{
title: "Clean Protests & Penalties",
description: "Structured incident reporting with video clip references. Steward review workflows. Transparent penalty application. Professional race control.",
MockupComponent: ProtestWorkflowMockup
},
{
title: "Find Your Perfect League",
description: "Search and discover leagues by game, region, and skill level. Browse featured competitions, check driver counts, and join communities that match your racing style.",
MockupComponent: LeagueDiscoveryMockup
},
{
title: "Your Racing Identity",
description: "Cross-league driver profiles with career stats, achievements, and racing history. Build your reputation across multiple championships and showcase your progression.",
MockupComponent: DriverProfileMockup
}
];
function FeatureCard({ feature, index }: { feature: typeof features[0], index: number }) {
return (
<Box
display="flex"
flexDirection="column"
gap={6}
group
>
<Box aspectRatio="video" fullWidth position="relative">
<Box position="absolute" inset="-0.5" bg="linear-gradient(to right, rgba(239, 68, 68, 0.2), rgba(59, 130, 246, 0.2), rgba(239, 68, 68, 0.2))" rounded="lg" opacity={0} groupHoverOpacity={1} transition blur="sm" />
<Box position="relative">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</Box>
</Box>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={2}>
<Heading level={3} weight="medium" style={{ background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', filter: 'drop-shadow(0 0 15px rgba(220,0,0,0.4))', WebkitTextStroke: '0.5px rgba(220,0,0,0.2)' }}>
{feature.title}
</Heading>
</Box>
<Text size={{ base: 'sm', sm: 'base' }} color="text-gray-400" weight="light" leading="relaxed">
{feature.description}
</Text>
</Stack>
</Box>
);
}
export function FeatureGrid() {
return (
<Section variant="default">
<Container position="relative" zIndex={10}>
<Container size="sm" center>
<Box>
<Heading level={2} weight="semibold" style={{ background: 'linear-gradient(to right, #dc2626, #ffffff, #2563eb)', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', filter: 'drop-shadow(0 0 20px rgba(220,0,0,0.5))', WebkitTextStroke: '1px rgba(220,0,0,0.2)' }}>
Building for League Racing
</Heading>
<Text size={{ base: 'base', sm: 'lg' }} color="text-gray-400" block mt={{ base: 4, sm: 6 }}>
These features are in development. Join the community to help shape what gets built first
</Text>
</Box>
</Container>
<Box mx="auto" mt={{ base: 8, sm: 12, md: 16 }} display="grid" gridCols={{ base: 1, lg: 2, xl: 3 }} gap={{ base: 10, sm: 12, md: 16 }} maxWidth={{ base: '2xl', lg: 'none' }}>
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}
</Box>
</Container>
</Section>
);
}

View File

@@ -0,0 +1,171 @@
import { useRef } from 'react';
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={discordUrl}
variant="primary"
px={8}
py={4}
size="lg"
bg="bg-[#5865F2]"
hoverBg="bg-[#4752C4]"
shadow="0 0 20px rgba(88,101,242,0.3)"
hoverScale
transition
aria-label="Join us on Discord"
>
{/* Discord Logo SVG */}
<Box
as="svg"
w="7"
h="7"
viewBox="0 0 71 55"
fill="none"
xmlns="http://www.w3.org/2000/svg"
transition
groupHoverScale
>
<g clipPath="url(#clip0)">
<path
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white"/>
</clipPath>
</defs>
</Box>
<Text>Join us on Discord</Text>
</Button>
</Box>
</Stack>
</Container>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Text } from '@/ui/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>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Crown, Flag } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
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 objectFit="cover" />
</Box>
{/* Info */}
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate block className="group-hover:text-primary-blue transition-colors">
{name}
</Text>
<Stack direction="row" align="center" gap={2}>
<Flag className="w-3 h-3 text-gray-500" />
<Text size="xs" color="text-gray-500">{nationality}</Text>
{categoryLabel && (
<Text size="xs" className={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" className={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Stack>
</Box>
{/* Stats */}
<Stack direction="row" align="center" gap={4}>
<Box textAlign="center">
<Text color="text-primary-blue" weight="semibold" font="mono" block>{rating.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Rating</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" weight="semibold" font="mono" block>{wins}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Wins</Text>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,105 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeaderboardItem } from '@/components/leaderboards/LeaderboardItem';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Award, ChevronRight } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface LeaderboardPreviewProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
onNavigate: (href: string) => void;
}
export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) {
const top5 = drivers.slice(0, 5);
return (
<Stack gap={4} mb={10}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
center
w="10"
h="10"
rounded="xl"
bg="bg-gradient-to-br from-yellow-400/20 to-amber-600/10"
border
borderColor="border-yellow-400/30"
>
<Icon icon={Award} size={5} color="rgb(250, 204, 21)" />
</Box>
<Box>
<Heading level={2}>Top Drivers</Heading>
<Text size="xs" color="text-gray-500">Highest rated competitors</Text>
</Box>
</Stack>
<Button
variant="secondary"
onClick={() => onNavigate(routes.leaderboards.drivers)}
icon={<Icon icon={ChevronRight} size={4} />}
>
Full Rankings
</Button>
</Stack>
<LeaderboardList>
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1;
return (
<LeaderboardItem
key={driver.id}
position={position}
name={driver.name}
avatarUrl={driver.avatarUrl}
nationality={driver.nationality}
rating={driver.rating}
wins={driver.wins}
skillLevelLabel={levelConfig?.label}
skillLevelColor={levelConfig?.color}
categoryLabel={categoryConfig?.label}
categoryColor={categoryConfig?.color}
onClick={() => onDriverClick(driver.id)}
/>
);
})}
</LeaderboardList>
</Stack>
);
}

View File

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

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/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>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card';
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
import { EmptyState as UiEmptyState } from '@/components/shared/state/EmptyState';
interface EmptyStateProps {
title: string;

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Calendar, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { ActivityFeedItem } from '@/ui/ActivityFeedItem';
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';

View File

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

View File

@@ -0,0 +1,101 @@
import React, { ReactNode } from 'react';
import { TableRow, TableCell } from '@/ui/Table';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { DriverIdentity } from '@/components/drivers/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 textAlign="right">
{actions}
</TableCell>
)}
</TableRow>
);
}

View File

@@ -13,8 +13,8 @@ import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
interface LeagueMembersProps {
leagueId: string;

View File

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

View File

@@ -7,7 +7,7 @@ import { useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components
import { StateContainer } from '@/ui/StateContainer';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';

View File

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

View File

@@ -21,7 +21,7 @@ import {
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import { Input } from '@/ui/Input';
import RangeField from '@/ui/RangeField';
import { RangeField } from '@/components/shared/RangeField';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';

View File

@@ -0,0 +1,78 @@
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 "./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,
drivers,
leagueId,
onReviewProtest,
}: 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;
const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId];
return (
<ProtestListItem
key={protest.id}
protesterName={protester?.name || 'Unknown'}
protesterHref={`/drivers/${protest.protestingDriverId}`}
accusedName={accused?.name || 'Unknown'}
accusedHref={`/drivers/${protest.accusedDriverId}`}
status={protest.status}
isUrgent={isUrgent}
daysOld={daysSinceFiled}
lap={protest.incident?.lap ?? 0}
filedAtLabel={new Date(filedAt).toLocaleDateString()}
description={protest.incident?.description || protest.description}
proofVideoUrl={protest.proofVideoUrl || undefined}
isAdmin={true}
onReview={() => onReviewProtest(protest)}
/>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,58 @@
import { routes } from '@/lib/routing/RouteConfig';
import { ProtestListItem } from './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)}
/>
);
}

View File

@@ -0,0 +1,118 @@
import { AlertCircle, AlertTriangle, Video } from 'lucide-react';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/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>
);
}

View File

@@ -0,0 +1,146 @@
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react';
interface MockupStackProps {
children: ReactNode;
index?: number;
}
export 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>
);
}

View File

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

View File

@@ -1,7 +1,7 @@
import { User, Clock, ChevronRight } from 'lucide-react';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { CountrySelect } from '@/ui/CountrySelect';
import { CountrySelect } from '@/components/shared/CountrySelect';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';

View File

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

View File

@@ -0,0 +1,59 @@
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>
);
}

View File

@@ -0,0 +1,30 @@
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)}
/>
);
}

View File

@@ -0,0 +1,45 @@
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,
}}
/>
);
}

View File

@@ -0,0 +1,61 @@
import { Card } from '@/ui/Card';
import { DriverEntryRow } from '@/components/drivers/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>
);
}

View File

@@ -0,0 +1,64 @@
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>
);
}

View File

@@ -0,0 +1,102 @@
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 '@/components/races/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>
);
}

View File

@@ -0,0 +1,143 @@
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
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';
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>
);
}

View File

@@ -0,0 +1,74 @@
import { routes } from '@/lib/routing/RouteConfig';
import { RaceListItem as UiRaceListItem } from '@/components/races/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}
/>
);
}

View File

@@ -0,0 +1,39 @@
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}
/>
);
}

View File

@@ -0,0 +1,107 @@
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/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} fullWidth fullHeight 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>
);
}

View File

@@ -0,0 +1,106 @@
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>
);
}

View File

@@ -0,0 +1,62 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Card } from '@/ui/Card';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
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 { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
import { UpcomingRacesList } from '@/components/races/UpcomingRacesList';
import { Calendar } from 'lucide-react';
interface UpcomingRace {
id: string;
track: string;
car: string;
formattedDate: string;
formattedTime: string;
isMyLeague: boolean;
}
interface UpcomingRacesProps {
races: UpcomingRace[];
hasRaces: boolean;
}
export function UpcomingRaces({ races, hasRaces }: UpcomingRacesProps) {
return (
<Card>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={3} icon={<Icon icon={Calendar} size={5} color="var(--primary-blue)" />}>
Upcoming Races
</Heading>
<Link href={routes.public.races} variant="primary">
<Text size="xs">View all</Text>
</Link>
</Stack>
{hasRaces ? (
<UpcomingRacesList>
{races.slice(0, 5).map((race) => (
<UpcomingRaceItem
key={race.id}
track={race.track}
car={race.car}
formattedDate={race.formattedDate}
formattedTime={race.formattedTime}
isMyLeague={race.isMyLeague}
/>
))}
</UpcomingRacesList>
) : (
<MinimalEmptyState
icon={Calendar}
title="No upcoming races"
description="Check back later for new events"
/>
)}
</Card>
);
}

View File

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

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { RaceSummaryItem } from '@/ui/RaceSummaryItem';
type UpcomingRace = {
id: string;
track: string;
car: string;
scheduledAt: string | Date;
};
interface UpcomingRacesSidebarProps {
races: UpcomingRace[];
}
export function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
if (!races.length) {
return null;
}
return (
<Card bg="bg-iron-gray/80" p={4}>
<Stack direction="row" align="baseline" justify="between" mb={3}>
<Heading level={3}>Upcoming races</Heading>
<Button
as="a"
href="/races"
variant="secondary"
size="sm"
>
View all
</Button>
</Stack>
<Stack gap={3}>
{races.slice(0, 4).map((race) => {
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
return (
<RaceSummaryItem
key={race.id}
track={race.track}
meta={race.car}
date={scheduledAt}
/>
);
})}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,191 @@
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { CountryFlag } from '@/ui/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>
);
}

View File

@@ -0,0 +1,271 @@
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 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>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,228 @@
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { LoadingWrapperProps } from '@/ui/state-types';
import { Text } from '@/ui/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}
/>
);
}

View File

@@ -0,0 +1,265 @@
import React, { ReactNode } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
import { EmptyState } from '@/components/shared/state/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>
);
}

View File

@@ -0,0 +1,391 @@
'use client';
import React from 'react';
import { StateContainerProps, StateContainerConfig } from '@/ui/state-types';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
import { EmptyState } from '@/components/shared/state/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>
);
}
// Custom success render
if (config?.customRender?.success) {
return <>{config.customRender.success(data as T)}</>;
}
// 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>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { PageWrapper, PageWrapperProps } from '@/components/shared/state/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 '@/components/shared/state/PageWrapper';

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { BenefitCard } from '@/ui/BenefitCard';
import { BenefitCard } from '@/components/landing/BenefitCard';
interface SponsorBenefitCardProps {
icon: LucideIcon;

View File

@@ -0,0 +1,163 @@
import { motion, useReducedMotion } from 'framer-motion';
import { Building2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
interface SponsorHeroProps {
title: string;
subtitle: string;
children?: React.ReactNode;
}
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>
);
}

View File

@@ -8,7 +8,7 @@ import {
Car,
TrendingUp,
} from 'lucide-react';
import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
import { WorkflowMockup, WorkflowStep } from '@/components/mockups/WorkflowMockup';
const WORKFLOW_STEPS: WorkflowStep[] = [
{

View File

@@ -12,7 +12,7 @@ import { Heading } from '@/ui/Heading';
import { JoinRequestList } from '@/ui/JoinRequestList';
import { JoinRequestItem } from '@/ui/JoinRequestItem';
import { DangerZone } from '@/ui/DangerZone';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from "@/hooks/team";
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';

View File

@@ -0,0 +1,79 @@
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>
);
}

View File

@@ -0,0 +1,83 @@
import { Users } from 'lucide-react';
import { ReactNode } from 'react';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface TeamHeroSectionProps {
title: ReactNode;
description: string;
statsContent: ReactNode;
actionsContent: ReactNode;
sideContent: ReactNode;
}
export function TeamHeroSection({
title,
description,
statsContent,
actionsContent,
sideContent,
}: TeamHeroSectionProps) {
return (
<Box position="relative" mb={10} overflow="hidden">
{/* Main Hero Card */}
<Box
position="relative"
py={12}
px={8}
rounded="2xl"
style={{
background: 'linear-gradient(to bottom right, rgba(147, 51, 234, 0.3), rgba(38, 38, 38, 0.8), #0f1115)',
borderColor: 'rgba(147, 51, 234, 0.2)',
}}
border
>
{/* Background decorations */}
<Box position="absolute" top="0" right="0" w="80" h="80" bg="bg-purple-500" bgOpacity={0.1} rounded="full" blur="3xl" />
<Box position="absolute" bottom="0" left="1/4" w="64" h="64" bg="bg-neon-aqua" bgOpacity={0.05} rounded="full" blur="3xl" />
<Box position="absolute" top="1/2" right="1/4" w="48" h="48" bg="bg-yellow-400" bgOpacity={0.05} rounded="full" blur="2xl" />
<Box position="relative" zIndex={10}>
<Stack direction={{ base: 'col', lg: 'row' }} align="start" justify="between" gap={8}>
<Box maxWidth="xl">
{/* Badge */}
<Box mb={4}>
<Badge variant="primary" icon={Users}>
Team Racing
</Badge>
</Box>
<Heading level={1}>
{title}
</Heading>
<Text size="lg" color="text-gray-400" leading="relaxed" block mt={4} mb={6}>
{description}
</Text>
{/* Quick Stats */}
<Stack direction="row" gap={4} mb={6} wrap>
{statsContent}
</Stack>
{/* CTA Buttons */}
<Stack direction="row" gap={3} wrap>
{actionsContent}
</Stack>
</Box>
{/* Side Content */}
<Box w={{ base: 'full', lg: '72' }}>
{sideContent}
</Box>
</Stack>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,147 @@
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { SkillLevelButton } from '@/ui/SkillLevelButton';
import { Stack } from '@/ui/Stack';
import { TeamHeroSection as UiTeamHeroSection } from '@/components/teams/TeamHeroSection';
import { TeamHeroStats } from '@/components/teams/TeamHeroStats';
import { Text } from '@/ui/Text';
import {
Crown,
LucideIcon,
Plus,
Search,
Shield,
Star,
TrendingUp,
} from 'lucide-react';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
interface SkillLevelConfig {
id: SkillLevel;
label: string;
icon: LucideIcon;
color: string;
bgColor: string;
borderColor: string;
description: string;
}
const SKILL_LEVELS: SkillLevelConfig[] = [
{
id: 'pro',
label: 'Pro',
icon: Crown,
color: 'text-warning-amber',
bgColor: 'bg-yellow-400/10',
borderColor: 'border-yellow-400/30',
description: 'Elite competition, sponsored teams',
},
{
id: 'advanced',
label: 'Advanced',
icon: Star,
color: 'text-purple-400',
bgColor: 'bg-purple-900/10',
borderColor: 'border-purple-900/30',
description: 'Competitive racing, high consistency',
},
{
id: 'intermediate',
label: 'Intermediate',
icon: TrendingUp,
color: 'text-primary-blue',
bgColor: 'bg-blue-900/10',
borderColor: 'border-blue-900/30',
description: 'Growing skills, regular practice',
},
{
id: 'beginner',
label: 'Beginner',
icon: Shield,
color: 'text-performance-green',
bgColor: 'bg-green-900/10',
borderColor: 'border-green-900/30',
description: 'Learning the basics, friendly environment',
},
];
interface TeamHeroSectionProps {
teams: TeamSummaryViewModel[];
teamsByLevel: Record<string, TeamSummaryViewModel[]>;
recruitingCount: number;
onShowCreateForm: () => void;
onBrowseTeams: () => void;
onSkillLevelClick: (level: SkillLevel) => void;
}
export function TeamHeroSection({
teams,
teamsByLevel,
recruitingCount,
onShowCreateForm,
onBrowseTeams,
onSkillLevelClick,
}: TeamHeroSectionProps) {
return (
<UiTeamHeroSection
title={
<>
Find Your
<Text color="text-purple-400"> Crew</Text>
</>
}
description="Solo racing is great. Team racing is unforgettable. Join a team that matches your skill level and ambitions."
statsContent={
<TeamHeroStats teamCount={teams.length} recruitingCount={recruitingCount} />
}
actionsContent={
<>
<Button
variant="primary"
onClick={onShowCreateForm}
icon={<Icon icon={Plus} size={4} />}
bg="bg-purple-600"
>
Create Team
</Button>
<Button
variant="secondary"
onClick={onBrowseTeams}
icon={<Icon icon={Search} size={4} />}
>
Browse Teams
</Button>
</>
}
sideContent={
<>
<Text size="xs" color="text-gray-500" weight="medium" block mb={3} uppercase letterSpacing="0.05em">
Find Your Level
</Text>
<Stack gap={2}>
{SKILL_LEVELS.map((level) => {
const count = teamsByLevel[level.id]?.length || 0;
return (
<SkillLevelButton
key={level.id}
label={level.label}
icon={level.icon}
color={level.color}
bgColor={level.bgColor}
borderColor={level.borderColor}
count={count}
onClick={() => onSkillLevelClick(level.id)}
/>
);
})}
</Stack>
</>
}
/>
);
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Users, UserPlus } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
interface TeamHeroStatsProps {
teamCount: number;
recruitingCount: number;
}
export function TeamHeroStats({ teamCount, recruitingCount }: TeamHeroStatsProps) {
return (
<Stack direction="row" align="center" gap={4}>
<Box
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
bg="bg-iron-gray/50"
border={true}
borderColor="border-charcoal-outline"
>
<Icon icon={Users} size={4} color="text-purple-400" />
<Text weight="semibold" color="text-white">{teamCount}</Text>
<Text size="sm" color="text-gray-500">Teams</Text>
</Box>
<Box
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
bg="bg-iron-gray/50"
border={true}
borderColor="border-charcoal-outline"
>
<Icon icon={UserPlus} size={4} color="text-performance-green" />
<Text weight="semibold" color="text-white">{recruitingCount}</Text>
<Text size="sm" color="text-gray-500">Recruiting</Text>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
interface TeamIdentityProps {
name: string;
logoUrl: string;
performanceLevel?: string;
category?: string;
}
export function TeamIdentity({ name, logoUrl, performanceLevel, category }: TeamIdentityProps) {
return (
<Stack direction="row" align="center" gap={3}>
<Box width="10" height="10" rounded="lg" overflow="hidden" border borderColor="border-charcoal-outline">
<Image
src={logoUrl}
alt={name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box flex={1}>
<Text weight="semibold" color="text-white" block truncate>{name}</Text>
{(performanceLevel || category) && (
<Stack direction="row" align="center" gap={2} mt={1} wrap>
{performanceLevel && (
<Text size="xs" color="text-gray-500">{performanceLevel}</Text>
)}
{category && (
<Stack direction="row" align="center" gap={1}>
<Box width="1.5" height="1.5" rounded="full" bg="bg-primary-blue" opacity={0.5} />
<Text size="xs" color="text-primary-blue">{category}</Text>
</Stack>
)}
</Stack>
)}
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { getMediaUrl } from '@/lib/utilities/media';
import { TeamLeaderboardItem } from '@/ui/TeamLeaderboardItem';
import { TeamLeaderboardPreview as UiTeamLeaderboardPreview } from '@/ui/TeamLeaderboardPreview';
interface TeamLeaderboardPreviewProps {
topTeams: Array<{
id: string;
name: string;
logoUrl?: string;
category?: string;
memberCount: number;
totalWins: number;
isRecruiting: boolean;
rating?: number;
performanceLevel: string;
}>;
onTeamClick: (id: string) => void;
onViewFullLeaderboard: () => void;
}
export function TeamLeaderboardPreview({
topTeams,
onTeamClick,
onViewFullLeaderboard
}: TeamLeaderboardPreviewProps) {
const getMedalColor = (position: number) => {
switch (position) {
case 0: return '#facc15';
case 1: return '#d1d5db';
case 2: return '#d97706';
default: return '#6b7280';
}
};
const getMedalBg = (position: number) => {
switch (position) {
case 0: return 'rgba(250, 204, 21, 0.1)';
case 1: return 'rgba(209, 213, 219, 0.1)';
case 2: return 'rgba(217, 119, 6, 0.1)';
default: return 'rgba(38, 38, 38, 0.5)';
}
};
const getMedalBorder = (position: number) => {
switch (position) {
case 0: return 'rgba(250, 204, 21, 0.3)';
case 1: return 'rgba(209, 213, 219, 0.3)';
case 2: return 'rgba(217, 119, 6, 0.3)';
default: return 'rgba(38, 38, 38, 1)';
}
};
if (topTeams.length === 0) return null;
return (
<UiTeamLeaderboardPreview
title="Top Teams"
subtitle="Highest rated racing teams"
onViewFull={onViewFullLeaderboard}
>
{topTeams.map((team, index) => (
<TeamLeaderboardItem
key={team.id}
position={index + 1}
name={team.name}
logoUrl={team.logoUrl || getMediaUrl('team-logo', team.id)}
category={team.category}
memberCount={team.memberCount}
totalWins={team.totalWins}
isRecruiting={team.isRecruiting}
rating={team.rating}
onClick={() => onTeamClick(team.id)}
medalColor={getMedalColor(index)}
medalBg={getMedalBg(index)}
medalBorder={getMedalBorder(index)}
/>
))}
</UiTeamLeaderboardPreview>
);
}

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { Image } from '@/ui/Image';
import { Podium, PodiumItem } from '@/ui/Podium';
interface TeamPodiumProps {
teams: TeamSummaryViewModel[];
onClick: (id: string) => void;
}
export function TeamPodium({ teams, onClick }: TeamPodiumProps) {
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd
const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['28', '36', '20'];
const podiumPositions = [2, 1, 3];
const getPositionColor = (position: number) => {
switch (position) {
case 1:
return 'text-yellow-400';
case 2:
return 'text-gray-300';
case 3:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getBgColor = (position: number) => {
switch (position) {
case 1:
return 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite';
case 2:
return 'bg-iron-gray';
case 3:
return 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite';
default:
return 'bg-iron-gray/50';
}
};
return (
<Podium title="Top 3 Teams">
{podiumOrder.map((team, index) => {
const position = podiumPositions[index] ?? 0;
return (
<PodiumItem
key={team.id}
position={position}
height={podiumHeights[index] || '20'}
bgColor={getBgColor(position)}
positionColor={getPositionColor(position)}
cardContent={
<Button
variant="ghost"
onClick={() => onClick(team.id)}
h="auto"
mb={4}
p={0}
className="transition-all"
>
<Box
bg={getBgColor(position)}
rounded="xl"
border={true}
borderColor="border-charcoal-outline"
p={4}
position="relative"
>
{/* Crown for 1st place */}
{position === 1 && (
<Box position="absolute" top="-4" left="1/2" translateX="-1/2">
<Box position="relative">
<Box animate="pulse">
<Icon icon={Crown} size={8} color="text-warning-amber" />
</Box>
<Box position="absolute" inset="0" bg="bg-yellow-400" bgOpacity={0.3} blur="md" rounded="full" />
</Box>
</Box>
)}
{/* Team logo */}
<Box h="20" w="20" display="flex" center rounded="xl" bg="bg-deep-graphite" border={true} borderColor="border-charcoal-outline" overflow="hidden" mb={3}>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
objectFit="cover"
/>
</Box>
{/* Team name */}
<Text weight="bold" size="sm" color="text-white" align="center" block truncate maxWidth="28">
{team.name}
</Text>
{/* Category */}
{team.category && (
<Text size="xs" color="text-primary-blue" align="center" block mt={1}>
{team.category}
</Text>
)}
{/* Rating placeholder */}
<Text size="xl" weight="bold" color={getPositionColor(position)} align="center" block mt={1}>
</Text>
{/* Stats row */}
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Trophy} size={3} color="text-performance-green" />
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
</Stack>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Users} size={3} color="text-primary-blue" />
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
</Stack>
</Stack>
</Box>
</Button>
}
/>
);
})}
</Podium>
);
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { RankBadge } from '@/ui/RankBadge';
import { TeamIdentity } from '@/components/teams/TeamIdentity';
import { getMediaUrl } from '@/lib/utilities/media';
interface Team {
id: string;
name: string;
logoUrl?: string;
performanceLevel: string;
category?: string;
region?: string;
languages?: string[];
isRecruiting?: boolean;
memberCount: number;
totalWins: number;
totalRaces: number;
}
interface TeamRankingsTableProps {
teams: Team[];
sortBy: string;
onTeamClick: (id: string) => void;
}
export function TeamRankingsTable({ teams, sortBy, onTeamClick }: TeamRankingsTableProps) {
return (
<Card p={0} overflow="hidden">
<Table>
<TableHead>
<TableRow>
<TableHeader>
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Rank</Text>
</TableHeader>
<TableHeader>
<Text size="xs" weight="medium" color="text-gray-500" block>Team</Text>
</TableHeader>
<TableHeader>
<Box display={{ base: 'none', lg: 'block' }}>
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Members</Text>
</Box>
</TableHeader>
<TableHeader>
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Rating</Text>
</TableHeader>
<TableHeader>
<Text size="xs" weight="medium" color="text-gray-500" align="center" block>Wins</Text>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{teams.map((team, index) => (
<TableRow
key={team.id}
onClick={() => onTeamClick(team.id)}
clickable
>
<TableCell>
<RankBadge rank={index + 1} />
</TableCell>
<TableCell>
<TeamIdentity
name={team.name}
logoUrl={team.logoUrl || getMediaUrl('team-logo', team.id)}
performanceLevel={team.performanceLevel}
category={team.category}
/>
</TableCell>
<TableCell>
<Box display={{ base: 'none', lg: 'flex' }} alignItems="center" justifyContent="center">
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Users} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">{team.memberCount}</Text>
</Stack>
</Box>
</TableCell>
<TableCell>
<Box display="flex" center>
<Text font="mono" weight="semibold" color={sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}>
0
</Text>
</Box>
</TableCell>
<TableCell>
<Box display="flex" center>
<Text font="mono" weight="semibold" color={sortBy === 'wins' ? 'text-primary-blue' : 'text-white'}>
{team.totalWins}
</Text>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
);
}

View File

@@ -12,8 +12,8 @@ import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { routes } from '@/lib/routing/RouteConfig';
import { TeamRosterList } from '@/ui/TeamRosterList';
import { TeamRosterItem } from '@/ui/TeamRosterItem';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { TeamRosterItem } from '@/components/teams/TeamRosterItem';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
import { sortMembers } from '@/lib/utilities/roster-utils';
export type TeamRole = 'owner' | 'admin' | 'member';

View File

@@ -0,0 +1,73 @@
import React, { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface TeamRosterItemProps {
driver: DriverViewModel;
href: string;
roleLabel: string;
joinedAt: string | Date;
rating: number | null;
overallRank: number | null;
actions?: ReactNode;
}
export function TeamRosterItem({
driver,
href,
roleLabel,
joinedAt,
rating,
overallRank,
actions,
}: TeamRosterItemProps) {
return (
<Box
bg="bg-iron-gray/50"
rounded="lg"
border={true}
borderColor="border-charcoal-outline"
p={4}
>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<DriverIdentity
driver={driver}
href={href}
contextLabel={roleLabel}
meta={
<Text size="xs" color="text-gray-400">
{driver.country} Joined {new Date(joinedAt).toLocaleDateString()}
</Text>
}
size="md"
/>
{rating !== null && (
<Stack direction="row" align="center" gap={6}>
<Box display="flex" flexDirection="col" alignItems="center">
<Text size="lg" weight="bold" color="text-primary-blue" block>
{rating}
</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
</Box>
{overallRank !== null && (
<Box display="flex" flexDirection="col" alignItems="center">
<Text size="sm" color="text-gray-300" block>#{overallRank}</Text>
<Text size="xs" color="text-gray-500">Rank</Text>
</Box>
)}
</Stack>
)}
{actions && (
<Stack direction="row" align="center" gap={2}>
{actions}
</Stack>
)}
</Stack>
</Box>
);
}

View File

@@ -5,8 +5,8 @@ import { useTeamStandings } from "@/hooks/team/useTeamStandings";
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { StandingsList } from '@/ui/StandingsList';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { Trophy } from 'lucide-react';
interface TeamStandingsProps {

View File

@@ -0,0 +1,145 @@
import React from 'react';
import { Trophy, Crown, Users } from 'lucide-react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { Image } from '@/ui/Image';
import { Podium, PodiumItem } from '@/ui/Podium';
interface TopThreePodiumProps {
teams: TeamSummaryViewModel[];
onClick: (id: string) => void;
}
export function TopThreePodium({ teams, onClick }: TopThreePodiumProps) {
const top3 = teams.slice(0, 3) as [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel];
if (teams.length < 3) return null;
// Display order: 2nd, 1st, 3rd
const podiumOrder: [TeamSummaryViewModel, TeamSummaryViewModel, TeamSummaryViewModel] = [
top3[1],
top3[0],
top3[2],
];
const podiumHeights = ['28', '36', '20'];
const podiumPositions = [2, 1, 3];
const getPositionColor = (position: number) => {
switch (position) {
case 1:
return 'text-yellow-400';
case 2:
return 'text-gray-300';
case 3:
return 'text-amber-600';
default:
return 'text-gray-500';
}
};
const getBgColor = (position: number) => {
switch (position) {
case 1:
return 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite';
case 2:
return 'bg-iron-gray';
case 3:
return 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite';
default:
return 'bg-iron-gray/50';
}
};
return (
<Podium title="Top 3 Teams">
{podiumOrder.map((team, index) => {
const position = podiumPositions[index] ?? 0;
return (
<PodiumItem
key={team.id}
position={position}
height={podiumHeights[index] || '20'}
bgColor={getBgColor(position)}
positionColor={getPositionColor(position)}
cardContent={
<Button
variant="ghost"
onClick={() => onClick(team.id)}
h="auto"
mb={4}
p={0}
transition
>
<Box
bg={getBgColor(position)}
rounded="xl"
border
borderColor="border-charcoal-outline"
p={4}
position="relative"
>
{/* Crown for 1st place */}
{position === 1 && (
<Box position="absolute" top="-4" left="1/2" translateX="-1/2">
<Box position="relative">
<Box animate="pulse">
<Icon icon={Crown} size={8} color="var(--warning-amber)" />
</Box>
<Box position="absolute" inset="0" bg="bg-yellow-400" bgOpacity={0.3} blur="md" rounded="full" />
</Box>
</Box>
)}
{/* Team logo */}
<Box h="20" w="20" display="flex" center rounded="xl" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" overflow="hidden" mb={3}>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={80}
height={80}
objectFit="cover"
/>
</Box>
{/* Team name */}
<Text weight="bold" size="sm" color="text-white" textAlign="center" block truncate maxWidth="28">
{team.name}
</Text>
{/* Category */}
{team.category && (
<Text size="xs" color="text-primary-blue" textAlign="center" block mt={1}>
{team.category}
</Text>
)}
{/* Rating placeholder */}
<Text size="xl" weight="bold" color={getPositionColor(position)} textAlign="center" block mt={1}>
</Text>
{/* Stats row */}
<Stack direction="row" align="center" justify="center" gap={3} mt={2}>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Trophy} size={3} color="var(--performance-green)" />
<Text size="xs" color="text-gray-400">{team.totalWins}</Text>
</Stack>
<Stack direction="row" align="center" gap={1}>
<Icon icon={Users} size={3} color="var(--primary-blue)" />
<Text size="xs" color="text-gray-400">{team.memberCount}</Text>
</Stack>
</Stack>
</Box>
</Button>
}
/>
);
})}
</Podium>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import {
Handshake,
MessageCircle,
Calendar,
Trophy,
LucideIcon,
} from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { BenefitCard } from '@/components/landing/BenefitCard';
interface Benefit {
icon: LucideIcon;
title: string;
description: string;
}
export function WhyJoinTeamSection() {
const benefits: Benefit[] = [
{
icon: Handshake,
title: 'Shared Strategy',
description: 'Develop setups together, share telemetry, and coordinate pit strategies for endurance races.',
},
{
icon: MessageCircle,
title: 'Team Communication',
description: 'Discord integration, voice chat during races, and dedicated team channels.',
},
{
icon: Calendar,
title: 'Coordinated Schedule',
description: 'Team calendars, practice sessions, and organized race attendance.',
},
{
icon: Trophy,
title: 'Team Championships',
description: 'Compete in team-based leagues and build your collective reputation.',
},
];
return (
<Box mb={12}>
<Box textAlign="center" mb={8}>
<Box mb={2}>
<Heading level={2}>Why Join a Team?</Heading>
</Box>
<Text color="text-gray-400">Racing is better when you have teammates to share the journey</Text>
</Box>
<Grid cols={4} gap={4}>
{benefits.map((benefit) => (
<BenefitCard
key={benefit.title}
icon={benefit.icon}
title={benefit.title}
description={benefit.description}
/>
))}
</Grid>
</Box>
);
}