website refactor

This commit is contained in:
2026-01-18 22:55:55 +01:00
parent b43a23a48c
commit aeaa43f4d3
179 changed files with 4736 additions and 6832 deletions

View File

@@ -1,6 +1,7 @@
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/primitives/Stack'; import { Group } from '@/ui/Group';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface AchievementCardProps { interface AchievementCardProps {
title: string; title: string;
@@ -10,13 +11,6 @@ interface AchievementCardProps {
rarity: 'common' | 'rare' | 'epic' | 'legendary'; rarity: 'common' | 'rare' | 'epic' | 'legendary';
} }
const rarityColors = {
common: 'border-gray-500 bg-gray-500/10',
rare: 'border-blue-400 bg-blue-400/10',
epic: 'border-purple-400 bg-purple-400/10',
legendary: 'border-warning-amber bg-warning-amber/10'
};
export function AchievementCard({ export function AchievementCard({
title, title,
description, description,
@@ -24,27 +18,27 @@ export function AchievementCard({
unlockedAt, unlockedAt,
rarity, rarity,
}: AchievementCardProps) { }: AchievementCardProps) {
const rarityVariantMap = {
common: 'rarity-common',
rare: 'rarity-rare',
epic: 'rarity-epic',
legendary: 'rarity-legendary'
} as const;
return ( return (
<Card <Card
p={4} variant={rarityVariantMap[rarity]}
rounded="lg"
variant="outline"
className={rarityColors[rarity]}
> >
<Stack direction="row" align="start" gap={3}> <Group direction="row" align="start" gap={3}>
<Text size="3xl">{icon}</Text> <Text size="3xl">{icon}</Text>
<Stack gap={1} flexGrow={1}> <Group direction="column" gap={1}>
<Text weight="medium" color="text-white">{title}</Text> <Text weight="medium" variant="high">{title}</Text>
<Text size="xs" color="text-gray-400">{description}</Text> <Text size="xs" variant="med">{description}</Text>
<Text size="xs" color="text-gray-500"> <Text size="xs" variant="low">
{new Date(unlockedAt).toLocaleDateString('en-US', { {DateDisplay.formatShort(unlockedAt)}
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</Text> </Text>
</Stack> </Group>
</Stack> </Group>
</Card> </Card>
); );
} }

View File

@@ -1,11 +1,12 @@
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay'; import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Grid } from '@/ui/primitives/Grid'; import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack'; import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react'; import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
import React from 'react';
interface Achievement { interface Achievement {
id: string; id: string;
@@ -35,44 +36,44 @@ function getAchievementIcon(icon: string) {
export function AchievementGrid({ achievements }: AchievementGridProps) { export function AchievementGrid({ achievements }: AchievementGridProps) {
return ( return (
<Card> <Card>
<Stack mb={4}> <Group direction="column" gap={4} fullWidth>
<Stack direction="row" align="center" justify="between"> <Group direction="row" align="center" justify="between" fullWidth>
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}> <Group direction="row" align="center" gap={2}>
Achievements <Icon icon={Award} size={5} intent="warning" />
</Heading> <Heading level={2}>
<Text size="sm" color="text-gray-500" weight="normal">{achievements.length} earned</Text> Achievements
</Stack> </Heading>
</Stack> </Group>
<Text size="sm" variant="low" weight="normal">{achievements.length} earned</Text>
</Group>
</Group>
<Grid cols={1} gap={4}> <Grid cols={1} gap={4}>
{achievements.map((achievement) => { {achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon); const AchievementIcon = getAchievementIcon(achievement.icon);
const rarity = AchievementDisplay.getRarityColor(achievement.rarity); const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
return ( return (
<Card <Card
key={achievement.id} key={achievement.id}
variant="outline" variant={rarity.surface}
p={4}
rounded="xl"
className={rarity.surface === 'muted' ? 'bg-panel-gray/40' : ''}
> >
<Stack direction="row" align="start" gap={3}> <Group direction="row" align="start" gap={3}>
<Stack bg="panel-gray/40" rounded="lg" p={3} align="center" justify="center"> <Card variant="default" style={{ padding: '0.75rem' }}>
<Icon icon={AchievementIcon} size={5} color={rarity.icon} /> <Icon icon={AchievementIcon} size={5} intent={rarity.iconIntent} />
</Stack> </Card>
<Stack flex={1}> <Group direction="column" fullWidth>
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text> <Text weight="semibold" size="sm" variant="high" block>{achievement.title}</Text>
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text> <Text size="xs" variant="med" block>{achievement.description}</Text>
<Stack direction="row" align="center" gap={2} mt={2}> <Group direction="row" align="center" gap={2}>
<Text size="xs" color={rarity.text} weight="medium"> <Text size="xs" variant={rarity.text} weight="medium" uppercase>
{achievement.rarity.toUpperCase()} {achievement.rarity}
</Text> </Text>
<Text size="xs" color="text-gray-500"></Text> <Text size="xs" variant="low"></Text>
<Text size="xs" color="text-gray-500"> <Text size="xs" variant="low">
{AchievementDisplay.formatDate(achievement.earnedAt)} {AchievementDisplay.formatDate(achievement.earnedAt)}
</Text> </Text>
</Stack> </Group>
</Stack> </Group>
</Stack> </Group>
</Card> </Card>
); );
})} })}

View File

@@ -1,5 +1,7 @@
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/primitives/Stack'; import { Group } from '@/ui/Group';
import { Card } from '@/ui/Card';
import React from 'react';
interface MilestoneItemProps { interface MilestoneItemProps {
label: string; label: string;
@@ -9,12 +11,14 @@ interface MilestoneItemProps {
export function MilestoneItem({ label, value, icon }: MilestoneItemProps) { export function MilestoneItem({ label, value, icon }: MilestoneItemProps) {
return ( return (
<Stack direction="row" align="center" justify="between" p={3} rounded="md" bg="bg-deep-graphite" border borderColor="border-charcoal-outline"> <Card variant="dark">
<Stack direction="row" align="center" gap={3}> <Group direction="row" align="center" justify="between" fullWidth>
<Text size="xl">{icon}</Text> <Group direction="row" align="center" gap={3}>
<Text size="sm" color="text-gray-400">{label}</Text> <Text size="xl">{icon}</Text>
</Stack> <Text size="sm" variant="low">{label}</Text>
<Text size="sm" weight="medium" color="text-white">{value}</Text> </Group>
</Stack> <Text size="sm" weight="medium" variant="high">{value}</Text>
</Group>
</Card>
); );
} }

View File

@@ -1,27 +1,25 @@
'use client'; 'use client';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Stack } from '@/ui/primitives/Stack'; import { ControlBar } from '@/ui/ControlBar';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Group } from '@/ui/Group';
import { useState } from 'react'; import { useState } from 'react';
export function ActionFiltersBar() { export function ActionFiltersBar() {
const [filter, setFilter] = useState('all'); const [filter, setFilter] = useState('all');
return ( return (
<Stack <ControlBar
direction="row" actions={
h="12" <Input
borderBottom placeholder="SEARCH_ID..."
borderColor="border-[#23272B]" />
align="center" }
px={6}
bg="bg-[#0C0D0F]"
gap={6}
> >
<Stack direction="row" align="center" gap={2}> <Group direction="row" align="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Filter:</Text> <Text size="xs" variant="low" weight="bold" uppercase>Filter:</Text>
<Select <Select
options={[ options={[
{ label: 'All Types', value: 'all' }, { label: 'All Types', value: 'all' },
@@ -32,9 +30,9 @@ export function ActionFiltersBar() {
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
fullWidth={false} fullWidth={false}
/> />
</Stack> </Group>
<Stack direction="row" align="center" gap={2}> <Group direction="row" align="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Status:</Text> <Text size="xs" variant="low" weight="bold" uppercase>Status:</Text>
<Select <Select
options={[ options={[
{ label: 'All Status', value: 'all' }, { label: 'All Status', value: 'all' },
@@ -46,12 +44,7 @@ export function ActionFiltersBar() {
onChange={() => {}} onChange={() => {}}
fullWidth={false} fullWidth={false}
/> />
</Stack> </Group>
<Stack className="ml-auto"> </ControlBar>
<Input
placeholder="SEARCH_ID..."
/>
</Stack>
</Stack>
); );
} }

View File

@@ -28,19 +28,19 @@ export function ActionList({ actions }: ActionListProps) {
clickable clickable
> >
<TableCell> <TableCell>
<Text font="mono" size="xs" color="text-gray-400">{action.timestamp}</Text> <Text font="mono" size="xs" variant="low">{action.timestamp}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="xs" weight="medium" color="text-gray-200">{action.type}</Text> <Text size="xs" weight="medium" variant="med">{action.type}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="xs" color="text-gray-400">{action.initiator}</Text> <Text size="xs" variant="low">{action.initiator}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<ActionStatusBadge status={action.status} /> <ActionStatusBadge status={action.status} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="xs" color="text-gray-400"> <Text size="xs" variant="low">
{action.details} {action.details}
</Text> </Text>
</TableCell> </TableCell>

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { Stack } from '@/ui/primitives/Stack'; import { Header } from '@/ui/Header';
import { StatusIndicator } from '@/ui/StatusIndicator'; import { StatusIndicator } from '@/ui/StatusIndicator';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
import React from 'react';
interface ActionsHeaderProps { interface ActionsHeaderProps {
title: string; title: string;
@@ -11,31 +12,15 @@ interface ActionsHeaderProps {
export function ActionsHeader({ title }: ActionsHeaderProps) { export function ActionsHeader({ title }: ActionsHeaderProps) {
return ( return (
<Stack <Header
as="header" actions={<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />}
direction="row"
h="16"
borderBottom
borderColor="border-[#23272B]"
align="center"
px={6}
bg="bg-[#141619]"
> >
<Stack direction="row" align="center" gap={4}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Stack <div style={{ width: '0.5rem', height: '1.5rem', backgroundColor: 'var(--ui-color-intent-primary)', borderRadius: 'var(--ui-radius-sm)' }} />
w="2" <Text as="h1" size="xl" weight="medium" variant="high" uppercase>
h="6"
bg="bg-[#198CFF]"
rounded="sm"
shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]"
>{null}</Stack>
<Text as="h1" size="xl" weight="medium" letterSpacing="tight" uppercase>
{title} {title}
</Text> </Text>
</Stack> </div>
<Stack className="ml-auto" direction="row" align="center" gap={4}> </Header>
<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />
</Stack>
</Stack>
); );
} }

View File

@@ -1,9 +1,6 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { DangerZone } from '@/ui/DangerZone';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import React from 'react'; import React from 'react';
interface AdminDangerZonePanelProps { interface AdminDangerZonePanelProps {
@@ -24,20 +21,11 @@ export function AdminDangerZonePanel({
children children
}: AdminDangerZonePanelProps) { }: AdminDangerZonePanelProps) {
return ( return (
<Card borderColor="border-error-red/30" bg="bg-error-red/5"> <DangerZone
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={6}> title={title}
<Stack> description={description}
<Heading level={4} weight="bold" color="text-error-red"> >
{title} {children}
</Heading> </DangerZone>
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
</Stack>
<Stack>
{children}
</Stack>
</Stack>
</Card>
); );
} }

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Stack } from '@/ui/primitives/Stack';
import React from 'react'; import React from 'react';
interface AdminDataTableProps { interface AdminDataTableProps {
@@ -20,13 +19,15 @@ export function AdminDataTable({
maxHeight maxHeight
}: AdminDataTableProps) { }: AdminDataTableProps) {
return ( return (
<Card p={0} overflow="hidden"> <Card padding={0}>
<Stack <div
overflow="auto" style={{
maxHeight={typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight} overflow: 'auto',
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
}}
> >
{children} {children}
</Stack> </div>
</Card> </Card>
); );
} }

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { Icon } from '@/ui/Icon'; import { EmptyState } from '@/ui/EmptyState';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -26,23 +24,13 @@ export function AdminEmptyState({
action action
}: AdminEmptyStateProps) { }: AdminEmptyStateProps) {
return ( return (
<Stack center py={20} gap={4}> <EmptyState
<Icon icon={icon} size={12} color="#23272B" /> icon={icon}
<Stack align="center"> title={title}
<Text size="lg" weight="bold" color="text-white" block textAlign="center"> description={description}
{title} variant="minimal"
</Text> >
{description && ( {action}
<Text size="sm" color="text-gray-500" block mt={1} textAlign="center"> </EmptyState>
{description}
</Text>
)}
</Stack>
{action && (
<Stack mt={2}>
{action}
</Stack>
)}
</Stack>
); );
} }

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { ProgressLine } from '@/components/shared/ux/ProgressLine'; import { ProgressLine } from '@/ui/ProgressLine';
import { Heading } from '@/ui/Heading'; import { SectionHeader } from '@/ui/SectionHeader';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import React from 'react'; import React from 'react';
interface AdminHeaderPanelProps { interface AdminHeaderPanelProps {
@@ -26,27 +24,11 @@ export function AdminHeaderPanel({
isLoading = false isLoading = false
}: AdminHeaderPanelProps) { }: AdminHeaderPanelProps) {
return ( return (
<Stack position="relative" pb={4} borderBottom borderColor="border-charcoal-outline"> <SectionHeader
<Stack direction="row" align="center" justify="between"> title={title}
<Stack> description={description}
<Heading level={1} weight="bold" color="text-white"> actions={actions}
{title} loading={<ProgressLine isLoading={isLoading} />}
</Heading> />
{description && (
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Stack>
{actions && (
<Stack direction="row" align="center" gap={3}>
{actions}
</Stack>
)}
</Stack>
<Stack position="absolute" bottom="0" left="0" w="full">
<ProgressLine isLoading={isLoading} />
</Stack>
</Stack>
); );
} }

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { Heading } from '@/ui/Heading'; import { SectionHeader } from '@/ui/SectionHeader';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import React from 'react'; import React from 'react';
interface AdminSectionHeaderProps { interface AdminSectionHeaderProps {
@@ -23,22 +21,11 @@ export function AdminSectionHeader({
actions actions
}: AdminSectionHeaderProps) { }: AdminSectionHeaderProps) {
return ( return (
<Stack direction="row" align="center" justify="between" mb={4}> <SectionHeader
<Stack> title={title}
<Heading level={3} weight="bold" color="text-white"> description={description}
{title} actions={actions}
</Heading> variant="minimal"
{description && ( />
<Text size="xs" color="text-gray-500" block mt={0.5}>
{description}
</Text>
)}
</Stack>
{actions && (
<Stack direction="row" align="center" gap={2}>
{actions}
</Stack>
)}
</Stack>
); );
} }

View File

@@ -1,14 +1,14 @@
'use client'; 'use client';
import { Grid } from '@/ui/primitives/Grid'; import { StatGrid } from '@/ui/StatGrid';
import { StatCard } from '@/ui/StatCard';
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import React from 'react';
interface AdminStat { interface AdminStat {
label: string; label: string;
value: string | number; value: string | number;
icon: LucideIcon; icon: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange'; intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
trend?: { trend?: {
value: number; value: number;
isPositive: boolean; isPositive: boolean;
@@ -27,18 +27,10 @@ interface AdminStatsPanelProps {
*/ */
export function AdminStatsPanel({ stats }: AdminStatsPanelProps) { export function AdminStatsPanel({ stats }: AdminStatsPanelProps) {
return ( return (
<Grid cols={1} mdCols={2} lgCols={4} gap={4}> <StatGrid
{stats.map((stat, index) => ( stats={stats}
<StatCard columns={{ base: 1, md: 2, lg: 4 }}
key={stat.label} variant="card"
label={stat.label} />
value={stat.value}
icon={stat.icon}
variant={stat.variant}
trend={stat.trend}
delay={index * 0.05}
/>
))}
</Grid>
); );
} }

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { ControlBar } from '@/ui/ControlBar';
import { Stack } from '@/ui/primitives/Stack';
import React from 'react'; import React from 'react';
interface AdminToolbarProps { interface AdminToolbarProps {
@@ -20,17 +19,11 @@ export function AdminToolbar({
leftContent leftContent
}: AdminToolbarProps) { }: AdminToolbarProps) {
return ( return (
<Card p={3} bg="bg-charcoal/50" borderColor="border-charcoal-outline"> <ControlBar
<Stack direction="row" align="center" justify="between" gap={4} wrap> leftContent={leftContent}
{leftContent && ( variant="dark"
<Stack flexGrow={1}> >
{leftContent} {children}
</Stack> </ControlBar>
)}
<Stack direction="row" align="center" gap={3} flexGrow={leftContent ? 0 : 1} wrap>
{children}
</Stack>
</Stack>
</Card>
); );
} }

View File

@@ -3,9 +3,10 @@
import { DateDisplay } from '@/lib/display-objects/DateDisplay'; import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { IconButton } from '@/ui/IconButton';
import { Stack } from '@/ui/primitives/Stack';
import { SimpleCheckbox } from '@/ui/SimpleCheckbox'; import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
import { Badge } from '@/ui/Badge';
import { DriverIdentity } from '@/ui/DriverIdentity';
import { import {
Table, Table,
TableBody, TableBody,
@@ -15,8 +16,9 @@ import {
TableRow TableRow
} from '@/ui/Table'; } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { MoreVertical, Shield, Trash2 } from 'lucide-react'; import { MoreVertical, Trash2 } from 'lucide-react';
import { UserStatusTag } from './UserStatusTag'; import { UserStatusTag } from './UserStatusTag';
import React from 'react';
interface AdminUsersTableProps { interface AdminUsersTableProps {
users: AdminUsersViewData['users']; users: AdminUsersViewData['users'];
@@ -49,7 +51,7 @@ export function AdminUsersTable({
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableHeader width="10"> <TableHeader w="2.5rem">
<SimpleCheckbox <SimpleCheckbox
checked={allSelected} checked={allSelected}
onChange={onSelectAll} onChange={onSelectAll}
@@ -74,55 +76,35 @@ export function AdminUsersTable({
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" align="center" gap={3}> <DriverIdentity
<Stack driver={{
bg="bg-primary-blue/10" id: user.id,
rounded="full" name: user.displayName,
p={2} avatarUrl: null
border }}
borderColor="border-primary-blue/20" meta={user.email}
> size="sm"
<Icon icon={Shield} size={4} color="#198CFF" /> />
</Stack>
<Stack>
<Text weight="semibold" color="text-white" block>
{user.displayName}
</Text>
<Text size="xs" color="text-gray-500" block>
{user.email}
</Text>
</Stack>
</Stack>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" gap={1.5} wrap> <div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
{user.roles.map((role) => ( {user.roles.map((role) => (
<Stack <Badge key={role} variant="default" size="sm">
key={role} {role}
px={2} </Badge>
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>
</Stack>
))} ))}
</Stack> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<UserStatusTag status={user.status} /> <UserStatusTag status={user.status} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="sm" color="text-gray-400"> <Text size="sm" variant="low">
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'} {user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" align="center" justify="end" gap={2}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
{user.status === 'active' ? ( {user.status === 'active' ? (
<Button <Button
size="sm" size="sm"
@@ -141,24 +123,22 @@ export function AdminUsersTable({
</Button> </Button>
) : null} ) : null}
<Button <IconButton
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => onDeleteUser(user.id)} onClick={() => onDeleteUser(user.id)}
disabled={deletingUserId === user.id} disabled={deletingUserId === user.id}
icon={<Icon icon={Trash2} size={3} />} icon={Trash2}
> title="Delete"
{deletingUserId === user.id ? '...' : ''} />
</Button>
<Button <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
icon={<Icon icon={MoreVertical} size={4} />} icon={MoreVertical}
> title="More"
{''} />
</Button> </div>
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Stack } from '@/ui/primitives/Stack'; import { BulkActions } from '@/ui/BulkActions';
import { Text } from '@/ui/Text';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react'; import React from 'react';
interface BulkActionBarProps { interface BulkActionBarProps {
@@ -28,65 +26,28 @@ export function BulkActionBar({
onClearSelection onClearSelection
}: BulkActionBarProps) { }: BulkActionBarProps) {
return ( return (
<AnimatePresence> <BulkActions
{selectedCount > 0 && ( selectedCount={selectedCount}
<Stack isOpen={selectedCount > 0}
as={motion.div} >
initial={{ y: 100, opacity: 0 }} {actions.map((action) => (
animate={{ y: 0, opacity: 1 }} <Button
exit={{ y: 100, opacity: 0 }} key={action.label}
position="fixed" size="sm"
bottom="8" variant={action.variant === 'danger' ? 'danger' : (action.variant || 'primary')}
left="1/2" onClick={action.onClick}
translateX="-1/2" icon={action.icon}
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}> {action.label}
<Stack direction="row" align="center" gap={3}> </Button>
<Stack bg="bg-primary-blue" rounded="full" px={2} py={0.5}> ))}
<Text size="xs" weight="bold" color="text-white"> <Button
{selectedCount} size="sm"
</Text> variant="ghost"
</Stack> onClick={onClearSelection}
<Text size="sm" weight="medium" color="text-white"> >
Items Selected Cancel
</Text> </Button>
</Stack> </BulkActions>
<Stack w="px" h="6" bg="bg-charcoal-outline">{null}</Stack>
<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>
</Stack>
)}
</AnimatePresence>
); );
} }

View File

@@ -3,11 +3,11 @@
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Stack } from '@/ui/primitives/Stack';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Filter, Search } from 'lucide-react'; import { Filter, Search } from 'lucide-react';
import { AdminToolbar } from './AdminToolbar'; import { AdminToolbar } from './AdminToolbar';
import React from 'react';
interface UserFiltersProps { interface UserFiltersProps {
search: string; search: string;
@@ -31,9 +31,9 @@ export function UserFilters({
return ( return (
<AdminToolbar <AdminToolbar
leftContent={ leftContent={
<Stack direction="row" align="center" gap={2}> <React.Fragment>
<Icon icon={Filter} size={4} color="#9ca3af" /> <Icon icon={Filter} size={4} intent="low" />
<Text weight="medium" color="text-white">Filters</Text> <Text weight="medium" variant="high">Filters</Text>
{(search || roleFilter || statusFilter) && ( {(search || roleFilter || statusFilter) && (
<Button <Button
onClick={onClearFilters} onClick={onClearFilters}
@@ -43,7 +43,7 @@ export function UserFilters({
Clear all Clear all
</Button> </Button>
)} )}
</Stack> </React.Fragment>
} }
> >
<Input <Input
@@ -51,8 +51,7 @@ export function UserFilters({
placeholder="Search by email or name..." placeholder="Search by email or name..."
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
icon={<Icon icon={Search} size={4} color="#9ca3af" />} style={{ width: '300px' }}
width="300px"
/> />
<Select <Select

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { MetricCard } from '@/ui/MetricCard'; import { MetricCard } from '@/ui/MetricCard';
import { Grid } from '@/ui/primitives/Grid'; import { StatGrid } from '@/ui/StatGrid';
import { Shield, Users } from 'lucide-react'; import { Shield, Users } from 'lucide-react';
import React from 'react';
interface UserStatsSummaryProps { interface UserStatsSummaryProps {
total: number; total: number;
@@ -11,25 +12,16 @@ interface UserStatsSummaryProps {
} }
export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) { export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) {
const stats = [
{ label: 'Total Users', value: total, icon: Users, intent: 'primary' as const },
{ label: 'Active', value: activeCount, intent: 'success' as const },
{ label: 'Admins', value: adminCount, icon: Shield, intent: 'telemetry' as const },
];
return ( return (
<Grid cols={3} gap={4}> <StatGrid
<MetricCard stats={stats}
label="Total Users" columns={3}
value={total} />
icon={Users}
color="text-blue-400"
/>
<MetricCard
label="Active"
value={activeCount}
color="text-performance-green"
/>
<MetricCard
label="Admins"
value={adminCount}
icon={Shield}
color="text-purple-400"
/>
</Grid>
); );
} }

View File

@@ -1,17 +1,15 @@
import React from 'react'; import React from 'react';
import { Footer } from '@/ui/Footer';
interface AppFooterProps { interface AppFooterProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
} }
/** /**
* AppFooter is the bottom section of the application. * AppFooter is the bottom section of the application.
*/ */
export function AppFooter({ children, className = '' }: AppFooterProps) { export function AppFooter({ children }: AppFooterProps) {
return ( return (
<footer className={`bg-[#141619] border-t border-[#23272B] py-8 px-4 md:px-6 ${className}`}> <Footer />
{children}
</footer>
); );
} }

View File

@@ -1,20 +1,18 @@
import React from 'react'; import React from 'react';
import { Header } from '@/ui/Header';
interface AppHeaderProps { interface AppHeaderProps {
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
/** /**
* AppHeader is the top control bar of the application. * AppHeader is the top control bar of the application.
* It follows the "Telemetry Workspace" structure. * It follows the "Telemetry Workspace" structure.
*/ */
export function AppHeader({ children, className = '' }: AppHeaderProps) { export function AppHeader({ children }: AppHeaderProps) {
return ( return (
<header <Header>
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
>
{children} {children}
</header> </Header>
); );
} }

View File

@@ -1,20 +1,18 @@
import React from 'react'; import React from 'react';
import { Sidebar } from '@/ui/Sidebar';
interface AppSidebarProps { interface AppSidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string;
} }
/** /**
* AppSidebar is the "dashboard rail" of the application. * AppSidebar is the "dashboard rail" of the application.
* It provides global navigation and context. * It provides global navigation and context.
*/ */
export function AppSidebar({ children, className = '' }: AppSidebarProps) { export function AppSidebar({ children }: AppSidebarProps) {
return ( return (
<aside <Sidebar>
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
>
{children} {children}
</aside> </Sidebar>
); );
} }

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { SectionHeader } from '@/ui/SectionHeader';
import React from 'react'; import React from 'react';
interface AuthCardProps { interface AuthCardProps {
@@ -18,31 +18,15 @@ interface AuthCardProps {
*/ */
export function AuthCard({ children, title, description }: AuthCardProps) { export function AuthCard({ children, title, description }: AuthCardProps) {
return ( return (
<Card bg="surface-charcoal" borderColor="outline-steel" rounded="lg" position="relative" overflow="hidden"> <Card variant="dark">
{/* Subtle top accent line */} <SectionHeader
<Stack title={title}
position="absolute" description={description}
top="0" variant="minimal"
left="0" />
w="full" <div>
h="1px"
bg="linear-gradient(to right, transparent, rgba(25, 140, 255, 0.3), transparent)"
>{null}</Stack>
<Stack p={{ base: 6, md: 8 }}>
<Stack as="header" mb={8} align="center">
<Text as="h1" size="xl" weight="semibold" color="text-white" letterSpacing="tight" mb={2} block textAlign="center">
{title}
</Text>
{description && (
<Text size="sm" color="text-med" block textAlign="center">
{description}
</Text>
)}
</Stack>
{children} {children}
</Stack> </div>
</Card> </Card>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Grid } from '@/ui/primitives/Grid'; import { Grid } from '@/ui/Grid';
import React from 'react'; import React from 'react';
interface AuthProviderButtonsProps { interface AuthProviderButtonsProps {
@@ -14,8 +14,10 @@ interface AuthProviderButtonsProps {
*/ */
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) { export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
return ( return (
<Grid cols={1} gap={3} mb={6}> <div style={{ marginBottom: '1.5rem' }}>
{children} <Grid cols={1} gap={3}>
</Grid> {children}
</Grid>
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Stack } from '@/ui/primitives/Stack'; import { AuthLayout } from '@/ui/AuthLayout';
import React from 'react'; import React from 'react';
interface AuthShellProps { interface AuthShellProps {
@@ -15,37 +15,8 @@ interface AuthShellProps {
*/ */
export function AuthShell({ children }: AuthShellProps) { export function AuthShell({ children }: AuthShellProps) {
return ( return (
<Stack as="main" minHeight="screen" align="center" justify="center" p={4} bg="base-black" position="relative" overflow="hidden"> <AuthLayout>
{/* Subtle background glow - top right */} {children}
<Stack </AuthLayout>
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"
>{null}</Stack>
{/* Subtle background glow - bottom left */}
<Stack
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"
>{null}</Stack>
<Stack w="full" maxWidth="400px" position="relative" zIndex={10} {...({ animate: "fade-in" } as any)}>
{children}
</Stack>
</Stack>
); );
} }

View File

@@ -8,7 +8,7 @@ import {
Trophy, Trophy,
Car, Car,
} from 'lucide-react'; } from 'lucide-react';
import { WorkflowMockup, WorkflowStep } from '@/components/mockups/WorkflowMockup'; import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
const WORKFLOW_STEPS: WorkflowStep[] = [ const WORKFLOW_STEPS: WorkflowStep[] = [
{ {
@@ -16,35 +16,35 @@ const WORKFLOW_STEPS: WorkflowStep[] = [
icon: UserPlus, icon: UserPlus,
title: 'Create Account', title: 'Create Account',
description: 'Sign up with email or connect iRacing', description: 'Sign up with email or connect iRacing',
color: 'text-primary-blue', intent: 'primary',
}, },
{ {
id: 2, id: 2,
icon: LinkIcon, icon: LinkIcon,
title: 'Link iRacing', title: 'Link iRacing',
description: 'Connect your iRacing profile for stats', description: 'Connect your iRacing profile for stats',
color: 'text-purple-400', intent: 'telemetry',
}, },
{ {
id: 3, id: 3,
icon: Settings, icon: Settings,
title: 'Configure Profile', title: 'Configure Profile',
description: 'Set up your racing preferences', description: 'Set up your racing preferences',
color: 'text-warning-amber', intent: 'warning',
}, },
{ {
id: 4, id: 4,
icon: Trophy, icon: Trophy,
title: 'Join Leagues', title: 'Join Leagues',
description: 'Find and join competitive leagues', description: 'Find and join competitive leagues',
color: 'text-performance-green', intent: 'success',
}, },
{ {
id: 5, id: 5,
icon: Car, icon: Car,
title: 'Start Racing', title: 'Start Racing',
description: 'Compete and track your progress', description: 'Compete and track your progress',
color: 'text-primary-blue', intent: 'primary',
}, },
]; ];

View File

@@ -1,28 +1,29 @@
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ListItem, ListItemInfo } from '@/ui/ListItem';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Car, Trophy, Users } from 'lucide-react'; import { Car, Trophy, Users } from 'lucide-react';
import React from 'react';
const USER_ROLES = [ const USER_ROLES = [
{ {
icon: Car, icon: Car,
title: 'Driver', title: 'Driver',
description: 'Race, track stats, join teams', description: 'Race, track stats, join teams',
color: 'primary-blue', intent: 'primary' as const,
}, },
{ {
icon: Trophy, icon: Trophy,
title: 'League Admin', title: 'League Admin',
description: 'Organize leagues and events', description: 'Organize leagues and events',
color: 'performance-green', intent: 'success' as const,
}, },
{ {
icon: Users, icon: Users,
title: 'Team Manager', title: 'Team Manager',
description: 'Manage team and drivers', description: 'Manage team and drivers',
color: 'purple-400', intent: 'telemetry' as const,
}, },
] as const; ] as const;
@@ -33,72 +34,46 @@ interface UserRolesPreviewProps {
export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) { export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
if (variant === 'compact') { if (variant === 'compact') {
return ( return (
<Stack mt={8} display={{ base: 'block', lg: 'none' }}> <div style={{ marginTop: '2rem' }}>
<Text align="center" size="xs" color="text-gray-500" mb={4} block> <Text align="center" size="xs" variant="low" block marginBottom={4}>
One account for all roles One account for all roles
</Text> </Text>
<Stack direction="row" justify="center" gap={6}> <div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem' }}>
{USER_ROLES.map((role) => ( {USER_ROLES.map((role) => (
<Stack key={role.title} direction="col" align="center"> <div key={role.title} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem' }}>
<Stack <div style={{ width: '2rem', height: '2rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
w="8" <Icon icon={role.icon} size={4} intent={role.intent} />
h="8" </div>
rounded="lg" <Text size="xs" variant="low">{role.title}</Text>
bg={`bg-${role.color}/20`} </div>
align="center"
justify="center"
mb={1}
>
<Text color={`text-${role.color}`}>
<Icon icon={role.icon} size={4} />
</Text>
</Stack>
<Text size="xs" color="text-gray-500">{role.title}</Text>
</Stack>
))} ))}
</Stack> </div>
</Stack> </div>
); );
} }
return ( return (
<Stack direction="col" gap={3} mb={8}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '2rem' }}>
{USER_ROLES.map((role, index) => ( {USER_ROLES.map((role, index) => (
<Stack <motion.div
as={motion.div}
key={role.title} key={role.title}
{...({ initial={{ opacity: 0, x: -20 }}
initial: { opacity: 0, x: -20 }, animate={{ opacity: 1, x: 0 }}
animate: { opacity: 1, x: 0 }, transition={{ delay: index * 0.1 }}
transition: { delay: index * 0.1 }
} as any)}
direction="row"
align="center"
gap={4}
p={4}
rounded="xl"
bg="bg-iron-gray/50"
border
borderColor="border-charcoal-outline"
> >
<Stack <ListItem>
w="10" <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
h="10" <div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
rounded="lg" <Icon icon={role.icon} size={5} intent={role.intent} />
bg={`bg-${role.color}/20`} </div>
align="center" <ListItemInfo
justify="center" title={role.title}
> description={role.description}
<Text color={`text-${role.color}`}> />
<Icon icon={role.icon} size={5} /> </div>
</Text> </ListItem>
</Stack> </motion.div>
<Stack>
<Heading level={4}>{role.title}</Heading>
<Text size="sm" color="text-gray-500">{role.description}</Text>
</Stack>
</Stack>
))} ))}
</Stack> </div>
); );
} }

View File

@@ -1,6 +1,8 @@
'use client';
import { Panel } from '@/ui/Panel'; import { Panel } from '@/ui/Panel';
import { Stack } from '@/ui/primitives/Stack';
import { ActivityFeed } from '../feed/ActivityFeed'; import { ActivityFeed } from '../feed/ActivityFeed';
import React from 'react';
interface FeedItem { interface FeedItem {
id: string; id: string;
@@ -25,10 +27,8 @@ interface ActivityFeedPanelProps {
*/ */
export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) { export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) {
return ( return (
<Panel title="Activity Feed" padding={0}> <Panel title="Activity Feed">
<Stack px={6} pb={6}> <ActivityFeed items={items} hasItems={hasItems} />
<ActivityFeed items={items} hasItems={hasItems} />
</Stack>
</Panel> </Panel>
); );
} }

View File

@@ -1,5 +1,7 @@
'use client';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/primitives/Stack'; import { ControlBar } from '@/ui/ControlBar';
import React from 'react'; import React from 'react';
interface DashboardControlBarProps { interface DashboardControlBarProps {
@@ -11,17 +13,17 @@ interface DashboardControlBarProps {
* DashboardControlBar * DashboardControlBar
* *
* The top header bar for page-level controls and context. * The top header bar for page-level controls and context.
* Uses UI primitives to comply with architectural constraints.
*/ */
export function DashboardControlBar({ title, actions }: DashboardControlBarProps) { export function DashboardControlBar({ title, actions }: DashboardControlBarProps) {
return ( return (
<Stack direction="row" h="full" align="center" justify="between" px={6}> <ControlBar
<Heading level={6} weight="bold"> leftContent={
{title} <Heading level={6} weight="bold">
</Heading> {title}
<Stack direction="row" align="center" gap={4}> </Heading>
{actions} }
</Stack> >
</Stack> {actions}
</ControlBar>
); );
} }

View File

@@ -3,9 +3,10 @@ import { Avatar } from '../../ui/Avatar';
import { Badge } from '../../ui/Badge'; import { Badge } from '../../ui/Badge';
import { Heading } from '../../ui/Heading'; import { Heading } from '../../ui/Heading';
import { Icon } from '../../ui/Icon'; import { Icon } from '../../ui/Icon';
import { Box } from '../../ui/primitives/Box'; import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '../../ui/ProfileHero';
import { Stack } from '../../ui/primitives/Stack'; import { BadgeGroup } from '../../ui/BadgeGroup';
import { Text } from '../../ui/Text'; import { QuickStatCard, QuickStatItem } from '../../ui/QuickStatCard';
import React from 'react';
interface DashboardHeroProps { interface DashboardHeroProps {
driverName: string; driverName: string;
@@ -14,7 +15,6 @@ interface DashboardHeroProps {
rank: number; rank: number;
totalRaces: number; totalRaces: number;
winRate: number; winRate: number;
className?: string;
} }
export function DashboardHero({ export function DashboardHero({
@@ -24,123 +24,50 @@ export function DashboardHero({
rank, rank,
totalRaces, totalRaces,
winRate, winRate,
className = '',
}: DashboardHeroProps) { }: DashboardHeroProps) {
return ( return (
<Box <ProfileHero glowColor="aqua">
position="relative" <div style={{ display: 'flex', alignItems: 'center', gap: '2rem', flexWrap: 'wrap' }}>
bg="bg-[#0C0D0F]" {/* Avatar Section */}
borderBottom <ProfileAvatar
borderColor="border-[#23272B]" badge={<Icon icon={Star} size={5} intent="high" />}
overflow="hidden" >
className={className} <Avatar
> src={avatarUrl || undefined}
{/* Background Glow */} alt={driverName}
<Box size="xl"
position="absolute" />
top={-100} </ProfileAvatar>
right={-100}
w="500px"
h="500px"
bg="bg-primary-blue/10"
rounded="full"
blur="3xl"
/>
<Box p={{ base: 6, md: 10 }} position="relative" zIndex={10}> {/* Info Section */}
<Stack direction={{ base: 'col', md: 'row' }} align="center" gap={8}> <div style={{ flex: 1, minWidth: '200px' }}>
{/* Avatar Section */} <Heading level={1}>{driverName}</Heading>
<Box position="relative">
<Box <ProfileStatsGroup>
p={1} <ProfileStat label="Rating" value={rating} intent="telemetry" />
rounded="2xl" <ProfileStat label="Rank" value={`#${rank}`} intent="warning" />
bg="bg-[#141619]" <ProfileStat label="Starts" value={totalRaces} intent="low" />
border </ProfileStatsGroup>
borderColor="border-[#23272B]"
>
<Avatar
src={avatarUrl}
alt={driverName}
size={120}
className="rounded-xl"
/>
</Box>
<Box
position="absolute"
bottom={-2}
right={-2}
w="10"
h="10"
rounded="xl"
bg="bg-[#4ED4E0]"
borderWidth="2px"
borderStyle="solid"
borderColor="border-[#0C0D0F]"
display="flex"
center
>
<Icon icon={Star} size={5} color="#0C0D0F" />
</Box>
</Box>
{/* Info Section */} <BadgeGroup>
<Stack flex={1} align={{ base: 'center', md: 'start' }} gap={4}> <Badge variant="primary" icon={Trophy}>
<Box> {winRate}% Win Rate
<Heading level={1} uppercase letterSpacing="tighter" mb={2}> </Badge>
{driverName} <Badge variant="info" icon={Flag}>
</Heading> Pro License
<Stack direction="row" gap={4}> </Badge>
<Stack gap={0.5}> <Badge variant="default" icon={Users}>
<Text size="xs" color="text-gray-500" uppercase>Rating</Text> Team Redline
<Text size="sm" weight="bold" color="text-[#4ED4E0]" font="mono">{rating}</Text> </Badge>
</Stack> </BadgeGroup>
<Stack gap={0.5}> </div>
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
<Text size="sm" weight="bold" color="text-[#FFBE4D]" font="mono">#{rank}</Text>
</Stack>
<Stack gap={0.5}>
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
<Text size="sm" weight="bold" color="text-gray-300" font="mono">{totalRaces}</Text>
</Stack>
</Stack>
</Box>
<Stack direction="row" gap={3} wrap> {/* Quick Stats */}
<Badge variant="primary" rounded="lg" icon={Trophy}> <QuickStatCard>
{winRate}% Win Rate <QuickStatItem label="Podiums" value="12" />
</Badge> <QuickStatItem label="Wins" value="4" />
<Badge variant="info" rounded="lg" icon={Flag}> </QuickStatCard>
Pro License </div>
</Badge> </ProfileHero>
<Badge variant="default" rounded="lg" icon={Users}>
Team Redline
</Badge>
</Stack>
</Stack>
{/* Quick Stats */}
<Stack
direction="row"
gap={4}
p={6}
bg="bg-white/5"
rounded="2xl"
border
borderColor="border-white/10"
className="backdrop-blur-md"
>
<Stack align="center" px={4}>
<Text size="2xl" weight="bold" color="text-white">12</Text>
<Text size="xs" color="text-gray-500" uppercase>Podiums</Text>
</Stack>
<Box w="1px" h="10" bg="bg-white/10" />
<Stack align="center" px={4}>
<Text size="2xl" weight="bold" color="text-white">4</Text>
<Text size="xs" color="text-gray-500" uppercase>Wins</Text>
</Stack>
</Stack>
</Stack>
</Box>
</Box>
); );
} }

View File

@@ -1,57 +1,64 @@
'use client';
import { DashboardHero as UiDashboardHero } from '@/components/dashboard/DashboardHero'; import { DashboardHero as UiDashboardHero } from '@/components/dashboard/DashboardHero';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { StatBox } from '@/ui/StatBox'; import { StatGrid } from '@/ui/StatGrid';
import { Flag, Medal, Target, Trophy, User, Users } from 'lucide-react'; import { Flag, Medal, Target, Trophy, User, Users } from 'lucide-react';
import React from 'react';
interface DashboardHeroProps { interface DashboardHeroProps {
currentDriver: { currentDriver: {
name: string; name: string;
avatarUrl: string; avatarUrl: string;
country: string; country: string;
rating: string | number; rating: number;
rank: string | number; rank: number;
totalRaces: string | number; totalRaces: number;
wins: string | number; wins: number;
podiums: string | number; podiums: number;
consistency: string; consistency: string;
}; };
activeLeaguesCount: string | number; activeLeaguesCount: number;
} }
export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) { export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) {
return ( return (
<UiDashboardHero <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
driverName={currentDriver.name} <UiDashboardHero
avatarUrl={currentDriver.avatarUrl} driverName={currentDriver.name}
country={currentDriver.country} avatarUrl={currentDriver.avatarUrl}
rating={currentDriver.rating} rating={currentDriver.rating}
rank={currentDriver.rank} rank={currentDriver.rank}
totalRaces={currentDriver.totalRaces} totalRaces={currentDriver.totalRaces}
actions={ winRate={Math.round((currentDriver.wins / currentDriver.totalRaces) * 100) || 0}
<> />
<Link href={routes.public.leagues} variant="ghost">
<Button variant="secondary" icon={<Icon icon={Flag} size={4} />}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
Browse Leagues <Link href={routes.public.leagues}>
</Button> <Button variant="secondary" icon={<Icon icon={Flag} size={4} />}>
</Link> Browse Leagues
<Link href={routes.protected.profile} variant="ghost"> </Button>
<Button variant="primary" icon={<Icon icon={User} size={4} />}> </Link>
View Profile <Link href={routes.protected.profile}>
</Button> <Button variant="primary" icon={<Icon icon={User} size={4} />}>
</Link> View Profile
</> </Button>
} </Link>
stats={ </div>
<>
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" /> <StatGrid
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#FFBE4D" /> variant="box"
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#198CFF" /> columns={{ base: 2, md: 4 }}
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" /> stats={[
</> { icon: Trophy, label: 'Wins', value: currentDriver.wins, intent: 'success' },
} { icon: Medal, label: 'Podiums', value: currentDriver.podiums, intent: 'warning' },
/> { icon: Target, label: 'Consistency', value: currentDriver.consistency, intent: 'primary' },
{ icon: Users, label: 'Active Leagues', value: activeLeaguesCount, intent: 'telemetry' },
]}
/>
</div>
); );
} }

View File

@@ -1,4 +1,7 @@
import { Stack } from '@/ui/primitives/Stack'; import { Sidebar } from '@/ui/Sidebar';
import { Header } from '@/ui/Header';
import { MainContent } from '@/ui/MainContent';
import { Box } from '@/ui/primitives/Box';
import React from 'react'; import React from 'react';
interface DashboardShellProps { interface DashboardShellProps {
@@ -12,28 +15,25 @@ interface DashboardShellProps {
* *
* The primary layout container for the Telemetry Workspace. * The primary layout container for the Telemetry Workspace.
* Orchestrates the sidebar rail, top control bar, and main content area. * 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) { export function DashboardShell({ children, rail, controlBar }: DashboardShellProps) {
return ( return (
<Stack direction="row" h="screen" overflow="hidden" bg="base-black" color="white"> <Box display="flex" height="100vh" style={{ overflow: 'hidden', backgroundColor: 'var(--ui-color-bg-base)' }}>
{rail && ( {rail && (
<Stack as="aside" w="16" flexShrink={0} borderRight bg="surface-charcoal" borderColor="var(--color-outline)"> <Sidebar>
{rail} {rail}
</Stack> </Sidebar>
)} )}
<Stack flexGrow={1} overflow="hidden"> <Box display="flex" flexDirection="col" flex={1} style={{ overflow: 'hidden' }}>
{controlBar && ( {controlBar && (
<Stack as="header" h="14" borderBottom bg="surface-charcoal" borderColor="var(--color-outline)"> <Header>
{controlBar} {controlBar}
</Stack> </Header>
)} )}
<Stack as="main" flexGrow={1} overflow="auto" p={6}> <MainContent maxWidth="7xl">
<Stack maxWidth="7xl" mx="auto" gap={6} fullWidth> {children}
{children} </MainContent>
</Stack> </Box>
</Stack> </Box>
</Stack>
</Stack>
); );
} }

View File

@@ -1,3 +1,5 @@
'use client';
import React from 'react'; import React from 'react';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot'; import { StatusDot } from '@/ui/StatusDot';
@@ -19,33 +21,23 @@ interface RecentActivityTableProps {
* RecentActivityTable * RecentActivityTable
* *
* A high-density table for displaying recent events and telemetry logs. * A high-density table for displaying recent events and telemetry logs.
* Uses UI primitives to comply with architectural constraints.
*/ */
export function RecentActivityTable({ items }: RecentActivityTableProps) { 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 ( return (
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableHeader> <TableHeader>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Type</Text> <Text size="xs" weight="bold" uppercase variant="low">Type</Text>
</TableHeader> </TableHeader>
<TableHeader> <TableHeader>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Description</Text> <Text size="xs" weight="bold" uppercase variant="low">Description</Text>
</TableHeader> </TableHeader>
<TableHeader> <TableHeader>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Time</Text> <Text size="xs" weight="bold" uppercase variant="low">Time</Text>
</TableHeader> </TableHeader>
<TableHeader> <TableHeader>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Status</Text> <Text size="xs" weight="bold" uppercase variant="low">Status</Text>
</TableHeader> </TableHeader>
</TableRow> </TableRow>
</TableHead> </TableHead>
@@ -53,16 +45,16 @@ export function RecentActivityTable({ items }: RecentActivityTableProps) {
{items.map((item) => ( {items.map((item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell> <TableCell>
<Text font="mono" color="var(--color-telemetry)" size="xs">{item.type}</Text> <Text font="mono" variant="telemetry" size="xs">{item.type}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="var(--color-text-med)" size="xs">{item.description}</Text> <Text variant="med" size="xs">{item.description}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="var(--color-text-low)" size="xs">{item.timestamp}</Text> <Text variant="low" size="xs">{item.timestamp}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusDot color={getStatusColor(item.status)} size={1.5} /> <StatusDot intent={item.status === 'info' ? 'primary' : item.status} size="sm" />
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -1,5 +1,5 @@
import { Panel } from '@/ui/Panel'; import { Panel } from '@/ui/Panel';
import { Stack } from '@/ui/primitives/Stack'; import { Text } from '@/ui/Text';
import React from 'react'; import React from 'react';
interface TelemetryPanelProps { interface TelemetryPanelProps {
@@ -11,14 +11,13 @@ interface TelemetryPanelProps {
* TelemetryPanel * TelemetryPanel
* *
* A dense, instrument-grade panel for displaying data and controls. * A dense, instrument-grade panel for displaying data and controls.
* Uses UI primitives to comply with architectural constraints.
*/ */
export function TelemetryPanel({ title, children }: TelemetryPanelProps) { export function TelemetryPanel({ title, children }: TelemetryPanelProps) {
return ( return (
<Panel title={title} variant="dark" padding={4}> <Panel title={title} variant="dark" padding={4}>
<Stack fontSize="sm"> <Text size="sm" variant="med">
{children} {children}
</Stack> </Text>
</Panel> </Panel>
); );
} }

View File

@@ -4,11 +4,12 @@ import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/primitives/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Bug, Shield, X } from 'lucide-react'; import { FloatingAction } from '@/ui/FloatingAction';
import { useCallback, useEffect, useState } from 'react'; import { DebugPanel } from '@/ui/DebugPanel';
import { StatGrid } from '@/ui/StatGrid';
import { Bug, Shield } from 'lucide-react';
import React, { useCallback, useEffect, useState } from 'react';
// Extend Window interface for debug globals // Extend Window interface for debug globals
declare global { declare global {
@@ -187,141 +188,94 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
} }
return ( return (
<Stack position="fixed" bottom="4" left="4" zIndex={50}> <React.Fragment>
{/* Main Toggle Button */} {/* Main Toggle Button */}
{!isOpen && ( {!isOpen && (
<Button <FloatingAction
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
p={3}
rounded="full"
variant={debugEnabled ? 'primary' : 'secondary'} variant={debugEnabled ? 'primary' : 'secondary'}
className={`transition-all hover:scale-110 ${debugEnabled ? 'bg-green-600' : 'bg-iron-gray'}`}
title={debugEnabled ? 'Debug Mode Active' : 'Enable Debug Mode'} title={debugEnabled ? 'Debug Mode Active' : 'Enable Debug Mode'}
> >
<Icon icon={Bug} size={5} /> <Icon icon={Bug} size={5} />
</Button> </FloatingAction>
)} )}
{/* Debug Panel */} {/* Debug Panel */}
{isOpen && ( {isOpen && (
<Stack w="80" bg="bg-deep-graphite" border={true} borderColor="border-charcoal-outline" rounded="xl" shadow="2xl" overflow="hidden"> <DebugPanel
{/* Header */} title="Debug Control"
<Stack direction="row" align="center" justify="between" px={3} py={2} bg="bg-iron-gray/50" borderBottom={true} borderColor="border-charcoal-outline"> onClose={() => setIsOpen(false)}
<Stack direction="row" align="center" gap={2}> icon={<Icon icon={Bug} size={4} intent="success" />}
<Icon icon={Bug} size={4} color="text-green-400" /> >
<Text size="sm" weight="semibold" color="text-white">Debug Control</Text> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
</Stack> {/* Debug Toggle */}
<Button <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', borderRadius: '0.5rem' }}>
onClick={() => setIsOpen(false)} <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
p={1} <Icon icon={Shield} size={4} intent={debugEnabled ? 'success' : 'low'} />
variant="ghost" <Text size="sm" weight="medium">Debug Mode</Text>
className="hover:bg-charcoal-outline rounded" </div>
> <Button
<Icon icon={X} size={4} color="text-gray-400" /> onClick={toggleDebug}
</Button> size="sm"
</Stack> variant={debugEnabled ? 'primary' : 'secondary'}
>
{debugEnabled ? 'ON' : 'OFF'}
</Button>
</div>
{/* Content */} {/* Metrics */}
<Stack p={3}> {debugEnabled && (
<Stack gap={3}> <StatGrid
{/* Debug Toggle */} variant="box"
<Stack direction="row" align="center" justify="between" bg="bg-iron-gray/30" p={2} rounded="md" border={true} borderColor="border-charcoal-outline"> columns={3}
<Stack direction="row" align="center" gap={2}> stats={[
<Icon icon={Shield} size={4} color={debugEnabled ? 'text-green-400' : 'text-gray-500'} /> { label: 'Errors', value: metrics.errors, intent: 'critical', icon: Bug },
<Text size="sm" weight="medium">Debug Mode</Text> { label: 'API', value: metrics.apiRequests, intent: 'primary', icon: Bug },
</Stack> { label: 'Failures', value: metrics.apiFailures, intent: 'warning', icon: Bug },
<Button ]}
onClick={toggleDebug} />
size="sm" )}
variant={debugEnabled ? 'primary' : 'secondary'}
className={debugEnabled ? 'bg-green-600 hover:bg-green-700' : ''}
>
{debugEnabled ? 'ON' : 'OFF'}
</Button>
</Stack>
{/* Metrics */} {/* Actions */}
{debugEnabled && ( {debugEnabled && (
<Grid cols={3} gap={2}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center"> <Text size="xs" weight="semibold" variant="low" uppercase>Test Actions</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Errors</Text> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<Text size="lg" weight="bold" color="text-red-400" block>{metrics.errors}</Text> <Button onClick={triggerTestError} variant="danger" size="sm">Test Error</Button>
</Stack> <Button onClick={triggerTestApiCall} size="sm">Test API</Button>
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center"> </div>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>API</Text>
<Text size="lg" weight="bold" color="text-blue-400" block>{metrics.apiRequests}</Text>
</Stack>
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center">
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Failures</Text>
<Text size="lg" weight="bold" color="text-yellow-400" block>{metrics.apiFailures}</Text>
</Stack>
</Grid>
)}
{/* Actions */} <Text size="xs" weight="semibold" variant="low" uppercase style={{ marginTop: '0.5rem' }}>Utilities</Text>
{debugEnabled && ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
<Stack gap={2}> <Button onClick={copyDebugInfo} variant="secondary" size="sm">Copy Info</Button>
<Text size="xs" weight="semibold" color="text-gray-400">Test Actions</Text> <Button onClick={clearAllLogs} variant="secondary" size="sm">Clear Logs</Button>
<Grid cols={2} gap={2}> </div>
<Button </div>
onClick={triggerTestError} )}
variant="danger"
size="sm"
>
Test Error
</Button>
<Button
onClick={triggerTestApiCall}
size="sm"
>
Test API
</Button>
</Grid>
<Text size="xs" weight="semibold" color="text-gray-400" mt={2}>Utilities</Text> {/* Quick Links */}
<Grid cols={2} gap={2}> {debugEnabled && (
<Button <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
onClick={copyDebugInfo} <Text size="xs" weight="semibold" variant="low" uppercase>Quick Access</Text>
variant="secondary" <div style={{ fontSize: '10px', color: 'var(--ui-color-text-low)', fontFamily: 'var(--ui-font-mono)' }}>
size="sm" <div> window.__GRIDPILOT_GLOBAL_HANDLER__</div>
> <div> window.__GRIDPILOT_API_LOGGER__</div>
Copy Info <div> window.__GRIDPILOT_REACT_ERRORS__</div>
</Button> </div>
<Button </div>
onClick={clearAllLogs} )}
variant="secondary"
size="sm"
>
Clear Logs
</Button>
</Grid>
</Stack>
)}
{/* Quick Links */} {/* Status */}
{debugEnabled && ( <div style={{ paddingTop: '0.5rem', borderTop: '1px solid var(--ui-color-border-muted)', textAlign: 'center' }}>
<Stack gap={1}> <Text size="xs" variant="low" style={{ fontSize: '10px' }}>
<Text size="xs" weight="semibold" color="text-gray-400">Quick Access</Text> {debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
<Stack color="text-gray-500" className="font-mono" style={{ fontSize: '10px' }}> {isDev && ' • Development Environment'}
<Text block> window.__GRIDPILOT_GLOBAL_HANDLER__</Text> </Text>
<Text block> window.__GRIDPILOT_API_LOGGER__</Text> </div>
<Text block> window.__GRIDPILOT_REACT_ERRORS__</Text> </div>
</Stack> </DebugPanel>
</Stack>
)}
{/* Status */}
<Stack pt={2} borderTop={true} borderColor="border-charcoal-outline" align="center">
<Text size="xs" color="text-gray-500" style={{ fontSize: '10px' }} textAlign="center">
{debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
{isDev && ' • Development Environment'}
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
)} )}
</Stack> </React.Fragment>
); );
} }

View File

@@ -1,52 +0,0 @@
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
export function ActiveDriverCard({
name,
avatarUrl,
categoryLabel,
categoryColor,
skillLevelLabel,
skillLevelColor,
onClick,
}: ActiveDriverCardProps) {
return (
<Stack
as="button"
{...({ type: 'button' } as any)}
onClick={onClick}
p={3}
rounded="xl"
bg="bg-iron-gray/40"
border
borderColor="border-charcoal-outline"
className="transition-all hover:border-performance-green/40 group text-center"
>
<Stack position="relative" w="12" h="12" mx="auto" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" mb={2}>
<Image src={avatarUrl || '/default-avatar.png'} alt={name} objectFit="cover" fill />
<Stack position="absolute" bottom="0" right="0" w="3" h="3" rounded="full" bg="bg-performance-green" border borderColor="border-iron-gray" style={{ borderWidth: '2px' }}>{null}</Stack>
</Stack>
<Text
size="sm"
weight="medium"
color="text-white"
truncate
block
groupHoverTextColor="performance-green"
transition
>
{name}
</Text>
<Stack direction="row" align="center" justify="center" gap={1}>
{categoryLabel && (
<Text size="xs" color={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" color={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Stack>
</Stack>
);
}

View File

@@ -1,85 +0,0 @@
import { Badge } from '@/ui/Badge';
import { Image } from '@/ui/Image';
import { Link } from '@/ui/Link';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text';
export interface DriverIdentityProps {
driver: {
id: string;
name: string;
avatarUrl: string | null;
};
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
size?: 'sm' | 'md';
}
export function DriverIdentity(props: DriverIdentityProps) {
const { driver, href, contextLabel, meta, size = 'md' } = props;
const avatarSize = size === 'sm' ? 40 : 48;
const nameSize = size === 'sm' ? 'sm' : 'base';
const avatarUrl = driver.avatarUrl;
const content = (
<Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">
<Box
rounded="full"
bg="bg-primary-blue/20"
overflow="hidden"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={0}
w={`${avatarSize}px`}
h={`${avatarSize}px`}
>
{avatarUrl ? (
<Image
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={avatarSize} />
)}
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} minWidth="0">
<Text size={nameSize} weight="medium" color="text-white" truncate>
{driver.name}
</Text>
{contextLabel && (
<Badge variant="default" bg="bg-charcoal-outline/60" size="xs">
{contextLabel}
</Badge>
)}
</Box>
{meta && (
<Text size="xs" color="text-gray-400" mt={0.5} truncate block>
{meta}
</Text>
)}
</Box>
</Box>
);
if (href) {
return (
<Link href={href} block variant="ghost">
{content}
</Link>
);
}
return <Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">{content}</Box>;
}

View File

@@ -1,9 +1,10 @@
'use client';
import React from 'react'; import React from 'react';
import { Card } from '@/ui/Card'; import { Panel } from '@/ui/Panel';
import { Heading } from '@/ui/Heading';
import { RankingListItem } from '@/components/leaderboards/RankingListItem'; import { RankingListItem } from '@/components/leaderboards/RankingListItem';
import { RankingList } from '@/components/leaderboards/RankingList'; import { RankingList } from '@/components/leaderboards/RankingList';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState'; import { EmptyState } from '@/ui/EmptyState';
export interface DriverRanking { export interface DriverRanking {
type: 'overall' | 'league'; type: 'overall' | 'league';
@@ -21,19 +22,18 @@ interface DriverRankingsProps {
export function DriverRankings({ rankings }: DriverRankingsProps) { export function DriverRankings({ rankings }: DriverRankingsProps) {
if (!rankings || rankings.length === 0) { if (!rankings || rankings.length === 0) {
return ( return (
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}> <Panel title="Rankings">
<Heading level={3} mb={2}>Rankings</Heading> <EmptyState
<MinimalEmptyState
title="No ranking data available yet" title="No ranking data available yet"
description="Compete in leagues to earn your first results." description="Compete in leagues to earn your first results."
variant="minimal"
/> />
</Card> </Panel>
); );
} }
return ( return (
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}> <Panel title="Rankings">
<Heading level={3} mb={4}>Rankings</Heading>
<RankingList> <RankingList>
{rankings.map((ranking, index) => ( {rankings.map((ranking, index) => (
<RankingListItem <RankingListItem
@@ -47,6 +47,6 @@ export function DriverRankings({ rankings }: DriverRankingsProps) {
/> />
))} ))}
</RankingList> </RankingList>
</Card> </Panel>
); );
} }

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import { EmptyState } from '@/components/shared/state/EmptyState'; import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Pagination } from '@/ui/Pagination'; import { Pagination } from '@/ui/Pagination';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ControlBar } from '@/ui/ControlBar';
import { Trophy } from 'lucide-react'; import { Trophy } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import React from 'react';
interface RaceHistoryProps { interface RaceHistoryProps {
driverId: string; driverId: string;
@@ -24,7 +25,6 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
async function loadRaceHistory() { async function loadRaceHistory() {
try { try {
// Driver race history is not exposed via API yet. // Driver race history is not exposed via API yet.
// Keep as placeholder until an endpoint exists.
} catch (err) { } catch (err) {
console.error('Failed to load race history:', err); console.error('Failed to load race history:', err);
} finally { } finally {
@@ -40,18 +40,7 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
const totalPages = Math.ceil(filteredResults.length / resultsPerPage); const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
if (loading) { if (loading) {
return ( return <LoadingWrapper variant="spinner" message="Loading race history..." />;
<Stack gap={4}>
<Stack display="flex" alignItems="center" gap={2}>
{[1, 2, 3].map(i => (
<Stack key={i} h="9" w="24" bg="bg-iron-gray" rounded="md" animate="pulse" />
))}
</Stack>
<Card>
<LoadingWrapper variant="skeleton" skeletonCount={3} />
</Card>
</Stack>
);
} }
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
@@ -60,50 +49,50 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
icon={Trophy} icon={Trophy}
title="No race history yet" title="No race history yet"
description="Complete races to build your racing record" description="Complete races to build your racing record"
variant="minimal"
/> />
); );
} }
return ( return (
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<Stack display="flex" alignItems="center" gap={2}> <ControlBar>
<Button <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
variant={filter === 'all' ? 'primary' : 'secondary'} <Button
onClick={() => { setFilter('all'); setPage(1); }} variant={filter === 'all' ? 'primary' : 'secondary'}
size="sm" onClick={() => { setFilter('all'); setPage(1); }}
> size="sm"
All Races >
</Button> All Races
<Button </Button>
variant={filter === 'wins' ? 'primary' : 'secondary'} <Button
onClick={() => { setFilter('wins'); setPage(1); }} variant={filter === 'wins' ? 'primary' : 'secondary'}
size="sm" onClick={() => { setFilter('wins'); setPage(1); }}
> size="sm"
Wins Only >
</Button> Wins Only
<Button </Button>
variant={filter === 'podiums' ? 'primary' : 'secondary'} <Button
onClick={() => { setFilter('podiums'); setPage(1); }} variant={filter === 'podiums' ? 'primary' : 'secondary'}
size="sm" onClick={() => { setFilter('podiums'); setPage(1); }}
> size="sm"
Podiums >
</Button> Podiums
</Stack> </Button>
</div>
</ControlBar>
<Card> <Card>
{/* No results until API provides driver results */} <div style={{ minHeight: '10rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack minHeight="100px" display="flex" center> <Text variant="low">No results found for the selected filter.</Text>
<Text color="text-gray-500">No results found for the selected filter.</Text> </div>
</Stack>
<Pagination <Pagination
currentPage={page} currentPage={page}
totalPages={totalPages} totalPages={totalPages}
totalItems={filteredResults.length}
itemsPerPage={resultsPerPage}
onPageChange={setPage} onPageChange={setPage}
/> />
</Card> </Card>
</Stack> </div>
); );
} }

View File

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

View File

@@ -2,7 +2,6 @@
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -21,32 +20,30 @@ interface AppErrorBoundaryViewProps {
*/ */
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) { export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
return ( return (
<Stack gap={6} align="center" fullWidth> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
{/* Header Icon */} {/* Header Icon */}
<Stack <div
p={4} style={{
rounded="full" padding: '1rem',
bg="bg-warning-amber" borderRadius: '9999px',
bgOpacity={0.1} backgroundColor: 'rgba(255, 190, 77, 0.1)',
border border: '1px solid rgba(255, 190, 77, 0.3)'
borderColor="border-warning-amber" }}
> >
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" /> <Icon icon={AlertTriangle} size={8} intent="warning" />
</Stack> </div>
{/* Typography */} {/* Typography */}
<Stack gap={2} align="center"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<Heading level={1} weight="bold"> <Heading level={1} weight="bold">
<Text uppercase letterSpacing="tighter"> {title}
{title}
</Text>
</Heading> </Heading>
<Text color="text-gray-400" align="center" maxWidth="md" leading="relaxed"> <Text variant="low" align="center" style={{ maxWidth: '32rem' }} leading="relaxed">
{description} {description}
</Text> </Text>
</Stack> </div>
{children} {children}
</Stack> </div>
); );
} }

View File

@@ -1,382 +0,0 @@
'use client';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ApiError } from '@/lib/api/base/ApiError';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/primitives/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Activity, AlertTriangle, Copy, RefreshCw, Terminal, X } from 'lucide-react';
import { useEffect, useState } from 'react';
interface DevErrorPanelProps {
error: ApiError;
onReset: () => void;
}
/**
* Developer-focused error panel with detailed debugging information
*/
export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth());
const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus());
const [copied, setCopied] = useState(false);
useEffect(() => {
// Update status on mount
const health = connectionMonitor.getHealth();
setConnectionStatus(health);
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
// Listen for status changes
const handleStatusChange = () => {
setConnectionStatus(connectionMonitor.getHealth());
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
};
connectionMonitor.on('success', handleStatusChange);
connectionMonitor.on('failure', handleStatusChange);
connectionMonitor.on('connected', handleStatusChange);
connectionMonitor.on('disconnected', handleStatusChange);
connectionMonitor.on('degraded', handleStatusChange);
return () => {
connectionMonitor.off('success', handleStatusChange);
connectionMonitor.off('failure', handleStatusChange);
connectionMonitor.off('connected', handleStatusChange);
connectionMonitor.off('disconnected', handleStatusChange);
connectionMonitor.off('degraded', handleStatusChange);
};
}, []);
const copyToClipboard = async () => {
const debugInfo = {
error: {
type: error.type,
message: error.message,
context: error.context,
stack: error.stack,
},
connection: connectionStatus,
circuitBreakers,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
};
try {
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silent failure for clipboard operations
}
};
const triggerHealthCheck = async () => {
await connectionMonitor.performHealthCheck();
setConnectionStatus(connectionMonitor.getHealth());
};
const resetCircuitBreakers = () => {
CircuitBreakerRegistry.getInstance().resetAll();
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
};
const getSeverityVariant = (): 'danger' | 'warning' | 'info' | 'default' => {
switch (error.getSeverity()) {
case 'error': return 'danger';
case 'warn': return 'warning';
case 'info': return 'info';
default: return 'default';
}
};
const reliability = connectionMonitor.getReliability();
return (
<Stack
position="fixed"
inset="0"
zIndex={50}
overflow="auto"
bg="bg-deep-graphite"
p={4}
>
<Stack maxWidth="6xl" mx="auto" fullWidth>
<Stack gap={4}>
{/* Header */}
<Stack bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="lg" p={4} direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Icon icon={Terminal} size={5} color="rgb(59, 130, 246)" />
<Heading level={2}>API Error Debug Panel</Heading>
<Badge variant={getSeverityVariant()}>
{error.type}
</Badge>
</Stack>
<Stack direction="row" gap={2}>
<Button
variant="secondary"
onClick={copyToClipboard}
icon={<Icon icon={Copy} size={4} />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button
variant="primary"
onClick={onReset}
icon={<Icon icon={X} size={4} />}
>
Close
</Button>
</Stack>
</Stack>
{/* Error Details */}
<Grid cols={1} lgCols={2} gap={4}>
<Stack gap={4}>
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
<Icon icon={AlertTriangle} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Error Details</Text>
</Stack>
<Stack p={4}>
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Type:</Text>
<Text className="col-span-2" color="text-red-400" weight="bold">{error.type}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Message:</Text>
<Text className="col-span-2" color="text-gray-300">{error.message}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Endpoint:</Text>
<Text className="col-span-2" color="text-primary-blue">{error.context.endpoint || 'N/A'}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Method:</Text>
<Text className="col-span-2" color="text-warning-amber">{error.context.method || 'N/A'}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Status:</Text>
<Text className="col-span-2">{error.context.statusCode || 'N/A'}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Retry Count:</Text>
<Text className="col-span-2">{error.context.retryCount || 0}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Timestamp:</Text>
<Text className="col-span-2" color="text-gray-500">{error.context.timestamp}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Retryable:</Text>
<Text className="col-span-2" color={error.isRetryable() ? 'text-performance-green' : 'text-red-400'}>
{error.isRetryable() ? 'Yes' : 'No'}
</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Connectivity:</Text>
<Text className="col-span-2" color={error.isConnectivityIssue() ? 'text-red-400' : 'text-performance-green'}>
{error.isConnectivityIssue() ? 'Yes' : 'No'}
</Text>
</Grid>
{error.context.troubleshooting && (
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Troubleshoot:</Text>
<Text className="col-span-2" color="text-warning-amber">{error.context.troubleshooting}</Text>
</Grid>
)}
</Stack>
</Stack>
</Card>
{/* Connection Status */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
<Icon icon={Activity} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Connection Health</Text>
</Stack>
<Stack p={4}>
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Status:</Text>
<Text className="col-span-2" weight="bold" color={
connectionStatus.status === 'connected' ? 'text-performance-green' :
connectionStatus.status === 'degraded' ? 'text-warning-amber' :
'text-red-400'
}>
{connectionStatus.status.toUpperCase()}
</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Reliability:</Text>
<Text className="col-span-2">{reliability.toFixed(2)}%</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Total Requests:</Text>
<Text className="col-span-2">{connectionStatus.totalRequests}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Successful:</Text>
<Text className="col-span-2" color="text-performance-green">{connectionStatus.successfulRequests}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Failed:</Text>
<Text className="col-span-2" color="text-red-400">{connectionStatus.failedRequests}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Consecutive Failures:</Text>
<Text className="col-span-2">{connectionStatus.consecutiveFailures}</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Avg Response:</Text>
<Text className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</Text>
</Grid>
<Grid cols={3} gap={2}>
<Text color="text-gray-500">Last Check:</Text>
<Text className="col-span-2" color="text-gray-500">
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
</Text>
</Grid>
</Stack>
</Stack>
</Card>
</Stack>
{/* Right Column */}
<Stack gap={4}>
{/* Circuit Breakers */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
<Text size="lg"></Text>
<Text weight="semibold" color="text-white">Circuit Breakers</Text>
</Stack>
<Stack p={4}>
{Object.keys(circuitBreakers).length === 0 ? (
<Stack align="center" py={4}>
<Text color="text-gray-500">No circuit breakers active</Text>
</Stack>
) : (
<Stack gap={2} maxHeight="12rem" overflow="auto" style={{ fontSize: '0.75rem' }}>
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
<Stack key={endpoint} direction="row" align="center" justify="between" p={2} bg="bg-deep-graphite" rounded="md" border borderColor="border-charcoal-outline">
<Text color="text-primary-blue" truncate flexGrow={1}>{endpoint}</Text>
<Stack px={2} py={1} rounded="sm" bg={
status.state === 'CLOSED' ? 'bg-green-500/20' :
status.state === 'OPEN' ? 'bg-red-500/20' :
'bg-yellow-500/20'
}>
<Text color={
status.state === 'CLOSED' ? 'text-performance-green' :
status.state === 'OPEN' ? 'text-red-400' :
'text-warning-amber'
}>
{status.state}
</Text>
</Stack>
<Text color="text-gray-500" className="ml-2">{status.failures} failures</Text>
</Stack>
))}
</Stack>
)}
</Stack>
</Card>
{/* Actions */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Actions</Text>
</Stack>
<Stack p={4}>
<Stack gap={2}>
<Button
variant="primary"
onClick={triggerHealthCheck}
fullWidth
icon={<Icon icon={RefreshCw} size={4} />}
>
Run Health Check
</Button>
<Button
variant="secondary"
onClick={resetCircuitBreakers}
fullWidth
icon={<Text size="lg">🔄</Text>}
>
Reset Circuit Breakers
</Button>
<Button
variant="danger"
onClick={() => {
connectionMonitor.reset();
setConnectionStatus(connectionMonitor.getHealth());
}}
fullWidth
icon={<Text size="lg">🗑</Text>}
>
Reset Connection Stats
</Button>
</Stack>
</Stack>
</Card>
{/* Quick Fixes */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Quick Fixes</Text>
</Stack>
<Stack p={4}>
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
<Text color="text-gray-400">Common solutions:</Text>
<Stack as="ul" gap={1} pl={4}>
<Stack as="li"><Text color="text-gray-300">Check API server is running</Text></Stack>
<Stack as="li"><Text color="text-gray-300">Verify CORS configuration</Text></Stack>
<Stack as="li"><Text color="text-gray-300">Check environment variables</Text></Stack>
<Stack as="li"><Text color="text-gray-300">Review network connectivity</Text></Stack>
<Stack as="li"><Text color="text-gray-300">Check API rate limits</Text></Stack>
</Stack>
</Stack>
</Stack>
</Card>
{/* Raw Error */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2}>
<Text weight="semibold" color="text-white">Raw Error</Text>
</Stack>
<Stack p={4}>
<Stack as="pre" p={2} bg="bg-deep-graphite" rounded="md" overflow="auto" maxHeight="8rem" style={{ fontSize: '0.75rem' }} color="text-gray-400">
{JSON.stringify({
type: error.type,
message: error.message,
context: error.context,
}, null, 2)}
</Stack>
</Stack>
</Card>
</Stack>
</Grid>
{/* Console Output */}
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
<Icon icon={Terminal} size={4} color="text-white" />
<Text weight="semibold" color="text-white">Console Output</Text>
</Stack>
<Stack p={4} bg="bg-deep-graphite" style={{ fontSize: '0.75rem' }}>
<Text color="text-gray-500" block mb={2}>{'>'} {error.getDeveloperMessage()}</Text>
<Text color="text-gray-600" block>Check browser console for full stack trace and additional debug info.</Text>
</Stack>
</Card>
</Stack>
</Stack>
</Stack>
);
}

View File

@@ -3,10 +3,10 @@
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ChevronDown, ChevronUp, Copy, Terminal } from 'lucide-react'; import { Accordion } from '@/ui/Accordion';
import { useState } from 'react'; import { Copy } from 'lucide-react';
import React, { useState } from 'react';
interface ErrorDetailsProps { interface ErrorDetailsProps {
error: Error & { digest?: string }; error: Error & { digest?: string };
@@ -19,7 +19,6 @@ interface ErrorDetailsProps {
* Part of the 500 route redesign. * Part of the 500 route redesign.
*/ */
export function ErrorDetails({ error }: ErrorDetailsProps) { export function ErrorDetails({ error }: ErrorDetailsProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyError = async () => { const copyError = async () => {
@@ -41,62 +40,28 @@ export function ErrorDetails({ error }: ErrorDetailsProps) {
}; };
return ( return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white"> <div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
<Stack <Accordion title="Technical Logs">
as="button" <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
onClick={() => setShowDetails(!showDetails)} <Card variant="outline">
direction="row" <Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
align="center"
justify="center"
gap={2}
color="text-gray-500"
className="transition-all hover:text-gray-300"
>
<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} />}
</Stack>
{showDetails && (
<Stack gap={3}>
<Card
variant="outline"
rounded="md"
p={4}
fullWidth
maxHeight="48"
overflow="auto"
borderColor="border-white"
className="bg-graphite-black/40"
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'} {error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`} {error.digest && `\n\nDigest: ${error.digest}`}
</Text> </Text>
</Card> </Card>
<Stack direction="row" justify="end"> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={copyError} onClick={copyError}
icon={<Icon icon={Copy} size={3} />} icon={<Icon icon={Copy} size={3} intent="low" />}
height="8"
fontSize="10px"
> >
{copied ? 'Copied to Clipboard' : 'Copy Error Details'} {copied ? 'Copied!' : 'Copy Details'}
</Button> </Button>
</Stack> </div>
</Stack> </div>
)} </Accordion>
</Stack> </div>
); );
} }

View File

@@ -3,10 +3,10 @@
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ChevronDown, ChevronUp, Copy } from 'lucide-react'; import { Accordion } from '@/ui/Accordion';
import { useState } from 'react'; import { Copy } from 'lucide-react';
import React, { useState } from 'react';
interface ErrorDetailsBlockProps { interface ErrorDetailsBlockProps {
error: Error & { digest?: string }; error: Error & { digest?: string };
@@ -19,7 +19,6 @@ interface ErrorDetailsBlockProps {
* Follows "Precision Racing Minimal" theme. * Follows "Precision Racing Minimal" theme.
*/ */
export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) { export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyError = async () => { const copyError = async () => {
@@ -41,61 +40,28 @@ export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
}; };
return ( return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white"> <div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
<Stack <Accordion title="Technical Logs">
as="button" <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
onClick={() => setShowDetails(!showDetails)} <Card variant="outline">
direction="row" <Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
align="center"
justify="center"
gap={2}
className="transition-all"
>
<Text
size="xs"
color="text-gray-500"
className="hover:text-gray-300 flex items-center gap-2"
uppercase
letterSpacing="widest"
weight="medium"
>
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
</Text>
</Stack>
{showDetails && (
<Stack gap={3}>
<Card
variant="outline"
rounded="md"
p={4}
fullWidth
maxHeight="48"
overflow="auto"
borderColor="border-white"
className="bg-graphite-black/40"
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'} {error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`} {error.digest && `\n\nDigest: ${error.digest}`}
</Text> </Text>
</Card> </Card>
<Stack direction="row" justify="end"> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={copyError} onClick={copyError}
icon={<Icon icon={Copy} size={3} />} icon={<Icon icon={Copy} size={3} intent="low" />}
height="8"
fontSize="10px"
> >
{copied ? 'Copied to Clipboard' : 'Copy Error Details'} {copied ? 'Copied!' : 'Copy Details'}
</Button> </Button>
</Stack> </div>
</Stack> </div>
)} </Accordion>
</Stack> </div>
); );
} }

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
import { Icon } from '@/ui/Icon'; import React from 'react';
import { Stack } from '@/ui/primitives/Stack';
import { Home, RefreshCw } from 'lucide-react';
interface ErrorRecoveryActionsProps { interface ErrorRecoveryActionsProps {
onRetry: () => void; onRetry: () => void;
@@ -18,30 +16,9 @@ interface ErrorRecoveryActionsProps {
*/ */
export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) { export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) {
return ( return (
<Stack <ErrorActionButtons
direction="row" onRetry={onRetry}
wrap onGoHome={onHome}
align="center" />
justify="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>
</Stack>
); );
} }

View File

@@ -2,11 +2,12 @@
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Glow } from '@/ui/Glow'; import { Glow } from '@/ui/Glow';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { AppErrorBoundaryView } from './AppErrorBoundaryView'; import { AppErrorBoundaryView } from './AppErrorBoundaryView';
import { ErrorDetailsBlock } from './ErrorDetailsBlock'; import { ErrorDetailsBlock } from './ErrorDetailsBlock';
import { ErrorRecoveryActions } from './ErrorRecoveryActions'; import { ErrorRecoveryActions } from './ErrorRecoveryActions';
import React from 'react';
interface ErrorScreenProps { interface ErrorScreenProps {
error: Error & { digest?: string }; error: Error & { digest?: string };
@@ -22,54 +23,27 @@ interface ErrorScreenProps {
*/ */
export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) { export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) {
return ( return (
<Stack <ErrorPageContainer size="lg" variant="glass">
as="main"
minHeight="screen"
fullWidth
align="center"
justify="center"
bg="bg-deep-graphite"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents */} {/* Background Accents */}
<Glow color="primary" size="xl" position="center" opacity={0.05} /> <Glow color="primary" size="xl" position="center" opacity={0.05} />
<Card <AppErrorBoundaryView
variant="outline" title="System Malfunction"
rounded="lg" description="The application encountered an unexpected state. Our telemetry has logged the incident."
p={8}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
borderColor="border-white"
className="bg-white/5 backdrop-blur-md"
> >
<AppErrorBoundaryView {/* Error Message Summary */}
title="System Malfunction" <div style={{ width: '100%', marginBottom: '1.5rem' }}>
description="The application encountered an unexpected state. Our telemetry has logged the incident." <Card variant="outline">
> <Text font="mono" size="sm" variant="warning" block>
{/* Error Message Summary */}
<Card
variant="outline"
rounded="md"
p={4}
fullWidth
borderColor="border-white"
className="bg-graphite-black/20"
>
<Text font="mono" size="sm" color="text-warning-amber" block>
{error.message || 'Unknown execution error'} {error.message || 'Unknown execution error'}
</Text> </Text>
</Card> </Card>
</div>
<ErrorRecoveryActions onRetry={reset} onHome={onHome} /> <ErrorRecoveryActions onRetry={reset} onHome={onHome} />
<ErrorDetailsBlock error={error} /> <ErrorDetailsBlock error={error} />
</AppErrorBoundaryView> </AppErrorBoundaryView>
</Card> </ErrorPageContainer>
</Stack>
); );
} }

View File

@@ -5,9 +5,10 @@ import { Card } from '@/ui/Card';
import { Glow } from '@/ui/Glow'; import { Glow } from '@/ui/Glow';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { AlertTriangle, Home, RefreshCw, Terminal } from 'lucide-react'; import { AlertTriangle, Home, RefreshCw, Terminal } from 'lucide-react';
import React from 'react';
interface GlobalErrorScreenProps { interface GlobalErrorScreenProps {
error: Error & { digest?: string }; error: Error & { digest?: string };
@@ -23,88 +24,44 @@ interface GlobalErrorScreenProps {
*/ */
export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) { export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) {
return ( return (
<Stack <ErrorPageContainer size="lg" variant="glass">
as="main"
minHeight="screen"
fullWidth
align="center"
justify="center"
bg="bg-base-black"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents - Subtle telemetry vibe */} {/* Background Accents - Subtle telemetry vibe */}
<Glow color="primary" size="xl" position="center" opacity={0.03} /> <Glow color="primary" size="xl" position="center" opacity={0.03} />
<Card <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--ui-color-border-default)' }}>
variant="outline" <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
rounded="none" <Icon icon={AlertTriangle} size={5} intent="warning" />
p={0} <Heading level={2} weight="bold">
maxWidth="2xl" System Fault Detected
fullWidth </Heading>
position="relative" </div>
zIndex={10} <Text font="mono" size="xs" variant="low" uppercase>
borderColor="border-white" Status: Critical
className="bg-graphite-black/10" </Text>
> </div>
{/* System Status Header */}
<Stack <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
borderBottom {/* Fault Description */}
borderColor="border-white" <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
px={6} <Text variant="med" size="base" leading="relaxed">
py={4} The application kernel encountered an unrecoverable execution error.
direction="row" Telemetry has been captured for diagnostic review.
align="center"
justify="between"
className="bg-white/5"
>
<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> </Text>
</Stack>
<Stack p={8}> <SystemStatusPanel error={error} />
<Stack gap={8}> </div>
{/* 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} /> {/* Recovery Actions */}
</Stack> <RecoveryActions onRetry={reset} onHome={onHome} />
</div>
{/* Recovery Actions */} {/* Footer / Metadata */}
<RecoveryActions onRetry={reset} onHome={onHome} /> <div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px solid var(--ui-color-border-default)', textAlign: 'right' }}>
</Stack> <Text font="mono" size="xs" variant="low">
</Stack> GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
</Text>
{/* Footer / Metadata */} </div>
<Stack </ErrorPageContainer>
borderTop
borderColor="border-white"
px={6}
py={3}
direction="row"
justify="end"
className="bg-white/5"
>
<Text font="mono" size="xs" color="text-gray-600">
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
</Text>
</Stack>
</Card>
</Stack>
); );
} }
@@ -115,30 +72,23 @@ export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenPro
*/ */
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) { function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
return ( return (
<Card <Card variant="outline">
variant="outline" <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
rounded="none" <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
p={4} <Icon icon={Terminal} size={3} intent="low" />
fullWidth <Text font="mono" size="xs" variant="low" uppercase>
borderColor="border-white"
className="bg-graphite-black/20"
>
<Stack gap={3}>
<Stack direction="row" align="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 Fault Log
</Text> </Text>
</Stack> </div>
<Text font="mono" size="sm" color="text-warning-amber" block> <Text font="mono" size="sm" variant="warning" block>
{error.message || 'Unknown execution fault'} {error.message || 'Unknown execution fault'}
</Text> </Text>
{error.digest && ( {error.digest && (
<Text font="mono" size="xs" color="text-gray-600" block> <Text font="mono" size="xs" variant="low" block>
Digest: {error.digest} Digest: {error.digest}
</Text> </Text>
)} )}
</Stack> </div>
</Card> </Card>
); );
} }
@@ -150,19 +100,11 @@ function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
*/ */
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) { function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
return ( return (
<Stack <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
direction="row"
wrap
align="center"
gap={4}
fullWidth
>
<Button <Button
variant="primary" variant="primary"
onClick={onRetry} onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />} icon={<Icon icon={RefreshCw} size={4} />}
rounded="none"
px={8}
> >
Reboot Session Reboot Session
</Button> </Button>
@@ -170,11 +112,9 @@ function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: ()
variant="secondary" variant="secondary"
onClick={onHome} onClick={onHome}
icon={<Icon icon={Home} size={4} />} icon={<Icon icon={Home} size={4} />}
rounded="none"
px={8}
> >
Return to Pits Return to Pits
</Button> </Button>
</Stack> </div>
); );
} }

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Stack } from '@/ui/primitives/Stack'; import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot';
interface NotFoundActionsProps { interface NotFoundActionsProps {
primaryLabel: string; primaryLabel: string;
@@ -17,12 +18,11 @@ interface NotFoundActionsProps {
*/ */
export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) { export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) {
return ( return (
<Stack direction="row" gap={4} align="center" justify="center"> <Group direction="row" gap={4} align="center" justify="center">
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
onClick={onPrimaryClick} onClick={onPrimaryClick}
minWidth="200px"
> >
{primaryLabel} {primaryLabel}
</Button> </Button>
@@ -32,18 +32,13 @@ export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundAction
size="lg" size="lg"
onClick={() => window.history.back()} onClick={() => window.history.back()}
> >
<Stack direction="row" gap={2} align="center"> <Group direction="row" gap={2} align="center">
<Stack <StatusDot intent="telemetry" size="sm" />
width={2} <Text size="xs" weight="bold" uppercase variant="low">
height={2}
rounded="full"
bg="soft-steel"
/>
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-gray-400">
Previous Sector Previous Sector
</Text> </Text>
</Stack> </Group>
</Button> </Button>
</Stack> </Group>
); );
} }

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import React from 'react';
interface NotFoundCallToActionProps { interface NotFoundCallToActionProps {
label: string; label: string;
@@ -17,7 +17,7 @@ interface NotFoundCallToActionProps {
*/ */
export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) { export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) {
return ( return (
<Stack gap={4} align="center"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
@@ -25,9 +25,9 @@ export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionPro
> >
{label} {label}
</Button> </Button>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="widest"> <Text size="xs" variant="low" uppercase>
Telemetry connection lost Telemetry connection lost
</Text> </Text>
</Stack> </div>
); );
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { Stack } from '@/ui/primitives/Stack'; import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
interface NotFoundDiagnosticsProps { interface NotFoundDiagnosticsProps {
errorCode: string; errorCode: string;
@@ -15,35 +16,19 @@ interface NotFoundDiagnosticsProps {
*/ */
export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) { export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) {
return ( return (
<Stack gap={3} align="center"> <Group direction="column" gap={3} align="center">
<Stack <Badge variant="primary" size="md">
px={3} {errorCode}
py={1} </Badge>
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>
</Stack>
<Text <Text
size="xs" size="xs"
color="text-gray-500" variant="low"
uppercase uppercase
letterSpacing="widest"
weight="medium" weight="medium"
align="center"
> >
Telemetry connection lost // Sector data unavailable Telemetry connection lost // Sector data unavailable
</Text> </Text>
</Stack> </Group>
); );
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { Stack } from '@/ui/primitives/Stack'; import { NavGroup } from '@/ui/NavGroup';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import React from 'react'; import React from 'react';
interface NotFoundHelpLinksProps { interface NotFoundHelpLinksProps {
@@ -16,31 +17,20 @@ interface NotFoundHelpLinksProps {
*/ */
export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) { export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) {
return ( return (
<Stack direction="row" gap={6} align="center" wrap center> <NavGroup direction="horizontal" gap={6} align="center">
{links.map((link, index) => ( {links.map((link) => (
<React.Fragment key={link.href}> <Link key={link.href} href={link.href} variant="ghost" underline="none">
<Stack <Text
as="a" variant="low"
href={link.href} hoverVariant="primary"
transition weight="medium"
display="inline-block" size="xs"
uppercase
> >
<Text {link.label}
color="text-gray-400" </Text>
hoverTextColor="primary-accent" </Link>
weight="medium"
size="xs"
letterSpacing="widest"
uppercase
>
{link.label}
</Text>
</Stack>
{index < links.length - 1 && (
<Stack width="1px" height="12px" bg="border-gray" opacity={0.5} />
)}
</React.Fragment>
))} ))}
</Stack> </NavGroup>
); );
} }

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { Glow } from '@/ui/Glow'; import { Glow } from '@/ui/Glow';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { FooterSection } from '@/ui/FooterSection';
import { NotFoundActions } from './NotFoundActions'; import { NotFoundActions } from './NotFoundActions';
import { NotFoundDiagnostics } from './NotFoundDiagnostics'; import { NotFoundDiagnostics } from './NotFoundDiagnostics';
import { NotFoundHelpLinks } from './NotFoundHelpLinks'; import { NotFoundHelpLinks } from './NotFoundHelpLinks';
import React from 'react';
interface NotFoundScreenProps { interface NotFoundScreenProps {
errorCode: string; errorCode: string;
@@ -37,105 +38,44 @@ export function NotFoundScreen({
]; ];
return ( return (
<Stack <ErrorPageContainer size="lg" variant="glass">
as="main"
minHeight="screen"
align="center"
justify="center"
bg="graphite-black"
position="relative"
overflow="hidden"
fullWidth
>
{/* Background Glow Accent */} {/* Background Glow Accent */}
<Glow color="primary" size="xl" opacity={0.1} position="center" /> <Glow color="primary" size="xl" opacity={0.1} position="center" />
<Card <NotFoundDiagnostics errorCode={errorCode} />
variant="outline"
p={12} <Text
rounded="none" as="h1"
maxWidth="2xl" size="4xl"
fullWidth weight="bold"
mx={6} variant="high"
position="relative" uppercase
zIndex={10} block
className="bg-white/5 backdrop-blur-md" align="center"
style={{ marginTop: '1rem', marginBottom: '2rem' }}
> >
<Stack gap={12} align="center"> {title}
{/* Header Section */} </Text>
<Stack gap={4} align="center">
<NotFoundDiagnostics errorCode={errorCode} />
<Text
as="h1"
size="4xl"
weight="bold"
color="text-white"
letterSpacing="tighter"
uppercase
block
leading="none"
textAlign="center"
>
{title}
</Text>
</Stack>
{/* Visual Separator */} <Text
<Stack width="full" height="1px" bg="primary-accent" opacity={0.3} position="relative" align="center" justify="center"> size="xl"
<Stack variant="med"
w="3" block
h="3" weight="medium"
bg="primary-accent" align="center"
>{null}</Stack> style={{ marginBottom: '3rem' }}
</Stack> >
{message}
</Text>
{/* Message Section */} <NotFoundActions
<Text primaryLabel={actionLabel}
size="xl" onPrimaryClick={onActionClick}
color="text-gray-400" />
maxWidth="lg"
leading="relaxed"
block
weight="medium"
textAlign="center"
>
{message}
</Text>
{/* Actions Section */} <FooterSection>
<NotFoundActions <NotFoundHelpLinks links={helpLinks} />
primaryLabel={actionLabel} </FooterSection>
onPrimaryClick={onActionClick} </ErrorPageContainer>
/>
{/* Footer Section */}
<Stack pt={8} width="full">
<Stack height="1px" width="full" bg="border-gray" opacity={0.1} mb={8}>{null}</Stack>
<NotFoundHelpLinks links={helpLinks} />
</Stack>
</Stack>
</Card>
{/* Subtle Edge Details */}
<Stack
position="absolute"
top={0}
left={0}
right={0}
h="2px"
bg="primary-accent"
opacity={0.1}
>{null}</Stack>
<Stack
position="absolute"
bottom={0}
left={0}
right={0}
h="2px"
bg="primary-accent"
opacity={0.1}
>{null}</Stack>
</Stack>
); );
} }

View File

@@ -2,8 +2,8 @@
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Home, LifeBuoy, RefreshCw } from 'lucide-react'; import { Home, LifeBuoy, RefreshCw } from 'lucide-react';
import React from 'react';
interface RecoveryActionsProps { interface RecoveryActionsProps {
onRetry: () => void; onRetry: () => void;
@@ -18,19 +18,11 @@ interface RecoveryActionsProps {
*/ */
export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) { export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
return ( return (
<Stack <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.75rem', flexWrap: 'wrap', width: '100%' }}>
direction="row"
wrap
align="center"
justify="center"
gap={3}
fullWidth
>
<Button <Button
variant="primary" variant="primary"
onClick={onRetry} onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />} icon={<Icon icon={RefreshCw} size={4} />}
width="160px"
> >
Retry Session Retry Session
</Button> </Button>
@@ -38,7 +30,6 @@ export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
variant="secondary" variant="secondary"
onClick={onHome} onClick={onHome}
icon={<Icon icon={Home} size={4} />} icon={<Icon icon={Home} size={4} />}
width="160px"
> >
Return to Pits Return to Pits
</Button> </Button>
@@ -49,10 +40,9 @@ export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
icon={<Icon icon={LifeBuoy} size={4} />} icon={<Icon icon={LifeBuoy} size={4} />}
width="160px"
> >
Contact Support Contact Support
</Button> </Button>
</Stack> </div>
); );
} }

View File

@@ -3,9 +3,9 @@
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import React from 'react';
interface ServerErrorPanelProps { interface ServerErrorPanelProps {
message?: string; message?: string;
@@ -20,57 +20,53 @@ interface ServerErrorPanelProps {
*/ */
export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) { export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) {
return ( return (
<Stack gap={6} align="center" fullWidth> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
{/* Status Indicator */} {/* Status Indicator */}
<Stack <div
p={4} style={{
rounded="full" padding: '1rem',
bg="bg-warning-amber" borderRadius: '9999px',
{...({ bgOpacity: 0.1 } as any)} backgroundColor: 'rgba(255, 190, 77, 0.1)',
border border: '1px solid rgba(255, 190, 77, 0.3)',
borderColor="border-warning-amber" display: 'flex',
align="center" alignItems: 'center',
justify="center" justifyContent: 'center'
}}
> >
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" /> <Icon icon={AlertTriangle} size={8} intent="warning" />
</Stack> </div>
{/* Primary Message */} {/* Primary Message */}
<Stack gap={2} align="center"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<Heading level={1} weight="bold"> <Heading level={1} weight="bold">
CRITICAL_SYSTEM_FAILURE CRITICAL_SYSTEM_FAILURE
</Heading> </Heading>
<Text color="text-gray-400" textAlign="center" maxWidth="md"> <Text variant="low" align="center" style={{ maxWidth: '32rem' }}>
The application engine encountered an unrecoverable state. The application engine encountered an unrecoverable state.
Telemetry has been dispatched to engineering. Telemetry has been dispatched to engineering.
</Text> </Text>
</Stack> </div>
{/* Technical Summary */} {/* Technical Summary */}
<Card <div style={{ width: '100%' }}>
variant="outline" <Card variant="outline">
rounded="md" <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
p={4} <Text font="mono" size="sm" variant="warning" block>
fullWidth STATUS: 500_INTERNAL_SERVER_ERROR
borderColor="border-white"
className="bg-graphite-black/20"
>
<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> </Text>
)} {message && (
{incidentId && ( <Text font="mono" size="xs" variant="low" block>
<Text font="mono" size="xs" color="text-gray-500" block> EXCEPTION: {message}
INCIDENT_ID: {incidentId} </Text>
</Text> )}
)} {incidentId && (
</Stack> <Text font="mono" size="xs" variant="low" block>
</Card> INCIDENT_ID: {incidentId}
</Stack> </Text>
)}
</div>
</Card>
</div>
</div>
); );
} }

View File

@@ -1,11 +1,13 @@
'use client';
import React from 'react'; import React from 'react';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading'; import { SectionHeader } from '@/ui/SectionHeader';
import { ActivityItem } from '@/ui/ActivityItem'; import { ActivityItem } from '@/ui/ActivityItem';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { ActivityFeedList } from '@/components/feed/ActivityFeedList'; import { EmptyState } from '@/ui/EmptyState';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState'; import { Button } from '@/ui/Button';
interface FeedItem { interface FeedItem {
id: string; id: string;
@@ -24,27 +26,34 @@ interface ActivityFeedProps {
export function ActivityFeed({ items, hasItems }: ActivityFeedProps) { export function ActivityFeed({ items, hasItems }: ActivityFeedProps) {
return ( return (
<Card> <Card>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="var(--primary-blue)" />} mb={4}> <SectionHeader
Recent Activity title="Recent Activity"
</Heading> variant="minimal"
actions={<Icon icon={Activity} size={5} intent="primary" />}
/>
{hasItems ? ( {hasItems ? (
<ActivityFeedList> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{items.slice(0, 5).map((item) => ( {items.slice(0, 5).map((item) => (
<ActivityItem <ActivityItem
key={item.id} key={item.id}
headline={item.headline} title={item.headline}
body={item.body} description={item.body}
formattedTime={item.formattedTime} timestamp={item.formattedTime}
ctaHref={item.ctaHref} >
ctaLabel={item.ctaLabel} {item.ctaHref && item.ctaLabel && (
/> <Button variant="ghost" size="sm" as="a" href={item.ctaHref}>
{item.ctaLabel}
</Button>
)}
</ActivityItem>
))} ))}
</ActivityFeedList> </div>
) : ( ) : (
<MinimalEmptyState <EmptyState
icon={Activity} icon={Activity}
title="No activity yet" title="No activity yet"
description="Join leagues and add friends to see activity here" description="Join leagues and add friends to see activity here"
variant="minimal"
/> />
)} )}
</Card> </Card>

View File

@@ -1,9 +1,7 @@
'use client';
import { ActivityItem } from '@/ui/ActivityItem';
import { Box } from '@/ui/primitives/Box'; import React, { ReactNode } from 'react';
import { Surface } from '@/ui/primitives/Surface';
import { Text } from '@/ui/Text';
import { ReactNode } from 'react';
interface ActivityFeedItemProps { interface ActivityFeedItemProps {
icon: ReactNode; icon: ReactNode;
@@ -17,34 +15,13 @@ export function ActivityFeedItem({
timestamp, timestamp,
}: ActivityFeedItemProps) { }: ActivityFeedItemProps) {
return ( return (
<Box <ActivityItem
display="flex" title=""
alignItems="start" description={typeof content === 'string' ? content : undefined}
gap={3} timestamp={timestamp}
py={3} icon={icon}
borderBottom
style={{ borderColor: 'rgba(38, 38, 38, 0.3)' }}
className="last:border-0"
> >
<Surface {typeof content !== 'string' && content}
variant="muted" </ActivityItem>
w="8"
h="8"
rounded="full"
display="flex"
center
flexShrink={0}
>
{icon}
</Surface>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" leading="relaxed" block>
{content}
</Text>
<Text size="xs" color="text-gray-500" mt={1} block>
{timestamp}
</Text>
</Box>
</Box>
); );
} }

View File

@@ -1,5 +1,4 @@
import { Stack } from '@/ui/primitives/Stack'; import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
interface ActivityFeedListProps { interface ActivityFeedListProps {
children: ReactNode; children: ReactNode;
@@ -7,8 +6,8 @@ interface ActivityFeedListProps {
export function ActivityFeedList({ children }: ActivityFeedListProps) { export function ActivityFeedList({ children }: ActivityFeedListProps) {
return ( return (
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{children} {children}
</Stack> </div>
); );
} }

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { FeedItem } from '@/ui/FeedItem'; import { FeedItem } from '@/ui/FeedItem';
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
interface FeedItemData { interface FeedItemData {
id: string; id: string;
@@ -15,18 +16,6 @@ interface FeedItemData {
ctaLabel?: string; ctaLabel?: string;
} }
function timeAgo(timestamp: Date | string): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} d ago`;
}
async function resolveActor() { async function resolveActor() {
return null; return null;
} }
@@ -55,20 +44,23 @@ export function FeedItemCard({ item }: FeedItemCardProps) {
return ( return (
<FeedItem <FeedItem
actorName={actor?.name} user={{
actorAvatarUrl={actor?.avatarUrl} name: actor?.name || 'Unknown',
typeLabel={item.type.startsWith('friend') ? 'FR' : 'LG'} avatar: actor?.avatarUrl
headline={item.headline} }}
body={item.body} timestamp={TimeDisplay.timeAgo(item.timestamp)}
timeAgo={timeAgo(item.timestamp)} content={
cta={item.ctaHref && item.ctaLabel ? ( <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ fontWeight: 'bold' }}>{item.headline}</div>
{item.body && <div>{item.body}</div>}
</div>
}
actions={item.ctaHref && item.ctaLabel ? (
<Button <Button
as="a" as="a"
href={item.ctaHref} href={item.ctaHref}
variant="secondary" variant="secondary"
size="sm" size="sm"
px={4}
py={2}
> >
{item.ctaLabel} {item.ctaLabel}
</Button> </Button>

View File

@@ -1,6 +1,6 @@
import { FeedItemCard } from '@/components/feed/FeedItemCard'; import { FeedItemCard } from '@/components/feed/FeedItemCard';
import { FeedEmptyState } from '@/ui/FeedEmptyState'; import { FeedEmptyState } from '@/ui/FeedEmptyState';
import { Stack } from '@/ui/primitives/Stack'; import React from 'react';
interface FeedItemData { interface FeedItemData {
id: string; id: string;
@@ -23,10 +23,10 @@ export function FeedList({ items }: FeedListProps) {
} }
return ( return (
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{items.map(item => ( {items.map(item => (
<FeedItemCard key={item.id} item={item} /> <FeedItemCard key={item.id} item={item} />
))} ))}
</Stack> </div>
); );
} }

View File

@@ -1,28 +1,13 @@
import { ActiveDriverCard } from '@/components/drivers/ActiveDriverCard'; 'use client';
import { ActiveDriverCard } from '@/ui/ActiveDriverCard';
import { mediaConfig } from '@/lib/config/mediaConfig'; import { mediaConfig } from '@/lib/config/mediaConfig';
import { Heading } from '@/ui/Heading'; import { SectionHeader } from '@/ui/SectionHeader';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
import React from 'react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface RecentActivityProps { interface RecentActivityProps {
drivers: { drivers: {
@@ -40,45 +25,28 @@ export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps)
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6); const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
return ( return (
<Box mb={10}> <div style={{ marginBottom: '2.5rem' }}>
<Box display="flex" alignItems="center" gap={3} mb={4}> <SectionHeader
<Box title="Active Drivers"
display="flex" description="Currently competing in leagues"
h="10" variant="minimal"
w="10" actions={<Icon icon={Activity} size={5} intent="success" />}
alignItems="center" />
justifyContent="center"
rounded="xl"
bg="bg-performance-green/10"
border
borderColor="border-performance-green/20"
>
<Icon icon={Activity} size={5} color="rgb(16, 185, 129)" />
</Box>
<Box>
<Heading level={2}>Active Drivers</Heading>
<Text size="xs" color="text-gray-500">Currently competing in leagues</Text>
</Box>
</Box>
<Box display="grid" responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={3}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '0.75rem' }}>
{activeDrivers.map((driver) => { {activeDrivers.map((driver) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
return ( return (
<ActiveDriverCard <ActiveDriverCard
key={driver.id} key={driver.id}
name={driver.name} name={driver.name}
avatarUrl={driver.avatarUrl || mediaConfig.avatars.defaultFallback} avatarUrl={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
categoryLabel={categoryConfig?.label} categoryLabel={driver.category}
categoryColor={categoryConfig?.color} skillLevelLabel={driver.skillLevel}
skillLevelLabel={levelConfig?.label}
skillLevelColor={levelConfig?.color}
onClick={() => onDriverClick(driver.id)} onClick={() => onDriverClick(driver.id)}
/> />
); );
})} })}
</Box> </div>
</Box> </div>
); );
} }

View File

@@ -1,65 +0,0 @@
import { Text } from '@/ui/Text';
import { Box } from '@/ui/primitives/Box';
import { LucideIcon } from 'lucide-react';
import Link from 'next/link';
interface NavLinkProps {
href: string;
label: string;
icon?: LucideIcon;
isActive?: boolean;
variant?: 'sidebar' | 'top';
}
/**
* NavLink provides a consistent link component for navigation.
* Supports both sidebar and top navigation variants.
*/
export function NavLink({ href, label, icon: Icon, isActive, variant = 'sidebar' }: NavLinkProps) {
if (variant === 'top') {
return (
<Box
as={Link}
href={href}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
transition
color={isActive ? 'primary-accent' : 'text-gray-400'}
hoverTextColor="white"
>
{Icon && <Icon size={18} />}
<Text size="sm" weight={isActive ? 'bold' : 'medium'}>
{label}
</Text>
</Box>
);
}
return (
<Box
as={Link}
href={href}
display="flex"
alignItems="center"
gap={3}
px={3}
py={2}
rounded="md"
transition
bg={isActive ? 'primary-accent/10' : 'transparent'}
color={isActive ? 'primary-accent' : 'text-gray-400'}
hoverBg={isActive ? 'primary-accent/10' : 'white/5'}
hoverTextColor={isActive ? 'primary-accent' : 'white'}
group
>
{Icon && <Icon size={20} color={isActive ? '#198CFF' : '#6B7280'} />}
<Text weight="medium">{label}</Text>
{isActive && (
<Box ml="auto" w="4px" h="16px" bg="primary-accent" rounded="full" shadow="[0_0_8px_rgba(25,140,255,0.5)]" />
)}
</Box>
);
}

View File

@@ -1,7 +1,8 @@
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { ChevronDown, ChevronUp, Minus } from 'lucide-react'; import { ChevronDown, ChevronUp, Minus } from 'lucide-react';
import React from 'react';
interface DeltaChipProps { interface DeltaChipProps {
value: number; value: number;
@@ -11,40 +12,26 @@ interface DeltaChipProps {
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) { export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
if (value === 0) { if (value === 0) {
return ( return (
<Box display="flex" alignItems="center" gap={1} color="text-gray-600"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Icon icon={Minus} size={3} /> <Icon icon={Minus} size={3} intent="low" />
<Text size="xs" font="mono">0</Text> <Text size="xs" font="mono" variant="low">0</Text>
</Box> </div>
); );
} }
const isPositive = value > 0; const isPositive = value > 0;
const color = isPositive const variant = isPositive ? 'success' : 'critical';
? (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 IconComponent = isPositive ? ChevronUp : ChevronDown;
const absoluteValue = Math.abs(value); const absoluteValue = Math.abs(value);
return ( return (
<Box <Badge variant={variant} size="sm">
display="flex" <div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
alignItems="center" <Icon icon={IconComponent} size={3} />
gap={0.5} <Text size="xs" font="mono" weight="bold">
color={color} {absoluteValue}
bg={`${color.replace('text-', 'bg-')}/10`} </Text>
px={1.5} </div>
py={0.5} </Badge>
rounded="full"
>
<Icon icon={IconComponent} size={3} />
<Text size="xs" font="mono" weight="bold">
{absoluteValue}
</Text>
</Box>
); );
} }

View File

@@ -1,6 +1,7 @@
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack'; import { Input } from '@/ui/Input';
import { Text } from '@/ui/Text'; import { ControlBar } from '@/ui/ControlBar';
import { Button } from '@/ui/Button';
import { Filter, Search } from 'lucide-react'; import { Filter, Search } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -18,70 +19,32 @@ export function LeaderboardFiltersBar({
children, children,
}: LeaderboardFiltersBarProps) { }: LeaderboardFiltersBarProps) {
return ( return (
<Stack <div style={{ marginBottom: '1.5rem' }}>
mb={6} <ControlBar
p={3} leftContent={
bg="bg-deep-charcoal/40" <div style={{ maxWidth: '32rem', width: '100%' }}>
border <Input
borderColor="border-charcoal-outline/50" type="text"
rounded="lg" value={searchQuery}
blur="sm" onChange={(e) => onSearchChange?.(e.target.value)}
> placeholder={placeholder}
<Stack direction="row" align="center" justify="between" gap={4}> icon={<Icon icon={Search} size={4} intent="low" />}
<Stack position="relative" flexGrow={1} maxWidth="md"> fullWidth
<Stack />
position="absolute" </div>
left="3" }
top="1/2" >
transform="translateY(-50%)" <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
pointerEvents="none"
zIndex={10}
>
<Icon icon={Search} size={4} color="text-gray-500" />
</Stack>
<Stack
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"
/>
</Stack>
<Stack direction="row" align="center" gap={4}>
{children} {children}
<Stack <Button
display="flex" variant="secondary"
alignItems="center" size="sm"
gap={2} icon={<Icon icon={Filter} size={3.5} intent="low" />}
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" /> Filters
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text> </Button>
</Stack> </div>
</Stack> </ControlBar>
</Stack> </div>
</Stack>
); );
} }

View File

@@ -1,15 +1,20 @@
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import React from 'react';
interface RankBadgeProps { interface RankBadgeProps {
rank: number; rank: number;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md';
showLabel?: boolean;
} }
export function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) { export function RankBadge({ rank, size = 'md' }: RankBadgeProps) {
const getVariant = (rank: number): 'warning' | 'primary' | 'info' | 'default' => {
if (rank <= 3) return 'warning';
if (rank <= 10) return 'primary';
if (rank <= 50) return 'info';
return 'default';
};
const getMedalEmoji = (rank: number) => { const getMedalEmoji = (rank: number) => {
switch (rank) { switch (rank) {
case 1: return '🥇'; case 1: return '🥇';
@@ -21,32 +26,12 @@ export function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProp
const medal = getMedalEmoji(rank); const medal = getMedalEmoji(rank);
const sizeClasses = {
sm: 'text-sm px-2 py-1',
md: 'text-base px-3 py-1.5',
lg: 'text-lg px-4 py-2'
};
const getRankColor = (rank: number) => {
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
};
return ( return (
<Box <Badge variant={getVariant(rank)} size={size}>
as="span" <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
display="inline-flex" {medal && <span>{medal}</span>}
alignItems="center" <Text size="xs" weight="bold">#{rank}</Text>
gap={1.5} </div>
rounded="md" </Badge>
border
className={`font-medium ${getRankColor(rank)} ${sizeClasses[size]}`}
>
{medal && <Text>{medal}</Text>}
{showLabel && <Text>#{rank}</Text>}
{!showLabel && !medal && <Text>#{rank}</Text>}
</Box>
); );
} }

View File

@@ -1,8 +1,8 @@
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Crown, Medal } from 'lucide-react'; import { Crown, Medal } from 'lucide-react';
import React from 'react';
interface RankMedalProps { interface RankMedalProps {
rank: number; rank: number;
@@ -12,11 +12,12 @@ interface RankMedalProps {
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) { export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
const isTop3 = rank <= 3; const isTop3 = rank <= 3;
const variant = MedalDisplay.getVariant(rank);
const sizeMap = { const sizePx = {
sm: '7', sm: '1.75rem',
md: '8', md: '2rem',
lg: '10', lg: '2.5rem',
}; };
const textSizeMap = { const textSizeMap = {
@@ -32,22 +33,23 @@ export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps
}; };
return ( return (
<Box <div
display="flex" style={{
alignItems="center" display: 'flex',
justifyContent="center" alignItems: 'center',
rounded="full" justifyContent: 'center',
border borderRadius: '9999px',
h={sizeMap[size]} border: '1px solid var(--ui-color-border-default)',
w={sizeMap[size]} height: sizePx[size],
bg={MedalDisplay.getBg(rank)} width: sizePx[size],
color={MedalDisplay.getColor(rank)} backgroundColor: 'var(--ui-color-bg-surface-muted)'
}}
> >
{isTop3 && showIcon ? ( {isTop3 && showIcon ? (
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} /> <Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} intent={variant as any} />
) : ( ) : (
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text> <Text weight="bold" size={textSizeMap[size]} variant={variant as any}>{rank}</Text>
)} )}
</Box> </div>
); );
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react'; import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { EmptyState as UiEmptyState } from '@/components/shared/state/EmptyState'; import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
import React from 'react';
interface EmptyStateProps { interface EmptyStateProps {
title: string; title: string;
@@ -32,8 +33,10 @@ export function EmptyState({
onClick: onAction, onClick: onAction,
icon: actionIcon, icon: actionIcon,
} : undefined} } : undefined}
/> variant="minimal"
{children} >
{children}
</UiEmptyState>
</Card> </Card>
); );
} }

View File

@@ -1,9 +1,13 @@
import { Button } from '@/ui/Button'; 'use client';
import { Heading } from '@/ui/Heading';
import { IconButton } from '@/ui/IconButton';
import { Panel } from '@/ui/Panel';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Check, Clock, X } from 'lucide-react'; import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
import { EmptyState } from '@/ui/EmptyState';
import { Check, Clock, X, UserPlus } from 'lucide-react';
import React from 'react';
interface JoinRequestsPanelProps { interface JoinRequestsPanelProps {
requests: Array<{ requests: Array<{
@@ -20,67 +24,49 @@ interface JoinRequestsPanelProps {
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) { export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
if (requests.length === 0) { if (requests.length === 0) {
return ( return (
<Stack p={8} border borderDash borderColor="border-steel-grey" bg="surface-charcoal/20" textAlign="center"> <EmptyState
<Text color="text-gray-500" size="sm">No pending join requests</Text> icon={UserPlus}
</Stack> title="No pending join requests"
variant="minimal"
/>
); );
} }
return ( return (
<Stack border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden"> <Panel title={`Pending Requests (${requests.length})`}>
<Stack p={4} borderBottom borderColor="border-steel-grey" bg="base-graphite/50"> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<Heading level={4} weight="bold" className="uppercase tracking-widest text-gray-400 text-[10px]">
Pending Requests ({requests.length})
</Heading>
</Stack>
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
{requests.map((request) => ( {requests.map((request) => (
<Stack key={request.id} p={4} className="hover:bg-white/[0.02] transition-colors"> <ListItem key={request.id}>
<Stack direction="row" align="start" justify="between" gap={4}> <ListItemInfo
<Stack direction="row" align="center" gap={3}> title={request.driverName}
<Stack w="10" h="10" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center> description={request.message}
<Text size="xs" weight="bold" color="text-primary-blue"> meta={
{request.driverName.substring(0, 2).toUpperCase()} <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
</Text> <Icon icon={Clock} size={3} intent="low" />
</Stack> <Text size="xs" variant="low" font="mono">{request.requestedAt}</Text>
<Stack> </div>
<Text weight="bold" size="sm" color="text-white" block>{request.driverName}</Text> }
<Stack direction="row" align="center" gap={1.5} mt={0.5}> />
<Icon icon={Clock} size={3} color="text-gray-500" /> <ListItemActions>
<Text size="xs" color="text-gray-500" font="mono">{request.requestedAt}</Text> <IconButton
</Stack> variant="secondary"
</Stack> size="sm"
</Stack> onClick={() => onDecline(request.id)}
icon={X}
<Stack direction="row" align="center" gap={2}> intent="critical"
<Button title="Decline"
variant="secondary" />
size="sm" <IconButton
onClick={() => onDecline(request.id)} variant="primary"
className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10" size="sm"
> onClick={() => onAccept(request.id)}
<Icon icon={X} size={4} color="text-red-400" /> icon={Check}
</Button> title="Accept"
<Button />
variant="primary" </ListItemActions>
size="sm" </ListItem>
onClick={() => onAccept(request.id)}
className="h-8 w-8 p-0 flex items-center justify-center"
>
<Icon icon={Check} size={4} />
</Button>
</Stack>
</Stack>
{request.message && (
<Stack mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
<Text size="xs" color="text-gray-400" italic leading="relaxed">
&ldquo;{request.message}&rdquo;
</Text>
</Stack>
)}
</Stack>
))} ))}
</Stack> </div>
</Stack> </Panel>
); );
} }

View File

@@ -1,13 +1,13 @@
'use client';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image'; import { Image } from '@/ui/Image';
import { PlaceholderImage } from '@/ui/PlaceholderImage'; import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight } from 'lucide-react'; import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard';
import { ReactNode } from 'react'; import { Calendar as LucideCalendar } from 'lucide-react';
import React, { ReactNode } from 'react';
interface LeagueCardProps { interface LeagueCardProps {
name: string; name: string;
@@ -42,151 +42,68 @@ export function LeagueCard({
fillPercentage, fillPercentage,
hasOpenSlots, hasOpenSlots,
openSlotsCount, openSlotsCount,
isTeamLeague: _isTeamLeague,
usedDriverSlots: _usedDriverSlots,
maxDrivers: _maxDrivers,
timingSummary, timingSummary,
onClick, onClick,
}: LeagueCardProps) { }: LeagueCardProps) {
return ( return (
<Stack <UILeagueCard
position="relative"
cursor={onClick ? 'pointer' : 'default'}
h="full"
onClick={onClick} onClick={onClick}
className="group" coverUrl={coverUrl}
logo={
<div style={{ width: '3rem', height: '3rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)', backgroundColor: 'var(--ui-color-bg-base)' }}>
{logoUrl ? (
<Image
src={logoUrl}
alt={`${name} logo`}
width={48}
height={48}
objectFit="cover"
/>
) : (
<PlaceholderImage size={48} />
)}
</div>
}
badges={
<React.Fragment>
{badges}
{championshipBadge}
</React.Fragment>
}
> >
{/* Card Container */} <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<Stack <div style={{ width: '0.25rem', height: '1rem', backgroundColor: 'var(--ui-color-intent-primary)' }} />
position="relative" <Heading level={3} weight="bold" truncate>{name}</Heading>
h="full" </div>
rounded="none"
bg="panel-gray/40" <Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem', marginBottom: '1rem' }} block leading="relaxed">
border {description || 'No description available'}
borderColor="border-gray/50" </Text>
overflow="hidden"
transition
className="hover:border-primary-accent/30 hover:bg-panel-gray/60 transition-all duration-300"
>
{/* Cover Image */}
<Stack position="relative" h="32" overflow="hidden">
<Image
src={coverUrl}
alt={`${name} cover`}
fullWidth
fullHeight
objectFit="cover"
className="transition-transform duration-500 group-hover:scale-105 opacity-60"
/>
{/* Gradient Overlay */}
<Stack position="absolute" inset="0" bg="linear-gradient(to top, #0C0D0F, transparent)" />
{/* Badges - Top Left */}
<Stack position="absolute" top="3" left="3" display="flex" alignItems="center" gap={2}>
{badges}
</Stack>
{/* Championship Type Badge - Top Right */} <LeagueCardStats
<Stack position="absolute" top="3" right="3"> label={slotLabel}
{championshipBadge} value={`${usedSlots}/${maxSlots || '∞'}`}
</Stack> percentage={fillPercentage}
intent={fillPercentage >= 90 ? 'warning' : fillPercentage >= 70 ? 'primary' : 'success'}
/>
{/* Logo */} {hasOpenSlots && (
<Stack position="absolute" left="4" bottom="-6" zIndex={10}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.5rem', backgroundColor: 'rgba(25, 140, 255, 0.05)', border: '1px solid rgba(25, 140, 255, 0.2)', borderRadius: 'var(--ui-radius-sm)', width: 'fit-content', marginBottom: '1rem' }}>
<Stack w="12" h="12" rounded="none" overflow="hidden" border borderColor="border-gray/50" bg="graphite-black" shadow="xl"> <div style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)' }} />
{logoUrl ? ( <Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
<Image </div>
src={logoUrl} )}
alt={`${name} logo`}
width={48}
height={48}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={48} />
)}
</Stack>
</Stack>
</Stack>
{/* Content */} <LeagueCardFooter>
<Stack pt={8} px={4} pb={4} display="flex" flexDirection="col" fullHeight> {timingSummary && (
{/* Title & Description */} <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Stack direction="row" align="center" gap={2} mb={1}> <Icon icon={LucideCalendar} size={3} intent="low" />
<Stack w="1" h="4" bg="primary-accent" /> <Text size="xs" variant="low" font="mono">
<Heading level={3} fontSize="lg" weight="bold" className="line-clamp-1 group-hover:text-primary-accent transition-colors tracking-tight"> {timingSummary.split('•')[1]?.trim() || timingSummary}
{name} </Text>
</Heading> </div>
</Stack> )}
<Text size="xs" color="text-gray-500" lineClamp={2} mb={4} style={{ height: '2.5rem' }} block leading="relaxed"> </LeagueCardFooter>
{description || 'No description available'} </UILeagueCard>
</Text>
{/* Stats Row */}
<Stack display="flex" alignItems="center" gap={3} mb={4}>
{/* Primary Slots (Drivers/Teams/Nations) */}
<Stack flexGrow={1}>
<Stack display="flex" alignItems="center" justifyContent="between" mb={1.5}>
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">{slotLabel}</Text>
<Text size="xs" color="text-gray-400" font="mono">
{usedSlots}/{maxSlots || '∞'}
</Text>
</Stack>
<Stack h="1" rounded="none" bg="border-gray/30" overflow="hidden">
<Stack
h="full"
rounded="none"
transition
bg={
fillPercentage >= 90
? 'warning-amber'
: fillPercentage >= 70
? 'primary-accent'
: 'success-green'
}
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
/>
</Stack>
</Stack>
{/* Open Slots Badge */}
{hasOpenSlots && (
<Stack display="flex" alignItems="center" gap={1.5} px={2} py={1} rounded="none" bg="primary-accent/5" border borderColor="primary-accent/20">
<Stack w="1.5" h="1.5" rounded="full" bg="primary-accent" className="animate-pulse" />
<Text size="xs" color="text-primary-accent" weight="bold" className="uppercase tracking-tighter">
{openSlotsCount} OPEN
</Text>
</Stack>
)}
</Stack>
{/* Spacer to push footer to bottom */}
<Stack flexGrow={1} />
{/* Footer Info */}
<Stack display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-gray/30" mt="auto">
<Stack display="flex" alignItems="center" gap={3}>
{timingSummary && (
<Stack display="flex" alignItems="center" gap={2}>
<Icon icon={LucideCalendar} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500" font="mono">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</Stack>
)}
</Stack>
{/* View Arrow */}
<Stack display="flex" alignItems="center" gap={1} className="group-hover:text-primary-accent transition-colors">
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">VIEW</Text>
<Icon icon={LucideChevronRight} size={3} color="text-gray-500" className="transition-transform group-hover:translate-x-0.5" />
</Stack>
</Stack>
</Stack>
</Stack>
</Stack>
); );
} }

View File

@@ -3,237 +3,13 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react'; import { InfoFlyout } from '@/ui/InfoFlyout';
import React, { useEffect, useRef, useState } from 'react'; import { Stepper } from '@/ui/Stepper';
import { createPortal } from 'react-dom'; import { Button } from '@/ui/Button';
import { IconButton } from '@/ui/IconButton';
// ============================================================================ import { Check, HelpCircle, TrendingDown, Zap } from 'lucide-react';
// INFO FLYOUT (duplicated for self-contained component) import React, { useRef, useState } from 'react';
// ============================================================================
interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
anchorRef: React.RefObject<HTMLElement>;
}
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const flyoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen && anchorRef.current && mounted) {
const rect = anchorRef.current.getBoundingClientRect();
const flyoutWidth = Math.min(380, window.innerWidth - 40);
const flyoutHeight = 450;
const padding = 16;
let left = rect.right + 12;
let top = rect.top;
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
if (left < padding) {
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
}
top = rect.top - flyoutHeight / 3;
if (top + flyoutHeight > window.innerHeight - padding) {
top = window.innerHeight - flyoutHeight - padding;
}
if (top < padding) top = padding;
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
setPosition({ top, left });
}
}, [isOpen, anchorRef, mounted]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleClickOutside = (e: MouseEvent) => {
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<Stack
ref={flyoutRef}
position="fixed"
zIndex={50}
w="380px"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="xl"
shadow="2xl"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
>
<Stack
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
position="sticky"
top="0"
bg="bg-iron-gray"
zIndex={10}
>
<Stack direction="row" align="center" gap={2}>
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
</Stack>
<Stack
as="button"
type="button"
onClick={onClose}
display="flex"
h="6"
w="6"
alignItems="center"
justifyContent="center"
rounded="md"
transition
hoverBg="bg-charcoal-outline"
>
<Icon icon={X} size={4} color="text-gray-400" />
</Stack>
</Stack>
<Stack p={4}>
{children}
</Stack>
</Stack>,
document.body
);
}
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
return (
<Stack
as="button"
ref={buttonRef}
type="button"
onClick={onClick}
display="flex"
h="5"
w="5"
alignItems="center"
justifyContent="center"
rounded="full"
transition
color="text-gray-500"
hoverTextColor="text-primary-blue"
hoverBg="bg-primary-blue/10"
>
<Icon icon={HelpCircle} size={3.5} />
</Stack>
);
}
// Drop Rules Mockup
function DropRulesMockup() {
const results = [
{ round: 'R1', pts: 25, dropped: false },
{ round: 'R2', pts: 18, dropped: false },
{ round: 'R3', pts: 4, dropped: true },
{ round: 'R4', pts: 15, dropped: false },
{ round: 'R5', pts: 12, dropped: false },
{ round: 'R6', pts: 0, dropped: true },
];
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
return (
<Stack bg="bg-deep-graphite" rounded="lg" p={4}>
<Stack display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
</Stack>
<Stack display="flex" gap={1} mb={3}>
{results.map((r, i) => (
<Stack
key={i}
flexGrow={1}
p={2}
rounded="lg"
textAlign="center"
border
transition
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
opacity={r.dropped ? 0.5 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'border-dashed' : ''}
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
{r.round}
</Text>
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={r.dropped ? 'line-through' : ''}
block
>
{r.pts}
</Text>
</Stack>
))}
</Stack>
<Stack display="flex" justifyContent="between" alignItems="center">
<Text size="xs" color="text-gray-500">Total counted:</Text>
<Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
</Stack>
<Stack display="flex" justifyContent="between" alignItems="center" mt={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Without drops:
</Text>
<Text font="mono"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{wouldBe} pts
</Text>
</Stack>
</Stack>
);
}
interface LeagueDropSectionProps { interface LeagueDropSectionProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;
@@ -243,43 +19,6 @@ interface LeagueDropSectionProps {
type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN'; type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
// Drop rule info content
const DROP_RULE_INFO: Record<DropStrategy, { title: string; description: string; details: string[]; example: string }> = {
none: {
title: 'All Results Count',
description: 'Every race result affects the championship standings with no exceptions.',
details: [
'All race results count toward final standings',
'No safety net for bad races or DNFs',
'Rewards consistency across entire season',
'Best for shorter seasons (4-6 races)',
],
example: '8 races × your points = your total',
},
bestNResults: {
title: 'Best N Results',
description: 'Only your top N race results count toward the championship.',
details: [
'Choose how many of your best races count',
'Extra races become "bonus" opportunities',
'Protects against occasional bad days',
'Encourages trying even when behind',
],
example: 'Best 6 of 8 races count',
},
dropWorstN: {
title: 'Drop Worst N Results',
description: 'Your N worst race results are excluded from championship calculations.',
details: [
'Automatically removes your worst performances',
'Great for handling DNFs or incidents',
'All other races count normally',
'Common in real-world championships',
],
example: 'Drop 2 worst → 6 of 8 count',
},
};
const DROP_OPTIONS: Array<{ const DROP_OPTIONS: Array<{
value: DropStrategy; value: DropStrategy;
label: string; label: string;
@@ -317,13 +56,7 @@ export function LeagueDropSection({
const disabled = readOnly || !onChange; const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy || { strategy: 'none' as const }; const dropPolicy = form.dropPolicy || { strategy: 'none' as const };
const [showDropFlyout, setShowDropFlyout] = useState(false); const [showDropFlyout, setShowDropFlyout] = useState(false);
const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState<DropStrategy | null>(null);
const dropInfoRef = useRef<HTMLButtonElement>(null!); const dropInfoRef = useRef<HTMLButtonElement>(null!);
const dropRuleRefs = useRef<Record<DropStrategy, HTMLButtonElement | null>>({
none: null,
bestNResults: null,
dropWorstN: null,
});
const handleStrategyChange = (strategy: DropStrategy) => { const handleStrategyChange = (strategy: DropStrategy) => {
if (disabled || !onChange) return; if (disabled || !onChange) return;
@@ -344,10 +77,8 @@ export function LeagueDropSection({
onChange(next); onChange(next);
}; };
const handleNChange = (delta: number) => { const handleNChange = (newValue: number) => {
if (disabled || !onChange || dropPolicy.strategy === 'none') return; if (disabled || !onChange || dropPolicy.strategy === 'none') return;
const current = dropPolicy.n ?? 1;
const newValue = Math.max(1, current + delta);
onChange({ onChange({
...form, ...form,
dropPolicy: { dropPolicy: {
@@ -360,328 +91,79 @@ export function LeagueDropSection({
const needsN = dropPolicy.strategy !== 'none'; const needsN = dropPolicy.strategy !== 'none';
return ( return (
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Section header */} {/* Section header */}
<Stack display="flex" alignItems="center" gap={3}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Stack display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10"> <div style={{ display: 'flex', width: '2.5rem', height: '2.5rem', alignItems: 'center', justifyContent: 'center', borderRadius: '0.75rem', backgroundColor: 'rgba(25, 140, 255, 0.1)' }}>
<Icon icon={TrendingDown} size={5} color="text-primary-blue" /> <Icon icon={TrendingDown} size={5} intent="primary" />
</Stack> </div>
<Stack flexGrow={1}> <div style={{ flex: 1 }}>
<Stack display="flex" alignItems="center" gap={2}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Heading level={3}>Drop Rules</Heading> <Heading level={3}>Drop Rules</Heading>
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} /> <IconButton
</Stack> ref={dropInfoRef}
<Text size="xs" color="text-gray-500">Protect from bad races</Text> icon={HelpCircle}
</Stack> size="sm"
</Stack> variant="ghost"
onClick={() => setShowDropFlyout(true)}
title="Help"
/>
</div>
<Text size="xs" variant="low">Protect from bad races</Text>
</div>
</div>
{/* Drop Rules Flyout */}
<InfoFlyout <InfoFlyout
isOpen={showDropFlyout} isOpen={showDropFlyout}
onClose={() => setShowDropFlyout(false)} onClose={() => setShowDropFlyout(false)}
title="Drop Rules Explained" title="Drop Rules Explained"
anchorRef={dropInfoRef} anchorRef={dropInfoRef}
> >
<Stack gap={4}> <Text size="xs" variant="low" block>
<Text size="xs" color="text-gray-400" block> Drop rules allow drivers to exclude their worst results from championship calculations.
Drop rules allow drivers to exclude their worst results from championship calculations. This protects against mechanical failures, bad luck, or occasional poor performances.
This protects against mechanical failures, bad luck, or occasional poor performances. </Text>
</Text>
<Stack>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Visual Example
</Text>
<DropRulesMockup />
</Stack>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Strategies
</Text>
<Stack gap={2}>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base"></Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
All Count
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Every race affects standings. Best for short seasons.
</Text>
</Stack>
</Stack>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🏆</Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Best N Results
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Only your top N races count. Extra races are optional.
</Text>
</Stack>
</Stack>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
<Text size="base">🗑</Text>
<Stack>
<Text size="xs" weight="medium" color="text-white" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Drop Worst N
</Text>
<Text size="xs" color="text-gray-500" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
>
Exclude your N worst results. Forgives bad days.
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
<Stack rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
<Stack display="flex" alignItems="start" gap={2}>
<Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
<Stack>
<Text size="xs" color="text-gray-400"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
>
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
&quot;Best 6&quot; or &quot;Drop 2&quot; are popular choices.
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
</InfoFlyout> </InfoFlyout>
{/* Strategy buttons + N stepper inline */} {/* Strategy buttons + N stepper inline */}
<Stack display="flex" flexWrap="wrap" alignItems="center" gap={2}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
{DROP_OPTIONS.map((option) => { {DROP_OPTIONS.map((option) => {
const isSelected = dropPolicy.strategy === option.value; const isSelected = dropPolicy.strategy === option.value;
const ruleInfo = DROP_RULE_INFO[option.value];
return ( return (
<Stack key={option.value} display="flex" alignItems="center" position="relative"> <Button
<Stack key={option.value}
as="button" variant={isSelected ? 'primary' : 'secondary'}
type="button" size="sm"
disabled={disabled} onClick={() => handleStrategyChange(option.value)}
onClick={() => handleStrategyChange(option.value)} disabled={disabled}
display="flex" >
alignItems="center" <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
gap={2} {isSelected && <Icon icon={Check} size={3} />}
px={3} <span>{option.emoji}</span>
py={2} <span>{option.label}</span>
rounded="lg" </div>
border </Button>
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
{/* Radio indicator */}
<Stack
display="flex"
h="4"
w="4"
alignItems="center"
justifyContent="center"
rounded="full"
border
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
bg={isSelected ? 'bg-primary-blue' : ''}
flexShrink={0}
transition
>
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
</Stack>
<Text size="sm">{option.emoji}</Text>
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
{option.label}
</Text>
</Stack>
{/* Info button - separate from main button */}
<Stack
as="button"
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
}}
display="flex"
alignItems="center"
justifyContent="center"
px={2}
py={2}
rounded="lg"
border
borderWidth="2px"
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.6 : 1}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
>
<Icon icon={HelpCircle} size={3.5} color="text-gray-500"
// eslint-disable-next-line gridpilot-rules/component-classification
className="hover:text-primary-blue transition-colors"
/>
</Stack>
{/* Drop Rule Info Flyout */}
<InfoFlyout
isOpen={activeDropRuleFlyout === option.value}
onClose={() => setActiveDropRuleFlyout(null)}
title={ruleInfo.title}
anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
>
<Stack gap={4}>
<Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
<Stack gap={2}>
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
// eslint-disable-next-line gridpilot-rules/component-classification
className="tracking-wide"
block
mb={2}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
How It Works
</Text>
<Stack gap={1.5}>
{ruleInfo.details.map((detail, idx) => (
<Stack key={idx} display="flex" alignItems="start" gap={2}>
<Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
<Text size="xs" color="text-gray-400">{detail}</Text>
</Stack>
))}
</Stack>
</Stack>
<Stack rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
<Stack display="flex" alignItems="center" gap={2}>
<Text size="base">{option.emoji}</Text>
<Stack>
<Text size="xs" color="text-gray-400" block
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
Example
</Text>
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
</Stack>
</Stack>
</Stack>
</Stack>
</InfoFlyout>
</Stack>
); );
})} })}
{/* N Stepper - only show when needed */}
{needsN && ( {needsN && (
<Stack display="flex" alignItems="center" gap={1} ml={2}> <div style={{ marginLeft: '0.5rem' }}>
<Text size="xs" color="text-gray-500" mr={1}>N =</Text> <Stepper
<Stack value={dropPolicy.n ?? 1}
as="button" onChange={handleNChange}
type="button" label="N ="
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
onClick={() => handleNChange(-1)}
display="flex"
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
>
</Stack>
<Stack display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
<Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
</Stack>
<Stack
as="button"
type="button"
disabled={disabled} disabled={disabled}
onClick={() => handleNChange(1)} />
display="flex" </div>
h="7"
w="7"
alignItems="center"
justifyContent="center"
rounded="md"
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
color="text-gray-400"
transition
hoverTextColor={!disabled ? 'text-white' : undefined}
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
opacity={disabled ? 0.4 : 1}
>
+
</Stack>
</Stack>
)} )}
</Stack> </div>
{/* Explanation text */} {/* Explanation text */}
<Text size="xs" color="text-gray-500" block> <Text size="xs" variant="low" block>
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'} {dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`} {dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`} {dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
</Text> </Text>
</Stack> </div>
); );
} }

View File

@@ -1,8 +1,10 @@
'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/primitives/Stack'; import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
import React from 'react';
interface League { interface League {
leagueId: string; leagueId: string;
@@ -18,40 +20,34 @@ interface LeagueListItemProps {
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) { export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
return ( return (
<Card <ListItem>
variant="outline" <ListItemInfo
p={4} title={league.name}
className="bg-graphite-black border-[#262626]" description={league.description}
> meta={
<Stack direction="row" align="center" justify="between" fullWidth> league.membershipRole && (
<Stack style={{ flex: 1, minWidth: 0 }}> <Text size="xs" variant="low">
<Text weight="medium" color="text-white" block>{league.name}</Text>
<Text size="xs" color="text-gray-400" block mt={1} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{league.description}
</Text>
{league.membershipRole && (
<Text size="xs" color="text-gray-500" block mt={1}>
Your role:{' '} Your role:{' '}
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text> <Text as="span" variant="med" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
</Text> </Text>
)} )
</Stack> }
<Stack direction="row" align="center" gap={2} style={{ marginLeft: '1rem' }}> />
<Link <ListItemActions>
href={`/leagues/${league.leagueId}`} <Link
variant="ghost" href={`/leagues/${league.leagueId}`}
> variant="ghost"
<Text size="sm" color="text-gray-300">View</Text> >
<Text size="sm" variant="med">View</Text>
</Link>
{isAdmin && (
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
<Button variant="primary" size="sm">
Manage
</Button>
</Link> </Link>
{isAdmin && ( )}
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost"> </ListItemActions>
<Button variant="primary" size="sm"> </ListItem>
Manage
</Button>
</Link>
)}
</Stack>
</Stack>
</Card>
); );
} }

View File

@@ -1,10 +1,10 @@
import { DriverIdentity } from '@/components/drivers/DriverIdentity'; import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Badge } from '@/ui/Badge'; import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/primitives/Box';
import { TableCell, TableRow } from '@/ui/Table'; import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ReactNode } from 'react'; import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import React, { ReactNode } from 'react';
interface LeagueMemberRowProps { interface LeagueMemberRowProps {
driver?: DriverViewModel; driver?: DriverViewModel;
@@ -41,7 +41,7 @@ export function LeagueMemberRow({
return ( return (
<TableRow variant={isTopPerformer ? 'highlight' : 'default'}> <TableRow variant={isTopPerformer ? 'highlight' : 'default'}>
<TableCell> <TableCell>
<Box display="flex" alignItems="center" gap={2}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{driver ? ( {driver ? (
<DriverIdentity <DriverIdentity
driver={driver} driver={driver}
@@ -51,28 +51,28 @@ export function LeagueMemberRow({
size="md" size="md"
/> />
) : ( ) : (
<Text color="text-white">Unknown Driver</Text> <Text variant="high">Unknown Driver</Text>
)} )}
{isCurrentUser && ( {isCurrentUser && (
<Text size="xs" color="text-gray-500">(You)</Text> <Text size="xs" variant="low">(You)</Text>
)} )}
{isTopPerformer && ( {isTopPerformer && (
<Text size="xs"></Text> <Text size="xs"></Text>
)} )}
</Box> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="text-primary-blue" weight="medium"> <Text variant="primary" weight="medium">
{rating || '—'} {rating || '—'}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="text-gray-300"> <Text variant="med">
#{rank || '—'} #{rank || '—'}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="text-green-400" weight="medium"> <Text variant="success" weight="medium">
{wins || 0} {wins || 0}
</Text> </Text>
</TableCell> </TableCell>
@@ -82,12 +82,8 @@ export function LeagueMemberRow({
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="text-white" size="sm"> <Text variant="high" size="sm">
{new Date(joinedAt).toLocaleDateString('en-US', { {DateDisplay.formatShort(joinedAt)}
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</Text> </Text>
</TableCell> </TableCell>
{actions && ( {actions && (

View File

@@ -2,7 +2,8 @@
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow'; import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
import { LeagueMemberTable } from '@/components/leagues/LeagueMemberTable'; import { LeagueMemberTable } from '@/components/leagues/LeagueMemberTable';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState'; import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DRIVER_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
@@ -11,10 +12,11 @@ import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole'; import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Box } from '@/ui/primitives/Box';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ControlBar } from '@/ui/ControlBar';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import React from 'react';
interface LeagueMembersProps { interface LeagueMembersProps {
leagueId: string; leagueId: string;
@@ -83,7 +85,6 @@ export function LeagueMembers({
return null; return null;
}; };
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const sortedMembers = [...members].sort((a, b) => { const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) { switch (sortBy) {
case 'role': case 'role':
@@ -120,31 +121,30 @@ export function LeagueMembers({
}; };
if (loading) { if (loading) {
return ( return <LoadingWrapper variant="spinner" message="Loading members..." />;
<Box textAlign="center" py={8}>
<Text color="text-gray-400">Loading members...</Text>
</Box>
);
} }
if (members.length === 0) { if (members.length === 0) {
return ( return (
<MinimalEmptyState <EmptyState
title="No members found" title="No members found"
description="This league doesn't have any members yet." description="This league doesn't have any members yet."
variant="minimal"
/> />
); );
} }
return ( return (
<Box> <div>
{/* Sort Controls */} <ControlBar
<Box display="flex" alignItems="center" justifyContent="between" mb={4}> leftContent={
<Text size="sm" color="text-gray-400"> <Text size="sm" variant="low">
{members.length} {members.length === 1 ? 'member' : 'members'} {members.length} {members.length === 1 ? 'member' : 'members'}
</Text> </Text>
<Box display="flex" alignItems="center" gap={2}> }
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text> >
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Text size="sm" variant="low">Sort by:</Text>
<Select <Select
value={sortBy} value={sortBy}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
@@ -158,11 +158,10 @@ export function LeagueMembers({
]} ]}
fullWidth={false} fullWidth={false}
/> />
</Box> </div>
</Box> </ControlBar>
{/* Members Table */} <div style={{ overflowX: 'auto', marginTop: '1rem' }}>
<Box overflow="auto">
<LeagueMemberTable showActions={showActions}> <LeagueMemberTable showActions={showActions}>
{sortedMembers.map((member, index) => { {sortedMembers.map((member, index) => {
const isCurrentUser = member.driverId === currentDriverId; const isCurrentUser = member.driverId === currentDriverId;
@@ -191,7 +190,7 @@ export function LeagueMembers({
href={routes.driver.detail(member.driverId)} href={routes.driver.detail(member.driverId)}
meta={ratingAndWinsMeta} meta={ratingAndWinsMeta}
actions={showActions && !cannotModify && !isCurrentUser ? ( actions={showActions && !cannotModify && !isCurrentUser ? (
<Box display="flex" alignItems="center" justifyContent="end" gap={2}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
{onUpdateRole && ( {onUpdateRole && (
<Select <Select
value={member.role} value={member.role}
@@ -202,8 +201,6 @@ export function LeagueMembers({
{ value: 'admin', label: 'Admin' }, { value: 'admin', label: 'Admin' },
]} ]}
fullWidth={false} fullWidth={false}
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-xs py-1 px-2"
/> />
)} )}
{onRemoveMember && ( {onRemoveMember && (
@@ -211,18 +208,17 @@ export function LeagueMembers({
variant="ghost" variant="ghost"
onClick={() => onRemoveMember(member.driverId)} onClick={() => onRemoveMember(member.driverId)}
size="sm" size="sm"
color="text-error-red"
> >
Remove <Text variant="critical">Remove</Text>
</Button> </Button>
)} )}
</Box> </div>
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500"></Text> : undefined)} ) : (showActions && cannotModify ? <Text size="xs" variant="low"></Text> : undefined)}
/> />
); );
})} })}
</LeagueMemberTable> </LeagueMemberTable>
</Box> </div>
</Box> </div>
); );
} }

View File

@@ -1,12 +1,7 @@
'use client'; 'use client';
import { Badge } from '@/ui/Badge'; import { SponsorshipCard } from '@/ui/SponsorshipCard';
import { Heading } from '@/ui/Heading'; import React from 'react';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Surface } from '@/ui/primitives/Surface';
import { Text } from '@/ui/Text';
import { DollarSign } from 'lucide-react';
interface SponsorshipSlot { interface SponsorshipSlot {
id: string; id: string;
@@ -26,42 +21,12 @@ interface SponsorshipSlotCardProps {
export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) { export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
return ( return (
<Surface <SponsorshipCard
variant="muted" name={slot.name}
rounded="lg" description={slot.description}
border price={`${slot.price} ${slot.currency}`}
padding={4} isAvailable={slot.isAvailable}
// eslint-disable-next-line gridpilot-rules/component-classification sponsoredBy={slot.sponsoredBy?.name}
style={{ />
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
borderColor: slot.isAvailable ? '#10b981' : '#262626'
}}
>
<Stack gap={3}>
<Stack direction="row" align="start" justify="between">
<Heading level={4}>{slot.name}</Heading>
<Badge variant={slot.isAvailable ? 'success' : 'default'}>
{slot.isAvailable ? 'Available' : 'Taken'}
</Badge>
</Stack>
<Text size="sm" color="text-gray-300" block>{slot.description}</Text>
<Stack direction="row" align="center" gap={2}>
<Icon icon={DollarSign} size={4} color="#9ca3af" />
<Text weight="semibold" color="text-white">
{slot.price} {slot.currency}
</Text>
</Stack>
{!slot.isAvailable && slot.sponsoredBy && (
// eslint-disable-next-line gridpilot-rules/component-classification
<Stack pt={3} style={{ borderTop: '1px solid #262626' }}>
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>
</Stack>
)}
</Stack>
</Surface>
); );
} }

View File

@@ -1,11 +1,12 @@
'use client'; 'use client';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack'; import { Panel } from '@/ui/Panel';
import { Surface } from '@/ui/primitives/Surface';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { PositionBadge } from '@/ui/ResultRow';
import { TrendingUp, Trophy } from 'lucide-react'; import { TrendingUp, Trophy } from 'lucide-react';
import React from 'react';
interface StandingsEntry { interface StandingsEntry {
position: number; position: number;
@@ -23,94 +24,63 @@ interface StandingsTableShellProps {
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) { export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
return ( return (
<Surface variant="dark" border rounded="lg" overflow="hidden"> <Panel
<Stack px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20"> title={title}
<Stack direction="row" align="center" justify="between"> variant="dark"
<Stack direction="row" align="center" gap={2}> padding={0}
<Icon icon={Trophy} size={4} color="text-warning-amber" /> footer={
<Text weight="bold" letterSpacing="wider" size="sm" display="block"> <Text size="xs" variant="low">{standings.length} Drivers</Text>
{title.toUpperCase()} }
</Text>
</Stack>
<Stack 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>
</Stack>
</Stack>
</Stack>
<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 (
<Stack
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"> <div style={{ overflowX: 'auto' }}>
{position} <Table>
</Text> <TableHead>
</Stack> <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>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Text weight="bold" variant="high">{entry.driverName}</Text>
{entry.change !== undefined && entry.change !== 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
<Icon
icon={TrendingUp}
size={3}
intent={entry.change > 0 ? 'success' : 'critical'}
style={{ transform: entry.change < 0 ? 'rotate(180deg)' : undefined }}
/>
<Text size="xs" variant={entry.change > 0 ? 'success' : 'critical'}>
{Math.abs(entry.change)}
</Text>
</div>
)}
</div>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" variant={entry.wins > 0 ? 'high' : 'low'}>{entry.wins}</Text>
</TableCell>
<TableCell textAlign="center">
<Text size="sm" variant={entry.podiums > 0 ? 'high' : 'low'}>{entry.podiums}</Text>
</TableCell>
<TableCell textAlign="right">
<Text weight="bold" variant="primary">{entry.points}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Panel>
); );
} }

View File

@@ -1,8 +1,13 @@
'use client';
import { IconButton } from '@/ui/IconButton'; import { IconButton } from '@/ui/IconButton';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Stack } from '@/ui/primitives/Stack';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { ControlBar } from '@/ui/ControlBar';
import { SegmentedControl } from '@/ui/SegmentedControl';
import { Icon } from '@/ui/Icon';
import { Grid, List, Search } from 'lucide-react'; import { Grid, List, Search } from 'lucide-react';
import React from 'react';
export interface MediaFiltersBarProps { export interface MediaFiltersBarProps {
searchQuery: string; searchQuery: string;
@@ -24,56 +29,41 @@ export function MediaFiltersBar({
onViewModeChange, onViewModeChange,
}: MediaFiltersBarProps) { }: MediaFiltersBarProps) {
return ( return (
<Stack <div style={{ marginBottom: '1.5rem' }}>
direction={{ base: 'col', md: 'row' }} <ControlBar
align={{ base: 'stretch', md: 'center' }} leftContent={
justify="between" <div style={{ maxWidth: '32rem', width: '100%' }}>
gap={4} <Input
p={4} placeholder="Search media assets..."
bg="bg-charcoal/20" value={searchQuery}
border onChange={(e) => onSearchChange(e.target.value)}
borderColor="border-charcoal-outline/20" icon={<Icon icon={Search} size={4} intent="low" />}
rounded="xl" fullWidth
>
<Stack flexGrow={1} maxWidth={{ md: 'md' }} gap={0}>
<Input
placeholder="Search media assets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search size={18} />}
/>
</Stack>
<Stack direction="row" align="center" gap={3}>
<Stack w="40" gap={0}>
<Select
value={category}
onChange={(e) => onCategoryChange(e.target.value)}
options={categories}
/>
</Stack>
{onViewModeChange && (
<Stack direction="row" bg="bg-charcoal/40" p={1} rounded="lg" border borderColor="border-charcoal-outline/20" gap={0}>
<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 </div>
icon={List} }
variant={viewMode === 'list' ? 'primary' : 'ghost'} >
size="sm" <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
onClick={() => onViewModeChange('list')} <div style={{ width: '10rem' }}>
color={viewMode === 'list' ? 'text-white' : 'text-gray-400'} <Select
backgroundColor={viewMode === 'list' ? 'bg-blue-600' : undefined} value={category}
onChange={(e) => onCategoryChange(e.target.value)}
options={categories}
/> />
</Stack> </div>
)}
</Stack> {onViewModeChange && (
</Stack> <SegmentedControl
options={[
{ id: 'grid', label: '', icon: <Icon icon={Grid} size={4} /> },
{ id: 'list', label: '', icon: <Icon icon={List} size={4} /> },
]}
activeId={viewMode}
onChange={(id) => onViewModeChange(id as 'grid' | 'list')}
/>
)}
</div>
</ControlBar>
</div>
); );
} }

View File

@@ -1,12 +1,15 @@
'use client'; 'use client';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { useState } from 'react'; import { useState } from 'react';
import { MediaCard } from './MediaCard'; import { MediaCard } from '@/ui/MediaCard';
import { MediaFiltersBar } from './MediaFiltersBar'; import { MediaFiltersBar } from './MediaFiltersBar';
import { MediaGrid } from './MediaGrid'; import { Grid } from '@/ui/Grid';
import { MediaViewerModal } from './MediaViewerModal'; import { MediaViewerModal } from './MediaViewerModal';
import { SectionHeader } from '@/ui/SectionHeader';
import { EmptyState } from '@/ui/EmptyState';
import { Search } from 'lucide-react';
import React from 'react';
export interface MediaAsset { export interface MediaAsset {
id: string; id: string;
@@ -60,17 +63,11 @@ export function MediaGallery({
}; };
return ( return (
<Box display="flex" flexDirection="col" gap={6}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<Box> <SectionHeader
<Text as="h1" size="3xl" weight="bold" color="text-white"> title={title}
{title} description={description}
</Text> />
{description && (
<Text block size="sm" color="text-gray-400" mt={1}>
{description}
</Text>
)}
</Box>
<MediaFiltersBar <MediaFiltersBar
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -83,7 +80,7 @@ export function MediaGallery({
/> />
{filteredAssets.length > 0 ? ( {filteredAssets.length > 0 ? (
<MediaGrid columns={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : { base: 1 }}> <Grid cols={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : 1} gap={4}>
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<MediaCard <MediaCard
key={asset.id} key={asset.id}
@@ -93,11 +90,14 @@ export function MediaGallery({
onClick={() => setViewerAsset(asset)} onClick={() => setViewerAsset(asset)}
/> />
))} ))}
</MediaGrid> </Grid>
) : ( ) : (
<Box py={20} center bg="bg-charcoal/10" rounded="xl" border borderStyle="dashed" borderColor="border-charcoal-outline/20"> <EmptyState
<Text color="text-gray-500">No media assets found matching your criteria.</Text> icon={Search}
</Box> title="No media assets found"
description="Try adjusting your search or filters"
variant="minimal"
/>
)} )}
<MediaViewerModal <MediaViewerModal
@@ -109,6 +109,6 @@ export function MediaGallery({
onNext={handleNext} onNext={handleNext}
onPrev={handlePrev} onPrev={handlePrev}
/> />
</Box> </div>
); );
} }

View File

@@ -1,16 +1,16 @@
import { Grid } from '@/ui/primitives/Grid'; import { Grid } from '@/ui/Grid';
import React from 'react'; import React from 'react';
export interface MediaGridProps { export interface MediaGridProps {
children: React.ReactNode; children: React.ReactNode;
columns?: { columns?: number | {
base?: number; base?: number;
sm?: number; sm?: number;
md?: number; md?: number;
lg?: number; lg?: number;
xl?: number; xl?: number;
}; };
gap?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 16; gap?: number;
} }
export function MediaGrid({ export function MediaGrid({
@@ -20,10 +20,8 @@ export function MediaGrid({
}: MediaGridProps) { }: MediaGridProps) {
return ( return (
<Grid <Grid
cols={(columns.base ?? 1) as any} cols={columns}
mdCols={columns.md as any} gap={gap}
lgCols={columns.lg as any}
gap={gap as any}
> >
{children} {children}
</Grid> </Grid>

View File

@@ -2,10 +2,10 @@
import { IconButton } from '@/ui/IconButton'; import { IconButton } from '@/ui/IconButton';
import { Image } from '@/ui/Image'; import { Image } from '@/ui/Image';
import { Box } from '@/ui/primitives/Box'; import { Modal } from '@/ui/Modal';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { AnimatePresence, motion } from 'framer-motion'; import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'; import React from 'react';
export interface MediaViewerModalProps { export interface MediaViewerModalProps {
isOpen: boolean; isOpen: boolean;
@@ -27,143 +27,61 @@ export function MediaViewerModal({
onPrev, onPrev,
}: MediaViewerModalProps) { }: MediaViewerModalProps) {
return ( return (
<AnimatePresence> <Modal
{isOpen && ( isOpen={isOpen}
<Box onClose={onClose}
position="fixed" title={title}
inset="0" size="xl"
zIndex={100} footer={
display="flex" <div style={{ width: '100%', textAlign: 'center' }}>
alignItems="center" <Text size="xs" variant="low" uppercase>
justifyContent="center" Precision Racing Media Viewer
p={{ base: 4, md: 8 }} </Text>
> </div>
{/* Backdrop with frosted blur */} }
<Box actions={
as={motion.div} <IconButton
initial={{ opacity: 0 }} icon={Download}
animate={{ opacity: 1 }} variant="secondary"
exit={{ opacity: 0 }} size="sm"
transition={{ duration: 0.2, ease: 'easeOut' }} onClick={() => src && window.open(src, '_blank')}
position="absolute" title="Download"
inset="0" />
bg="bg-black/80" }
blur="md" >
onClick={onClose} <div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '20rem' }}>
{src ? (
<Image
src={src}
alt={alt}
objectFit="contain"
/> />
) : (
<Text variant="low">No image selected</Text>
)}
{/* Content Container */} {/* Navigation Controls */}
<Box {onPrev && (
as={motion.div} <div style={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
initial={{ opacity: 0, scale: 0.95 }} <IconButton
animate={{ opacity: 1, scale: 1 }} icon={ChevronLeft}
exit={{ opacity: 0, scale: 0.95 }} variant="secondary"
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} onClick={onPrev}
position="relative" title="Previous"
zIndex={10} />
w="full" </div>
maxWidth="6xl" )}
maxHeight="full" {onNext && (
display="flex" <div style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
flexDirection="col" <IconButton
> icon={ChevronRight}
{/* Header */} variant="secondary"
<Box onClick={onNext}
display="flex" title="Next"
alignItems="center" />
justifyContent="between" </div>
mb={4} )}
color="text-white" </div>
> </Modal>
<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

@@ -1,11 +1,10 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton'; import { Modal } from '@/ui/Modal';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { NotificationStat, NotificationDeadline } from '@/ui/NotificationContent';
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -18,9 +17,8 @@ import {
Trophy, Trophy,
Users, Users,
Vote, Vote,
X,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import React from 'react';
import type { Notification, NotificationAction } from './notificationTypes'; import type { Notification, NotificationAction } from './notificationTypes';
interface ModalNotificationProps { interface ModalNotificationProps {
@@ -42,71 +40,12 @@ const notificationIcons: Record<string, typeof Bell> = {
race_reminder: Flag, race_reminder: Flag,
}; };
const notificationColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
protest_filed: {
bg: 'bg-critical-red/10',
border: 'border-critical-red/50',
text: 'text-critical-red',
glow: 'shadow-[0_0_60px_rgba(227,92,92,0.3)]',
},
protest_defense_requested: {
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/50',
text: 'text-warning-amber',
glow: 'shadow-[0_0_60px_rgba(255,197,86,0.3)]',
},
protest_vote_required: {
bg: 'bg-primary-accent/10',
border: 'border-primary-accent/50',
text: 'text-primary-accent',
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
},
penalty_issued: {
bg: 'bg-critical-red/10',
border: 'border-critical-red/50',
text: 'text-critical-red',
glow: 'shadow-[0_0_60px_rgba(227,92,92,0.3)]',
},
race_performance_summary: {
bg: 'bg-panel-gray',
border: 'border-warning-amber/60',
text: 'text-warning-amber',
glow: 'shadow-[0_0_80px_rgba(255,197,86,0.2)]',
},
race_final_results: {
bg: 'bg-panel-gray',
border: 'border-primary-accent/60',
text: 'text-primary-accent',
glow: 'shadow-[0_0_80px_rgba(25,140,255,0.2)]',
},
};
export function ModalNotification({ export function ModalNotification({
notification, notification,
onAction, onAction,
onDismiss, onDismiss,
onNavigate, onNavigate,
}: ModalNotificationProps) { }: ModalNotificationProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Animate in
const timeout = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timeout);
}, []);
// Handle ESC key to dismiss
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && onDismiss && !notification.requiresResponse) {
onDismiss(notification);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [notification, onDismiss]);
const handleAction = (action: NotificationAction) => { const handleAction = (action: NotificationAction) => {
onAction(notification, action.id); onAction(notification, action.id);
if (action.href && onNavigate) { if (action.href && onNavigate) {
@@ -122,13 +61,6 @@ export function ModalNotification({
}; };
const NotificationIcon = notificationIcons[notification.type] || AlertCircle; const NotificationIcon = notificationIcons[notification.type] || AlertCircle;
const colors = notificationColors[notification.type] || {
bg: 'bg-panel-gray',
border: 'border-border-gray',
text: 'text-gray-400',
glow: 'shadow-card',
};
const data: Record<string, unknown> = notification.data ?? {}; const data: Record<string, unknown> = notification.data ?? {};
const getNumber = (value: unknown): number | null => { const getNumber = (value: unknown): number | null => {
@@ -148,7 +80,6 @@ export function ModalNotification({
const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime()); const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
// Check if there's a deadline
const deadlineValue = data.deadline; const deadlineValue = data.deadline;
const deadline: Date | null = const deadline: Date | null =
isValidDate(deadlineValue) isValidDate(deadlineValue)
@@ -158,200 +89,96 @@ export function ModalNotification({
: null; : null;
const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime()); const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime());
// Special celebratory styling for race notifications
const isRaceNotification = notification.type.startsWith('race_'); const isRaceNotification = notification.type.startsWith('race_');
const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0; const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0; const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
const ratingChange = provisionalRatingChange || finalRatingChange; const ratingChange = provisionalRatingChange || finalRatingChange;
const protestId = getString(data.protestId); const protestId = getString(data.protestId);
return ( return (
<Box <Modal
position="fixed" isOpen={true}
inset="0" onClose={onDismiss ? () => onDismiss(notification) : undefined}
zIndex={100} title={notification.title}
display="flex" description={isRaceNotification ? 'Race Update' : 'Action Required'}
alignItems="center" icon={<Icon icon={NotificationIcon} size={5} intent="primary" />}
justifyContent="center" footer={
p={4} <React.Fragment>
transition {notification.actions && notification.actions.length > 0 ? (
bg={isVisible ? 'bg-black/80' : 'bg-transparent'} notification.actions.map((action, index) => (
className={isVisible ? 'backdrop-blur-sm' : ''} <Button
> key={index}
<Box variant={action.type === 'primary' ? 'primary' : 'secondary'}
w="full" onClick={() => handleAction(action)}
maxWidth="lg" >
transform {action.label}
transition </Button>
opacity={isVisible ? 1 : 0} ))
className={isVisible ? 'scale-100' : 'scale-95'} ) : (
> isRaceNotification ? (
<Box <>
rounded="sm" <Button
border variant="secondary"
borderColor={colors.border} onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
bg="panel-gray"
shadow={colors.glow}
overflow="hidden"
>
{/* Header */}
<Box
px={6}
py={4}
bg="graphite-black"
borderBottom
borderColor={colors.border}
>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={4}>
<Box
p={2}
rounded="sm"
bg="panel-gray"
border
borderColor={colors.border}
> >
<Icon icon={NotificationIcon} size={5} color={colors.text} /> Dismiss
</Box> </Button>
<Box> <Button
<Text variant="primary"
size="xs" onClick={handlePrimaryAction}
weight="bold" >
className="uppercase tracking-widest" {notification.type === 'race_performance_summary' ? 'View Results' : 'View Standings'}
color="text-gray-500" </Button>
> </>
{isRaceNotification ? 'Race Update' : 'Action Required'} ) : (
</Text> <Button variant="primary" onClick={handlePrimaryAction}>
<Heading level={3} weight="bold" color="text-white"> {notification.actionUrl ? 'View Details' : 'Acknowledge'}
{notification.title} </Button>
</Heading> )
</Box>
</Box>
{/* X button for dismissible notifications */}
{onDismiss && !notification.requiresResponse && (
<IconButton
icon={X}
onClick={() => onDismiss(notification)}
variant="ghost"
size="md"
color="text-gray-500"
title="Dismiss notification"
/>
)}
</Box>
</Box>
{/* Body */}
<Box px={6} py={6}>
<Text
leading="relaxed"
size="base"
color="text-gray-300"
block
>
{notification.message}
</Text>
{/* Race performance stats */}
{isRaceNotification && (
<Box display="grid" gridCols={2} gap={4} mt={6}>
<Box bg="graphite-black" rounded="sm" p={4} border borderColor="border-border-gray">
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest">POSITION</Text>
<Text size="2xl" weight="bold" color="text-white" block>
{notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
</Text>
</Box>
<Box bg="graphite-black" rounded="sm" p={4} border borderColor="border-border-gray">
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest">RATING</Text>
<Text size="2xl" weight="bold" color={ratingChange >= 0 ? 'text-success-green' : 'text-critical-red'} block>
{ratingChange >= 0 ? '+' : ''}
{ratingChange}
</Text>
</Box>
</Box>
)}
{/* Deadline warning */}
{hasDeadline && !isRaceNotification && (
<Box mt={6} display="flex" alignItems="center" gap={3} px={4} py={3} rounded="sm" bg="warning-amber/5" border borderColor="border-warning-amber/20">
<Icon icon={Clock} size={5} color="text-warning-amber" />
<Box>
<Text size="sm" weight="bold" color="text-warning-amber" block className="uppercase tracking-wider">Response Required</Text>
<Text size="xs" color="text-gray-500" block mt={0.5}>
By {deadline ? deadline.toLocaleDateString() : ''} {deadline ? deadline.toLocaleTimeString() : ''}
</Text>
</Box>
</Box>
)}
{/* Additional context from data */}
{protestId && (
<Box mt={6} p={3} rounded="sm" bg="graphite-black" border borderColor="border-border-gray">
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest text-[10px]">PROTEST ID</Text>
<Text size="xs" color="text-gray-400" font="mono" block>
{protestId}
</Text>
</Box>
)}
</Box>
{/* Actions */}
<Box
px={6}
py={4}
borderTop
borderColor="border-border-gray"
bg="graphite-black"
>
<Box display="flex" flexWrap="wrap" gap={3} justifyContent="end">
{notification.actions && notification.actions.length > 0 ? (
notification.actions.map((action, index) => (
<Button
key={index}
variant={action.type === 'primary' ? 'primary' : 'secondary'}
onClick={() => handleAction(action)}
className={action.type === 'danger' ? 'bg-critical-red hover:bg-critical-red/90' : ''}
>
{action.label}
</Button>
))
) : (
isRaceNotification ? (
<>
<Button
variant="secondary"
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
>
Dismiss
</Button>
<Button
variant="primary"
onClick={handlePrimaryAction}
>
{notification.type === 'race_performance_summary' ? 'View Results' : 'View Standings'}
</Button>
</>
) : (
<Button variant="primary" onClick={handlePrimaryAction}>
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
</Button>
)
)}
</Box>
</Box>
{/* Cannot dismiss warning */}
{notification.requiresResponse && !isRaceNotification && (
<Box px={6} py={2} bg="critical-red/5" borderTop borderColor="border-critical-red/10">
<Text size="xs" color="text-critical-red" textAlign="center" block weight="medium">
This action is required to continue
</Text>
</Box>
)} )}
</Box> </React.Fragment>
</Box> }
</Box> >
<Text leading="relaxed" size="base" variant="med" block>
{notification.message}
</Text>
{isRaceNotification && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
<NotificationStat
label="POSITION"
value={notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
/>
<NotificationStat
label="RATING"
value={`${ratingChange >= 0 ? '+' : ''}${ratingChange}`}
intent={ratingChange >= 0 ? 'success' : 'critical'}
/>
</div>
)}
{hasDeadline && !isRaceNotification && (
<NotificationDeadline
label="Response Required"
deadline={`By ${deadline ? deadline.toLocaleDateString() : ''} ${deadline ? deadline.toLocaleTimeString() : ''}`}
icon={Clock}
/>
)}
{protestId && (
<div style={{ marginTop: '1.5rem', padding: '0.75rem', backgroundColor: 'var(--ui-color-bg-base)', border: '1px solid var(--ui-color-border-default)', borderRadius: 'var(--ui-radius-sm)' }}>
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>PROTEST ID</Text>
<Text size="xs" variant="med" font="mono" block>{protestId}</Text>
</div>
)}
{notification.requiresResponse && !isRaceNotification && (
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
<Text size="xs" variant="critical" weight="medium">
This action is required to continue
</Text>
</div>
)}
</Modal>
); );
} }

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton'; import { Toast } from '@/ui/Toast';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { import {
AlertTriangle, AlertTriangle,
@@ -13,9 +12,8 @@ import {
Trophy, Trophy,
Users, Users,
Vote, Vote,
X,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import type { Notification } from './notificationTypes'; import type { Notification } from './notificationTypes';
interface ToastNotificationProps { interface ToastNotificationProps {
@@ -36,16 +34,6 @@ const notificationIcons: Record<string, typeof Bell> = {
race_reminder: Flag, race_reminder: Flag,
}; };
const notificationColors: Record<string, { bg: string; border: string; text: string }> = {
protest_filed: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
protest_defense_requested: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
protest_vote_required: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
penalty_issued: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
race_results_posted: { bg: 'bg-performance-green/10', border: 'border-performance-green/30', text: 'text-performance-green' },
league_invite: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
race_reminder: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
};
export function ToastNotification({ export function ToastNotification({
notification, notification,
onDismiss, onDismiss,
@@ -55,6 +43,7 @@ export function ToastNotification({
}: ToastNotificationProps) { }: ToastNotificationProps) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false); const [isExiting, setIsExiting] = useState(false);
const [progress, setProgress] = useState(100);
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setIsExiting(true); setIsExiting(true);
@@ -64,17 +53,22 @@ export function ToastNotification({
}, [notification, onDismiss]); }, [notification, onDismiss]);
useEffect(() => { useEffect(() => {
// Animate in
const showTimeout = setTimeout(() => setIsVisible(true), 10); const showTimeout = setTimeout(() => setIsVisible(true), 10);
// Auto-hide const startTime = Date.now();
const hideTimeout = setTimeout(() => { const interval = setInterval(() => {
handleDismiss(); const elapsed = Date.now() - startTime;
}, autoHideDuration); const remaining = Math.max(0, 100 - (elapsed / autoHideDuration) * 100);
setProgress(remaining);
if (remaining === 0) {
clearInterval(interval);
handleDismiss();
}
}, 10);
return () => { return () => {
clearTimeout(showTimeout); clearTimeout(showTimeout);
clearTimeout(hideTimeout); clearInterval(interval);
}; };
}, [autoHideDuration, handleDismiss]); }, [autoHideDuration, handleDismiss]);
@@ -87,88 +81,30 @@ export function ToastNotification({
}; };
const NotificationIcon = notificationIcons[notification.type] || Bell; const NotificationIcon = notificationIcons[notification.type] || Bell;
const colors = notificationColors[notification.type] || {
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
text: 'text-gray-400',
};
return ( return (
<Box <Toast
transform title={notification.title ?? 'Notification'}
transition onClose={handleDismiss}
translateX={isVisible && !isExiting ? '0' : 'full'} icon={<Icon icon={NotificationIcon} size={5} intent="primary" />}
opacity={isVisible && !isExiting ? 1 : 0} isVisible={isVisible}
isExiting={isExiting}
progress={progress}
> >
<Box <Text size="xs" variant="low" lineClamp={2}>
w="96" {notification.message}
rounded="xl" </Text>
border {notification.actionUrl && (
borderColor={colors.border} <div
bg={colors.bg} onClick={handleClick}
shadow="2xl" style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer' }}
overflow="hidden" >
> <Text size="xs" weight="medium" variant="primary">
{/* Progress bar */} View details
<Box h="1" bg="bg-iron-gray/50" overflow="hidden"> </Text>
<Box <Icon icon={ExternalLink} size={3} intent="primary" />
h="full" </div>
bg={colors.text.replace('text-', 'bg-')} )}
// eslint-disable-next-line gridpilot-rules/component-classification </Toast>
className="animate-toast-progress"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ animationDuration: `${autoHideDuration}ms` }}
/>
</Box>
<Box p={4}>
<Box display="flex" gap={3}>
{/* Icon */}
<Box p={2} rounded="lg" bg={colors.bg} flexShrink={0}>
<Icon icon={NotificationIcon} size={5} color={colors.text} />
</Box>
{/* Content */}
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
<Text size="sm" weight="semibold" color="text-white" truncate>
{notification.title ?? 'Notification'}
</Text>
<IconButton
icon={X}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleDismiss();
}}
variant="ghost"
size="sm"
color="text-gray-400"
/>
</Box>
<Text size="xs" color="text-gray-400" lineClamp={2} mt={1}>
{notification.message}
</Text>
{notification.actionUrl && (
<Box
as="button"
onClick={handleClick}
mt={2}
display="flex"
alignItems="center"
gap={1}
cursor="pointer"
hoverScale
>
<Text size="xs" weight="medium" color={colors.text}>
View details
</Text>
<Icon icon={ExternalLink} size={3} color={colors.text} />
</Box>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
); );
} }

View File

@@ -1,11 +1,11 @@
'use client'; 'use client';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Panel } from '@/ui/Panel';
import { Heading } from '@/ui/Heading'; import { AccountItem } from '@/ui/AccountItem';
import { Stack } from '@/ui/primitives/Stack'; import { Badge } from '@/ui/Badge';
import { Text } from '@/ui/Text';
import { Globe, Link as LinkIcon } from 'lucide-react'; import { Globe, Link as LinkIcon } from 'lucide-react';
import React from 'react';
interface ConnectedAccountsPanelProps { interface ConnectedAccountsPanelProps {
iracingId?: string | number; iracingId?: string | number;
@@ -14,56 +14,35 @@ interface ConnectedAccountsPanelProps {
export function ConnectedAccountsPanel({ iracingId, onConnectIRacing }: ConnectedAccountsPanelProps) { export function ConnectedAccountsPanel({ iracingId, onConnectIRacing }: ConnectedAccountsPanelProps) {
return ( return (
<section aria-labelledby="connected-accounts-heading"> <Panel title="Connected Accounts">
<Card> <AccountItem
<Stack gap={6}> icon={Globe}
<Heading level={3} id="connected-accounts-heading" fontSize="1.125rem">Connected Accounts</Heading> title="iRacing"
description={iracingId ? `Connected ID: ${iracingId}` : 'Not connected'}
<Stack gap={4} className="divide-y divide-border-gray/30"> intent="telemetry"
<Stack direction="row" justify="between" align="center" pt={0}> action={
<Stack direction="row" align="center" gap={3}> iracingId ? (
<Stack backgroundColor="#1e293b" p={2} rounded="md"> <Badge variant="success">Verified</Badge>
<Globe size={20} color="#4ED4E0" /> ) : (
</Stack> <Button size="sm" variant="secondary" onClick={onConnectIRacing}>
<Stack gap={0.5}> Connect
<Text weight="bold" size="sm">iRacing</Text> </Button>
<Text size="xs" color="#9ca3af"> )
{iracingId ? `Connected ID: ${iracingId}` : 'Not connected'} }
</Text> />
</Stack>
</Stack>
{!iracingId && (
<Button size="sm" variant="secondary" onClick={onConnectIRacing}>
Connect
</Button>
)}
{iracingId && (
<Stack backgroundColor="rgba(16, 185, 129, 0.1)" px={2} py={1} rounded="full">
<Text size="xs" color="#10b981" weight="bold">Verified</Text>
</Stack>
)}
</Stack>
<Stack direction="row" justify="between" align="center" pt={4}> <AccountItem
<Stack direction="row" align="center" gap={3}> icon={LinkIcon}
<Stack backgroundColor="#1e293b" p={2} rounded="md"> title="Discord"
<LinkIcon size={20} color="#198CFF" /> description="Connect for notifications"
</Stack> intent="primary"
<Stack gap={0.5}> action={
<Text weight="bold" size="sm">Discord</Text> <Button size="sm" variant="secondary">
<Text size="xs" color="#9ca3af">Connect for notifications</Text> Connect
</Stack> </Button>
</Stack> }
/>
<Button size="sm" variant="secondary"> </Panel>
Connect
</Button>
</Stack>
</Stack>
</Stack>
</Card>
</section>
); );
} }

View File

@@ -2,9 +2,9 @@
import { LeagueListItem } from '@/components/leagues/LeagueListItem'; import { LeagueListItem } from '@/components/leagues/LeagueListItem';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ProfileSection } from './ProfileSection'; import { ProfileSection } from './ProfileSection';
import React from 'react';
interface League { interface League {
leagueId: string; leagueId: string;
@@ -22,23 +22,23 @@ interface MembershipPanelProps {
export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) { export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) {
return ( return (
<Stack gap={8}> <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<ProfileSection <ProfileSection
title="Leagues You Own" title="Leagues You Own"
description="Manage the leagues you have created and lead." description="Manage the leagues you have created and lead."
> >
{ownedLeagues.length === 0 ? ( {ownedLeagues.length === 0 ? (
<Card> <Card>
<Text size="sm" color="text-gray-400"> <Text size="sm" variant="low">
You don&apos;t own any leagues yet. You don&apos;t own any leagues yet.
</Text> </Text>
</Card> </Card>
) : ( ) : (
<Stack gap={3}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{ownedLeagues.map((league) => ( {ownedLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} isAdmin /> <LeagueListItem key={league.leagueId} league={league} isAdmin />
))} ))}
</Stack> </div>
)} )}
</ProfileSection> </ProfileSection>
@@ -48,18 +48,18 @@ export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanel
> >
{memberLeagues.length === 0 ? ( {memberLeagues.length === 0 ? (
<Card> <Card>
<Text size="sm" color="text-gray-400"> <Text size="sm" variant="low">
You&apos;re not a member of any other leagues yet. You&apos;re not a member of any other leagues yet.
</Text> </Text>
</Card> </Card>
) : ( ) : (
<Stack gap={3}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{memberLeagues.map((league) => ( {memberLeagues.map((league) => (
<LeagueListItem key={league.leagueId} league={league} /> <LeagueListItem key={league.leagueId} league={league} />
))} ))}
</Stack> </div>
)} )}
</ProfileSection> </ProfileSection>
</Stack> </div>
); );
} }

View File

@@ -1,11 +1,10 @@
'use client'; 'use client';
import { Card } from '@/ui/Card'; import { Panel } from '@/ui/Panel';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/primitives/Stack';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';
import { Toggle } from '@/ui/Toggle'; import { Toggle } from '@/ui/Toggle';
import { ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
import React from 'react';
interface PreferencesPanelProps { interface PreferencesPanelProps {
preferences: { preferences: {
@@ -22,75 +21,54 @@ interface PreferencesPanelProps {
export function PreferencesPanel({ preferences, isEditing, onUpdate }: PreferencesPanelProps) { export function PreferencesPanel({ preferences, isEditing, onUpdate }: PreferencesPanelProps) {
if (isEditing) { if (isEditing) {
return ( return (
<section aria-labelledby="preferences-heading"> <Panel title="Racing Preferences">
<Card> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<Stack gap={6}> <Select
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading> label="Favorite Car Class"
value={preferences.favoriteCarClass}
<Stack gap={4}> onChange={(e) => onUpdate?.({ favoriteCarClass: e.target.value })}
<Select options={[
label="Favorite Car Class" { value: 'GT3', label: 'GT3' },
value={preferences.favoriteCarClass} { value: 'GT4', label: 'GT4' },
onChange={(e) => onUpdate?.({ favoriteCarClass: e.target.value })} { value: 'Formula', label: 'Formula' },
options={[ { value: 'LMP2', label: 'LMP2' },
{ value: 'GT3', label: 'GT3' }, ]}
{ value: 'GT4', label: 'GT4' }, />
{ value: 'Formula', label: 'Formula' }, <Select
{ value: 'LMP2', label: 'LMP2' }, label="Competitive Level"
]} value={preferences.competitiveLevel}
/> onChange={(e) => onUpdate?.({ competitiveLevel: e.target.value })}
<Select options={[
label="Competitive Level" { value: 'casual', label: 'Casual' },
value={preferences.competitiveLevel} { value: 'competitive', label: 'Competitive' },
onChange={(e) => onUpdate?.({ competitiveLevel: e.target.value })} { value: 'professional', label: 'Professional' },
options={[ ]}
{ value: 'casual', label: 'Casual' }, />
{ value: 'competitive', label: 'Competitive' },
{ value: 'professional', label: 'Professional' }, <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', paddingTop: '0.5rem' }}>
]} <Toggle
/> label="Public Profile"
checked={preferences.showProfile}
<Stack gap={3} pt={2}> onChange={(checked) => onUpdate?.({ showProfile: checked })}
<Toggle />
label="Public Profile" <Toggle
checked={preferences.showProfile} label="Show Race History"
onChange={(checked) => onUpdate?.({ showProfile: checked })} checked={preferences.showHistory}
/> onChange={(checked) => onUpdate?.({ showHistory: checked })}
<Toggle />
label="Show Race History" </div>
checked={preferences.showHistory} </div>
onChange={(checked) => onUpdate?.({ showHistory: checked })} </Panel>
/>
</Stack>
</Stack>
</Stack>
</Card>
</section>
); );
} }
return ( return (
<section aria-labelledby="preferences-heading"> <Panel title="Racing Preferences">
<Card> <ProfileStatsGroup>
<Stack gap={6}> <ProfileStat label="Car Class" value={preferences.favoriteCarClass} intent="primary" />
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading> <ProfileStat label="Level" value={preferences.competitiveLevel} intent="telemetry" />
<ProfileStat label="Visibility" value={preferences.showProfile ? 'Public' : 'Private'} intent="low" />
<Stack direction="row" gap={8} wrap> </ProfileStatsGroup>
<Stack gap={1}> </Panel>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Car Class</Text>
<Text color="#d1d5db">{preferences.favoriteCarClass}</Text>
</Stack>
<Stack gap={1}>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Level</Text>
<Text color="#d1d5db" capitalize>{preferences.competitiveLevel}</Text>
</Stack>
<Stack gap={1}>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Visibility</Text>
<Text color="#d1d5db">{preferences.showProfile ? 'Public' : 'Private'}</Text>
</Stack>
</Stack>
</Stack>
</Card>
</section>
); );
} }

View File

@@ -1,11 +1,6 @@
import { Panel } from '@/ui/Panel';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { User } from 'lucide-react'; import React from 'react';
interface ProfileBioProps { interface ProfileBioProps {
bio: string; bio: string;
@@ -13,13 +8,10 @@ interface ProfileBioProps {
export function ProfileBio({ bio }: ProfileBioProps) { export function ProfileBio({ bio }: ProfileBioProps) {
return ( return (
<Card> <Panel
<Box mb={3}> title="About"
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}> >
About <Text variant="med">{bio}</Text>
</Heading> </Panel>
</Box>
<Text color="text-gray-300">{bio}</Text>
</Card>
); );
} }

View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Card } from '@/ui/Card'; import { Panel } from '@/ui/Panel';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { TextArea } from '@/ui/TextArea'; import { TextArea } from '@/ui/TextArea';
import { ProfileStat } from '@/ui/ProfileHero';
import React from 'react';
interface ProfileDetailsPanelProps { interface ProfileDetailsPanelProps {
driver: { driver: {
@@ -21,60 +21,45 @@ interface ProfileDetailsPanelProps {
export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDetailsPanelProps) { export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDetailsPanelProps) {
if (isEditing) { if (isEditing) {
return ( return (
<section aria-labelledby="profile-details-heading"> <Panel title="Profile Details">
<Card> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<Stack gap={6}> <Input
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading> label="Nationality (ISO Code)"
<Stack gap={4}> value={driver.country}
<Input onChange={(e) => onUpdate?.({ country: e.target.value })}
label="Nationality (ISO Code)" placeholder="e.g. US, GB, DE"
value={driver.country} />
onChange={(e) => onUpdate?.({ country: e.target.value })} <TextArea
placeholder="e.g. US, GB, DE" label="Bio"
maxLength={2} value={driver.bio || ''}
/> onChange={(e) => onUpdate?.({ bio: e.target.value })}
<TextArea placeholder="Tell the community about your racing career..."
label="Bio" />
value={driver.bio || ''} </div>
onChange={(e) => onUpdate?.({ bio: e.target.value })} </Panel>
placeholder="Tell the community about your racing career..."
rows={4}
/>
</Stack>
</Stack>
</Card>
</section>
); );
} }
return ( return (
<section aria-labelledby="profile-details-heading"> <Panel title="Profile Details">
<Card> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<Stack gap={6}> <div>
<Stack direction="row" justify="between" align="center"> <Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Nationality</Text>
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
</Stack> <Text size="xl">
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
<Stack gap={4}> </Text>
<Stack gap={1}> <Text variant="med">{driver.country}</Text>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Nationality</Text> </div>
<Stack direction="row" align="center" gap={2}> </div>
<Text size="xl">
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
</Text>
<Text color="#d1d5db">{driver.country}</Text>
</Stack>
</Stack>
<Stack gap={1}> <div>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Bio</Text> <Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Bio</Text>
<Text color="#d1d5db" lineHeight="relaxed"> <Text variant="med" leading="relaxed">
{driver.bio || 'No bio provided.'} {driver.bio || 'No bio provided.'}
</Text> </Text>
</Stack> </div>
</Stack> </div>
</Stack> </Panel>
</Card>
</section>
); );
} }

View File

@@ -5,10 +5,10 @@ import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image'; import { Image } from '@/ui/Image';
import { Stack } from '@/ui/primitives/Stack'; import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
import { Surface } from '@/ui/primitives/Surface';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react'; import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
import React from 'react';
interface ProfileHeaderProps { interface ProfileHeaderProps {
driver: { driver: {
@@ -36,103 +36,63 @@ export function ProfileHeader({
isOwnProfile, isOwnProfile,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
return ( return (
<header> <ProfileHero variant="muted">
<Surface variant="muted" rounded="xl" border padding={6} backgroundColor="#141619" borderColor="#23272B"> <div style={{ display: 'flex', alignItems: 'center', gap: '2rem', flexWrap: 'wrap' }}>
<Stack direction="row" align="center" gap={8} wrap> <ProfileAvatar>
{/* Avatar with telemetry-style border */} <Image
<Stack position="relative"> src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
<Stack alt={driver.name}
width="6rem" width={96}
height="6rem" height={96}
rounded="md" objectFit="cover"
border />
borderColor="#23272B" </ProfileAvatar>
p={0.5}
backgroundColor="#0C0D0F"
>
<Stack width="100%" height="100%" rounded="sm" overflow="hidden">
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={96}
height={96}
objectFit="cover"
fullWidth
fullHeight
/>
</Stack>
</Stack>
</Stack>
{/* Driver Info */} <div style={{ flex: 1, minWidth: '200px' }}>
<Stack flexGrow={1} minWidth="0"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
<Stack direction="row" align="center" gap={3} mb={1}> <Heading level={1}>{driver.name}</Heading>
<Heading level={1} fontSize="1.5rem"> <Text size="2xl" aria-label={`Country: ${driver.country}`}>
{driver.name} {CountryFlagDisplay.fromCountryCode(driver.country).toString()}
</Heading> </Text>
<Text size="2xl" aria-label={`Country: ${driver.country}`}> </div>
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Globe size={14} color="var(--ui-color-text-low)" />
<Text size="xs" font="mono" variant="low">ID: {driver.iracingId}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Calendar size={14} color="var(--ui-color-text-low)" />
<Text size="xs" variant="low">
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Text> </Text>
</Stack> </div>
</div>
</div>
<Stack direction="row" align="center" gap={4} color="#9ca3af"> <ProfileStatsGroup>
<Stack direction="row" align="center" gap={1.5}> {stats && (
<Globe size={14} /> <React.Fragment>
<Text size="xs" font="mono">ID: {driver.iracingId}</Text> <ProfileStat label="RATING" value={stats.rating} intent="primary" />
</Stack> <ProfileStat label="GLOBAL RANK" value={`#${globalRank}`} intent="warning" />
<Stack width="1px" height="12px" backgroundColor="#23272B" /> </React.Fragment>
<Stack direction="row" align="center" gap={1.5}>
<Calendar size={14} />
<Text size="xs">
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Text>
</Stack>
</Stack>
</Stack>
{/* Stats Grid */}
<Stack direction="row" align="center" gap={6}>
{stats && (
<>
<Stack>
<Stack gap={0.5}>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">RATING</Text>
<Stack direction="row" align="center" gap={1.5}>
<Star size={14} color="#198CFF" />
<Text font="mono" size="lg" weight="bold" color="#198CFF">{stats.rating}</Text>
</Stack>
</Stack>
</Stack>
<Stack width="1px" height="32px" backgroundColor="#23272B" />
<Stack>
<Stack gap={0.5}>
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">GLOBAL RANK</Text>
<Stack direction="row" align="center" gap={1.5}>
<Trophy size={14} color="#FFBE4D" />
<Text font="mono" size="lg" weight="bold" color="#FFBE4D">#{globalRank}</Text>
</Stack>
</Stack>
</Stack>
</>
)}
</Stack>
{/* Actions */}
{!isOwnProfile && onAddFriend && (
<Stack ml="auto">
<Button
variant={friendRequestSent ? 'secondary' : 'primary'}
onClick={onAddFriend}
disabled={friendRequestSent}
size="sm"
icon={<UserPlus size={16} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</Stack>
)} )}
</Stack> </ProfileStatsGroup>
</Surface>
</header> {!isOwnProfile && onAddFriend && (
<div style={{ marginLeft: 'auto' }}>
<Button
variant={friendRequestSent ? 'secondary' : 'primary'}
onClick={onAddFriend}
disabled={friendRequestSent}
size="sm"
icon={<UserPlus size={16} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</div>
)}
</div>
</ProfileHero>
); );
} }

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { Heading } from '@/ui/Heading'; import { SectionHeader } from '@/ui/SectionHeader';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import React from 'react'; import React from 'react';
interface ProfileSectionProps { interface ProfileSectionProps {
@@ -14,19 +12,14 @@ interface ProfileSectionProps {
export function ProfileSection({ title, description, action, children }: ProfileSectionProps) { export function ProfileSection({ title, description, action, children }: ProfileSectionProps) {
return ( return (
<Stack mb={8}> <section style={{ marginBottom: '2rem' }}>
<Stack direction="row" align="center" justify="between" mb={4}> <SectionHeader
<Stack> title={title}
<Heading level={2}>{title}</Heading> description={description}
{description && ( actions={action}
<Text color="text-gray-400" size="sm" mt={1} block> variant="minimal"
{description} />
</Text> <div>{children}</div>
)} </section>
</Stack>
{action && <Stack>{action}</Stack>}
</Stack>
<Stack>{children}</Stack>
</Stack>
); );
} }

View File

@@ -1,11 +1,11 @@
import { Box } from '../../ui/primitives/Box'; import { StatGrid } from '../../ui/StatGrid';
import { Grid } from '../../ui/primitives/Grid'; import { Bug } from 'lucide-react';
import { Text } from '../../ui/Text'; import React from 'react';
interface Stat { interface Stat {
label: string; label: string;
value: string | number; value: string | number;
color?: string; intent?: 'primary' | 'telemetry' | 'success' | 'critical';
} }
interface ProfileStatGridProps { interface ProfileStatGridProps {
@@ -13,22 +13,18 @@ interface ProfileStatGridProps {
} }
export function ProfileStatGrid({ stats }: ProfileStatGridProps) { export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
const mappedStats = stats.map(stat => ({
label: stat.label,
value: stat.value,
intent: stat.intent || 'primary',
icon: Bug // Default icon if none provided, but StatBox requires one
}));
return ( return (
<Grid cols={2} mdCols={4} gap={4}> <StatGrid
{stats.map((stat, idx) => ( stats={mappedStats}
<Box columns={{ base: 2, md: 4 }}
key={idx} variant="box"
p={4} />
bg="bg-[#0f1115]"
rounded="xl"
border
borderColor="border-[#262626]"
textAlign="center"
>
<Text size="3xl" weight="bold" color={stat.color} block mb={1}>{stat.value}</Text>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em">{stat.label}</Text>
</Box>
))}
</Grid>
); );
} }

View File

@@ -1,10 +1,9 @@
'use client';
import { SegmentedControl } from '@/ui/SegmentedControl';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Surface } from '@/ui/primitives/Surface';
import { BarChart3, TrendingUp, User } from 'lucide-react'; import { BarChart3, TrendingUp, User } from 'lucide-react';
import React from 'react';
export type ProfileTab = 'overview' | 'stats' | 'ratings'; export type ProfileTab = 'overview' | 'stats' | 'ratings';
@@ -14,34 +13,17 @@ interface ProfileTabsProps {
} }
export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) { export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
const options = [
{ id: 'overview', label: 'Overview', icon: <Icon icon={User} size={4} /> },
{ id: 'stats', label: 'Detailed Stats', icon: <Icon icon={BarChart3} size={4} /> },
{ id: 'ratings', label: 'Ratings', icon: <Icon icon={TrendingUp} size={4} /> },
];
return ( return (
<Surface variant="muted" rounded="xl" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', width: 'fit-content' }}> <SegmentedControl
<Box style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> options={options}
<Button activeId={activeTab}
variant={activeTab === 'overview' ? 'primary' : 'ghost'} onChange={(id) => onTabChange(id as ProfileTab)}
onClick={() => onTabChange('overview')} />
size="sm"
icon={<Icon icon={User} size={4} />}
>
Overview
</Button>
<Button
variant={activeTab === 'stats' ? 'primary' : 'ghost'}
onClick={() => onTabChange('stats')}
size="sm"
icon={<Icon icon={BarChart3} size={4} />}
>
Detailed Stats
</Button>
<Button
variant={activeTab === 'ratings' ? 'primary' : 'ghost'}
onClick={() => onTabChange('ratings')}
size="sm"
icon={<Icon icon={TrendingUp} size={4} />}
>
Ratings
</Button>
</Box>
</Surface>
); );
} }

View File

@@ -8,8 +8,8 @@ import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/Drive
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image'; import { Image } from '@/ui/Image';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { UserDropdown, UserDropdownHeader, UserDropdownItem, UserDropdownFooter } from '@/ui/UserDropdown';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { import {
BarChart3, BarChart3,
@@ -22,7 +22,7 @@ import {
Settings, Settings,
Shield Shield
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
// Hook to detect demo user mode based on session // Hook to detect demo user mode based on session
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } { function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
@@ -143,32 +143,20 @@ export function UserPill() {
// Handle unauthenticated users // Handle unauthenticated users
if (!session) { if (!session) {
return ( return (
<Box display="flex" alignItems="center" gap={2}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Link <Link
href={routes.auth.login} href={routes.auth.login}
variant="secondary" variant="secondary"
rounded="full"
px={4}
py={1.5}
size="xs"
hoverTextColor="text-white"
hoverBorderColor="border-gray-500"
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href={routes.auth.signup} href={routes.auth.signup}
variant="primary" variant="primary"
rounded="full"
px={4}
py={1.5}
size="xs"
shadow="0 0 12px rgba(25,140,255,0.5)"
hoverBg="rgba(25,140,255,0.9)"
> >
Get Started Get Started
</Link> </Link>
</Box> </div>
); );
} }
@@ -186,292 +174,123 @@ export function UserPill() {
'super-admin': 'Super Admin', 'super-admin': 'Super Admin',
} as Record<string, string>)[demoRole || 'driver'] : null; } as Record<string, string>)[demoRole || 'driver'] : null;
const roleColor = isDemo ? ({ const roleIntent = isDemo ? ({
'driver': 'text-primary-blue', 'driver': 'primary',
'sponsor': 'text-performance-green', 'sponsor': 'success',
'league-owner': 'text-purple-400', 'league-owner': 'primary',
'league-steward': 'text-warning-amber', 'league-steward': 'warning',
'league-admin': 'text-red-400', 'league-admin': 'critical',
'system-owner': 'text-indigo-400', 'system-owner': 'primary',
'super-admin': 'text-pink-400', 'super-admin': 'primary',
} as Record<string, string>)[demoRole || 'driver'] : null; } as Record<string, 'primary' | 'success' | 'warning' | 'critical'>)[demoRole || 'driver'] : 'low';
return ( return (
<Box position="relative" display="inline-flex" alignItems="center" data-user-pill> <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }} data-user-pill>
<Box <button
as="button"
type="button" type="button"
onClick={() => setIsMenuOpen((open) => !open)} onClick={() => setIsMenuOpen((open) => !open)}
display="flex" style={{
alignItems="center" display: 'flex',
gap={3} alignItems: 'center',
rounded="full" gap: '0.75rem',
border borderRadius: '9999px',
px={3} border: `1px solid ${isMenuOpen ? 'var(--ui-color-intent-primary)' : 'var(--ui-color-border-default)'}`,
py={1.5} padding: '0.375rem 0.75rem',
transition background: 'var(--ui-color-bg-surface-muted)',
cursor="pointer" cursor: 'pointer'
bg="linear-gradient(to r, var(--iron-gray), var(--deep-graphite))" }}
borderColor={isMenuOpen ? 'border-primary-blue/50' : 'border-charcoal-outline'}
transform={isMenuOpen ? 'scale(1.02)' : 'scale(1)'}
> >
{/* Avatar */} {/* Avatar */}
<Box position="relative"> <div style={{ position: 'relative' }}>
{avatarUrl ? ( {avatarUrl ? (
<Box w="8" h="8" rounded="full" overflow="hidden" bg="bg-charcoal-outline" display="flex" alignItems="center" justifyContent="center" border borderColor="border-charcoal-outline/80"> <div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
<Image <Image
src={avatarUrl} src={avatarUrl}
alt={displayName} alt={displayName}
objectFit="cover" objectFit="cover"
fill
/> />
</Box> </div>
) : ( ) : (
<Box w="8" h="8" rounded="full" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" display="flex" alignItems="center" justifyContent="center"> <div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text size="xs" weight="bold" color="text-primary-blue"> <Text size="xs" weight="bold" variant="high">
{displayName[0]?.toUpperCase() || 'U'} {displayName[0]?.toUpperCase() || 'U'}
</Text> </Text>
</Box> </div>
)} )}
<Box position="absolute" bottom="-0.5" right="-0.5" w="3" h="3" rounded="full" bg="bg-primary-blue" border borderColor="border-deep-graphite" borderWidth="2px" /> </div>
</Box>
{/* Info */} {/* Info */}
<Box display={{ base: 'none', sm: 'flex' }} flexDirection="col" alignItems="start"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'start' }}>
<Text size="xs" weight="semibold" color="text-white" truncate maxWidth="100px" block> <Text size="xs" weight="semibold" variant="high" truncate style={{ maxWidth: '100px' }}>
{displayName} {displayName}
</Text> </Text>
{roleLabel && ( {roleLabel && (
<Text size="xs" color={roleColor || 'text-gray-400'} weight="medium" fontSize="10px"> <Text size="xs" variant={roleIntent as any} weight="medium" style={{ fontSize: '10px' }}>
{roleLabel} {roleLabel}
</Text> </Text>
)} )}
</Box> </div>
{/* Chevron */} {/* Chevron */}
<Icon icon={ChevronDown} size={3.5} color="rgb(107, 114, 128)" groupHoverTextColor="text-gray-300" /> <Icon icon={ChevronDown} size={3.5} intent="low" />
</Box> </button>
<AnimatePresence> <UserDropdown isOpen={isMenuOpen}>
{isMenuOpen && ( <UserDropdownHeader variant={isDemo ? 'demo' : 'default'}>
<Box <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
as={motion.div} {avatarUrl ? (
initial={{ opacity: 0, y: -10, scale: 0.95 }} <div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
animate={{ opacity: 1, y: 0, scale: 1 }} <Image
exit={{ opacity: 0, y: -10, scale: 0.95 }} src={avatarUrl}
transition={{ duration: 0.15 }} alt={displayName}
position="absolute" objectFit="cover"
right={0} />
top="100%" </div>
mt={2} ) : (
zIndex={50} <div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
> <Text size="xs" weight="bold" variant="high">
<Box w="56" rounded="xl" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" shadow="xl" overflow="hidden"> {displayName[0]?.toUpperCase() || 'U'}
{/* Header */} </Text>
<Box p={4} borderBottom borderColor="border-charcoal-outline" bg={`linear-gradient(to r, ${isDemo ? 'rgba(59, 130, 246, 0.1)' : 'rgba(38, 38, 38, 0.2)'}, transparent)`}> </div>
<Box display="flex" alignItems="center" gap={3}> )}
{avatarUrl ? ( <div>
<Box w="10" h="10" rounded="lg" overflow="hidden" bg="bg-charcoal-outline" display="flex" alignItems="center" justifyContent="center" border borderColor="border-charcoal-outline/80"> <Text size="sm" weight="semibold" variant="high" block>{displayName}</Text>
<Image {roleLabel && (
src={avatarUrl} <Text size="xs" variant="low" block>{roleLabel}</Text>
alt={displayName} )}
objectFit="cover" </div>
fill </div>
/> </UserDropdownHeader>
</Box>
) : (
<Box w="10" h="10" rounded="lg" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" display="flex" alignItems="center" justifyContent="center">
<Text size="xs" weight="bold" color="text-primary-blue">
{displayName[0]?.toUpperCase() || 'U'}
</Text>
</Box>
)}
<Box>
<Text size="sm" weight="semibold" color="text-white" block>{displayName}</Text>
{roleLabel && (
<Text size="xs" color={roleColor || 'text-gray-400'} block>{roleLabel}</Text>
)}
{isDemo && (
<Text size="xs" color="text-gray-500" block>Demo Account</Text>
)}
</Box>
</Box>
{isDemo && (
<Text size="xs" color="text-gray-500" block mt={2}>
Development account - not for production use
</Text>
)}
</Box>
{/* Menu Items */} <div style={{ padding: '0.25rem 0' }}>
<Box py={1}> {hasAdminAccess && (
{/* Admin link for Owner/Super Admin users */} <UserDropdownItem href="/admin" icon={Shield} label="Admin Area" intent="primary" onClick={() => setIsMenuOpen(false)} />
{hasAdminAccess && ( )}
<Link
href="/admin" {isDemo && demoRole === 'sponsor' && (
block <React.Fragment>
px={4} <UserDropdownItem href="/sponsor" icon={BarChart3} label="Dashboard" onClick={() => setIsMenuOpen(false)} />
py={2.5} <UserDropdownItem href={routes.sponsor.campaigns} icon={Megaphone} label="My Sponsorships" onClick={() => setIsMenuOpen(false)} />
onClick={() => setIsMenuOpen(false)} <UserDropdownItem href={routes.sponsor.billing} icon={CreditCard} label="Billing" onClick={() => setIsMenuOpen(false)} />
> </React.Fragment>
<Icon icon={Shield} size={4} color="rgb(129, 140, 248)" mr={3} /> )}
<Text size="sm" color="text-gray-200">Admin Area</Text>
</Link>
)}
{/* Sponsor portal link for demo sponsor users */}
{isDemo && demoRole === 'sponsor' && (
<>
<Link
href="/sponsor"
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={BarChart3} size={4} color="rgb(16, 185, 129)" mr={3} />
<Text size="sm" color="text-gray-200">Dashboard</Text>
</Link>
<Link
href={routes.sponsor.campaigns}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={Megaphone} size={4} color="rgb(59, 130, 246)" mr={3} />
<Text size="sm" color="text-gray-200">My Sponsorships</Text>
</Link>
<Link
href={routes.sponsor.billing}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={CreditCard} size={4} color="rgb(245, 158, 11)" mr={3} />
<Text size="sm" color="text-gray-200">Billing</Text>
</Link>
<Link
href={routes.sponsor.settings}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={Settings} size={4} color="rgb(156, 163, 175)" mr={3} />
<Text size="sm" color="text-gray-200">Settings</Text>
</Link>
</>
)}
{/* Regular user profile links */} <UserDropdownItem href={routes.protected.profile} label="Profile" onClick={() => setIsMenuOpen(false)} />
<Link <UserDropdownItem href={routes.protected.profileLeagues} label="Manage leagues" onClick={() => setIsMenuOpen(false)} />
href={routes.protected.profile} <UserDropdownItem href={routes.protected.profileLiveries} icon={Paintbrush} label="Liveries" onClick={() => setIsMenuOpen(false)} />
block <UserDropdownItem href={routes.protected.profileSponsorshipRequests} icon={Handshake} label="Sponsorship Requests" intent="success" onClick={() => setIsMenuOpen(false)} />
px={4} <UserDropdownItem href={routes.protected.profileSettings} icon={Settings} label="Settings" onClick={() => setIsMenuOpen(false)} />
py={2.5} </div>
onClick={() => setIsMenuOpen(false)}
>
<Text size="sm" color="text-gray-200">Profile</Text>
</Link>
<Link
href={routes.protected.profileLeagues}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Text size="sm" color="text-gray-200">Manage leagues</Text>
</Link>
<Link
href={routes.protected.profileLiveries}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={Paintbrush} size={4} mr={2} />
<Text size="sm" color="text-gray-200">Liveries</Text>
</Link>
<Link
href={routes.protected.profileSponsorshipRequests}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={Handshake} size={4} color="rgb(16, 185, 129)" mr={2} />
<Text size="sm" color="text-gray-200">Sponsorship Requests</Text>
</Link>
<Link
href={routes.protected.profileSettings}
block
px={4}
py={2.5}
onClick={() => setIsMenuOpen(false)}
>
<Icon icon={Settings} size={4} mr={2} />
<Text size="sm" color="text-gray-200">Settings</Text>
</Link>
{/* Demo-specific info */} <UserDropdownFooter>
{isDemo && ( <UserDropdownItem
<Box px={4} py={2} borderTop borderColor="border-charcoal-outline/50" mt={1}> icon={LogOut}
<Text size="xs" color="text-gray-500" italic>Demo users have limited profile access</Text> label="Logout"
</Box> intent="critical"
)} onClick={isDemo ? handleLogout : undefined}
</Box> />
</UserDropdownFooter>
{/* Footer */} </UserDropdown>
<Box borderTop borderColor="border-charcoal-outline"> </div>
{isDemo ? (
<Box
as="button"
type="button"
onClick={handleLogout}
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
px={4}
py={3}
cursor="pointer"
transition
bg="transparent"
hoverBg="rgba(239, 68, 68, 0.05)"
hoverColor="text-racing-red"
>
<Text size="sm" color="text-gray-500">Logout</Text>
<Icon icon={LogOut} size={4} color="rgb(107, 114, 128)" />
</Box>
) : (
<Box as="form" action="/auth/logout" method="POST">
<Box
as="button"
type="submit"
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
px={4}
py={3}
cursor="pointer"
transition
bg="transparent"
hoverBg="rgba(239, 68, 68, 0.1)"
hoverColor="text-red-400"
>
<Text size="sm" color="text-gray-500">Logout</Text>
<Icon icon={LogOut} size={4} color="rgb(107, 114, 128)" />
</Box>
</Box>
)}
</Box>
</Box>
</Box>
)}
</AnimatePresence>
</Box>
); );
} }

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { Badge } from '@/ui/Badge'; import { Badge } from '@/ui/Badge';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react'; import { RaceCard, RaceTimeColumn, RaceInfo } from '@/ui/RaceCard';
import { Car, Trophy, Zap, ArrowRight } from 'lucide-react';
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface RaceListItemProps { interface RaceListItemProps {
track: string; track: string;
@@ -41,103 +42,52 @@ export function RaceListItem({
onClick, onClick,
statusConfig, statusConfig,
}: RaceListItemProps) { }: RaceListItemProps) {
const StatusIcon = statusConfig.icon; const isLive = status === 'running';
return ( return (
<Stack <RaceCard onClick={onClick} isLive={isLive}>
onClick={onClick} <RaceTimeColumn
position="relative" date={dateLabel}
overflow="hidden" time={dayLabel || timeLabel || ''}
rounded="xl" relativeTime={relativeTimeLabel || timeLabel}
bg="bg-surface-charcoal" isLive={isLive}
border />
borderColor="border-outline-steel"
p={4}
cursor="pointer"
transition
hoverScale
group
>
{/* Live indicator */}
{status === 'running' && (
<Stack
position="absolute"
top="0"
left="0"
right="0"
h="1"
bg="bg-success-green"
animate="pulse"
/>
)}
<Stack direction="row" align="center" gap={4}> <div style={{ width: '1px', height: '2.5rem', backgroundColor: 'var(--ui-color-border-muted)', opacity: 0.2 }} />
{/* Time/Date Column */}
<Stack flexShrink={0} textAlign="center" width="16">
{dateLabel && (
<Text size="xs" color="text-gray-500" block uppercase>
{dateLabel}
</Text>
)}
<Text size={dayLabel ? "2xl" : "lg"} weight="bold" color="text-white" block>
{dayLabel || timeLabel}
</Text>
<Text size="xs" color={status === 'running' ? 'text-success-green' : 'text-gray-400'} block>
{status === 'running' ? 'LIVE' : relativeTimeLabel || timeLabel}
</Text>
</Stack>
{/* Divider */} <RaceInfo
<Stack w="px" h="10" alignSelf="stretch" bg="border-outline-steel" /> title={track}
subtitle={car}
{/* Main Content */} badge={
<Stack flexGrow={1} minWidth="0"> <Badge variant={statusConfig.variant}>
<Stack direction="row" align="start" justify="between" gap={4}> <Icon icon={statusConfig.icon} size={3.5} />
<Stack minWidth="0"> {statusConfig.label}
<Heading level={3} truncate groupHoverTextColor="text-primary-accent" transition> </Badge>
{track} }
</Heading> meta={
<Stack direction="row" align="center" gap={3} mt={1}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Stack direction="row" align="center" gap={1}> {strengthOfField && (
<Icon icon={Car} size={3.5} color="var(--text-gray-400)" /> <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Text size="sm" color="text-gray-400">{car}</Text> <Icon icon={Zap} size={3.5} intent="warning" />
</Stack> <Text size="sm" variant="low">SOF {strengthOfField}</Text>
{strengthOfField && ( </div>
<Stack direction="row" align="center" gap={1}> )}
<Icon icon={Zap} size={3.5} color="var(--warning-amber)" /> {leagueName && leagueHref && (
<Text size="sm" color="text-gray-400">SOF {strengthOfField}</Text>
</Stack>
)}
</Stack>
</Stack>
{/* Status Badge */}
<Badge variant={statusConfig.variant}>
<Icon icon={StatusIcon} size={3.5} />
{statusConfig.label}
</Badge>
</Stack>
{/* League Link */}
{leagueName && leagueHref && (
<Stack mt={3} pt={3} borderTop borderColor="border-outline-steel" borderOpacity={0.3}>
<Link <Link
href={leagueHref} href={leagueHref}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
variant="primary" variant="primary"
size="sm"
> >
<Icon icon={Trophy} size={3.5} mr={2} color="var(--primary-accent)" /> <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Text as="span" color="text-primary-accent">{leagueName}</Text> <Icon icon={Trophy} size={3.5} intent="primary" />
<Icon icon={ArrowRight} size={3} ml={2} color="var(--primary-accent)" /> <Text size="sm" variant="primary">{leagueName}</Text>
<Icon icon={ArrowRight} size={3} intent="primary" />
</div>
</Link> </Link>
</Stack> )}
)} </div>
</Stack> }
/>
{/* Arrow */} </RaceCard>
<Icon icon={ChevronRight} size={5} color="var(--text-gray-500)" flexShrink={0} groupHoverTextColor="text-primary-accent" transition />
</Stack>
</Stack>
); );
} }

View File

@@ -1,10 +1,11 @@
'use client';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Image } from '@/ui/Image'; import { Image } from '@/ui/Image';
import { Stack } from '@/ui/primitives/Stack'; import { ResultRow, PositionBadge, ResultPoints } from '@/ui/ResultRow';
import { Surface } from '@/ui/primitives/Surface';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import React from 'react';
interface ResultEntry { interface ResultEntry {
position: number; position: number;
@@ -29,78 +30,45 @@ interface RaceResultRowProps {
export function RaceResultRow({ result, points }: RaceResultRowProps) { export function RaceResultRow({ result, points }: RaceResultRowProps) {
const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result; const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result;
const getPositionColor = (pos: number) => {
if (pos === 1) return { bg: 'bg-yellow-500/20', color: 'text-yellow-400' };
if (pos === 2) return { bg: 'bg-gray-400/20', color: 'text-gray-300' };
if (pos === 3) return { bg: 'bg-amber-600/20', color: 'text-amber-600' };
return { bg: 'bg-iron-gray/50', color: 'text-gray-500' };
};
const posConfig = getPositionColor(position);
return ( return (
<Surface <ResultRow isHighlighted={isCurrentUser}>
variant={isCurrentUser ? 'muted' : 'dark'} <PositionBadge position={position} />
rounded="xl"
border={isCurrentUser}
padding={3}
className={isCurrentUser ? 'border-primary-blue/40' : ''}
style={isCurrentUser ? { background: 'linear-gradient(to right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1), transparent)' } : {}}
>
<Stack direction="row" align="center" gap={3}>
{/* Position */}
<Stack
width="10"
height="10"
rounded="lg"
display="flex"
center
className={`${posConfig.bg} ${posConfig.color}`}
>
<Text weight="bold">{position}</Text>
</Stack>
{/* Avatar */} {/* Avatar */}
<Stack position="relative" flexShrink={0}> <div style={{ position: 'relative', flexShrink: 0 }}>
<Stack width="10" height="10" rounded="full" overflow="hidden" border={isCurrentUser} borderColor="border-primary-blue/50" className={isCurrentUser ? 'border-2' : ''}> <div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)' }}>
<Image src={driverAvatar} alt={driverName} width={40} height={40} fullWidth fullHeight objectFit="cover" /> <Image src={driverAvatar} alt={driverName} width={40} height={40} objectFit="cover" />
</Stack> </div>
<Stack position="absolute" bottom="-0.5" right="-0.5" width="5" height="5" rounded="full" bg="bg-deep-graphite" display="flex" center style={{ fontSize: '0.625rem' }}> <div style={{ position: 'absolute', bottom: '-0.125rem', right: '-0.125rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-bg-base)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.625rem' }}>
{CountryFlagDisplay.fromCountryCode(country).toString()} {CountryFlagDisplay.fromCountryCode(country).toString()}
</Stack> </div>
</Stack> </div>
{/* Driver Info */} {/* Driver Info */}
<Stack flexGrow={1} minWidth="0"> <div style={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" align="center" gap={2}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Text weight="semibold" size="sm" color={isCurrentUser ? 'text-primary-blue' : 'text-white'} truncate>{driverName}</Text> <Text weight="semibold" size="sm" variant={isCurrentUser ? 'primary' : 'high'} truncate>{driverName}</Text>
{isCurrentUser && ( {isCurrentUser && (
<Stack px={2} py={0.5} rounded="full" bg="bg-primary-blue"> <Badge variant="primary" size="sm">YOU</Badge>
<Text size="xs" weight="bold" color="text-white">YOU</Text> )}
</Stack> </div>
)} <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
</Stack> <Text size="xs" variant="low">{car}</Text>
<Stack direction="row" align="center" gap={2} mt={1}> <Text size="xs" variant="low"></Text>
<Text size="xs" color="text-gray-500">{car}</Text> <Text size="xs" variant="low">Laps: {laps}</Text>
<Text size="xs" color="text-gray-500"></Text> <Text size="xs" variant="low"></Text>
<Text size="xs" color="text-gray-500">Laps: {laps}</Text> <Text size="xs" variant="low">Incidents: {incidents}</Text>
<Text size="xs" color="text-gray-500"></Text> </div>
<Text size="xs" color="text-gray-500">Incidents: {incidents}</Text> </div>
</Stack>
</Stack>
{/* Times */} {/* Times */}
<Stack textAlign="right" style={{ minWidth: '100px' }}> <div style={{ textAlign: 'right', minWidth: '100px' }}>
<Text size="sm" font="mono" color="text-white" block>{time}</Text> <Text size="sm" font="mono" variant="high" block>{time}</Text>
<Text size="xs" color="text-performance-green" block mt={1}>FL: {fastestLap}</Text> <Text size="xs" variant="success" block style={{ marginTop: '0.25rem' }}>FL: {fastestLap}</Text>
</Stack> </div>
{/* Points */} {/* Points */}
<Stack p={2} rounded="lg" border={true} borderColor="border-warning-amber/20" bg="bg-warning-amber/10" textAlign="center" style={{ minWidth: '3.5rem' }}> <ResultPoints points={points} />
<Text size="xs" color="text-gray-500" block>PTS</Text> </ResultRow>
<Text size="sm" weight="bold" color="text-warning-amber">{points}</Text>
</Stack>
</Stack>
</Surface>
); );
} }

View File

@@ -1,13 +1,13 @@
'use client';
import { Badge } from '@/ui/Badge'; import { Badge } from '@/ui/Badge';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link'; import { Link } from '@/ui/Link';
import { Stack } from '@/ui/primitives/Stack';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { PositionBadge } from '@/ui/ResultRow';
import { AlertTriangle, ExternalLink } from 'lucide-react'; import { AlertTriangle, ExternalLink } from 'lucide-react';
import { ReactNode } from 'react'; import React, { ReactNode } from 'react';
type PenaltyTypeDTO = type PenaltyTypeDTO =
| 'time_penalty' | 'time_penalty'
@@ -97,10 +97,10 @@ export function RaceResultsTable({
return pointsSystem[position] || 0; return pointsSystem[position] || 0;
}; };
const getPositionChangeColor = (change: number): string => { const getPositionChangeVariant = (change: number): 'success' | 'warning' | 'low' => {
if (change > 0) return 'text-performance-green'; if (change > 0) return 'success';
if (change < 0) return 'text-warning-amber'; if (change < 0) return 'warning';
return 'text-gray-500'; return 'low';
}; };
const getPositionChangeText = (change: number): string => { const getPositionChangeText = (change: number): string => {
@@ -111,14 +111,14 @@ export function RaceResultsTable({
if (results.length === 0) { if (results.length === 0) {
return ( return (
<Stack textAlign="center" py={8} gap={0}> <div style={{ textAlign: 'center', padding: '2rem 0' }}>
<Text color="text-gray-400">No results available</Text> <Text variant="low">No results available</Text>
</Stack> </div>
); );
} }
return ( return (
<Stack overflow="auto" gap={0}> <div style={{ overflowX: 'auto' }}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
@@ -129,7 +129,7 @@ export function RaceResultsTable({
<TableHeader>Points</TableHeader> <TableHeader>Points</TableHeader>
<TableHeader>+/-</TableHeader> <TableHeader>+/-</TableHeader>
<TableHeader>Penalties</TableHeader> <TableHeader>Penalties</TableHeader>
{isAdmin && <TableHeader className="text-right">Actions</TableHeader>} {isAdmin && <TableHeader textAlign="right">Actions</TableHeader>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -147,117 +147,85 @@ export function RaceResultsTable({
variant={isCurrentUser ? 'highlight' : 'default'} variant={isCurrentUser ? 'highlight' : 'default'}
> >
<TableCell> <TableCell>
<Stack <PositionBadge position={result.position} />
direction="row"
align="center"
justify="center"
w="8"
h="8"
rounded="lg"
bg={
result.position === 1
? 'bg-yellow-500/20'
: result.position === 2
? 'bg-gray-400/20'
: result.position === 3
? 'bg-amber-600/20'
: undefined
}
>
<Text
weight="bold"
size="sm"
color={
result.position === 1
? 'text-yellow-400'
: result.position === 2
? 'text-gray-300'
: result.position === 3
? 'text-amber-500'
: 'text-white'
}
>
{result.position}
</Text>
</Stack>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" align="center" gap={3}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{driver ? ( {driver ? (
<> <React.Fragment>
<Stack <div style={{
w="8" width: '2rem',
h="8" height: '2rem',
rounded="full" borderRadius: '9999px',
align="center" display: 'flex',
justify="center" alignItems: 'center',
flexShrink={0} justifyContent: 'center',
bg={isCurrentUser ? 'bg-primary-blue/30' : 'bg-iron-gray'} flexShrink: 0,
ring={isCurrentUser ? 'ring-2 ring-primary-blue/50' : undefined} backgroundColor: isCurrentUser ? 'rgba(25, 140, 255, 0.2)' : 'var(--ui-color-bg-surface-muted)',
> border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)'
}}>
<Text <Text
size="sm" size="sm"
weight="bold" weight="bold"
color={isCurrentUser ? 'text-primary-blue' : 'text-gray-400'} variant={isCurrentUser ? 'primary' : 'low'}
> >
{driver.name.charAt(0)} {driver.name.charAt(0)}
</Text> </Text>
</Stack> </div>
<Link <Link
href={`/drivers/${driver.id}`} href={`/drivers/${driver.id}`}
variant="ghost" variant={isCurrentUser ? 'primary' : 'inherit'}
className={`group ${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white'}`}
> >
<Text className="group-hover:underline">{driver.name}</Text> <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
{isCurrentUser && ( <Text weight={isCurrentUser ? 'semibold' : 'normal'}>{driver.name}</Text>
<Badge size="xs" variant="primary" bg="bg-primary-blue" color="text-white" rounded="full" style={{ marginLeft: '6px' }}> {isCurrentUser && (
You <Badge variant="primary" size="sm">You</Badge>
</Badge> )}
)} <Icon icon={ExternalLink} size={3} intent="low" />
<Icon icon={ExternalLink} size={3} className="ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" /> </div>
</Link> </Link>
</> </React.Fragment>
) : ( ) : (
<Text color="text-white">{getDriverName(result.driverId)}</Text> <Text variant="high">{getDriverName(result.driverId)}</Text>
)} )}
</Stack> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color={isFastestLap ? 'text-performance-green' : 'text-white'} weight={isFastestLap ? 'medium' : 'normal'}> <Text variant={isFastestLap ? 'success' : 'high'} weight={isFastestLap ? 'medium' : 'normal'}>
{formatLapTime(result.fastestLap)} {formatLapTime(result.fastestLap)}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}> <Text variant={result.incidents > 0 ? 'warning' : 'high'}>
{result.incidents}× {result.incidents}×
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text color="text-white" weight="medium"> <Text variant="high" weight="medium">
{getPoints(result.position)} {getPoints(result.position)}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text weight="medium" className={getPositionChangeColor(positionChange)}> <Text weight="medium" variant={getPositionChangeVariant(positionChange)}>
{getPositionChangeText(positionChange)} {getPositionChangeText(positionChange)}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
{driverPenalties.length > 0 ? ( {driverPenalties.length > 0 ? (
<Stack gap={1}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{driverPenalties.map((penalty, idx) => ( {driverPenalties.map((penalty, idx) => (
<Stack key={idx} direction="row" align="center" gap={1.5}> <div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
<Icon icon={AlertTriangle} size={3} color="var(--critical-red)" /> <Icon icon={AlertTriangle} size={3} intent="critical" />
<Text size="xs" color="text-red-400">{getPenaltyDescription(penalty)}</Text> <Text size="xs" variant="critical">{getPenaltyDescription(penalty)}</Text>
</Stack> </div>
))} ))}
</Stack> </div>
) : ( ) : (
<Text color="text-gray-500"></Text> <Text variant="low"></Text>
)} )}
</TableCell> </TableCell>
{isAdmin && ( {isAdmin && (
<TableCell className="text-right"> <TableCell style={{ textAlign: 'right' }}>
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)} {driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
</TableCell> </TableCell>
)} )}
@@ -266,6 +234,6 @@ export function RaceResultsTable({
})} })}
</TableBody> </TableBody>
</Table> </Table>
</Stack> </div>
); );
} }

View File

@@ -39,18 +39,18 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
clickable clickable
> >
<TableCell> <TableCell>
<Text size="xs" color="text-telemetry-aqua" weight="bold">{race.time}</Text> <Text size="xs" variant="telemetry" weight="bold">{race.time}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="sm" weight="bold" groupHoverTextColor="text-primary-accent"> <Text size="sm" weight="bold" variant="high">
{race.track} {race.track}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="xs" color="text-gray-400">{race.car}</Text> <Text size="xs" variant="low">{race.car}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text size="xs" color="text-gray-400">{race.leagueName || 'Official'}</Text> <Text size="xs" variant="low">{race.leagueName || 'Official'}</Text>
</TableCell> </TableCell>
<TableCell textAlign="right"> <TableCell textAlign="right">
<SessionStatusBadge status={race.status} /> <SessionStatusBadge status={race.status} />

View File

@@ -1,13 +1,14 @@
'use client';
import { SidebarRaceItem } from '@/components/races/SidebarRaceItem'; import { SidebarRaceItem } from '@/components/races/SidebarRaceItem';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import type { RaceViewData } from '@/lib/view-data/RacesViewData'; import type { RaceViewData } from '@/lib/view-data/RacesViewData';
import { Card } from '@/ui/Card'; import { Panel } from '@/ui/Panel';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { SidebarActionLink } from '@/ui/SidebarActionLink'; import { SidebarActionLink } from '@/ui/SidebarActionLink';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Clock, Trophy, Users } from 'lucide-react'; import { Clock, Trophy, Users } from 'lucide-react';
import React from 'react';
interface RaceSidebarProps { interface RaceSidebarProps {
upcomingRaces: RaceViewData[]; upcomingRaces: RaceViewData[];
@@ -17,88 +18,73 @@ interface RaceSidebarProps {
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) { export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
return ( return (
<Stack gap={6}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Upcoming This Week */} {/* Upcoming This Week */}
<Card> <Panel
<Stack gap={4}> title="Next Up"
<Stack direction="row" align="center" justify="between"> description="This week"
<Heading level={3} icon={<Icon icon={Clock} size={4} color="var(--primary-accent)" />}> >
Next Up {upcomingRaces.length === 0 ? (
</Heading> <div style={{ padding: '1rem 0', textAlign: 'center' }}>
<Text size="xs" color="text-gray-500">This week</Text> <Text size="sm" variant="low">No races scheduled this week</Text>
</Stack> </div>
) : (
{upcomingRaces.length === 0 ? ( <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<Stack py={4} textAlign="center"> {upcomingRaces.map((race) => (
<Text size="sm" color="text-gray-400">No races scheduled this week</Text> <SidebarRaceItem
</Stack> key={race.id}
) : ( race={{
<Stack gap={3}> id: race.id,
{upcomingRaces.map((race) => ( track: race.track,
<SidebarRaceItem scheduledAt: race.scheduledAt
key={race.id} }}
race={{ onClick={() => onRaceClick(race.id)}
id: race.id, />
track: race.track, ))}
scheduledAt: race.scheduledAt </div>
}} )}
onClick={() => onRaceClick(race.id)} </Panel>
/>
))}
</Stack>
)}
</Stack>
</Card>
{/* Recent Results */} {/* Recent Results */}
<Card> <Panel
<Stack gap={4}> title="Recent Results"
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="var(--warning-amber)" />}> >
Recent Results {recentResults.length === 0 ? (
</Heading> <div style={{ padding: '1rem 0', textAlign: 'center' }}>
<Text size="sm" variant="low">No completed races yet</Text>
{recentResults.length === 0 ? ( </div>
<Stack py={4} textAlign="center"> ) : (
<Text size="sm" color="text-gray-400">No completed races yet</Text> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
</Stack> {recentResults.map((race) => (
) : ( <SidebarRaceItem
<Stack gap={3}> key={race.id}
{recentResults.map((race) => ( race={{
<SidebarRaceItem id: race.id,
key={race.id} track: race.track,
race={{ scheduledAt: race.scheduledAt
id: race.id, }}
track: race.track, onClick={() => onRaceClick(race.id)}
scheduledAt: race.scheduledAt />
}} ))}
onClick={() => onRaceClick(race.id)} </div>
/> )}
))} </Panel>
</Stack>
)}
</Stack>
</Card>
{/* Quick Actions */} {/* Quick Actions */}
<Card> <Panel title="Quick Actions">
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<Heading level={3}>Quick Actions</Heading> <SidebarActionLink
<Stack gap={2}> href={routes.public.leagues}
<SidebarActionLink icon={Users}
href={routes.public.leagues} label="Browse Leagues"
icon={Users} />
label="Browse Leagues" <SidebarActionLink
/> href={routes.public.leaderboards}
<SidebarActionLink icon={Trophy}
href={routes.public.leaderboards} label="View Leaderboards"
icon={Trophy} />
label="View Leaderboards" </div>
iconColor="text-warning-amber" </Panel>
iconBgColor="bg-warning-amber/10" </div>
/>
</Stack>
</Stack>
</Card>
</Stack>
); );
} }

View File

@@ -1,6 +1,5 @@
import { Panel } from '@/ui/Panel';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -16,24 +15,12 @@ export function RaceSidebarPanel({
children children
}: RaceSidebarPanelProps) { }: RaceSidebarPanelProps) {
return ( return (
<Stack <Panel
bg="bg-panel-gray" title={title}
rounded="xl" variant="dark"
border padding={4}
borderColor="border-charcoal-outline"
overflow="hidden"
> >
<Stack p={4} borderBottom="1px solid" borderColor="border-charcoal-outline" bg="bg-graphite-black/30"> {children}
<Stack direction="row" align="center" gap={2}> </Panel>
{icon && <Icon icon={icon} size={4} color="#198CFF" />}
<Text weight="bold" size="sm" color="text-white" uppercase>
{title}
</Text>
</Stack>
</Stack>
<Stack p={4}>
{children}
</Stack>
</Stack>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Box } from '@/ui/primitives/Box'; import { StatGrid } from '@/ui/StatGrid';
import { StatGridItem } from '@/ui/StatGridItem';
import { CalendarDays, Clock, Trophy, Zap } from 'lucide-react'; import { CalendarDays, Clock, Trophy, Zap } from 'lucide-react';
import React from 'react';
interface RaceStatsProps { interface RaceStatsProps {
stats: { stats: {
@@ -12,32 +12,20 @@ interface RaceStatsProps {
} }
export function RaceStats({ stats }: RaceStatsProps) { export function RaceStats({ stats }: RaceStatsProps) {
const mappedStats = [
{ label: 'Total', value: stats.total, icon: CalendarDays, intent: 'low' as const },
{ label: 'Scheduled', value: stats.scheduled, icon: Clock, intent: 'primary' as const },
{ label: 'Live Now', value: stats.running, icon: Zap, intent: 'success' as const },
{ label: 'Completed', value: stats.completed, icon: Trophy, intent: 'low' as const },
];
return ( return (
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={4} mt={6}> <div style={{ marginTop: '1.5rem' }}>
<StatGridItem <StatGrid
label="Total" stats={mappedStats}
value={stats.total} columns={{ base: 2, md: 4 }}
icon={CalendarDays} variant="box"
color="text-gray-400"
/> />
<StatGridItem </div>
label="Scheduled"
value={stats.scheduled}
icon={Clock}
color="text-primary-blue"
/>
<StatGridItem
label="Live Now"
value={stats.running}
icon={Zap}
color="text-performance-green"
/>
<StatGridItem
label="Completed"
value={stats.completed}
icon={Trophy}
color="text-gray-400"
/>
</Box>
); );
} }

View File

@@ -1,59 +1,38 @@
import { Box } from '@/ui/primitives/Box'; import { StatusBadge } from '@/ui/StatusBadge';
import React from 'react';
interface RaceStatusBadgeProps { interface RaceStatusBadgeProps {
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string; status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
} }
export function RaceStatusBadge({ status }: RaceStatusBadgeProps) { export function RaceStatusBadge({ status }: RaceStatusBadgeProps) {
const config = { const config: Record<string, { variant: 'info' | 'success' | 'neutral' | 'warning'; label: string }> = {
scheduled: { scheduled: {
variant: 'info' as const, variant: 'info',
label: 'SCHEDULED', label: 'SCHEDULED',
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30'
}, },
running: { running: {
variant: 'success' as const, variant: 'success',
label: 'LIVE', label: 'LIVE',
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30'
}, },
completed: { completed: {
variant: 'neutral' as const, variant: 'neutral',
label: 'COMPLETED', label: 'COMPLETED',
color: 'text-gray-400',
bg: 'bg-gray-400/10',
border: 'border-gray-400/30'
}, },
cancelled: { cancelled: {
variant: 'warning' as const, variant: 'warning',
label: 'CANCELLED', label: 'CANCELLED',
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30'
}, },
}; };
const badgeConfig = config[status as keyof typeof config] || { const badgeConfig = config[status] || {
variant: 'neutral' as const, variant: 'neutral',
label: status.toUpperCase(), label: status.toUpperCase(),
color: 'text-gray-400',
bg: 'bg-gray-400/10',
border: 'border-gray-400/30'
}; };
return ( return (
<Box <StatusBadge variant={badgeConfig.variant}>
px={2.5}
py={0.5}
rounded="none"
border
className={`${badgeConfig.bg} ${badgeConfig.color} ${badgeConfig.border}`}
style={{ fontSize: '10px', fontWeight: '800', letterSpacing: '0.05em' }}
>
{badgeConfig.label} {badgeConfig.label}
</Box> </StatusBadge>
); );
} }

View File

@@ -1,5 +1,6 @@
import { Box } from '@/ui/primitives/Box'; import { RaceSummary } from '@/ui/RaceSummary';
import { Text } from '@/ui/Text'; import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import React from 'react';
interface RaceSummaryItemProps { interface RaceSummaryItemProps {
track: string; track: string;
@@ -9,19 +10,10 @@ interface RaceSummaryItemProps {
export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) { export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) {
return ( return (
<Box display="flex" justifyContent="between" gap={3}> <RaceSummary
<Box flexGrow={1} minWidth="0"> track={track}
<Text size="xs" color="text-white" block truncate>{track}</Text> meta={meta}
<Text size="xs" color="text-gray-400" block truncate>{meta}</Text> date={DateDisplay.formatShort(date)}
</Box> />
<Box textAlign="right">
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
{date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric'
})}
</Text>
</Box>
</Box>
); );
} }

View File

@@ -1,7 +1,6 @@
import { Icon } from '@/ui/Icon'; import { SidebarItem } from '@/ui/SidebarItem';
import { Box } from '@/ui/primitives/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ChevronRight } from 'lucide-react'; import React from 'react';
interface SidebarRaceItemProps { interface SidebarRaceItemProps {
race: { race: {
@@ -10,33 +9,24 @@ interface SidebarRaceItemProps {
scheduledAt: string; scheduledAt: string;
}; };
onClick?: () => void; onClick?: () => void;
className?: string;
} }
export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) { export function SidebarRaceItem({ race, onClick }: SidebarRaceItemProps) {
const scheduledAtDate = new Date(race.scheduledAt); const scheduledAtDate = new Date(race.scheduledAt);
return ( return (
<Box <SidebarItem
onClick={onClick} onClick={onClick}
display="flex" icon={
alignItems="center" <Text size="sm" weight="bold" variant="primary">
gap={3}
p={2}
rounded="lg"
cursor="pointer"
className={`hover:bg-deep-graphite transition-colors ${className || ''}`}
>
<Box flexShrink={0} width="10" height="10" bg="bg-primary-blue/10" rounded="lg" display="flex" center>
<Text size="sm" weight="bold" color="text-primary-blue">
{scheduledAtDate.getDate()} {scheduledAtDate.getDate()}
</Text> </Text>
</Box> }
<Box flexGrow={1} minWidth="0"> >
<Text size="sm" weight="medium" color="text-white" block truncate>{race.track}</Text> <Text size="sm" weight="medium" variant="high" block truncate>{race.track}</Text>
<Text size="xs" color="text-gray-500" block>{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</Text> <Text size="xs" variant="low" block>
</Box> {scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<Icon icon={ChevronRight} size={4} color="text-gray-500" /> </Text>
</Box> </SidebarItem>
); );
} }

View File

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

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