website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Input } from '@/ui/Input';
export function ActionFiltersBar() {
const [filter, setFilter] = useState('all');
return (
<Box
h="12"
borderBottom
borderColor="border-[#23272B]"
display="flex"
alignItems="center"
px={6}
bg="bg-[#0C0D0F]"
gap={6}
>
<Box display="flex" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Filter:</Text>
<Select
options={[
{ label: 'All Types', value: 'all' },
{ label: 'User Update', value: 'user' },
{ label: 'Onboarding', value: 'onboarding' }
]}
value={filter}
onChange={(e) => setFilter(e.target.value)}
fullWidth={false}
/>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Status:</Text>
<Select
options={[
{ label: 'All Status', value: 'all' },
{ label: 'Completed', value: 'completed' },
{ label: 'Pending', value: 'pending' },
{ label: 'Failed', value: 'failed' }
]}
value="all"
onChange={() => {}}
fullWidth={false}
/>
</Box>
<Box ml="auto">
<Input
placeholder="SEARCH_ID..."
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { ActionItem } from '@/lib/queries/ActionsPageQuery';
import { ActionStatusBadge } from './ActionStatusBadge';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Text } from '@/ui/Text';
interface ActionListProps {
actions: ActionItem[];
}
export function ActionList({ actions }: ActionListProps) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeader>Timestamp</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Initiator</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Details</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{actions.map((action) => (
<TableRow
key={action.id}
clickable
>
<TableCell>
<Text font="mono" size="xs" color="text-gray-400">{action.timestamp}</Text>
</TableCell>
<TableCell>
<Text size="xs" weight="medium" color="text-gray-200">{action.type}</Text>
</TableCell>
<TableCell>
<Text size="xs" color="text-gray-400">{action.initiator}</Text>
</TableCell>
<TableCell>
<ActionStatusBadge status={action.status} />
</TableCell>
<TableCell>
<Text size="xs" color="text-gray-400">
{action.details}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface ActionStatusBadgeProps {
status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS';
}
export function ActionStatusBadge({ status }: ActionStatusBadgeProps) {
const styles = {
PENDING: { bg: 'bg-amber-500/10', text: 'text-[#FFBE4D]', border: 'border-amber-500/20' },
COMPLETED: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/20' },
FAILED: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30' },
IN_PROGRESS: { bg: 'bg-blue-500/10', text: 'text-[#198CFF]', border: 'border-blue-500/20' },
};
const config = styles[status];
return (
<Box
as="span"
px={2}
py={0.5}
rounded="sm"
bg={config.bg}
border
borderColor={config.border}
display="inline-block"
>
<Text
size="xs"
weight="bold"
color={config.text}
uppercase
letterSpacing="tight"
fontSize="10px"
>
{status.replace('_', ' ')}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Activity } from 'lucide-react';
import { StatusIndicator } from '@/ui/StatusIndicator';
interface ActionsHeaderProps {
title: string;
}
export function ActionsHeader({ title }: ActionsHeaderProps) {
return (
<Box
as="header"
h="16"
borderBottom
borderColor="border-[#23272B]"
display="flex"
alignItems="center"
px={6}
bg="bg-[#141619]"
>
<Box display="flex" alignItems="center" gap={4}>
<Box
w="2"
h="6"
bg="bg-[#198CFF]"
rounded="sm"
shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]"
/>
<Text as="h1" size="xl" weight="medium" letterSpacing="tight" uppercase>
{title}
</Text>
</Box>
<Box ml="auto" display="flex" alignItems="center" gap={4}>
<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface AdminDangerZonePanelProps {
title: string;
description: string;
children: React.ReactNode;
}
/**
* AdminDangerZonePanel
*
* Semantic panel for destructive or dangerous admin actions.
* Restrained but clear warning styling.
*/
export function AdminDangerZonePanel({
title,
description,
children
}: AdminDangerZonePanelProps) {
return (
<Card borderColor="border-error-red/30" bg="bg-error-red/5">
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={6}>
<Box>
<Heading level={4} weight="bold" color="text-error-red">
{title}
</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
</Box>
<Box>
{children}
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
interface AdminDataTableProps {
children: React.ReactNode;
maxHeight?: string | number;
}
/**
* AdminDataTable
*
* Semantic wrapper for high-density admin tables.
* Provides a consistent container with "Precision Racing Minimal" styling.
*/
export function AdminDataTable({
children,
maxHeight
}: AdminDataTableProps) {
return (
<Card p={0} overflow="hidden">
<Box
overflow="auto"
maxHeight={maxHeight}
>
{children}
</Box>
</Card>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { LucideIcon } from 'lucide-react';
interface AdminEmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
}
/**
* AdminEmptyState
*
* Semantic empty state for admin lists and tables.
* Follows "Precision Racing Minimal" theme.
*/
export function AdminEmptyState({
icon,
title,
description,
action
}: AdminEmptyStateProps) {
return (
<Stack center py={20} gap={4}>
<Icon icon={icon} size={12} color="#23272B" />
<Box textAlign="center">
<Text size="lg" weight="bold" color="text-white" block>
{title}
</Text>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>
{description}
</Text>
)}
</Box>
{action && (
<Box mt={2}>
{action}
</Box>
)}
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
interface AdminHeaderPanelProps {
title: string;
description?: string;
actions?: React.ReactNode;
isLoading?: boolean;
}
/**
* AdminHeaderPanel
*
* Semantic header for admin pages.
* Includes title, description, actions, and a progress line for loading states.
*/
export function AdminHeaderPanel({
title,
description,
actions,
isLoading = false
}: AdminHeaderPanelProps) {
return (
<Box position="relative" pb={4} borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1} weight="bold" color="text-white">
{title}
</Heading>
{description && (
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Box>
{actions && (
<Stack direction="row" align="center" gap={3}>
{actions}
</Stack>
)}
</Stack>
<Box position="absolute" bottom="0" left="0" w="full">
<ProgressLine isLoading={isLoading} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface AdminSectionHeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
}
/**
* AdminSectionHeader
*
* Semantic header for sections within admin pages.
* Follows "Precision Racing Minimal" theme: dense, clear hierarchy.
*/
export function AdminSectionHeader({
title,
description,
actions
}: AdminSectionHeaderProps) {
return (
<Stack direction="row" align="center" justify="between" mb={4}>
<Box>
<Heading level={3} weight="bold" color="text-white">
{title}
</Heading>
{description && (
<Text size="xs" color="text-gray-500" block mt={0.5}>
{description}
</Text>
)}
</Box>
{actions && (
<Stack direction="row" align="center" gap={2}>
{actions}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Grid } from '@/ui/Grid';
import { StatCard } from '@/ui/StatCard';
import { LucideIcon } from 'lucide-react';
interface AdminStat {
label: string;
value: string | number;
icon: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange';
trend?: {
value: number;
isPositive: boolean;
};
}
interface AdminStatsPanelProps {
stats: AdminStat[];
}
/**
* AdminStatsPanel
*
* Semantic container for admin statistics.
* Renders a grid of StatCards.
*/
export function AdminStatsPanel({ stats }: AdminStatsPanelProps) {
return (
<Grid cols={1} mdCols={2} lgCols={4} gap={4}>
{stats.map((stat, index) => (
<StatCard
key={stat.label}
label={stat.label}
value={stat.value}
icon={stat.icon}
variant={stat.variant}
trend={stat.trend}
delay={index * 0.05}
/>
))}
</Grid>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface AdminToolbarProps {
children: React.ReactNode;
leftContent?: React.ReactNode;
}
/**
* AdminToolbar
*
* Semantic toolbar for admin pages.
* Used for filters, search, and secondary actions.
*/
export function AdminToolbar({
children,
leftContent
}: AdminToolbarProps) {
return (
<Card p={3} bg="bg-charcoal/50" borderColor="border-charcoal-outline">
<Stack direction="row" align="center" justify="between" gap={4} wrap>
{leftContent && (
<Box flexGrow={1}>
{leftContent}
</Box>
)}
<Stack direction="row" align="center" gap={3} flexGrow={leftContent ? 0 : 1} wrap>
{children}
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import React from 'react';
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell
} from '@/ui/Table';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
import { UserStatusTag } from './UserStatusTag';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Shield, Trash2, MoreVertical } from 'lucide-react';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
interface AdminUsersTableProps {
users: AdminUsersViewData['users'];
selectedUserIds: string[];
onSelectUser: (userId: string) => void;
onSelectAll: () => void;
onUpdateStatus: (userId: string, status: string) => void;
onDeleteUser: (userId: string) => void;
deletingUserId?: string | null;
}
/**
* AdminUsersTable
*
* Semantic table for managing users.
* High-density, instrument-grade UI.
*/
export function AdminUsersTable({
users,
selectedUserIds,
onSelectUser,
onSelectAll,
onUpdateStatus,
onDeleteUser,
deletingUserId
}: AdminUsersTableProps) {
const allSelected = users.length > 0 && selectedUserIds.length === users.length;
return (
<Table>
<TableHead>
<TableRow>
<TableHeader width="10">
<SimpleCheckbox
checked={allSelected}
onChange={onSelectAll}
aria-label="Select all users"
/>
</TableHeader>
<TableHeader>User</TableHeader>
<TableHeader>Roles</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Last Login</TableHeader>
<TableHeader textAlign="right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id} variant={selectedUserIds.includes(user.id) ? 'highlight' : 'default'}>
<TableCell>
<SimpleCheckbox
checked={selectedUserIds.includes(user.id)}
onChange={() => onSelectUser(user.id)}
aria-label={`Select user ${user.displayName}`}
/>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Box
bg="bg-primary-blue/10"
rounded="full"
p={2}
border
borderColor="border-primary-blue/20"
>
<Icon icon={Shield} size={4} color="#198CFF" />
</Box>
<Box>
<Text weight="semibold" color="text-white" block>
{user.displayName}
</Text>
<Text size="xs" color="text-gray-500" block>
{user.email}
</Text>
</Box>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" gap={1.5} wrap>
{user.roles.map((role) => (
<Box
key={role}
px={2}
py={0.5}
rounded="full"
bg="bg-charcoal-outline/30"
border
borderColor="border-charcoal-outline"
>
<Text size="xs" weight="medium" color="text-gray-300">
{role}
</Text>
</Box>
))}
</Stack>
</TableCell>
<TableCell>
<UserStatusTag status={user.status} />
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" justify="end" gap={2}>
{user.status === 'active' ? (
<Button
size="sm"
variant="secondary"
onClick={() => onUpdateStatus(user.id, 'suspended')}
>
Suspend
</Button>
) : user.status === 'suspended' ? (
<Button
size="sm"
variant="secondary"
onClick={() => onUpdateStatus(user.id, 'active')}
>
Activate
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => onDeleteUser(user.id)}
disabled={deletingUserId === user.id}
icon={<Icon icon={Trash2} size={3} />}
>
{deletingUserId === user.id ? '...' : ''}
</Button>
<Button
size="sm"
variant="ghost"
icon={<Icon icon={MoreVertical} size={4} />}
>
{''}
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { motion, AnimatePresence } from 'framer-motion';
interface BulkActionBarProps {
selectedCount: number;
actions: {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
icon?: React.ReactNode;
}[];
onClearSelection: () => void;
}
/**
* BulkActionBar
*
* Floating action bar that appears when items are selected in a table.
*/
export function BulkActionBar({
selectedCount,
actions,
onClearSelection
}: BulkActionBarProps) {
return (
<AnimatePresence>
{selectedCount > 0 && (
<Box
as={motion.div}
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
position="fixed"
bottom="8"
left="1/2"
translateX="-1/2"
zIndex={50}
bg="bg-surface-charcoal"
border
borderColor="border-primary-blue/50"
rounded="xl"
shadow="xl"
px={6}
py={4}
bgOpacity={0.9}
blur="md"
>
<Stack direction="row" align="center" gap={8}>
<Stack direction="row" align="center" gap={3}>
<Box bg="bg-primary-blue" rounded="full" px={2} py={0.5}>
<Text size="xs" weight="bold" color="text-white">
{selectedCount}
</Text>
</Box>
<Text size="sm" weight="medium" color="text-white">
Items Selected
</Text>
</Stack>
<Box w="px" h="6" bg="bg-charcoal-outline" />
<Stack direction="row" align="center" gap={3}>
{actions.map((action) => (
<Button
key={action.label}
size="sm"
variant={action.variant === 'danger' ? 'secondary' : (action.variant || 'primary')}
onClick={action.onClick}
icon={action.icon}
>
{action.label}
</Button>
))}
<Button
size="sm"
variant="ghost"
onClick={onClearSelection}
>
Cancel
</Button>
</Stack>
</Stack>
</Box>
)}
</AnimatePresence>
);
}

View File

@@ -2,14 +2,13 @@
import React from 'react';
import { Filter, Search } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Select } from '@/ui/Select';
import { AdminToolbar } from './AdminToolbar';
interface UserFiltersProps {
search: string;
@@ -31,13 +30,11 @@ export function UserFilters({
onClearFilters,
}: UserFiltersProps) {
return (
<Card>
<Stack gap={4}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Filter} size={4} color="#9ca3af" />
<Text weight="medium" color="text-white">Filters</Text>
</Stack>
<AdminToolbar
leftContent={
<Stack direction="row" align="center" gap={2}>
<Icon icon={Filter} size={4} color="#9ca3af" />
<Text weight="medium" color="text-white">Filters</Text>
{(search || roleFilter || statusFilter) && (
<Button
onClick={onClearFilters}
@@ -48,39 +45,38 @@ export function UserFilters({
</Button>
)}
</Stack>
<Grid cols={3} gap={4}>
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
/>
}
>
<Input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => onSearch(e.target.value)}
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
width="300px"
/>
<Select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
options={[
{ value: '', label: 'All Roles' },
{ value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
options={[
{ value: '', label: 'All Roles' },
{ value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
options={[
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
]}
/>
</Grid>
</Stack>
</Card>
<Select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
options={[
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
]}
/>
</AdminToolbar>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { StatusBadge } from '@/ui/StatusBadge';
import {
CheckCircle2,
AlertTriangle,
XCircle,
Clock,
LucideIcon
} from 'lucide-react';
export type UserStatus = 'active' | 'suspended' | 'deleted' | 'pending';
interface UserStatusTagProps {
status: UserStatus | string;
}
interface StatusConfig {
variant: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
icon: LucideIcon;
label: string;
}
/**
* UserStatusTag
*
* Semantic status indicator for users.
* Maps status strings to appropriate visual variants and icons.
*/
export function UserStatusTag({ status }: UserStatusTagProps) {
const normalizedStatus = status.toLowerCase() as UserStatus;
const config: Record<UserStatus, StatusConfig> = {
active: {
variant: 'success',
icon: CheckCircle2,
label: 'Active'
},
suspended: {
variant: 'warning',
icon: AlertTriangle,
label: 'Suspended'
},
deleted: {
variant: 'error',
icon: XCircle,
label: 'Deleted'
},
pending: {
variant: 'pending',
icon: Clock,
label: 'Pending'
}
};
const { variant, icon, label } = config[normalizedStatus] || {
variant: 'neutral',
icon: Clock,
label: status
};
return (
<StatusBadge variant={variant} icon={icon}>
{label}
</StatusBadge>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface AuthCardProps {
children: React.ReactNode;
title: string;
description?: string;
}
/**
* AuthCard
*
* A matte surface container for auth forms with a subtle accent glow.
*/
export function AuthCard({ children, title, description }: AuthCardProps) {
return (
<Box bg="surface-charcoal" border borderColor="outline-steel" rounded="lg" shadow="card" position="relative" overflow="hidden">
{/* Subtle top accent line */}
<Box
position="absolute"
top="0"
left="0"
w="full"
h="1px"
bg="linear-gradient(to right, transparent, rgba(25, 140, 255, 0.3), transparent)"
/>
<Box p={{ base: 6, md: 8 }}>
<Box as="header" mb={8} textAlign="center">
<Text as="h1" size="xl" weight="semibold" color="text-white" letterSpacing="tight" mb={2} block>
{title}
</Text>
{description && (
<Text size="sm" color="text-med" block>
{description}
</Text>
)}
</Box>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface AuthFooterLinksProps {
children: React.ReactNode;
}
/**
* AuthFooterLinks
*
* Semantic container for links at the bottom of auth cards.
*/
export function AuthFooterLinks({ children }: AuthFooterLinksProps) {
return (
<Box as="footer" mt={8} pt={6} borderTop borderStyle="solid" borderColor="outline-steel">
<Stack gap={3} align="center" textAlign="center">
{children}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface AuthFormProps {
children: React.ReactNode;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
/**
* AuthForm
*
* Semantic form wrapper for auth flows.
*/
export function AuthForm({ children, onSubmit }: AuthFormProps) {
return (
<Box as="form" onSubmit={onSubmit} noValidate>
<Stack gap={6}>
{children}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
interface AuthProviderButtonsProps {
children: React.ReactNode;
}
/**
* AuthProviderButtons
*
* Container for social login buttons (Google, Discord, etc.)
*/
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
return (
<Box display="grid" gridCols={1} gap={3} mb={6}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
interface AuthShellProps {
children: React.ReactNode;
}
/**
* AuthShell
*
* The outermost container for all authentication pages.
* Provides the "calm intensity" background and centered layout.
*/
export function AuthShell({ children }: AuthShellProps) {
return (
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" p={4} bg="base-black" position="relative" overflow="hidden">
{/* Subtle background glow - top right */}
<Box
position="absolute"
top="-10%"
right="-10%"
w="40%"
h="40%"
rounded="full"
bg="rgba(25, 140, 255, 0.05)"
blur="xl"
pointerEvents="none"
aria-hidden="true"
/>
{/* Subtle background glow - bottom left */}
<Box
position="absolute"
bottom="-10%"
left="-10%"
w="40%"
h="40%"
rounded="full"
bg="rgba(78, 212, 224, 0.05)"
blur="xl"
pointerEvents="none"
aria-hidden="true"
/>
<Box w="full" maxWidth="400px" position="relative" zIndex={10} animate="fade-in">
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Panel } from '@/ui/Panel';
import { Box } from '@/ui/Box';
import { ActivityFeed } from '../feed/ActivityFeed';
interface FeedItem {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface ActivityFeedPanelProps {
items: FeedItem[];
hasItems: boolean;
}
/**
* ActivityFeedPanel
*
* A semantic wrapper for the activity feed.
*/
export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) {
return (
<Panel title="Activity Feed" padding={0}>
<Box px={6} pb={6}>
<ActivityFeed items={items} hasItems={hasItems} />
</Box>
</Panel>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
interface DashboardControlBarProps {
title: string;
actions?: React.ReactNode;
}
/**
* DashboardControlBar
*
* The top header bar for page-level controls and context.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardControlBar({ title, actions }: DashboardControlBarProps) {
return (
<Box display="flex" h="full" alignItems="center" justifyContent="between" px={6}>
<Heading level={6} weight="bold">
{title}
</Heading>
<Stack direction="row" align="center" gap={4}>
{actions}
</Stack>
</Box>
);
}

View File

@@ -1,5 +1,3 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Button } from '@/ui/Button';
import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero';
@@ -48,10 +46,10 @@ export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHe
}
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)" />
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" />
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#FFBE4D" />
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#198CFF" />
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" />
</>
}
/>

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
interface KpiItem {
label: string;
value: string | number;
color?: string;
}
interface DashboardKpiRowProps {
items: KpiItem[];
}
/**
* DashboardKpiRow
*
* A horizontal row of key performance indicators with telemetry styling.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardKpiRow({ items }: DashboardKpiRowProps) {
return (
<Grid responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={4}>
{items.map((item, index) => (
<Box key={index} borderLeft pl={4} borderColor="var(--color-outline)">
<Text
size="xs"
weight="bold"
uppercase
letterSpacing="tighter"
color="var(--color-text-low)"
block
>
{item.label}
</Text>
<Text
size="xl"
font="mono"
weight="bold"
color={item.color || 'var(--color-text-high)'}
>
{item.value}
</Text>
</Box>
))}
</Grid>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Box } from '@/ui/Box';
interface DashboardRailProps {
children: React.ReactNode;
}
/**
* DashboardRail
*
* A thin sidebar rail for high-level navigation and status indicators.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardRail({ children }: DashboardRailProps) {
return (
<Box as="nav" display="flex" h="full" flexDirection="col" alignItems="center" py={4} gap={4}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Box } from '@/ui/Box';
interface DashboardShellProps {
children: React.ReactNode;
rail?: React.ReactNode;
controlBar?: React.ReactNode;
}
/**
* DashboardShell
*
* The primary layout container for the Telemetry Workspace.
* Orchestrates the sidebar rail, top control bar, and main content area.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardShell({ children, rail, controlBar }: DashboardShellProps) {
return (
<Box display="flex" h="screen" overflow="hidden" bg="base-black" color="white">
{rail && (
<Box as="aside" w="16" flexShrink={0} borderRight bg="surface-charcoal" borderColor="var(--color-outline)">
{rail}
</Box>
)}
<Box display="flex" flexGrow={1} flexDirection="col" overflow="hidden">
{controlBar && (
<Box as="header" h="14" borderBottom bg="surface-charcoal" borderColor="var(--color-outline)">
{controlBar}
</Box>
)}
<Box as="main" flexGrow={1} overflow="auto" p={6}>
<Box maxWidth="7xl" mx="auto" display="flex" flexDirection="col" gap={6}>
{children}
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,29 +0,0 @@
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,74 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot';
export interface ActivityItem {
id: string;
type: string;
description: string;
timestamp: string;
status?: 'success' | 'warning' | 'critical' | 'info';
}
interface RecentActivityTableProps {
items: ActivityItem[];
}
/**
* RecentActivityTable
*
* A high-density table for displaying recent events and telemetry logs.
* Uses UI primitives to comply with architectural constraints.
*/
export function RecentActivityTable({ items }: RecentActivityTableProps) {
const getStatusColor = (status?: string) => {
switch (status) {
case 'success': return 'var(--color-success)';
case 'warning': return 'var(--color-warning)';
case 'critical': return 'var(--color-critical)';
default: return 'var(--color-primary)';
}
};
return (
<Box overflow="auto">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="var(--color-outline)">
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Type</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Description</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Time</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Status</Text>
</Box>
</Box>
</Box>
<Box as="tbody">
{items.map((item) => (
<Box key={item.id} as="tr" borderBottom borderColor="rgba(35, 39, 43, 0.5)" hoverBg="rgba(255, 255, 255, 0.05)" transition>
<Box as="td" py={3}>
<Text font="mono" color="var(--color-telemetry)" size="xs">{item.type}</Text>
</Box>
<Box as="td" py={3}>
<Text color="var(--color-text-med)" size="xs">{item.description}</Text>
</Box>
<Box as="td" py={3}>
<Text color="var(--color-text-low)" size="xs">{item.timestamp}</Text>
</Box>
<Box as="td" py={3}>
<StatusDot color={getStatusColor(item.status)} size={1.5} />
</Box>
</Box>
))}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Surface } from '@/ui/Surface';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
interface TelemetryPanelProps {
title: string;
children: React.ReactNode;
}
/**
* TelemetryPanel
*
* A dense, instrument-grade panel for displaying data and controls.
* Uses UI primitives to comply with architectural constraints.
*/
export function TelemetryPanel({ title, children }: TelemetryPanelProps) {
return (
<Surface variant="dark" border rounded="sm" padding={4} shadow="sm">
<Heading level={6} mb={4} color="var(--color-text-low)">
{title}
</Heading>
<Box fontSize="sm">
{children}
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface DriverPerformanceOverviewProps {
stats: {
wins: number;
podiums: number;
totalRaces: number;
consistency: number;
dnfs: number;
bestFinish: number;
avgFinish: number;
};
}
export function DriverPerformanceOverview({ stats }: DriverPerformanceOverviewProps) {
const winRate = stats.totalRaces > 0 ? (stats.wins / stats.totalRaces) * 100 : 0;
const podiumRate = stats.totalRaces > 0 ? (stats.podiums / stats.totalRaces) * 100 : 0;
const metrics = [
{ label: 'Win Rate', value: `${winRate.toFixed(1)}%`, color: 'text-performance-green' },
{ label: 'Podium Rate', value: `${podiumRate.toFixed(1)}%`, color: 'text-warning-amber' },
{ label: 'Best Finish', value: `P${stats.bestFinish}`, color: 'text-white' },
{ label: 'Avg Finish', value: `P${stats.avgFinish.toFixed(1)}`, color: 'text-gray-400' },
{ label: 'Consistency', value: `${stats.consistency}%`, color: 'text-neon-aqua' },
{ label: 'DNFs', value: stats.dnfs, color: 'text-red-500' },
];
return (
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
<Heading level={3}>Performance Overview</Heading>
<Box display="grid" gridCols={{ base: 2, md: 3, lg: 6 }} gap={6}>
{metrics.map((metric, index) => (
<Box key={index} display="flex" flexDirection="col" gap={1}>
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{metric.label}
</Text>
<Text size="xl" weight="bold" font="mono" color={metric.color}>
{metric.value}
</Text>
</Box>
))}
</Box>
{/* Visual Progress Bars */}
<Box display="flex" flexDirection="col" gap={4} mt={2}>
<Box display="flex" flexDirection="col" gap={2}>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" weight="bold" color="text-gray-400">Win Rate</Text>
<Text size="xs" weight="bold" font="mono" color="text-performance-green">{winRate.toFixed(1)}%</Text>
</Box>
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
<Box
h="full"
bg="bg-performance-green"
shadow="shadow-[0_0_8px_rgba(34,197,94,0.4)]"
transition
width={`${winRate}%`}
/>
</Box>
</Box>
<Box display="flex" flexDirection="col" gap={2}>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" weight="bold" color="text-gray-400">Podium Rate</Text>
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">{podiumRate.toFixed(1)}%</Text>
</Box>
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
<Box
h="full"
bg="bg-warning-amber"
shadow="shadow-[0_0_8px_rgba(255,190,77,0.4)]"
transition
width={`${podiumRate}%`}
/>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React from 'react';
import { Globe, Trophy, UserPlus, Check } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { RatingBadge } from '@/ui/RatingBadge';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { SafetyRatingBadge } from './SafetyRatingBadge';
interface DriverProfileHeaderProps {
name: string;
avatarUrl?: string | null;
nationality: string;
rating: number;
safetyRating?: number;
globalRank?: number;
bio?: string | null;
friendRequestSent: boolean;
onAddFriend: () => void;
}
export function DriverProfileHeader({
name,
avatarUrl,
nationality,
rating,
safetyRating = 92,
globalRank,
bio,
friendRequestSent,
onAddFriend,
}: DriverProfileHeaderProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
return (
<Box position="relative" overflow="hidden" rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" p={{ base: 6, lg: 8 }}>
{/* Background Accents */}
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={8}>
{/* Avatar */}
<Box position="relative" h={{ base: '32', lg: '40' }} w={{ base: '32', lg: '40' }} flexShrink={0} overflow="hidden" rounded="2xl" border={true} borderWidth="2px" borderColor="border-charcoal-outline" bg="bg-deep-graphite" shadow="2xl">
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
{/* Info */}
<Box display="flex" flexGrow={1} flexDirection="col" gap={4}>
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={2}>
<Box>
<Stack direction="row" align="center" gap={3} mb={1}>
<Heading level={1}>{name}</Heading>
{globalRank && (
<Box display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20">
<Trophy size={12} color="#FFBE4D" />
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">
#{globalRank}
</Text>
</Box>
)}
</Stack>
<Stack direction="row" align="center" gap={4}>
<Stack direction="row" align="center" gap={1.5}>
<Globe size={14} color="#6B7280" />
<Text size="sm" color="text-gray-400">{nationality}</Text>
</Stack>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={2}>
<RatingBadge rating={rating} size="sm" />
<SafetyRatingBadge rating={safetyRating} size="sm" />
</Stack>
</Stack>
</Box>
<Box mt={{ base: 4, lg: 0 }}>
<Button
variant={friendRequestSent ? 'secondary' : 'primary'}
onClick={onAddFriend}
disabled={friendRequestSent}
icon={friendRequestSent ? <Check size={18} /> : <UserPlus size={18} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</Box>
</Box>
{bio && (
<Box maxWidth="3xl">
<Text size="sm" color="text-gray-400" leading="relaxed">
{bio}
</Text>
</Box>
)}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import React from 'react';
import { LayoutDashboard, BarChart3, ShieldCheck } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
export type ProfileTab = 'overview' | 'stats' | 'ratings';
interface DriverProfileTabsProps {
activeTab: ProfileTab;
onTabChange: (tab: ProfileTab) => void;
}
export function DriverProfileTabs({ activeTab, onTabChange }: DriverProfileTabsProps) {
const tabs = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboard },
{ id: 'stats', label: 'Career Stats', icon: BarChart3 },
{ id: 'ratings', label: 'Ratings', icon: ShieldCheck },
] as const;
return (
<Box display="flex" alignItems="center" gap={1} borderBottom borderColor="border-charcoal-outline">
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
return (
<Box
as="button"
key={tab.id}
onClick={() => onTabChange(tab.id)}
position="relative"
display="flex"
alignItems="center"
gap={2}
px={6}
py={4}
transition
hoverBg="bg-white/5"
color={isActive ? 'text-primary-blue' : 'text-gray-500'}
hoverTextColor={isActive ? 'text-primary-blue' : 'text-gray-300'}
>
<Icon size={18} />
<Text size="sm" weight={isActive ? 'bold' : 'medium'} color="inherit">
{tab.label}
</Text>
{isActive && (
<Box position="absolute" bottom="0" left="0" h="0.5" w="full" bg="bg-primary-blue" shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]" />
)}
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import React from 'react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { MapPin, Car, Clock, Users2, MailCheck } from 'lucide-react';
interface DriverRacingProfileProps {
racingStyle?: string | null;
favoriteTrack?: string | null;
favoriteCar?: string | null;
availableHours?: string | null;
lookingForTeam?: boolean;
openToRequests?: boolean;
}
export function DriverRacingProfile({
racingStyle,
favoriteTrack,
favoriteCar,
availableHours,
lookingForTeam,
openToRequests,
}: DriverRacingProfileProps) {
const details = [
{ label: 'Racing Style', value: racingStyle || 'Not specified', icon: Users2 },
{ label: 'Favorite Track', value: favoriteTrack || 'Not specified', icon: MapPin },
{ label: 'Favorite Car', value: favoriteCar || 'Not specified', icon: Car },
{ label: 'Availability', value: availableHours || 'Not specified', icon: Clock },
];
return (
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
<Box display="flex" alignItems="center" justifyContent="between">
<Heading level={3}>Racing Profile</Heading>
<Stack direction="row" gap={2}>
{lookingForTeam && (
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={1}>
<Users2 size={12} color="#198CFF" />
<Text size="xs" weight="bold" color="text-primary-blue" uppercase letterSpacing="tight">Looking for Team</Text>
</Box>
)}
{openToRequests && (
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-performance-green/10" border borderColor="border-performance-green/20" px={3} py={1}>
<MailCheck size={12} color="#22C55E" />
<Text size="xs" weight="bold" color="text-performance-green" uppercase letterSpacing="tight">Open to Requests</Text>
</Box>
)}
</Stack>
</Box>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
{details.map((detail, index) => {
const Icon = detail.icon;
return (
<Box key={index} display="flex" alignItems="center" gap={4} rounded="xl" border borderColor="border-charcoal-outline/50" bg="bg-deep-graphite/50" p={4}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50" color="text-gray-400">
<Icon size={20} />
</Box>
<Box display="flex" flexDirection="col">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{detail.label}
</Text>
<Text size="sm" weight="semibold" color="text-white">
{detail.value}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { Search } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Input } from '@/ui/Input';
interface DriverSearchBarProps {
query: string;
onChange: (query: string) => void;
}
export function DriverSearchBar({ query, onChange }: DriverSearchBarProps) {
return (
<Box position="relative" group>
<Input
value={query}
onChange={(e) => onChange(e.target.value)}
placeholder="Search drivers by name or nationality..."
icon={<Search size={20} />}
/>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface StatItem {
label: string;
value: string | number;
subValue?: string;
color?: string;
}
interface DriverStatsPanelProps {
stats: StatItem[];
}
export function DriverStatsPanel({ stats }: DriverStatsPanelProps) {
return (
<Box display="grid" gridCols={{ base: 2, sm: 3, lg: 6 }} gap="px" overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-charcoal-outline">
{stats.map((stat, index) => (
<Box key={index} display="flex" flexDirection="col" gap={1} bg="bg-deep-charcoal" p={5} transition hoverBg="bg-deep-charcoal/80">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{stat.label}
</Text>
<Box display="flex" alignItems="baseline" gap={1.5}>
<Text
size="2xl"
weight="bold"
font="mono"
color={stat.color || 'text-white'}
>
{stat.value}
</Text>
{stat.subValue && (
<Text size="xs" weight="bold" color="text-gray-600" font="mono">
{stat.subValue}
</Text>
)}
</Box>
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface DriverTableProps {
children: React.ReactNode;
}
export function DriverTable({ children }: DriverTableProps) {
return (
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
<TrendingUp size={20} color="#198CFF" />
</Box>
<Box>
<Heading level={2}>Driver Rankings</Heading>
<Text size="xs" color="text-gray-500">Top performers by skill rating</Text>
</Box>
</Stack>
<Box overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="border-charcoal-outline" bg="bg-deep-charcoal/80">
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="center" width="60px">#</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500">Driver</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" width="150px">Nationality</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="100px">Rating</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="80px">Wins</Box>
</Box>
</Box>
<Box as="tbody">
{children}
</Box>
</Box>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { RatingBadge } from '@/ui/RatingBadge';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
interface DriverTableRowProps {
rank: number;
name: string;
avatarUrl?: string | null;
nationality: string;
rating: number;
wins: number;
onClick: () => void;
}
export function DriverTableRow({
rank,
name,
avatarUrl,
nationality,
rating,
wins,
onClick,
}: DriverTableRowProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
return (
<Box
as="tr"
onClick={onClick}
cursor="pointer"
transition
hoverBg="bg-primary-blue/5"
group
borderBottom
borderColor="border-charcoal-outline/50"
>
<Box as="td" px={6} py={4} textAlign="center">
<Text
size="sm"
weight="bold"
font="mono"
color={rank <= 3 ? 'text-warning-amber' : 'text-gray-500'}
>
{rank}
</Text>
</Box>
<Box as="td" px={6} py={4}>
<Stack direction="row" align="center" gap={3}>
<Box position="relative" h="8" w="8" overflow="hidden" rounded="full" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal">
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
<Text
size="sm"
weight="semibold"
color="text-white"
groupHoverTextColor="text-primary-blue"
transition
>
{name}
</Text>
</Stack>
</Box>
<Box as="td" px={6} py={4}>
<Text size="xs" color="text-gray-400">{nationality}</Text>
</Box>
<Box as="td" px={6} py={4} textAlign="right">
<RatingBadge rating={rating} size="sm" />
</Box>
<Box as="td" px={6} py={4} textAlign="right">
<Text size="sm" weight="semibold" font="mono" color="text-performance-green">
{wins}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import React from 'react';
import { Users, Trophy } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface DriverStat {
label: string;
value: string | number;
color?: string;
animate?: boolean;
}
interface DriversDirectoryHeaderProps {
totalDrivers: number;
activeDrivers: number;
totalWins: number;
totalRaces: number;
onViewLeaderboard: () => void;
}
export function DriversDirectoryHeader({
totalDrivers,
activeDrivers,
totalWins,
totalRaces,
onViewLeaderboard,
}: DriversDirectoryHeaderProps) {
const stats: DriverStat[] = [
{ label: 'drivers', value: totalDrivers, color: 'text-primary-blue' },
{ label: 'active', value: activeDrivers, color: 'text-performance-green', animate: true },
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
];
return (
<Box
as="header"
position="relative"
overflow="hidden"
rounded="2xl"
border
borderColor="border-charcoal-outline/50"
bg="bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60"
p={{ base: 8, lg: 10 }}
>
{/* Background Accents */}
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
<Box position="absolute" bottom="-16" left="-16" w="64" h="64" rounded="full" bg="bg-neon-aqua/5" blur="3xl" />
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={8}>
<Box maxWidth="2xl">
<Stack direction="row" align="center" gap={3} mb={4}>
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" shadow="lg">
<Users size={24} color="#198CFF" />
</Box>
<Heading level={1}>Drivers</Heading>
</Stack>
<Text size="lg" color="text-gray-400" block leading="relaxed">
Meet the racers who make every lap count. From rookies to champions, track their journey and see who&apos;s dominating the grid.
</Text>
<Box display="flex" flexWrap="wrap" gap={6} mt={6}>
{stats.map((stat, index) => (
<Stack key={index} direction="row" align="center" gap={2}>
<Box
w="2"
h="2"
rounded="full"
bg={stat.color?.replace('text-', 'bg-') || 'bg-primary-blue'}
animate={stat.animate ? 'pulse' : 'none'}
/>
<Text size="sm" color="text-gray-400">
<Text as="span" weight="semibold" color="text-white">{stat.value}</Text> {stat.label}
</Text>
</Stack>
))}
</Box>
</Box>
<Stack gap={2}>
<Button
variant="primary"
onClick={onViewLeaderboard}
icon={<Trophy size={20} />}
>
View Leaderboard
</Button>
<Text size="xs" color="text-gray-500" align="center" block>
See full driver rankings
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React from 'react';
import { Shield } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface SafetyRatingBadgeProps {
rating: number;
size?: 'sm' | 'md' | 'lg';
}
export function SafetyRatingBadge({ rating, size = 'md' }: SafetyRatingBadgeProps) {
const getColor = (r: number) => {
if (r >= 90) return 'text-performance-green';
if (r >= 70) return 'text-warning-amber';
return 'text-red-500';
};
const getBgColor = (r: number) => {
if (r >= 90) return 'bg-performance-green/10';
if (r >= 70) return 'bg-warning-amber/10';
return 'bg-red-500/10';
};
const getBorderColor = (r: number) => {
if (r >= 90) return 'border-performance-green/20';
if (r >= 70) return 'border-warning-amber/20';
return 'border-red-500/20';
};
const sizeProps = {
sm: { px: 2, py: 0.5, gap: 1 },
md: { px: 3, py: 1, gap: 1.5 },
lg: { px: 4, py: 2, gap: 2 },
};
const iconSizes = {
sm: 12,
md: 14,
lg: 16,
};
const iconColors = {
'text-performance-green': '#22C55E',
'text-warning-amber': '#FFBE4D',
'text-red-500': '#EF4444',
};
const colorClass = getColor(rating);
return (
<Box
display="inline-flex"
alignItems="center"
rounded="full"
border
bg={getBgColor(rating)}
borderColor={getBorderColor(rating)}
{...sizeProps[size]}
>
<Shield size={iconSizes[size]} color={iconColors[colorClass as keyof typeof iconColors]} />
<Text
size={size === 'lg' ? 'sm' : 'xs'}
weight="bold"
font="mono"
color={colorClass}
>
SR {rating.toFixed(0)}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
interface AppErrorBoundaryViewProps {
title: string;
description: string;
children?: React.ReactNode;
}
/**
* AppErrorBoundaryView
*
* Semantic container for error boundary content.
* Follows "Precision Racing Minimal" theme.
*/
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
return (
<Stack gap={6} align="center" fullWidth>
{/* Header Icon */}
<Box
p={4}
rounded="full"
bg="bg-warning-amber"
bgOpacity={0.1}
border
borderColor="border-warning-amber"
>
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
</Box>
{/* Typography */}
<Stack gap={2} align="center">
<Heading level={1} weight="bold">
<Text uppercase letterSpacing="tighter">
{title}
</Text>
</Heading>
<Text color="text-gray-400" align="center" maxWidth="md" leading="relaxed">
{description}
</Text>
</Stack>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Copy, Terminal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface ErrorDetailsProps {
error: Error & { digest?: string };
}
/**
* ErrorDetails
*
* Handles the display of technical error information with a toggle.
* Part of the 500 route redesign.
*/
export function ErrorDetails({ error }: ErrorDetailsProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
const copyError = async () => {
const details = {
message: error.message,
digest: error.digest,
stack: error.stack,
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
timestamp: new Date().toISOString(),
};
try {
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silent fail
}
};
return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white">
<Box
as="button"
onClick={() => setShowDetails(!showDetails)}
display="flex"
alignItems="center"
justifyContent="center"
gap={2}
color="text-gray-500"
hoverTextColor="text-gray-300"
transition
>
<Icon icon={Terminal} size={3} />
<Text
size="xs"
weight="medium"
uppercase
letterSpacing="widest"
color="inherit"
>
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
</Text>
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
</Box>
{showDetails && (
<Stack gap={3}>
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
maxHeight="48"
overflow="auto"
border
borderColor="border-white"
bgOpacity={0.4}
hideScrollbar={false}
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`}
</Text>
</Surface>
<Box display="flex" justifyContent="end">
<Button
variant="secondary"
size="sm"
onClick={copyError}
icon={<Icon icon={Copy} size={3} />}
height="8"
fontSize="10px"
>
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
</Button>
</Box>
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import React, { useState } from 'react';
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
interface ErrorDetailsBlockProps {
error: Error & { digest?: string };
}
/**
* ErrorDetailsBlock
*
* Semantic component for technical error details.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
const copyError = async () => {
const details = {
message: error.message,
digest: error.digest,
stack: error.stack,
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
timestamp: new Date().toISOString(),
};
try {
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silent fail
}
};
return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white" bgOpacity={0.1}>
<Box
as="button"
onClick={() => setShowDetails(!showDetails)}
display="flex"
alignItems="center"
justifyContent="center"
gap={2}
transition
>
<Text
size="xs"
color="text-gray-500"
hoverTextColor="text-gray-300"
uppercase
letterSpacing="widest"
weight="medium"
display="flex"
alignItems="center"
gap={2}
>
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
</Text>
</Box>
{showDetails && (
<Stack gap={3}>
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
maxHeight="48"
overflow="auto"
border
borderColor="border-white"
bgOpacity={0.4}
hideScrollbar={false}
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`}
</Text>
</Surface>
<Box display="flex" justifyContent="end">
<Button
variant="secondary"
size="sm"
onClick={copyError}
icon={<Icon icon={Copy} size={3} />}
height="8"
fontSize="10px"
>
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
</Button>
</Box>
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import React from 'react';
import { RefreshCw, Home } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface ErrorRecoveryActionsProps {
onRetry: () => void;
onHome: () => void;
}
/**
* ErrorRecoveryActions
*
* Semantic component for error recovery buttons.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) {
return (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
justifyContent="center"
gap={3}
fullWidth
>
<Button
variant="primary"
onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />}
width="160px"
>
Retry Session
</Button>
<Button
variant="secondary"
onClick={onHome}
icon={<Icon icon={Home} size={4} />}
width="160px"
>
Return to Pits
</Button>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ErrorScreen } from './ErrorScreen';
describe('ErrorScreen', () => {
const mockError = new Error('Test error message');
(mockError as any).digest = 'test-digest';
(mockError as any).stack = 'test-stack-trace';
const mockReset = vi.fn();
const mockOnHome = vi.fn();
it('renders error message and system malfunction title', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
expect(screen.getByText('System Malfunction')).toBeDefined();
expect(screen.getByText('Test error message')).toBeDefined();
});
it('calls reset when Retry Session is clicked', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Retry Session');
fireEvent.click(button);
expect(mockReset).toHaveBeenCalledTimes(1);
});
it('calls onHome when Return to Pits is clicked', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Return to Pits');
fireEvent.click(button);
expect(mockOnHome).toHaveBeenCalledTimes(1);
});
it('toggles technical logs visibility', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
expect(screen.queryByText('test-stack-trace')).toBeNull();
const toggle = screen.getByText('Show Technical Logs');
fireEvent.click(toggle);
expect(screen.getByText(/test-stack-trace/)).toBeDefined();
expect(screen.getByText(/Digest: test-digest/)).toBeDefined();
fireEvent.click(screen.getByText('Hide Technical Logs'));
expect(screen.queryByText(/test-stack-trace/)).toBeNull();
});
});

View File

@@ -0,0 +1,80 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { Text } from '@/ui/Text';
import { AppErrorBoundaryView } from './AppErrorBoundaryView';
import { ErrorRecoveryActions } from './ErrorRecoveryActions';
import { ErrorDetailsBlock } from './ErrorDetailsBlock';
interface ErrorScreenProps {
error: Error & { digest?: string };
reset: () => void;
onHome: () => void;
}
/**
* ErrorScreen
*
* Semantic component for the root-level error boundary.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) {
return (
<Box
as="main"
minHeight="screen"
fullWidth
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-deep-graphite"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents */}
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Surface
variant="glass"
border
rounded="lg"
padding={8}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
shadow="xl"
borderColor="border-white"
bgOpacity={0.05}
>
<AppErrorBoundaryView
title="System Malfunction"
description="The application encountered an unexpected state. Our telemetry has logged the incident."
>
{/* Error Message Summary */}
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
border
borderColor="border-white"
bgOpacity={0.2}
>
<Text font="mono" size="sm" color="text-warning-amber" block>
{error.message || 'Unknown execution error'}
</Text>
</Surface>
<ErrorRecoveryActions onRetry={reset} onHome={onHome} />
<ErrorDetailsBlock error={error} />
</AppErrorBoundaryView>
</Surface>
</Box>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { AlertTriangle, RefreshCw, Home, Terminal } from 'lucide-react';
import { Button } from '@/ui/Button';
interface GlobalErrorScreenProps {
error: Error & { digest?: string };
reset: () => void;
onHome: () => void;
}
/**
* GlobalErrorScreen
*
* A strong, minimal "system fault" view for the root global error boundary.
* Instrument-grade UI following the "Precision Racing Minimal" theme.
*/
export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) {
return (
<Box
as="main"
minHeight="screen"
fullWidth
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-base-black"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents - Subtle telemetry vibe */}
<Glow color="primary" size="xl" position="center" opacity={0.03} />
<Surface
variant="dark"
border
rounded="none"
padding={0}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
borderColor="border-white"
bgOpacity={0.1}
>
{/* System Status Header */}
<Box
borderBottom
borderColor="border-white"
bgOpacity={0.05}
px={6}
py={4}
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Stack direction="row" gap={3} align="center">
<Icon icon={AlertTriangle} size={5} color="var(--warning-amber)" />
<Heading level={2} weight="bold">
<Text uppercase letterSpacing="widest" size="sm">
System Fault Detected
</Text>
</Heading>
</Stack>
<Text font="mono" size="xs" color="text-gray-500" uppercase>
Status: Critical
</Text>
</Box>
<Box p={8}>
<Stack gap={8}>
{/* Fault Description */}
<Stack gap={4}>
<Text color="text-gray-400" size="base" leading="relaxed">
The application kernel encountered an unrecoverable execution error.
Telemetry has been captured for diagnostic review.
</Text>
<SystemStatusPanel error={error} />
</Stack>
{/* Recovery Actions */}
<RecoveryActions onRetry={reset} onHome={onHome} />
</Stack>
</Box>
{/* Footer / Metadata */}
<Box
borderTop
borderColor="border-white"
bgOpacity={0.05}
px={6}
py={3}
display="flex"
justifyContent="end"
>
<Text font="mono" size="xs" color="text-gray-600">
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
</Text>
</Box>
</Surface>
</Box>
);
}
/**
* SystemStatusPanel
*
* Displays technical fault details in an instrument-grade panel.
*/
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
return (
<Surface
variant="dark"
rounded="none"
padding={4}
fullWidth
border
borderColor="border-white"
bgOpacity={0.2}
>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Terminal} size={3} color="var(--gray-500)" />
<Text font="mono" size="xs" color="text-gray-500" uppercase letterSpacing="wider">
Fault Log
</Text>
</Box>
<Text font="mono" size="sm" color="text-warning-amber" block>
{error.message || 'Unknown execution fault'}
</Text>
{error.digest && (
<Text font="mono" size="xs" color="text-gray-600" block>
Digest: {error.digest}
</Text>
)}
</Stack>
</Surface>
);
}
/**
* RecoveryActions
*
* Clear, instrument-grade recovery options.
*/
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
return (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
gap={4}
fullWidth
>
<Button
variant="primary"
onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />}
rounded="none"
px={8}
>
Reboot Session
</Button>
<Button
variant="secondary"
onClick={onHome}
icon={<Icon icon={Home} size={4} />}
rounded="none"
px={8}
>
Return to Pits
</Button>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface NotFoundActionsProps {
primaryLabel: string;
onPrimaryClick: () => void;
}
/**
* NotFoundActions
*
* Semantic component for the primary actions on the 404 page.
* Follows "Precision Racing Minimal" theme with crisp styling.
*/
export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) {
return (
<Stack direction="row" gap={4} align="center" justify="center">
<Button
variant="primary"
size="lg"
onClick={onPrimaryClick}
minWidth="200px"
>
{primaryLabel}
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => window.history.back()}
>
<Stack direction="row" gap={2} align="center">
<Box
width={2}
height={2}
rounded="full"
bg="soft-steel"
/>
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-gray-400">
Previous Sector
</Text>
</Stack>
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
interface NotFoundCallToActionProps {
label: string;
onClick: () => void;
}
/**
* NotFoundCallToAction
*
* Semantic component for the primary action on the 404 page.
* Follows "Precision Racing Minimal" theme with crisp styling.
*/
export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) {
return (
<Stack gap={4} align="center">
<Button
variant="primary"
size="lg"
onClick={onClick}
>
{label}
</Button>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="widest">
Telemetry connection lost
</Text>
</Stack>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface NotFoundDiagnosticsProps {
errorCode: string;
}
/**
* NotFoundDiagnostics
*
* Semantic component for displaying technical error details.
* Styled as a telemetry status indicator.
*/
export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) {
return (
<Stack gap={3} align="center">
<Box
px={3}
py={1}
border
borderColor="primary-accent"
bg="primary-accent"
bgOpacity={0.1}
rounded="sm"
>
<Text
size="xs"
weight="bold"
color="text-primary-accent"
uppercase
letterSpacing="widest"
>
{errorCode}
</Text>
</Box>
<Text
size="xs"
color="text-gray-500"
uppercase
letterSpacing="widest"
weight="medium"
>
Telemetry connection lost // Sector data unavailable
</Text>
</Stack>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface NotFoundHelpLinksProps {
links: Array<{ label: string; href: string }>;
}
/**
* NotFoundHelpLinks
*
* Semantic component for secondary navigation on the 404 page.
* Styled as technical metadata links.
*/
export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) {
return (
<Stack direction="row" gap={6} align="center" wrap center>
{links.map((link, index) => (
<React.Fragment key={link.href}>
<Box
as="a"
href={link.href}
transition
display="inline-block"
>
<Text
color="text-gray-400"
hoverTextColor="primary-accent"
weight="medium"
size="xs"
letterSpacing="widest"
uppercase
>
{link.label}
</Text>
</Box>
{index < links.length - 1 && (
<Box width="1px" height="12px" bg="border-gray" opacity={0.5} />
)}
</React.Fragment>
))}
</Stack>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { NotFoundActions } from './NotFoundActions';
import { NotFoundHelpLinks } from './NotFoundHelpLinks';
import { NotFoundDiagnostics } from './NotFoundDiagnostics';
interface NotFoundScreenProps {
errorCode: string;
title: string;
message: string;
actionLabel: string;
onActionClick: () => void;
}
/**
* NotFoundScreen
*
* App-specific semantic component for 404 states.
* Encapsulates the visual representation of the "Off Track" state.
* Redesigned for "Precision Racing Minimal" theme.
*/
export function NotFoundScreen({
errorCode,
title,
message,
actionLabel,
onActionClick
}: NotFoundScreenProps) {
const helpLinks = [
{ label: 'Support', href: '/support' },
{ label: 'Status', href: '/status' },
{ label: 'Documentation', href: '/docs' },
];
return (
<Box
as="main"
minHeight="100vh"
display="flex"
alignItems="center"
justifyContent="center"
bg="graphite-black"
position="relative"
overflow="hidden"
>
{/* Background Glow Accent */}
<Glow color="primary" size="xl" opacity={0.1} position="center" />
<Surface
variant="glass"
border
padding={12}
rounded="none"
maxWidth="2xl"
fullWidth
mx={6}
position="relative"
zIndex={10}
>
<Stack gap={12} align="center" textAlign="center">
{/* Header Section */}
<Stack gap={4} align="center">
<NotFoundDiagnostics errorCode={errorCode} />
<Text
as="h1"
size="4xl"
weight="bold"
color="text-white"
letterSpacing="tighter"
uppercase
block
leading="none"
>
{title}
</Text>
</Stack>
{/* Visual Separator */}
<Box width="full" height="1px" bg="primary-accent" opacity={0.3} position="relative" display="flex" alignItems="center" justifyContent="center">
<Box
width={3}
height={3}
bg="primary-accent"
/>
</Box>
{/* Message Section */}
<Text
size="xl"
color="text-gray-400"
maxWidth="lg"
leading="relaxed"
block
weight="medium"
>
{message}
</Text>
{/* Actions Section */}
<NotFoundActions
primaryLabel={actionLabel}
onPrimaryClick={onActionClick}
/>
{/* Footer Section */}
<Box pt={8} width="full">
<Box height="1px" width="full" bg="border-gray" opacity={0.1} mb={8} />
<NotFoundHelpLinks links={helpLinks} />
</Box>
</Stack>
</Surface>
{/* Subtle Edge Details */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="2px"
bg="primary-accent"
opacity={0.1}
/>
<Box
position="absolute"
bottom={0}
left={0}
right={0}
height="2px"
bg="primary-accent"
opacity={0.1}
/>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { RefreshCw, Home, LifeBuoy } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface RecoveryActionsProps {
onRetry: () => void;
onHome: () => void;
}
/**
* RecoveryActions
*
* Provides primary and secondary recovery paths for the user.
* Part of the 500 route redesign.
*/
export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
return (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
justifyContent="center"
gap={3}
fullWidth
>
<Button
variant="primary"
onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />}
width="160px"
>
Retry Session
</Button>
<Button
variant="secondary"
onClick={onHome}
icon={<Icon icon={Home} size={4} />}
width="160px"
>
Return to Pits
</Button>
<Button
variant="secondary"
as="a"
href="https://support.gridpilot.com"
target="_blank"
rel="noopener noreferrer"
icon={<Icon icon={LifeBuoy} size={4} />}
width="160px"
>
Contact Support
</Button>
</Box>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface ServerErrorPanelProps {
message?: string;
incidentId?: string;
}
/**
* ServerErrorPanel
*
* Displays the primary error information in an "instrument-grade" style.
* Part of the 500 route redesign.
*/
export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) {
return (
<Stack gap={6} align="center" fullWidth>
{/* Status Indicator */}
<Box
p={4}
rounded="full"
bg="bg-warning-amber"
bgOpacity={0.1}
border
borderColor="border-warning-amber"
>
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
</Box>
{/* Primary Message */}
<Stack gap={2} align="center">
<Heading level={1} weight="bold">
CRITICAL_SYSTEM_FAILURE
</Heading>
<Text color="text-gray-400" align="center" maxWidth="md">
The application engine encountered an unrecoverable state.
Telemetry has been dispatched to engineering.
</Text>
</Stack>
{/* Technical Summary */}
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
border
borderColor="border-white"
bgOpacity={0.2}
>
<Stack gap={2}>
<Text font="mono" size="sm" color="text-warning-amber" block>
STATUS: 500_INTERNAL_SERVER_ERROR
</Text>
{message && (
<Text font="mono" size="xs" color="text-gray-400" block>
EXCEPTION: {message}
</Text>
)}
{incidentId && (
<Text font="mono" size="xs" color="text-gray-500" block>
INCIDENT_ID: {incidentId}
</Text>
)}
</Stack>
</Surface>
</Stack>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
interface HomeFeatureDescriptionProps {
lead: string;
items: string[];
quote?: string;
accentColor?: 'primary' | 'aqua' | 'amber' | 'gray';
}
/**
* HomeFeatureDescription - A semantic component for feature descriptions on the home page.
* Refactored to use semantic HTML and Tailwind.
*/
export function HomeFeatureDescription({
lead,
items,
quote,
accentColor = 'primary',
}: HomeFeatureDescriptionProps) {
const borderColor = {
primary: 'primary-accent',
aqua: 'telemetry-aqua',
amber: 'warning-amber',
gray: 'border-gray',
}[accentColor];
const bgColor = {
primary: 'primary-accent/5',
aqua: 'telemetry-aqua/5',
amber: 'warning-amber/5',
gray: 'white/5',
}[accentColor];
return (
<Stack gap={6}>
<Text size="lg" color="text-gray-400" weight="medium" leading="relaxed">
{lead}
</Text>
<Box as="ul" display="flex" flexDirection="col" gap={2}>
{items.map((item, index) => (
<Box as="li" key={index} display="flex" alignItems="start" gap={2}>
<Text color="text-primary-accent"></Text>
<Text size="sm" color="text-gray-500">{item}</Text>
</Box>
))}
</Box>
{quote && (
<Box borderLeft borderStyle="solid" borderWidth="2px" borderColor={borderColor} pl={4} py={1} bg={bgColor}>
<Text color="text-gray-600" font="mono" size="xs" uppercase letterSpacing="widest" leading="relaxed">
{quote}
</Text>
</Box>
)}
</Stack>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import React from 'react';
import { Panel } from '@/ui/Panel';
import { Glow } from '@/ui/Glow';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Section } from '@/ui/Section';
interface HomeFeatureSectionProps {
heading: string;
description: React.ReactNode;
mockup: React.ReactNode;
layout: 'text-left' | 'text-right';
accentColor?: 'primary' | 'aqua' | 'amber';
}
/**
* HomeFeatureSection - A semantic section highlighting a feature.
* Refactored to use semantic HTML and Tailwind.
*/
export function HomeFeatureSection({
heading,
description,
mockup,
layout,
accentColor = 'primary',
}: HomeFeatureSectionProps) {
const accentBorderColor = {
primary: 'primary-accent/40',
aqua: 'telemetry-aqua/40',
amber: 'warning-amber/40',
}[accentColor];
const accentBgColor = {
primary: 'primary-accent',
aqua: 'telemetry-aqua',
amber: 'warning-amber',
}[accentColor];
const glowColor = ({
primary: 'primary',
aqua: 'aqua',
amber: 'amber',
}[accentColor] || 'primary') as 'primary' | 'aqua' | 'amber' | 'purple';
return (
<Section variant="dark" py={24} borderBottom borderColor="border-gray" overflow="hidden" position="relative">
<Glow
color={glowColor}
size="lg"
position={layout === 'text-left' ? 'bottom-left' : 'top-right'}
opacity={0.02}
/>
<Container>
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={{ base: 12, lg: 20 }} alignItems="center">
{/* Text Content */}
<Box display="flex" flexDirection="col" gap={8} order={{ lg: layout === 'text-right' ? 2 : 1 }}>
<Box display="flex" flexDirection="col" gap={4}>
<Box w="12" h="1" bg={accentBgColor} />
<Heading level={2} fontSize={{ base: '3xl', md: '5xl' }} weight="bold" letterSpacing="tighter" lineHeight="none" color="text-white">
{heading}
</Heading>
</Box>
<Box color="text-gray-500" borderLeft borderStyle="solid" borderColor="border-gray/20" pl={6}>
{description}
</Box>
</Box>
{/* Mockup Panel */}
<Box order={{ lg: layout === 'text-right' ? 1 : 2 }}>
<Panel padding={1} variant="dark" border={true} position="relative" group overflow="hidden">
<Box bg="graphite-black" minHeight="300px" display="flex" alignItems="center" justifyContent="center">
{mockup}
</Box>
{/* Decorative corner accents */}
<Box position="absolute" top="0" left="0" w="4" h="4" borderTop borderLeft borderColor={accentBorderColor} opacity={0.4} />
<Box position="absolute" bottom="0" right="0" w="4" h="4" borderBottom borderRight borderColor={accentBorderColor} opacity={0.4} />
</Panel>
</Box>
</Box>
</Container>
</Section>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import React from 'react';
import { Button } from '@/ui/Button';
import { Glow } from '@/ui/Glow';
import { Icon } from '@/ui/Icon';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Section } from '@/ui/Section';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
export function HomeFooterCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
return (
<Section variant="dark" py={32} borderBottom borderColor="border-gray/50" overflow="hidden" position="relative">
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Container>
<Box position="relative" overflow="hidden" bg="panel-gray/40" border borderColor="border-gray" p={{ base: 8, md: 12 }}>
{/* Discord brand accent */}
<Box position="absolute" top={0} left={0} right={0} h="1" bg="primary-accent" />
<Stack align="center" gap={12} center>
{/* Header */}
<Stack align="center" gap={6}>
<Box position="relative" display="flex" alignItems="center" justifyContent="center" w={{ base: 16, md: 20 }} h={{ base: 16, md: 20 }} bg="primary-accent/10" border borderColor="primary-accent/30">
<DiscordIcon color="text-primary-accent" size={40} />
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="primary-accent" />
<Box position="absolute" bottom="-1px" right="-1px" w="2" h="2" borderBottom borderRight borderColor="primary-accent" />
</Box>
<Stack gap={4} align="center">
<Heading level={2} weight="bold" color="text-white" fontSize={{ base: '2xl', md: '4xl' }} letterSpacing="tight">
Join the Grid on Discord
</Heading>
<Box w="16" h="1" bg="primary-accent" />
</Stack>
</Stack>
{/* Personal message */}
<Box maxWidth="2xl" mx="auto" textAlign="center">
<Stack gap={6}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
GridPilot is a <Text as="span" color="text-white" weight="bold">solo developer project</Text> built for the community.
</Text>
<Text size="base" color="text-gray-400" weight="normal" leading="relaxed">
We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap.
</Text>
</Stack>
</Box>
{/* Benefits grid */}
<Box maxWidth="4xl" mx="auto" fullWidth>
<Grid cols={1} mdCols={2} gap={6}>
<BenefitItem
icon={MessageSquare}
title="Share your pain points"
description="Tell us what frustrates you about league racing today."
/>
<BenefitItem
icon={Lightbulb}
title="Shape the product"
description="Your ideas directly influence our roadmap."
/>
<BenefitItem
icon={Users}
title="Connect with racers"
description="Join a community of like-minded competitive drivers."
/>
<BenefitItem
icon={Code}
title="Early Access"
description="Test new features before they go public."
/>
</Grid>
</Box>
{/* CTA Button */}
<Stack gap={6} pt={4} align="center">
<Button
as="a"
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
variant="primary"
size="lg"
px={16}
py={4}
h="auto"
icon={<DiscordIcon size={24} />}
>
Join Discord
</Button>
<Box border borderStyle="dashed" borderColor="primary-accent/50" px={4} py={1}>
<Text size="xs" color="text-primary-accent" weight="bold" font="mono" uppercase letterSpacing="widest">
Early Alpha Access Available
</Text>
</Box>
</Stack>
</Stack>
</Box>
</Container>
</Section>
);
}
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
return (
<Box display="flex" alignItems="start" gap={5} p={6} bg="panel-gray/20" border borderColor="border-gray" hoverBorderColor="primary-accent/30" transition group>
<Box display="flex" alignItems="center" justifyContent="center" flexShrink={0} w="10" h="10" bg="primary-accent/5" border borderColor="border-gray/50" groupHoverBorderColor="primary-accent/30" transition>
<Icon icon={icon} size={5} color="text-primary-accent" />
</Box>
<Stack gap={2}>
<Text size="base" weight="bold" color="text-white" letterSpacing="wide">{title}</Text>
<Text size="sm" color="text-gray-400" leading="relaxed">{description}</Text>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import React from 'react';
import { Button } from '@/ui/Button';
import { Glow } from '@/ui/Glow';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
interface HomeHeaderProps {
title: string;
subtitle: string;
description: string;
primaryAction: {
label: string;
href: string;
};
secondaryAction: {
label: string;
href: string;
};
}
/**
* HomeHeader - Semantic hero section for the landing page.
* Follows "Precision Racing Minimal" theme.
*/
export function HomeHeader({
title,
subtitle,
description,
primaryAction,
secondaryAction,
}: HomeHeaderProps) {
return (
<Box as="header" position="relative" overflow="hidden" bg="graphite-black" py={{ base: 24, lg: 32 }} borderBottom borderColor="border-gray">
<Glow color="primary" size="xl" position="top-right" opacity={0.1} />
<Container>
<Box maxWidth="4xl">
<Box display="flex" alignItems="center" gap={3} borderLeft borderStyle="solid" borderWidth="2px" borderColor="primary-accent" bg="primary-accent/5" px={4} py={1} mb={8}>
<Text size="xs" weight="bold" uppercase letterSpacing="0.3em" color="text-primary-accent">
{subtitle}
</Text>
</Box>
<Heading
level={1}
fontSize={{ base: '5xl', md: '7xl', lg: '8xl' }}
weight="bold"
color="text-white"
letterSpacing="tighter"
lineHeight="0.9"
mb={8}
>
{title}
</Heading>
<Box borderLeft borderStyle="solid" borderColor="border-gray" pl={8} mb={12} maxWidth="2xl">
<Text size="lg" color="text-gray-400" leading="relaxed" opacity={0.8}>
{description}
</Text>
</Box>
<Box display="flex" flexDirection={{ base: 'col', sm: 'row' }} gap={4}>
<Button
as="a"
href={primaryAction.href}
variant="primary"
h="14"
px={12}
fontSize="xs"
>
{primaryAction.label}
</Button>
<Button
as="a"
href={secondaryAction.href}
variant="secondary"
h="14"
px={12}
fontSize="xs"
bg="transparent"
borderColor="border-gray"
hoverBorderColor="primary-accent/50"
>
{secondaryAction.label}
</Button>
</Box>
</Box>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import React from 'react';
import { MetricCard } from '@/ui/MetricCard';
import { Activity, Users, Trophy, Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
/**
* HomeStatsStrip - A thin strip showing some status or quick info.
* Part of the "Telemetry-workspace" feel.
* Refactored to use semantic HTML and Tailwind.
*/
export function HomeStatsStrip() {
return (
<Box bg="graphite-black" borderBottom borderTop borderColor="border-gray/30" py={0}>
<Container>
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={0} borderLeft borderRight borderColor="border-gray/30">
<MetricCard
label="Active Drivers"
value="1,284"
icon={Users}
trend={{ value: 12, isPositive: true }}
border={false}
bg="transparent"
/>
<Box borderLeft borderColor="border-gray/30">
<MetricCard
label="Live Sessions"
value="42"
icon={Activity}
color="text-telemetry-aqua"
border={false}
bg="transparent"
/>
</Box>
<Box borderLeft borderColor="border-gray/30">
<MetricCard
label="Total Races"
value="15,402"
icon={Trophy}
color="text-warning-amber"
border={false}
bg="transparent"
/>
</Box>
<Box borderLeft borderColor="border-gray/30">
<MetricCard
label="Next Event"
value="14:00"
icon={Calendar}
border={false}
bg="transparent"
/>
</Box>
</Box>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { LeagueCard } from '@/ui/LeagueCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
interface League {
id: string;
name: string;
description: string;
}
interface LeagueSummaryPanelProps {
leagues: League[];
}
/**
* LeagueSummaryPanel - Semantic section for featured leagues.
*/
export function LeagueSummaryPanel({ leagues }: LeagueSummaryPanelProps) {
return (
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
FEATURED LEAGUES
</Heading>
<Link
href={routes.public.leagues}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
VIEW ALL
</Link>
</Box>
<Box display="flex" flexDirection="col" gap={4}>
{leagues.slice(0, 2).map((league) => (
<LeagueCard
key={league.id}
name={league.name}
description={league.description}
coverUrl="/images/ff1600.jpeg"
slotLabel="Drivers"
usedSlots={18}
maxSlots={24}
fillPercentage={75}
hasOpenSlots={true}
openSlotsCount={6}
/>
))}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import React from 'react';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { routes } from '@/lib/routing/RouteConfig';
import { Plus, Search, Shield, Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Text } from '@/ui/Text';
/**
* QuickLinksPanel - Semantic quick actions bar.
* Replaces HomeQuickActions with a more semantic implementation.
*/
export function QuickLinksPanel() {
const links = [
{ label: 'Find League', icon: Search, href: routes.public.leagues },
{ label: 'Join Team', icon: Users, href: routes.public.teams },
{ label: 'Create Race', icon: Plus, href: routes.protected.dashboard },
{ label: 'Rulebooks', icon: Shield, href: '#' },
];
return (
<Box as="nav" bg="panel-gray/50" py={8} borderBottom borderColor="border-gray/30">
<Container>
<Box display="flex" flexWrap="wrap" justifyContent="center" gap={4}>
{links.map((link) => (
<Button
key={link.label}
as="a"
href={link.href}
variant="secondary"
display="flex"
alignItems="center"
gap={3}
px={6}
bg="graphite-black"
borderColor="border-gray/50"
hoverBorderColor="primary-accent/50"
transition
group
>
<Icon
icon={link.icon}
size={4}
color="text-gray-500"
groupHoverTextColor="primary-accent"
transition
/>
<Text size="xs" weight="bold" uppercase letterSpacing="widest">
{link.label}
</Text>
</Button>
))}
</Box>
</Container>
</Box>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import React from 'react';
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
interface Race {
id: string;
track: string;
car: string;
formattedDate: string;
}
interface RecentRacesPanelProps {
races: Race[];
}
/**
* RecentRacesPanel - Semantic section for upcoming/recent races.
*/
export function RecentRacesPanel({ races }: RecentRacesPanelProps) {
return (
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
UPCOMING RACES
</Heading>
<Link
href={routes.public.races}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
FULL SCHEDULE
</Link>
</Box>
<Box display="flex" flexDirection="col" gap={3}>
{races.length === 0 ? (
<Box py={12} border borderStyle="dashed" borderColor="border-gray/30" bg="graphite-black/50" display="flex" alignItems="center" justifyContent="center">
<Text size="xs" font="mono" uppercase letterSpacing="widest" color="text-gray-600">
No races scheduled
</Text>
</Box>
) : (
races.slice(0, 3).map((race) => (
<UpcomingRaceItem
key={race.id}
track={race.track}
car={race.car}
formattedDate={race.formattedDate}
formattedTime="20:00 GMT"
isMyLeague={false}
/>
))
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import React from 'react';
import { TeamCard } from '@/ui/TeamCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
interface Team {
id: string;
name: string;
description: string;
logoUrl?: string;
}
interface TeamSummaryPanelProps {
teams: Team[];
}
/**
* TeamSummaryPanel - Semantic section for teams.
*/
export function TeamSummaryPanel({ teams }: TeamSummaryPanelProps) {
return (
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
TEAMS ON THE GRID
</Heading>
<Link
href={routes.public.teams}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
BROWSE TEAMS
</Link>
</Box>
<Box display="flex" flexDirection="col" gap={4}>
{teams.slice(0, 2).map((team) => (
<TeamCard
key={team.id}
name={team.name}
description={team.description}
logo={team.logoUrl}
memberCount={12}
isRecruiting={true}
/>
))}
</Box>
</Box>
);
}

View File

@@ -93,12 +93,12 @@ export function AlternatingSection({
order={{ lg: layout === 'text-right' ? 2 : 1 }}
>
<Stack gap={4}>
<Box w="12" h="1" bg="primary-accent" />
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" className="tracking-tight">
<Box w="8" h="1" bg="primary-accent" />
<Heading level={2} fontSize={{ base: '3xl', md: '5xl' }} weight="bold" className="tracking-tighter uppercase leading-none">
{heading}
</Heading>
</Stack>
<Box className="text-gray-400">
<Box className="text-gray-500 border-l border-border-gray/20 pl-6">
{typeof description === 'string' ? (
<Text size="lg" leading="relaxed" weight="normal">{description}</Text>
) : (
@@ -111,18 +111,18 @@ export function AlternatingSection({
<Box
position="relative"
order={{ lg: layout === 'text-right' ? 1 : 2 }}
className="bg-panel-gray/30 border border-border-gray/50 rounded-none p-2 shadow-2xl group"
className="bg-panel-gray/20 border border-border-gray/30 rounded-none p-1 shadow-2xl group"
>
<Box
fullWidth
minHeight={{ base: '240px', md: '380px' }}
className="overflow-hidden rounded-none border border-border-gray/30 bg-graphite-black"
className="overflow-hidden rounded-none border border-border-gray/20 bg-graphite-black"
>
{mockup}
</Box>
{/* Decorative corner accents */}
<Box position="absolute" top="-1px" left="-1px" w="4" h="4" borderTop borderLeft borderColor="primary-accent/50" />
<Box position="absolute" bottom="-1px" right="-1px" w="4" h="4" borderBottom borderRight borderColor="primary-accent/50" />
<Box position="absolute" top="-1px" left="-1px" w="3" h="3" borderTop borderLeft borderColor="primary-accent/40" />
<Box position="absolute" bottom="-1px" right="-1px" w="3" h="3" borderBottom borderRight borderColor="primary-accent/40" />
</Box>
</Box>
</Container>

View File

@@ -53,21 +53,21 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
display="flex"
flexDirection="column"
gap={6}
className="p-8 bg-panel-gray/40 border border-border-gray/50 rounded-none hover:border-primary-accent/30 transition-all duration-300 ease-smooth group relative overflow-hidden"
className="p-8 bg-panel-gray/20 border border-border-gray/20 rounded-none hover:border-primary-accent/20 transition-all duration-300 ease-smooth group relative overflow-hidden"
>
<Box aspectRatio="video" fullWidth position="relative" className="bg-graphite-black rounded-none overflow-hidden border border-border-gray/30">
<Box aspectRatio="video" fullWidth position="relative" className="bg-graphite-black rounded-none overflow-hidden border border-border-gray/20">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</Box>
<Stack gap={4}>
<Box display="flex" alignItems="center" gap={3}>
<Box w="1" h="4" bg="primary-accent" />
<Heading level={3} weight="bold" fontSize="xl" className="tracking-tight">
<Box w="1" h="3" bg="primary-accent" />
<Heading level={3} weight="bold" fontSize="lg" className="tracking-tighter uppercase">
{feature.title}
</Heading>
</Box>
<Text size="sm" color="text-gray-400" leading="relaxed" weight="normal">
<Text size="sm" color="text-gray-500" leading="relaxed" weight="normal" className="group-hover:text-gray-400 transition-colors">
{feature.description}
</Text>
</Stack>
@@ -77,7 +77,7 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
bottom="0"
left="0"
w="full"
h="1"
h="0.5"
bg="primary-accent"
className="scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left"
/>
@@ -87,24 +87,24 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
export function FeatureGrid() {
return (
<Section className="bg-graphite-black border-b border-border-gray/50 py-24">
<Section className="bg-graphite-black border-b border-border-gray/20 py-32">
<Container position="relative" zIndex={10}>
<Stack gap={16}>
<Box maxWidth="2xl">
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4} bg="primary-accent/5" py={1}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.3em]">
Engineered for Competition
</Text>
</Box>
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '4xl' }} className="tracking-tight">
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '5xl' }} className="tracking-tighter uppercase leading-none">
Building for League Racing
</Heading>
<Text size="lg" color="text-gray-400" block mt={6} leading="relaxed">
<Text size="lg" color="text-gray-500" block mt={6} leading="relaxed" className="border-l border-border-gray/20 pl-6">
Every feature is designed to reduce friction and increase immersion. Join our Discord to help shape the future of the platform.
</Text>
</Box>
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={8}>
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}

View File

@@ -29,11 +29,11 @@ export function LandingHero() {
<Box
position="absolute"
inset="0"
bg="url(/images/header.jpeg)"
backgroundImage="url(/images/header.jpeg)"
backgroundSize="cover"
backgroundPosition="center"
opacity={0.2}
style={{ transform: `translateY(${bgParallax * 0.5}px)` }}
transform={`translateY(${bgParallax * 0.5}px)`}
/>
{/* Robust gradient overlay */}
@@ -47,29 +47,30 @@ export function LandingHero() {
position="absolute"
inset="0"
bg="radial-gradient(circle at center, transparent 0%, #0C0D0F 100%)"
opacity={0.6}
opacity={0.8}
/>
<Glow color="primary" size="xl" position="center" opacity={0.1} />
<Glow color="primary" size="xl" position="center" opacity={0.15} />
<Container size="lg" position="relative" zIndex={10}>
<Stack gap={{ base: 8, md: 12 }}>
<Stack gap={6} maxWidth="3xl">
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={2}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={2} bg="primary-accent/5" py={1}>
<Text size="xs" weight="bold" color="text-primary-accent" uppercase letterSpacing="0.3em">
Precision Racing Infrastructure
</Text>
</Box>
<Heading
level={1}
fontSize={{ base: '3xl', sm: '4xl', md: '5xl', lg: '7xl' }}
fontSize={{ base: '4xl', sm: '5xl', md: '6xl', lg: '8xl' }}
weight="bold"
className="text-white leading-[1.1] tracking-tight"
color="text-white"
lineHeight="0.95"
letterSpacing="tighter"
>
Modern Motorsport <br />
<span className="text-primary-accent">Infrastructure.</span>
MODERN MOTORSPORT INFRASTRUCTURE.
</Heading>
<Text size={{ base: 'lg', md: 'xl' }} color="text-gray-400" weight="normal" leading="relaxed" className="max-w-2xl">
<Text size={{ base: 'lg', md: 'xl' }} color="text-gray-400" weight="normal" leading="relaxed" maxWidth="2xl" borderLeft borderStyle="solid" borderColor="border-gray" pl={6} opacity={0.3}>
GridPilot gives your league racing a real home. Results, standings, teams, and career progression engineered for precision and control.
</Text>
</Stack>
@@ -80,14 +81,23 @@ export function LandingHero() {
href={discordUrl}
variant="primary"
size="lg"
className="px-10 rounded-none uppercase tracking-widest text-xs font-bold"
px={12}
letterSpacing="0.2em"
fontSize="xs"
h="14"
>
Join the Grid
</Button>
<Button
variant="secondary"
size="lg"
className="px-10 rounded-none border-border-gray hover:border-primary-accent/50 uppercase tracking-widest text-xs font-bold"
px={12}
borderColor="border-gray"
hoverBorderColor="primary-accent/50"
letterSpacing="0.2em"
fontSize="xs"
h="14"
bg="transparent"
>
Explore Leagues
</Button>
@@ -99,7 +109,11 @@ export function LandingHero() {
gridCols={{ base: 1, sm: 2, lg: 4 }}
gap={8}
mt={12}
className="border-t border-border-gray/30 pt-12"
borderTop
borderStyle="solid"
borderColor="border-gray"
opacity={0.2}
pt={12}
>
{[
{ label: 'IDENTITY', text: 'Your racing career in one place', color: 'primary' },
@@ -107,14 +121,14 @@ export function LandingHero() {
{ label: 'PRECISION', text: 'Real-time results and standings', color: 'amber' },
{ label: 'COMMUNITY', text: 'Built for teams and leagues', color: 'green' }
].map((item) => (
<Stack key={item.label} gap={3} className="group">
<Box display="flex" alignItems="center" gap={2}>
<Box w="2" h="2" rounded="full" className={`bg-${item.color === 'primary' ? 'primary-accent' : item.color === 'aqua' ? 'telemetry-aqua' : item.color === 'amber' ? 'warning-amber' : 'success-green'}`} />
<Text size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-[0.2em] group-hover:text-white transition-colors">
<Stack key={item.label} gap={3} group cursor="default">
<Box display="flex" alignItems="center" gap={3}>
<Box w="1" h="3" bg={item.color === 'primary' ? 'primary-accent' : item.color === 'aqua' ? 'telemetry-aqua' : item.color === 'amber' ? 'warning-amber' : 'success-green'} />
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="0.2em" groupHoverTextColor="white" transition>
{item.label}
</Text>
</Box>
<Text size="sm" color="text-gray-400" className="group-hover:text-gray-200 transition-colors">
<Text size="sm" color="text-gray-500" groupHoverTextColor="gray-300" transition leading="relaxed">
{item.text}
</Text>
</Stack>

View File

@@ -1,39 +0,0 @@
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 h-16 md:h-20">
<div className="flex items-center space-x-6">
<Link href="/" className="inline-flex items-center group">
<div className="relative">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-7 transition-opacity group-hover:opacity-80"
priority
/>
<div className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary-accent transition-all group-hover:w-full" />
</div>
</Link>
<div className="hidden sm:flex items-center space-x-2 border-l border-border-gray/50 pl-6">
<div className="w-1.5 h-1.5 rounded-full bg-primary-accent animate-pulse" />
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-[0.2em] font-mono">
Motorsport Infrastructure
</Text>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="hidden md:flex items-center space-x-1 px-3 py-1 border border-border-gray/30 bg-panel-gray/20">
<Text size="xs" color="text-gray-600" weight="bold" font="mono" className="uppercase">Status:</Text>
<Text size="xs" color="text-success-green" weight="bold" font="mono" className="uppercase">Operational</Text>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { ChevronUp, ChevronDown, Minus } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface DeltaChipProps {
value: number;
type?: 'rank' | 'rating';
}
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
if (value === 0) {
return (
<Box display="flex" alignItems="center" gap={1} color="text-gray-600">
<Icon icon={Minus} size={3} />
<Text size="xs" font="mono">0</Text>
</Box>
);
}
const isPositive = value > 0;
const color = isPositive
? (type === 'rank' ? 'text-performance-green' : 'text-performance-green')
: (type === 'rank' ? 'text-error-red' : 'text-error-red');
// For rank, positive delta usually means dropping positions (e.g. +1 rank means 1st -> 2nd)
// But usually "Delta" in leaderboards means "positions gained/lost"
// Let's assume value is "positions gained" (positive = up, negative = down)
const IconComponent = isPositive ? ChevronUp : ChevronDown;
const absoluteValue = Math.abs(value);
return (
<Box
display="flex"
alignItems="center"
gap={0.5}
color={color}
bg={`${color.replace('text-', 'bg-')}/10`}
px={1.5}
py={0.5}
rounded="full"
>
<Icon icon={IconComponent} size={3} />
<Text size="xs" font="mono" weight="bold">
{absoluteValue}
</Text>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Search, Filter } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
interface LeaderboardFiltersBarProps {
searchQuery?: string;
onSearchChange?: (query: string) => void;
placeholder?: string;
children?: React.ReactNode;
}
export function LeaderboardFiltersBar({
searchQuery,
onSearchChange,
placeholder = 'Search drivers...',
children,
}: LeaderboardFiltersBarProps) {
return (
<Box
mb={6}
p={3}
bg="bg-deep-charcoal/40"
border
borderColor="border-charcoal-outline/50"
rounded="lg"
blur="sm"
>
<Stack direction="row" align="center" justify="between" gap={4}>
<Box position="relative" flexGrow={1} maxWidth="md">
<Box
position="absolute"
left="3"
top="1/2"
transform="translateY(-50%)"
pointerEvents="none"
zIndex={10}
>
<Icon icon={Search} size={4} color="text-gray-500" />
</Box>
<Box
as="input"
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
placeholder={placeholder}
w="full"
bg="bg-graphite-black/50"
border
borderColor="border-charcoal-outline"
rounded="md"
py={2}
pl={10}
pr={4}
fontSize="0.875rem"
color="text-white"
transition
hoverBorderColor="border-primary-blue/50"
/>
</Box>
<Stack direction="row" align="center" gap={4}>
{children}
<Box
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
bg="bg-graphite-black/30"
border
borderColor="border-charcoal-outline"
rounded="md"
cursor="pointer"
transition
hoverBg="bg-graphite-black/50"
hoverBorderColor="border-gray-600"
>
<Icon icon={Filter} size={3.5} color="text-gray-400" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text>
</Box>
</Stack>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { ArrowLeft, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface LeaderboardHeaderProps {
title: string;
description?: string;
icon?: LucideIcon;
onBack?: () => void;
backLabel?: string;
children?: React.ReactNode;
}
export function LeaderboardHeader({
title,
description,
icon,
onBack,
backLabel = 'Back',
children,
}: LeaderboardHeaderProps) {
return (
<Box mb={8}>
{onBack && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
{backLabel}
</Button>
</Box>
)}
<Stack direction="row" align="center" justify="between" gap={4}>
<Stack direction="row" align="center" gap={4}>
{icon && (
<Box
p={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.15), rgba(25, 140, 255, 0.05))"
border
borderColor="border-primary-blue/20"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon icon={icon} size={6} color="text-primary-blue" />
</Box>
)}
<Box>
<Heading level={1} weight="bold" letterSpacing="tight">{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1} size="sm">
{description}
</Text>
)}
</Box>
</Stack>
<Box>
{children}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { ArrowLeft, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface LeaderboardHeaderPanelProps {
title: string;
description?: string;
icon?: LucideIcon;
onBack?: () => void;
backLabel?: string;
children?: React.ReactNode;
}
export function LeaderboardHeaderPanel({
title,
description,
icon,
onBack,
backLabel = 'Back',
children,
}: LeaderboardHeaderPanelProps) {
return (
<Box mb={8}>
{onBack && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
{backLabel}
</Button>
</Box>
)}
<Stack direction="row" align="center" justify="between" gap={4}>
<Stack direction="row" align="center" gap={4}>
{icon && (
<Surface
variant="muted"
rounded="xl"
padding={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
border
borderColor="border-primary-blue/20"
>
<Icon icon={icon} size={7} color="text-primary-blue" />
</Surface>
)}
<Box>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Box>
</Stack>
{children}
</Stack>
</Box>
);
}

View File

@@ -1,116 +0,0 @@
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,173 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
interface PodiumDriver {
id: string;
name: string;
avatarUrl: string;
rating: number;
wins: number;
podiums: number;
}
interface LeaderboardPodiumProps {
podium: PodiumDriver[];
onDriverClick?: (id: string) => void;
}
export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumProps) {
// Order: 2nd, 1st, 3rd
const displayOrder = [1, 0, 2];
return (
<Box mb={12}>
<Box display="flex" alignItems="end" justifyContent="center" gap={4} maxWidth="4xl" mx="auto">
{displayOrder.map((index) => {
const driver = podium[index];
if (!driver) return <Box key={index} flexGrow={1} />;
const position = index + 1;
const isFirst = position === 1;
const config = {
1: { height: '48', scale: '1.1', zIndex: 10, shadow: 'shadow-warning-amber/20' },
2: { height: '36', scale: '1', zIndex: 0, shadow: 'shadow-white/5' },
3: { height: '28', scale: '0.9', zIndex: 0, shadow: 'shadow-white/5' },
}[position as 1 | 2 | 3];
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
display="flex"
flexDirection="col"
alignItems="center"
flexGrow={1}
transition
hoverScale
group
shadow={config.shadow}
zIndex={config.zIndex}
>
<Box position="relative" mb={4} transform={`scale(${config.scale})`}>
<Box
position="relative"
w={isFirst ? '32' : '24'}
h={isFirst ? '32' : '24'}
rounded="full"
overflow="hidden"
border
borderColor={isFirst ? 'border-warning-amber' : 'border-charcoal-outline'}
borderWidth="3px"
transition
groupHoverBorderColor="primary-blue"
shadow="xl"
>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={128}
height={128}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box
position="absolute"
bottom="-2"
left="50%"
w="10"
h="10"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
border
transform="translateX(-50%)"
borderWidth="2px"
bg={MedalDisplay.getBg(position)}
color={MedalDisplay.getColor(position)}
shadow="lg"
>
<Text size="sm" weight="bold">{position}</Text>
</Box>
</Box>
<Text
weight="bold"
color="text-white"
size={isFirst ? 'lg' : 'base'}
mb={1}
block
truncate
align="center"
px={2}
maxWidth="full"
groupHoverTextColor="text-primary-blue"
transition
>
{driver.name}
</Text>
<Text
font="mono"
weight="bold"
size={isFirst ? 'xl' : 'lg'}
block
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
>
{RatingDisplay.format(driver.rating)}
</Text>
<Stack direction="row" align="center" gap={3} mt={1}>
<Stack direction="row" align="center" gap={1}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Wins</Text>
<Text size="xs" weight="bold" color="text-performance-green">{driver.wins}</Text>
</Stack>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={1}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Podiums</Text>
<Text size="xs" weight="bold" color="text-white">{driver.podiums}</Text>
</Stack>
</Stack>
<Box
mt={6}
w="full"
h={config.height}
rounded="lg"
border
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
display="flex"
alignItems="center"
justifyContent="center"
blur="sm"
groupHoverBorderColor="primary-blue/30"
transition
>
<Text
weight="bold"
size="4xl"
color={MedalDisplay.getColor(position)}
opacity={0.1}
fontSize={isFirst ? '5rem' : '3.5rem'}
>
{position}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -1,105 +0,0 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeaderboardItem } from '@/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,49 @@
import React from 'react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { RankingRow } from './RankingRow';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface LeaderboardDriver {
id: string;
name: string;
avatarUrl: string;
rank: number;
rankDelta?: number;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
}
interface LeaderboardTableProps {
drivers: LeaderboardDriver[];
onDriverClick?: (id: string) => void;
}
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
return (
<LeaderboardTableShell isEmpty={drivers.length === 0} emptyMessage="No drivers found">
<Table>
<TableHead>
<TableRow>
<TableHeader w="32">Rank</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="center">Rating</TableHeader>
<TableHeader textAlign="center">Wins</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{drivers.map((driver) => (
<RankingRow
key={driver.id}
{...driver}
onClick={() => onDriverClick?.(driver.id)}
/>
))}
</TableBody>
</Table>
</LeaderboardTableShell>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface LeaderboardTableShellProps {
children: React.ReactNode;
isEmpty?: boolean;
emptyMessage?: string;
emptyDescription?: string;
}
export function LeaderboardTableShell({
children,
isEmpty,
emptyMessage = 'No data found',
emptyDescription = 'Try adjusting your filters or search query',
}: LeaderboardTableShellProps) {
if (isEmpty) {
return (
<Box
py={16}
textAlign="center"
bg="bg-iron-gray/20"
border
borderColor="border-charcoal-outline"
rounded="xl"
>
<Text size="4xl" block mb={4}>🔍</Text>
<Text color="text-gray-400" block mb={2} weight="semibold">{emptyMessage}</Text>
<Text size="sm" color="text-gray-500">{emptyDescription}</Text>
</Box>
);
}
return (
<Box
rounded="xl"
bg="bg-iron-gray/20"
border
borderColor="border-charcoal-outline"
overflow="hidden"
>
{children}
</Box>
);
}

View File

@@ -25,27 +25,29 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
padding={8}
position="relative"
overflow="hidden"
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
borderColor="border-yellow-500/20"
bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black"
borderColor="border-primary-blue/20"
>
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="purple" size="md" position="bottom-left" opacity={5} />
<Box position="relative" zIndex={10}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Surface
variant="muted"
rounded="xl"
padding={3}
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
<Box
p={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
border
borderColor="border-yellow-400/30"
borderColor="border-primary-blue/30"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon icon={Award} size={7} color="#facc15" />
</Surface>
<Icon icon={Award} size={7} color="text-primary-blue" />
</Box>
<Box>
<Heading level={1}>Leaderboards</Heading>
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
<Heading level={1} weight="bold" letterSpacing="tight">Leaderboards</Heading>
<Text color="text-gray-400" block mt={1} size="sm" uppercase letterSpacing="widest" weight="bold">Precision Performance Tracking</Text>
</Box>
</Stack>
@@ -53,25 +55,27 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
size="lg"
color="text-gray-400"
block
mb={6}
mb={8}
leading="relaxed"
maxWidth="42rem"
>
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics.
</Text>
<Stack direction="row" gap={3} wrap>
<Stack direction="row" gap={4} wrap>
<Button
variant="secondary"
variant="primary"
onClick={onNavigateToDrivers}
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
icon={<Icon icon={Trophy} size={4} />}
shadow="shadow-lg shadow-primary-blue/20"
>
Driver Rankings
</Button>
<Button
variant="secondary"
onClick={onNavigateToTeams}
icon={<Icon icon={Users} size={4} color="#a855f7" />}
icon={<Icon icon={Users} size={4} />}
hoverBg="bg-white/5"
>
Team Rankings
</Button>

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Crown, Medal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
interface RankMedalProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
}
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
const isTop3 = rank <= 3;
const sizeMap = {
sm: '7',
md: '8',
lg: '10',
};
const textSizeMap = {
sm: 'xs',
md: 'xs',
lg: 'sm',
} as const;
const iconSize = {
sm: 3,
md: 3.5,
lg: 4.5,
};
return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded="full"
border
h={sizeMap[size]}
w={sizeMap[size]}
bg={MedalDisplay.getBg(rank)}
color={MedalDisplay.getColor(rank)}
>
{isTop3 && showIcon ? (
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} />
) : (
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { RankMedal } from './RankMedal';
import { DeltaChip } from './DeltaChip';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
interface RankingRowProps {
id: string;
rank: number;
rankDelta?: number;
name: string;
avatarUrl: string;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
onClick?: () => void;
}
export function RankingRow({
rank,
rankDelta,
name,
avatarUrl,
nationality,
skillLevel,
racesCompleted,
rating,
wins,
onClick,
}: RankingRowProps) {
return (
<TableRow
clickable={!!onClick}
onClick={onClick}
group
>
<TableCell>
<Stack direction="row" align="center" gap={4}>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={rank} size="md" />
</Box>
{rankDelta !== undefined && (
<Box w="10">
<DeltaChip value={rankDelta} type="rank" />
</Box>
)}
</Stack>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={3}>
<Box
position="relative"
w="10"
h="10"
rounded="full"
overflow="hidden"
border
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue/50"
transition
>
<Image
src={avatarUrl}
alt={name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box minWidth="0">
<Text
weight="semibold"
color="text-white"
block
truncate
groupHoverTextColor="text-primary-blue"
transition
>
{name}
</Text>
<Stack direction="row" align="center" gap={2} mt={0.5}>
<Text size="xs" color="text-gray-500">{nationality}</Text>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Text size="xs" color="text-gray-500">{skillLevel}</Text>
</Stack>
</Box>
</Box>
</TableCell>
<TableCell textAlign="center">
<Text color="text-gray-400" font="mono">{racesCompleted}</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-primary-blue">
{RatingDisplay.format(rating)}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-performance-green">
{wins}
</Text>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
interface Season {
id: string;
name: string;
isActive?: boolean;
}
interface SeasonSelectorProps {
seasons: Season[];
selectedSeasonId: string;
onSeasonChange: (id: string) => void;
}
export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: SeasonSelectorProps) {
const options = seasons.map(season => ({
value: season.id,
label: `${season.name}${season.isActive ? ' (Active)' : ''}`
}));
return (
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" alignItems="center" gap={2} color="text-gray-500">
<Icon icon={Calendar} size={4} />
<Text size="xs" weight="bold" uppercase letterSpacing="wider">Season</Text>
</Box>
<Box width="48">
<Select
options={options}
value={selectedSeasonId}
onChange={(e) => onSeasonChange(e.target.value)}
fullWidth={true}
/>
</Box>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Users, Crown, ChevronRight } from 'lucide-react';
import { Users, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -8,8 +8,8 @@ import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { getMediaUrl } from '@/lib/utilities/media';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RankMedal } from './RankMedal';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface TeamLeaderboardPreviewProps {
teams: {
@@ -27,38 +27,57 @@ interface TeamLeaderboardPreviewProps {
}
export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) {
const top5 = teams; // Already sliced in builder when implemented
const top5 = teams;
return (
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<LeaderboardTableShell>
<Box
display="flex"
alignItems="center"
justifyContent="between"
px={5}
py={4}
borderBottom
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-gradient-to-br from-purple-500/15 to-purple-500/5"
border
borderColor="border-purple-500/20"
>
<Icon icon={Users} size={5} color="text-purple-400" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performing Teams</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToTeams}
size="sm"
hoverBg="bg-purple-500/10"
transition
>
<Stack direction="row" align="center" gap={2}>
<Text size="sm">View All</Text>
<Text size="sm" weight="medium">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</Box>
<Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top5.map((team) => {
<Stack gap={0}>
{top5.map((team, index) => {
const position = team.position;
const isLast = index === top5.length - 1;
return (
<Box
@@ -74,24 +93,29 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
hoverBg="bg-white/[0.02]"
group
borderBottom={!isLast}
borderColor="border-charcoal-outline/30"
>
<Box
display="flex"
h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={position} size="sm" />
</Box>
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
<Box
display="flex"
h="9"
w="9"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-graphite-black/50"
border
borderColor="border-charcoal-outline"
overflow="hidden"
groupHoverBorderColor="purple-400/50"
transition
>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
@@ -104,57 +128,45 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
</Box>
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
<Text
weight="semibold"
color="text-white"
truncate
groupHoverTextColor="text-purple-400"
transition
block
>
{team.name}
</Text>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
{team.category && (
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
<Text size="xs">{team.category}</Text>
<Text size="xs" weight="medium">{team.category}</Text>
</Box>
)}
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Box display="flex" alignItems="center" gap={1}>
<Icon icon={Users} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
</Box>
<Box as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className={SkillLevelDisplay.getColor(team.category || '')}
>
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
</Box>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Members
</Text>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text color="text-purple-400" font="mono" weight="bold" block size="sm">{team.memberCount}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Members</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
<Box textAlign="right" minWidth="12">
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
</Box>
</Box>
</Box>
);
})}
</Stack>
</Box>
</LeaderboardTableShell>
);
}
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { RankMedal } from './RankMedal';
import { getMediaUrl } from '@/lib/utilities/media';
interface TeamRankingRowProps {
id: string;
rank: number;
name: string;
logoUrl?: string;
rating: number;
wins: number;
races: number;
memberCount: number;
onClick?: () => void;
}
export function TeamRankingRow({
id,
rank,
name,
logoUrl,
rating,
wins,
races,
memberCount,
onClick,
}: TeamRankingRowProps) {
return (
<TableRow
clickable={!!onClick}
onClick={onClick}
group
>
<TableCell>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={rank} size="md" />
</Box>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={3}>
<Box
position="relative"
w="10"
h="10"
rounded="lg"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black/50"
groupHoverBorderColor="purple-400/50"
transition
>
<Image
src={logoUrl || getMediaUrl('team-logo', id)}
alt={name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box minWidth="0">
<Text
weight="semibold"
color="text-white"
block
truncate
groupHoverTextColor="text-purple-400"
transition
>
{name}
</Text>
<Text size="xs" color="text-gray-500" block mt={0.5}>
{memberCount} Members
</Text>
</Box>
</Box>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-purple-400">
{rating}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-performance-green">
{wins}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text color="text-gray-400" font="mono">{races}</Text>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react';
interface LeagueCardProps {
id: string;
name: string;
description?: string;
coverUrl: string;
logoUrl?: string;
gameName?: string;
memberCount: number;
maxMembers?: number;
nextRaceDate?: string;
championshipType: 'driver' | 'team' | 'nations' | 'trophy';
onClick?: () => void;
}
export function LeagueCard({
name,
description,
coverUrl,
logoUrl,
gameName,
memberCount,
maxMembers,
nextRaceDate,
championshipType,
onClick,
}: LeagueCardProps) {
const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0;
return (
<Box
as="article"
onClick={onClick}
position="relative"
display="flex"
flexDirection="col"
overflow="hidden"
border
borderColor="zinc-800"
bg="zinc-900/50"
hoverBorderColor="blue-500/30"
hoverBg="zinc-900"
transition
cursor="pointer"
group
>
{/* Cover Image */}
<Box position="relative" h="32" overflow="hidden">
<Box fullWidth fullHeight opacity={0.6}>
<Image
src={coverUrl}
alt={`${name} cover`}
fullWidth
fullHeight
objectFit="cover"
// eslint-disable-next-line gridpilot-rules/component-classification
className="transition-transform duration-500 group-hover:scale-105"
/>
</Box>
<Box position="absolute" inset="0" bg="linear-gradient(to top, #09090b, transparent)" />
{/* Game Badge */}
{gameName && (
<Box
position="absolute"
top="3"
left="3"
px={2}
py={1}
bg="zinc-900/80"
border
borderColor="white/10"
blur="sm"
>
<Text weight="bold" color="text-zinc-300" uppercase letterSpacing="0.05em" fontSize="10px">
{gameName}
</Text>
</Box>
)}
{/* Championship Icon */}
<Box
position="absolute"
top="3"
right="3"
p={1.5}
bg="zinc-900/80"
color="text-zinc-400"
border
borderColor="white/10"
blur="sm"
>
{championshipType === 'driver' && <Trophy size={14} />}
{championshipType === 'team' && <Users size={14} />}
</Box>
</Box>
{/* Content */}
<Box position="relative" display="flex" flexDirection="col" flexGrow={1} p={4} pt={6}>
{/* Logo */}
<Box
position="absolute"
top="-6"
left="4"
w="12"
h="12"
border
borderColor="zinc-800"
bg="zinc-950"
shadow="xl"
overflow="hidden"
>
{logoUrl ? (
<Image src={logoUrl} alt={`${name} logo`} fullWidth fullHeight objectFit="cover" />
) : (
<Box fullWidth fullHeight display="flex" alignItems="center" justifyContent="center" bg="zinc-900" color="text-zinc-700">
<Trophy size={20} />
</Box>
)}
</Box>
<Box display="flex" flexDirection="col" gap={1} mb={4}>
<Heading level={3} fontSize="lg" weight="bold" color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
className="group-hover:text-blue-400 transition-colors truncate"
>
{name}
</Heading>
<Text size="xs" color="text-zinc-500" lineClamp={2} leading="relaxed" h="8">
{description || 'No description available'}
</Text>
</Box>
{/* Stats */}
<Box display="flex" flexDirection="col" gap={3} mt="auto">
<Box display="flex" flexDirection="col" gap={1.5}>
<Box display="flex" justifyContent="between">
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Drivers</Text>
<Text color="text-zinc-400" font="mono" fontSize="10px">{memberCount}/{maxMembers || '∞'}</Text>
</Box>
<Box h="1" bg="zinc-800" overflow="hidden">
<Box
h="full"
transition
bg={fillPercentage > 90 ? 'bg-amber-500' : 'bg-blue-500'}
w={`${Math.min(fillPercentage, 100)}%`}
/>
</Box>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="zinc-800/50">
<Box display="flex" alignItems="center" gap={2} color="text-zinc-500">
<Calendar size={12} />
<Text weight="bold" uppercase font="mono" fontSize="10px">
{nextRaceDate || 'TBD'}
</Text>
</Box>
<Box display="flex" alignItems="center" gap={1} color="text-zinc-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="group-hover:text-blue-400 transition-colors"
>
<Text weight="bold" uppercase letterSpacing="widest" fontSize="10px">View</Text>
<Box
// eslint-disable-next-line gridpilot-rules/component-classification
className="transition-transform group-hover:translate-x-0.5"
>
<ChevronRight size={12} />
</Box>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { MembershipStatus } from './MembershipStatus';
import { getMediaUrl } from '@/lib/utilities/media';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { MembershipStatus } from './MembershipStatus';
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
name: string;
logoUrl?: string;
@@ -27,33 +29,61 @@ export function LeagueHeader({
description,
mainSponsor,
}: LeagueHeaderProps) {
const logoUrl = getMediaUrl('league-logo', leagueId);
return (
<UiLeagueHeader
name={leagueName}
description={description}
logoUrl={logoUrl}
statusContent={<MembershipStatus leagueId={leagueId} />}
sponsorContent={
mainSponsor ? (
mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-primary-blue"
hoverTextColor="text-primary-blue/80"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-primary-blue">{mainSponsor.name}</Text>
)
) : undefined
}
/>
<Box as="header" mb={8}>
<Stack direction="row" align="center" gap={6}>
<Box
position="relative"
w="20"
h="20"
overflow="hidden"
border
borderColor="white/10"
bg="zinc-900"
shadow="2xl"
>
<Image
src={`/api/media/league-logo/${leagueId}`}
alt={`${leagueName} logo`}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Stack gap={1}>
<Stack direction="row" align="center" gap={4}>
<Heading level={1} fontSize="3xl" weight="bold" color="text-white">
{leagueName}
{mainSponsor && (
<Text ml={3} size="lg" weight="normal" color="text-zinc-500">
by{' '}
{mainSponsor.websiteUrl ? (
<Box
as="a"
href={mainSponsor.websiteUrl}
target="_blank"
rel="noreferrer"
color="text-blue-500"
hoverTextColor="text-blue-400"
transition
>
{mainSponsor.name}
</Box>
) : (
<Text color="text-blue-500">{mainSponsor.name}</Text>
)}
</Text>
)}
</Heading>
<MembershipStatus leagueId={leagueId} />
</Stack>
{description && (
<Text color="text-zinc-400" size="sm" maxWidth="2xl" block leading="relaxed">
{description}
</Text>
)}
</Stack>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Trophy, Users, Timer, Activity, type LucideIcon } from 'lucide-react';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
interface LeagueHeaderPanelProps {
viewData: LeagueDetailViewData;
}
export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) {
return (
<Surface variant="dark" border rounded="lg" padding={6} position="relative" overflow="hidden">
{/* Background Accent */}
<Box
position="absolute"
top={0}
right={0}
w="300px"
h="100%"
bg="bg-gradient-to-l from-primary-blue/5 to-transparent"
pointerEvents="none"
/>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={6}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3}>
<Box p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
<Icon icon={Trophy} size={6} color="text-primary-blue" />
</Box>
<Heading level={1} letterSpacing="tight">
{viewData.name}
</Heading>
</Stack>
<Text color="text-gray-400" size="sm" maxWidth="42rem">
{viewData.description}
</Text>
</Stack>
<Stack direction="row" gap={8} wrap>
<StatItem
icon={Users}
label="Members"
value={viewData.info.membersCount.toString()}
color="text-primary-blue"
/>
<StatItem
icon={Timer}
label="Races"
value={viewData.info.racesCount.toString()}
color="text-neon-aqua"
/>
<StatItem
icon={Activity}
label="Avg SOF"
value={(viewData.info.avgSOF ?? 0).toString()}
color="text-performance-green"
/>
</Stack>
</Stack>
</Surface>
);
}
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
return (
<Stack gap={1}>
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={icon} size={3.5} color="text-gray-500" />
<Text size="xs" color="text-gray-500" weight="medium" letterSpacing="wider" display="block">
{label.toUpperCase()}
</Text>
</Stack>
<Text size="xl" weight="bold" color={color} lineHeight="none">
{value}
</Text>
</Stack>
);
}

View File

@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Link } from '@/ui/Link';
interface Tab {
label: string;
href: string;
exact?: boolean;
}
interface LeagueNavTabsProps {
tabs: Tab[];
currentPathname: string;
}
export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) {
return (
<Box as="nav" borderBottom borderColor="zinc-800" mb={6}>
<Stack as="ul" direction="row" gap={8} overflow="auto" hideScrollbar>
{tabs.map((tab) => {
const isActive = tab.exact
? currentPathname === tab.href
: currentPathname.startsWith(tab.href);
return (
<Box as="li" key={tab.href} position="relative">
<Link
href={tab.href}
variant="ghost"
pb={4}
display="block"
size="sm"
weight="medium"
color={isActive ? 'text-blue-500' : 'text-zinc-400'}
hoverTextColor={isActive ? 'text-blue-500' : 'text-zinc-200'}
transition
>
{tab.label}
</Link>
{isActive && (
<Box
position="absolute"
bottom="0"
left="0"
right="0"
h="0.5"
bg="bg-blue-500"
/>
)}
</Box>
);
})}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Shield, Info } from 'lucide-react';
interface Rule {
id: string;
title: string;
content: string;
}
interface LeagueRulesPanelProps {
rules: Rule[];
}
export function LeagueRulesPanel({ rules }: LeagueRulesPanelProps) {
return (
<Box as="section">
<Stack gap={8}>
<Box display="flex" alignItems="start" gap={4} p={4} bg="blue-500/5" border borderColor="blue-500/20">
<Box color="text-blue-500" mt={0.5}><Info size={20} /></Box>
<Stack gap={1}>
<Text size="sm" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">Code of Conduct</Text>
<Text size="sm" color="text-zinc-400" leading="relaxed">
All drivers are expected to maintain a high standard of sportsmanship.
Intentional wrecking or abusive behavior will result in immediate disqualification.
</Text>
</Stack>
</Box>
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
{rules.map((rule) => (
<Box as="article" key={rule.id} display="flex" flexDirection="col" gap={3} p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Box display="flex" alignItems="center" gap={3}>
<Box p={2} bg="zinc-800" color="text-zinc-400">
<Shield size={18} />
</Box>
<Heading level={3} fontSize="md" weight="bold" color="text-white">{rule.title}</Heading>
</Box>
<Text size="sm" color="text-zinc-400" leading="relaxed">
{rule.content}
</Text>
</Box>
))}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { MapPin, Clock } from 'lucide-react';
interface RaceEvent {
id: string;
title: string;
trackName: string;
date: string;
time: string;
status: 'upcoming' | 'live' | 'completed';
}
interface LeagueSchedulePanelProps {
events: RaceEvent[];
}
export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) {
return (
<Box as="section">
<Stack gap={4}>
{events.map((event) => (
<Box
as="article"
key={event.id}
display="flex"
alignItems="center"
gap={6}
p={4}
border
borderColor="zinc-800"
bg="zinc-900/50"
hoverBorderColor="zinc-700"
transition
>
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" w="16" h="16" borderRight borderColor="zinc-800" pr={6}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase>
{new Date(event.date).toLocaleDateString('en-US', { month: 'short' })}
</Text>
<Text size="2xl" weight="bold" color="text-white">
{new Date(event.date).toLocaleDateString('en-US', { day: 'numeric' })}
</Text>
</Box>
<Box flexGrow={1}>
<Heading level={3} fontSize="lg" weight="bold" color="text-white">{event.title}</Heading>
<Stack direction="row" gap={4} mt={1}>
<Box display="flex" alignItems="center" gap={1.5}>
<Box color="text-zinc-600"><MapPin size={14} /></Box>
<Text size="sm" color="text-zinc-400">{event.trackName}</Text>
</Box>
<Box display="flex" alignItems="center" gap={1.5}>
<Box color="text-zinc-600"><Clock size={14} /></Box>
<Text size="sm" color="text-zinc-400">{event.time}</Text>
</Box>
</Stack>
</Box>
<Box display="flex" alignItems="center" gap={3}>
{event.status === 'live' && (
<Box display="flex" alignItems="center" gap={1.5} px={2} py={1} bg="red-500/10" border borderColor="red-500/20">
<Box w="1.5" h="1.5" rounded="full" bg="bg-red-500" animate="pulse" />
<Text size="xs" weight="bold" color="text-red-500" uppercase letterSpacing="0.05em">
Live
</Text>
</Box>
)}
{event.status === 'upcoming' && (
<Box px={2} py={1} bg="blue-500/10" border borderColor="blue-500/20">
<Text size="xs" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">
Upcoming
</Text>
</Box>
)}
{event.status === 'completed' && (
<Box px={2} py={1} bg="zinc-800" border borderColor="zinc-700">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="0.05em">
Results
</Text>
</Box>
)}
</Box>
</Box>
))}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import React, { useCallback, useRef, useState } from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { routes } from '@/lib/routing/RouteConfig';
interface LeagueSliderProps {
title: string;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
export function LeagueSlider({
title,
icon: IconComp,
description,
leagues,
iconColor = 'text-primary-blue',
}: LeagueSliderProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScrollButtons = useCallback(() => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
}
}, []);
const scroll = useCallback((direction: 'left' | 'right') => {
if (scrollRef.current) {
const cardWidth = 340;
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}, []);
if (leagues.length === 0) return null;
return (
<Box mb={10}>
{/* Section header */}
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
<Icon icon={IconComp} size={5} color={iconColor} />
</Box>
<Box>
<Heading level={2}>{title}</Heading>
<Text size="xs" color="text-gray-500">{description}</Text>
</Box>
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
{leagues.length}
</Box>
</Stack>
{/* Navigation arrows */}
<Stack direction="row" align="center" gap={2}>
<Button
variant="secondary"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
size="sm"
w="2rem"
h="2rem"
>
<Icon icon={ChevronLeft} size={4} />
</Button>
<Button
variant="secondary"
onClick={() => scroll('right')}
disabled={!canScrollRight}
size="sm"
w="2rem"
h="2rem"
>
<Icon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
{/* Scrollable container with fade edges */}
<Box position="relative">
<Box
position="absolute"
top={0}
bottom={4}
left={0}
w="3rem"
bg="bg-gradient-to-r from-deep-graphite to-transparent"
zIndex={10}
pointerEvents="none"
/>
<Box
position="absolute"
top={0}
bottom={4}
right={0}
w="3rem"
bg="bg-gradient-to-l from-deep-graphite to-transparent"
zIndex={10}
pointerEvents="none"
/>
<Box
ref={scrollRef}
onScroll={checkScrollButtons}
display="flex"
gap={4}
overflow="auto"
pb={4}
px={4}
hideScrollbar
>
{leagues.map((league) => {
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<Box key={league.id} flexShrink={0} w="320px">
<Link href={routes.league.detail(league.id)} block>
<LeagueCard league={viewModel} />
</Link>
</Box>
);
})}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface StandingEntry {
position: number;
driverName: string;
teamName?: string;
points: number;
wins: number;
podiums: number;
gap: string;
}
interface LeagueStandingsTableProps {
standings: StandingEntry[];
}
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
return (
<Box as="section" overflow="hidden" border borderColor="zinc-800" bg="zinc-900/50">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="zinc-800" bg="zinc-900/80">
<Box as="th" px={4} py={3} w="12">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Pos</Text>
</Box>
<Box as="th" px={4} py={3}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Driver</Text>
</Box>
<Box as="th" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="center">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Wins</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="center">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Podiums</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="right">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
</Box>
<Box as="th" px={4} py={3} textAlign="right">
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Gap</Text>
</Box>
</Box>
</Box>
<Box as="tbody">
{standings.map((entry) => (
<Box as="tr" key={entry.driverName} borderBottom borderColor="zinc-800" hoverBg="zinc-800/50" transition>
<Box as="td" px={4} py={3}>
<Text size="sm" color="text-zinc-400" font="mono">{entry.position}</Text>
</Box>
<Box as="td" px={4} py={3}>
<Text size="sm" weight="medium" color="text-zinc-200">{entry.driverName}</Text>
</Box>
<Box as="td" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="center">
<Text size="sm" color="text-zinc-400">{entry.wins}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="center">
<Text size="sm" color="text-zinc-400">{entry.podiums}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="right">
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
</Box>
<Box as="td" px={4} py={3} textAlign="right">
<Text size="sm" color="text-zinc-500" font="mono">{entry.gap}</Text>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Calendar, MapPin, ChevronRight } from 'lucide-react';
import { Surface } from '@/ui/Surface';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Link } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
interface RaceEntry {
id: string;
name: string;
track: string;
scheduledAt: string;
status: 'upcoming' | 'live' | 'completed';
}
interface ScheduleTableProps {
races: RaceEntry[];
}
export function ScheduleTable({ races }: ScheduleTableProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Table>
<TableHead>
<TableRow>
<TableHeader>Race</TableHeader>
<TableHeader>Track</TableHeader>
<TableHeader>Date</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader textAlign="right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{races.length === 0 ? (
<TableRow>
<TableCell colSpan={5} textAlign="center" py={12}>
<Text color="text-gray-500">No races scheduled yet.</Text>
</TableCell>
</TableRow>
) : (
races.map((race) => (
<TableRow key={race.id}>
<TableCell>
<Text weight="medium" color="text-white">{race.name}</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
<Icon icon={MapPin} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">{race.track}</Text>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={3.5} color="text-gray-500" />
<Text size="sm" color="text-gray-400">
{new Date(race.scheduledAt).toLocaleDateString()}
</Text>
</Stack>
</TableCell>
<TableCell>
<StatusBadge status={race.status} />
</TableCell>
<TableCell textAlign="right">
<Link
href={routes.race.detail(race.id)}
variant="primary"
size="xs"
weight="bold"
letterSpacing="wider"
>
<Stack direction="row" align="center" gap={1}>
<Text>DETAILS</Text>
<Icon icon={ChevronRight} size={3} />
</Stack>
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Surface>
);
}
function StatusBadge({ status }: { status: RaceEntry['status'] }) {
const styles = {
upcoming: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
live: 'bg-performance-green/10 text-performance-green border-performance-green/20 animate-pulse',
completed: 'bg-primary-blue/10 text-primary-blue border-primary-blue/20',
};
return (
<Box
as="span"
px={2}
py={0.5}
rounded="full"
fontSize="10px"
border
bg={styles[status].split(' ')[0]}
color={styles[status].split(' ')[1]}
borderColor={styles[status].split(' ')[2]}
animate={status === 'live' ? 'pulse' : 'none'}
letterSpacing="widest"
weight="bold"
display="inline-block"
>
{status.toUpperCase()}
</Box>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from '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';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Trophy, TrendingUp } from 'lucide-react';
interface StandingsEntry {
position: number;
driverName: string;
points: number;
wins: number;
podiums: number;
change?: number;
}
interface StandingsTableShellProps {
standings: StandingsEntry[];
title?: string;
}
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={Trophy} size={4} color="text-warning-amber" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
{title.toUpperCase()}
</Text>
</Stack>
<Box px={2} py={0.5} rounded="md" bg="bg-charcoal-outline/50">
<Text size="xs" color="text-gray-400" weight="medium">{standings.length} Drivers</Text>
</Box>
</Stack>
</Box>
<Table>
<TableHead>
<TableRow>
<TableHeader w="4rem">Pos</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader textAlign="center">Wins</TableHeader>
<TableHeader textAlign="center">Podiums</TableHeader>
<TableHeader textAlign="right">Points</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{standings.map((entry) => (
<TableRow key={entry.driverName}>
<TableCell>
<PositionBadge position={entry.position} />
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Text weight="bold" color="text-white">{entry.driverName}</Text>
{entry.change !== undefined && entry.change !== 0 && (
<Stack direction="row" align="center" gap={0.5}>
<Icon
icon={TrendingUp}
size={3}
color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}
transform={entry.change < 0 ? 'rotate(180deg)' : undefined}
/>
<Text size="xs" color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}>
{Math.abs(entry.change)}
</Text>
</Stack>
)}
</Stack>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" color={entry.wins > 0 ? 'text-white' : 'text-gray-500'}>{entry.wins}</Text>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" color={entry.podiums > 0 ? 'text-white' : 'text-gray-500'}>{entry.podiums}</Text>
</TableCell>
<TableCell textAlign="right">
<Text weight="bold" color="text-primary-blue">{entry.points}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Surface>
);
}
function PositionBadge({ position }: { position: number }) {
const isPodium = position <= 3;
const colors = {
1: 'text-warning-amber bg-warning-amber/10 border-warning-amber/20',
2: 'text-gray-300 bg-gray-300/10 border-gray-300/20',
3: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
};
return (
<Box
center
w={8}
h={8}
rounded="md"
border={isPodium}
bg={isPodium ? colors[position as keyof typeof colors].split(' ')[1] : undefined}
color={isPodium ? colors[position as keyof typeof colors].split(' ')[0] : 'text-gray-500'}
borderColor={isPodium ? colors[position as keyof typeof colors].split(' ')[2] : undefined}
>
<Text size="sm" weight="bold">
{position}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import React from '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';
import { Clock, ShieldAlert, MessageSquare } from 'lucide-react';
import { Button } from '@/ui/Button';
interface Protest {
id: string;
raceName: string;
protestingDriver: string;
accusedDriver: string;
description: string;
submittedAt: string;
status: 'pending' | 'under_review' | 'resolved' | 'rejected';
}
interface StewardingQueuePanelProps {
protests: Protest[];
onReview: (id: string) => void;
}
export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePanelProps) {
return (
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={2}>
<Icon icon={ShieldAlert} size={4} color="text-error-red" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
STEWARDING QUEUE
</Text>
</Stack>
<Box px={2} py={0.5} rounded="md" bg="bg-error-red/10" border borderColor="border-error-red/20">
<Text size="xs" color="text-error-red" weight="bold">
{protests.filter(p => p.status === 'pending').length} Pending
</Text>
</Box>
</Stack>
</Box>
<Stack gap={0}>
{protests.length === 0 ? (
<Box py={12} center>
<Stack align="center" gap={3}>
<Icon icon={ShieldAlert} size={8} color="text-gray-700" />
<Text color="text-gray-500">No active protests in the queue.</Text>
</Stack>
</Box>
) : (
protests.map((protest) => (
<Box key={protest.id} p={6} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="start" gap={4}>
<Stack gap={3} flexGrow={1}>
<Stack direction="row" align="center" gap={2}>
<StatusIndicator status={protest.status} />
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">
{protest.raceName.toUpperCase()}
</Text>
<Box w={1} h={1} rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Clock} size={3} color="text-gray-600" />
<Text size="xs" color="text-gray-500">
{new Date(protest.submittedAt).toLocaleString()}
</Text>
</Stack>
</Stack>
<Box>
<Stack direction="row" align="center" gap={2} wrap>
<Text weight="bold" color="text-white">{protest.protestingDriver}</Text>
<Text size="xs" color="text-gray-600" weight="bold">VS</Text>
<Text weight="bold" color="text-white">{protest.accusedDriver}</Text>
</Stack>
<Text size="sm" color="text-gray-400" mt={2} lineClamp={2}>
&ldquo;{protest.description}&rdquo;
</Text>
</Box>
</Stack>
<Stack direction="row" align="center" gap={3} w={{ base: 'full', md: 'auto' }}>
<Button
variant="secondary"
size="sm"
onClick={() => onReview(protest.id)}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={MessageSquare} size={3.5} />
<Text>Review</Text>
</Stack>
</Button>
</Stack>
</Stack>
</Box>
))
)}
</Stack>
</Surface>
);
}
function StatusIndicator({ status }: { status: Protest['status'] }) {
const colors = {
pending: 'bg-warning-amber',
under_review: 'bg-primary-blue',
resolved: 'bg-performance-green',
rejected: 'bg-gray-500',
};
return (
<Box w={2} h={2} rounded="full" bg={colors[status]} animate={status === 'under_review' ? 'pulse' : 'none'} />
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import React from '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';
import { Button } from '@/ui/Button';
import { Wallet, ArrowUpRight, ArrowDownLeft, History } from 'lucide-react';
interface Transaction {
id: string;
type: 'credit' | 'debit';
amount: number;
description: string;
date: string;
}
interface WalletSummaryPanelProps {
balance: number;
currency: string;
transactions: Transaction[];
onDeposit: () => void;
onWithdraw: () => void;
}
export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
return (
<Stack gap={6}>
<Surface variant="dark" border rounded="lg" padding={8} position="relative" overflow="hidden">
{/* Background Pattern */}
<Box
position="absolute"
top="-5rem"
right="-5rem"
w="12rem"
h="12rem"
bg="bg-primary-blue/5"
rounded="full"
blur="xl"
pointerEvents="none"
/>
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={8}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Wallet} size={4} color="text-gray-500" />
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">AVAILABLE BALANCE</Text>
</Stack>
<Stack direction="row" align="baseline" gap={2}>
<Text size="4xl" weight="bold" color="text-white">
{balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</Text>
<Text size="xl" weight="medium" color="text-gray-500">{currency}</Text>
</Stack>
</Stack>
<Stack direction="row" gap={3}>
<Button variant="primary" onClick={onDeposit}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={ArrowDownLeft} size={4} />
<Text>Deposit</Text>
</Stack>
</Button>
<Button variant="secondary" onClick={onWithdraw}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
</Stack>
</Surface>
<Surface variant="dark" border rounded="lg" overflow="hidden">
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Stack direction="row" align="center" gap={2}>
<Icon icon={History} size={4} color="text-primary-blue" />
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
RECENT TRANSACTIONS
</Text>
</Stack>
</Box>
<Stack gap={0}>
{transactions.length === 0 ? (
<Box py={12} center>
<Text color="text-gray-500">No recent transactions.</Text>
</Box>
) : (
transactions.map((tx) => (
<Box key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
<Stack direction="row" justify="between" align="center">
<Stack direction="row" align="center" gap={4}>
<Box
center
w={10}
h={10}
rounded="full"
bg={tx.type === 'credit' ? 'bg-performance-green/10' : 'bg-error-red/10'}
>
<Icon
icon={tx.type === 'credit' ? ArrowDownLeft : ArrowUpRight}
size={4}
color={tx.type === 'credit' ? 'text-performance-green' : 'text-error-red'}
/>
</Box>
<Stack gap={0.5}>
<Text weight="medium" color="text-white">{tx.description}</Text>
<Text size="xs" color="text-gray-500">{new Date(tx.date).toLocaleDateString()}</Text>
</Stack>
</Stack>
<Text
weight="bold"
color={tx.type === 'credit' ? 'text-performance-green' : 'text-white'}
>
{tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)}
</Text>
</Stack>
</Box>
))
)}
</Stack>
</Surface>
</Stack>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Image } from '@/ui/Image';
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
export interface MediaCardProps {
src?: string;
alt?: string;
title?: string;
subtitle?: string;
aspectRatio?: string;
isLoading?: boolean;
error?: string;
onClick?: () => void;
actions?: React.ReactNode;
}
export function MediaCard({
src,
alt = 'Media asset',
title,
subtitle,
aspectRatio = '16/9',
isLoading,
error,
onClick,
actions,
}: MediaCardProps) {
return (
<Box
as={motion.div}
whileHover={{ y: -4 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} // Fast ease-out
h="full"
>
<Box
display="flex"
flexDirection="col"
bg="bg-charcoal/40"
border
borderColor="border-charcoal-outline/30"
rounded="lg"
overflow="hidden"
cursor={onClick ? 'pointer' : 'default'}
onClick={onClick}
group
h="full"
>
<Box position="relative" width="full" aspectRatio={aspectRatio}>
{isLoading ? (
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
) : error ? (
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
) : src ? (
<Box w="full" h="full" overflow="hidden">
<Image
src={src}
alt={alt}
objectFit="cover"
fullWidth
fullHeight
/>
</Box>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
{actions && (
<Box
position="absolute"
top="2"
right="2"
display="flex"
gap={2}
opacity={0}
groupHoverOpacity={1}
transition
>
{actions}
</Box>
)}
</Box>
{(title || subtitle) && (
<Box p={3} borderTop borderColor="border-charcoal-outline/20">
{title && (
<Text block size="sm" weight="semibold" truncate color="text-white">
{title}
</Text>
)}
{subtitle && (
<Text block size="xs" color="text-gray-400" truncate mt={0.5}>
{subtitle}
</Text>
)}
</Box>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Search, Grid, List } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Input } from '@/ui/Input';
import { IconButton } from '@/ui/IconButton';
import { Select } from '@/ui/Select';
export interface MediaFiltersBarProps {
searchQuery: string;
onSearchChange: (query: string) => void;
category: string;
onCategoryChange: (category: string) => void;
categories: { label: string; value: string }[];
viewMode?: 'grid' | 'list';
onViewModeChange?: (mode: 'grid' | 'list') => void;
}
export function MediaFiltersBar({
searchQuery,
onSearchChange,
category,
onCategoryChange,
categories,
viewMode = 'grid',
onViewModeChange,
}: MediaFiltersBarProps) {
return (
<Box
display="flex"
flexDirection={{ base: 'col', md: 'row' }}
alignItems={{ base: 'stretch', md: 'center' }}
justifyContent="between"
gap={4}
p={4}
bg="bg-charcoal/20"
border
borderColor="border-charcoal-outline/20"
rounded="xl"
>
<Box display="flex" flexGrow={1} maxWidth={{ md: 'md' }}>
<Input
placeholder="Search media assets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search size={18} />}
/>
</Box>
<Box display="flex" alignItems="center" gap={3}>
<Box w="40">
<Select
value={category}
onChange={(e) => onCategoryChange(e.target.value)}
options={categories}
/>
</Box>
{onViewModeChange && (
<Box display="flex" bg="bg-charcoal/40" p={1} rounded="lg" border borderColor="border-charcoal-outline/20">
<IconButton
icon={Grid}
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('grid')}
color={viewMode === 'grid' ? 'text-white' : 'text-gray-400'}
backgroundColor={viewMode === 'grid' ? 'bg-blue-600' : undefined}
/>
<IconButton
icon={List}
variant={viewMode === 'list' ? 'primary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('list')}
color={viewMode === 'list' ? 'text-white' : 'text-gray-400'}
backgroundColor={viewMode === 'list' ? 'bg-blue-600' : undefined}
/>
</Box>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import React, { useState } from 'react';
import { Box } from '@/ui/Box';
import { MediaGrid } from './MediaGrid';
import { MediaCard } from './MediaCard';
import { MediaFiltersBar } from './MediaFiltersBar';
import { MediaViewerModal } from './MediaViewerModal';
import { Text } from '@/ui/Text';
export interface MediaAsset {
id: string;
src: string;
title: string;
category: string;
date?: string;
dimensions?: string;
}
export interface MediaGalleryProps {
assets: MediaAsset[];
categories: { label: string; value: string }[];
title?: string;
description?: string;
}
export function MediaGallery({
assets,
categories,
title = 'Media Gallery',
description,
}: MediaGalleryProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [viewerAsset, setViewerAsset] = useState<MediaAsset | null>(null);
const filteredAssets = assets.filter((asset) => {
const matchesSearch = asset.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || asset.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const handleNext = () => {
if (!viewerAsset) return;
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
if (currentIndex < filteredAssets.length - 1) {
const nextAsset = filteredAssets[currentIndex + 1];
if (nextAsset) setViewerAsset(nextAsset);
}
};
const handlePrev = () => {
if (!viewerAsset) return;
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
if (currentIndex > 0) {
const prevAsset = filteredAssets[currentIndex - 1];
if (prevAsset) setViewerAsset(prevAsset);
}
};
return (
<Box display="flex" flexDirection="col" gap={6}>
<Box>
<Text as="h1" size="3xl" weight="bold" color="text-white">
{title}
</Text>
{description && (
<Text block size="sm" color="text-gray-400" mt={1}>
{description}
</Text>
)}
</Box>
<MediaFiltersBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
category={selectedCategory}
onCategoryChange={setSelectedCategory}
categories={categories}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{filteredAssets.length > 0 ? (
<MediaGrid columns={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : { base: 1 }}>
{filteredAssets.map((asset) => (
<MediaCard
key={asset.id}
src={asset.src}
title={asset.title}
subtitle={`${asset.category}${asset.dimensions ? `${asset.dimensions}` : ''}`}
onClick={() => setViewerAsset(asset)}
/>
))}
</MediaGrid>
) : (
<Box py={20} center bg="bg-charcoal/10" rounded="xl" border borderStyle="dashed" borderColor="border-charcoal-outline/20">
<Text color="text-gray-500">No media assets found matching your criteria.</Text>
</Box>
)}
<MediaViewerModal
isOpen={!!viewerAsset}
onClose={() => setViewerAsset(null)}
src={viewerAsset?.src}
alt={viewerAsset?.title}
title={viewerAsset?.title}
onNext={handleNext}
onPrev={handlePrev}
/>
</Box>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Box } from '@/ui/Box';
export interface MediaGridProps {
children: React.ReactNode;
columns?: {
base?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
};
gap?: 2 | 3 | 4 | 6 | 8;
}
export function MediaGrid({
children,
columns = { base: 1, sm: 2, md: 3, lg: 4 },
gap = 4,
}: MediaGridProps) {
return (
<Box
display="grid"
responsiveGridCols={{
base: columns.base,
md: columns.md,
lg: columns.lg,
}}
gap={gap}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { Box } from '@/ui/Box';
import { IconButton } from '@/ui/IconButton';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
export interface MediaViewerModalProps {
isOpen: boolean;
onClose: () => void;
src?: string;
alt?: string;
title?: string;
onNext?: () => void;
onPrev?: () => void;
}
export function MediaViewerModal({
isOpen,
onClose,
src,
alt = 'Media viewer',
title,
onNext,
onPrev,
}: MediaViewerModalProps) {
return (
<AnimatePresence>
{isOpen && (
<Box
position="fixed"
inset="0"
zIndex={100}
display="flex"
alignItems="center"
justifyContent="center"
p={{ base: 4, md: 8 }}
>
{/* Backdrop with frosted blur */}
<Box
as={motion.div}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
position="absolute"
inset="0"
bg="bg-black/80"
blur="md"
onClick={onClose}
/>
{/* Content Container */}
<Box
as={motion.div}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
position="relative"
zIndex={10}
w="full"
maxWidth="6xl"
maxHeight="full"
display="flex"
flexDirection="col"
>
{/* Header */}
<Box
display="flex"
alignItems="center"
justifyContent="between"
mb={4}
color="text-white"
>
<Box>
{title && (
<Text size="lg" weight="semibold" color="text-white">
{title}
</Text>
)}
</Box>
<Box display="flex" gap={2}>
<IconButton
icon={Download}
variant="secondary"
size="sm"
onClick={() => src && window.open(src, '_blank')}
color="text-white"
backgroundColor="bg-white/10"
/>
<IconButton
icon={X}
variant="secondary"
size="sm"
onClick={onClose}
color="text-white"
backgroundColor="bg-white/10"
/>
</Box>
</Box>
{/* Image Area */}
<Box
position="relative"
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-black/40"
rounded="xl"
overflow="hidden"
border
borderColor="border-white/10"
flexGrow={1}
minHeight="0"
>
{src ? (
<Image
src={src}
alt={alt}
objectFit="contain"
fullWidth
fullHeight
/>
) : (
<Box p={20}>
<Text color="text-gray-500">No image selected</Text>
</Box>
)}
{/* Navigation Controls */}
{onPrev && (
<Box position="absolute" left="4" top="1/2" translateY="-1/2">
<IconButton
icon={ChevronLeft}
variant="secondary"
onClick={onPrev}
color="text-white"
backgroundColor="bg-black/50"
/>
</Box>
)}
{onNext && (
<Box position="absolute" right="4" top="1/2" translateY="-1/2">
<IconButton
icon={ChevronRight}
variant="secondary"
onClick={onNext}
color="text-white"
backgroundColor="bg-black/50"
/>
</Box>
)}
</Box>
{/* Footer / Info */}
<Box mt={4} display="flex" justifyContent="center">
<Text size="xs" color="text-gray-400" uppercase letterSpacing="widest">
Precision Racing Media Viewer
</Text>
</Box>
</Box>
</Box>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef } from 'react';
import { Upload, File, X, CheckCircle2, AlertCircle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
export interface UploadDropzoneProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
maxSize?: number; // in bytes
isLoading?: boolean;
error?: string;
}
export function UploadDropzone({
onFilesSelected,
accept,
multiple = false,
maxSize,
isLoading,
error,
}: UploadDropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
validateAndSelectFiles(files);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
validateAndSelectFiles(files);
}
};
const validateAndSelectFiles = (files: File[]) => {
let filteredFiles = files;
if (accept) {
const acceptedTypes = accept.split(',').map(t => t.trim());
filteredFiles = filteredFiles.filter(file => {
return acceptedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.endsWith(type);
}
if (type.endsWith('/*')) {
return file.type.startsWith(type.replace('/*', ''));
}
return file.type === type;
});
});
}
if (maxSize) {
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
}
if (!multiple) {
filteredFiles = filteredFiles.slice(0, 1);
}
setSelectedFiles(filteredFiles);
onFilesSelected(filteredFiles);
};
const removeFile = (index: number) => {
const newFiles = [...selectedFiles];
newFiles.splice(index, 1);
setSelectedFiles(newFiles);
onFilesSelected(newFiles);
};
return (
<Box w="full">
<Box
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
display="flex"
flexDirection="col"
alignItems="center"
justifyContent="center"
p={8}
border
borderStyle="dashed"
borderColor={isDragging ? 'border-blue-500' : error ? 'border-amber-500' : 'border-charcoal-outline'}
bg={isDragging ? 'bg-blue-500/5' : 'bg-charcoal-outline/10'}
rounded="xl"
cursor="pointer"
transition
hoverBg="bg-charcoal-outline/20"
>
<Box as="input"
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept={accept}
multiple={multiple}
display="none"
/>
<Icon
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
size={10}
color={isDragging ? 'text-blue-500' : error ? 'text-amber-500' : 'text-gray-500'}
animate={isLoading ? 'pulse' : 'none'}
mb={4}
/>
<Text weight="semibold" size="lg" mb={1}>
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
</Text>
<Text size="sm" color="text-gray-500" align="center">
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
</Text>
{error && (
<Box display="flex" alignItems="center" gap={2} mt={4} color="text-amber-500">
<Icon icon={AlertCircle} size={4} />
<Text size="sm" weight="medium">{error}</Text>
</Box>
)}
</Box>
{selectedFiles.length > 0 && (
<Box mt={4} display="flex" flexDirection="col" gap={2}>
{selectedFiles.map((file, index) => (
<Box
key={`${file.name}-${index}`}
display="flex"
alignItems="center"
justifyContent="between"
p={3}
bg="bg-charcoal-outline/20"
rounded="lg"
border
borderColor="border-charcoal-outline/30"
>
<Box display="flex" alignItems="center" gap={3}>
<Icon icon={File} size={5} color="text-gray-400" />
<Box>
<Text block size="sm" weight="medium" truncate maxWidth="200px">
{file.name}
</Text>
<Text block size="xs" color="text-gray-500">
{Math.round(file.size / 1024)} KB
</Text>
</Box>
</Box>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
p={1}
h="auto"
>
<Icon icon={X} size={4} />
</Button>
</Box>
))}
</Box>
)}
</Box>
);
}

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