website refactor

This commit is contained in:
2026-01-19 18:01:30 +01:00
parent 6154d54435
commit 61b5cf3b64
120 changed files with 2226 additions and 2021 deletions

View File

@@ -8,7 +8,7 @@ import { NotificationProvider } from '@/components/notifications/NotificationPro
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import { DevToolbar } from '@/components/dev/DevToolbar';
import { ThemeProvider } from '@/ui/theme/ThemeProvider';
import { ThemeProvider } from '@/components/shared/ThemeProvider';
import React from 'react';
interface AppWrapperProps {

View File

@@ -1,6 +1,6 @@
'use client';
import { ProgressLine } from '@/ui/ProgressLine';
import { ProgressLine } from '@/components/shared/ProgressLine';
import { SectionHeader } from '@/ui/SectionHeader';
import React from 'react';

View File

@@ -1,5 +1,12 @@
import React from 'react';
import { Footer } from '@/ui/Footer';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Link } from '@/ui/Link';
import { Surface } from '@/ui/Surface';
import { BrandMark } from '@/ui/BrandMark';
import { Box } from '@/ui/Box';
interface AppFooterProps {
children?: React.ReactNode;
@@ -7,9 +14,76 @@ interface AppFooterProps {
/**
* AppFooter is the bottom section of the application.
* It follows the "Telemetry Workspace" structure: matte surface, thin separators, minimal.
* Aligned with ControlBar (Header) design.
*/
export function AppFooter({ children }: AppFooterProps) {
return (
<Footer />
<Surface
as="footer"
variant="muted"
paddingY={8}
marginTop="auto"
style={{ borderTop: '1px solid var(--ui-color-border-default)' }}
>
<Container size="xl">
{children ? (
children
) : (
<Grid cols={{ base: 1, md: 4 }} gap={12}>
<Stack gap={4}>
<Stack direction="row" align="center" gap={4}>
<BrandMark />
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={2} borderLeft borderColor="[#23272B]" pl={4}>
<Box w="4px" h="4px" rounded="full" bg="primary-accent" animate="pulse" />
<Text size="xs" variant="low" weight="bold" font="mono" letterSpacing="0.1em">
INFRASTRUCTURE
</Text>
</Box>
</Stack>
<Text size="sm" variant="low" maxWidth="xs">
The ultimate companion for sim racers. Track your performance, manage your team, and compete in leagues.
</Text>
</Stack>
<Stack gap={4}>
<Text size="xs" weight="bold" variant="high" uppercase letterSpacing="wider">Platform</Text>
<Stack gap={2}>
<Link href="/leagues" variant="secondary">Leagues</Link>
<Link href="/teams" variant="secondary">Teams</Link>
<Link href="/leaderboards" variant="secondary">Leaderboards</Link>
</Stack>
</Stack>
<Stack gap={4}>
<Text size="xs" weight="bold" variant="high" uppercase letterSpacing="wider">Support</Text>
<Stack gap={2}>
<Link href="/docs" variant="secondary">Documentation</Link>
<Link href="/status" variant="secondary">System Status</Link>
<Link href="/contact" variant="secondary">Contact Us</Link>
</Stack>
</Stack>
<Stack gap={4}>
<Text size="xs" weight="bold" variant="high" uppercase letterSpacing="wider">Legal</Text>
<Stack gap={2}>
<Link href="/privacy" variant="secondary">Privacy Policy</Link>
<Link href="/terms" variant="secondary">Terms of Service</Link>
</Stack>
</Stack>
</Grid>
)}
<Box borderTop marginTop={8} paddingTop={6} display="flex" justifyContent="between" alignItems="center">
<Text size="xs" variant="low">
© {new Date().getFullYear()} GridPilot. All rights reserved.
</Text>
<Stack direction="row" gap={4}>
<Text size="xs" variant="low" font="mono">v1.0.0-stable</Text>
<Box w="4px" h="4px" rounded="full" bg="success-green" />
</Stack>
</Box>
</Container>
</Surface>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
import { SectionHeader } from '@/ui/SectionHeader';
import React from 'react';
@@ -24,9 +23,7 @@ export function AuthCard({ children, title, description }: AuthCardProps) {
description={description}
variant="minimal"
/>
<Box>
{children}
</Box>
{children}
</Card>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import React from 'react';
interface AuthFooterLinksProps {
@@ -14,10 +14,8 @@ interface AuthFooterLinksProps {
*/
export function AuthFooterLinks({ children }: AuthFooterLinksProps) {
return (
<Stack as="footer" mt={8} pt={6} borderTop borderStyle="solid" borderColor="outline-steel">
<Stack gap={3} align="center">
{children}
</Stack>
</Stack>
<Group direction="column" gap={3} align="center" fullWidth>
{children}
</Group>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { Stack } from '@/ui/Stack';
import { Form } from '@/ui/Form';
import { Group } from '@/ui/Group';
import React from 'react';
interface AuthFormProps {
@@ -15,8 +16,10 @@ interface AuthFormProps {
*/
export function AuthForm({ children, onSubmit }: AuthFormProps) {
return (
<Stack as="form" {...({ onSubmit, noValidate: true } as any)} gap={6}>
{children}
</Stack>
<Form onSubmit={onSubmit}>
<Group direction="column" gap={6}>
{children}
</Group>
</Form>
);
}

View File

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

View File

@@ -10,7 +10,7 @@ import { Activity, AlertTriangle, ChevronDown, ChevronUp, MessageSquare, Wrench,
import { useEffect, useState } from 'react';
// Import our new components
import { Accordion } from '@/ui/Accordion';
import { Accordion } from '@/components/shared/Accordion';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';

View File

@@ -30,17 +30,17 @@ export function DriverEntryRow({
return (
<Stack
onClick={onClick}
display="flex"
alignItems="center"
direction="row"
align="center"
gap={3}
p={3}
rounded="xl"
cursor="pointer"
transition
bg={isCurrentUser ? 'bg-primary-blue/10' : 'transparent'}
bg={isCurrentUser ? 'rgba(25, 140, 255, 0.1)' : 'transparent'}
border
borderColor={isCurrentUser ? 'border-primary-blue/30' : 'transparent'}
hoverBorderColor={isCurrentUser ? 'primary-blue/40' : 'charcoal-outline/20'}
borderColor={isCurrentUser ? 'rgba(25, 140, 255, 0.3)' : 'transparent'}
hoverBorderColor={isCurrentUser ? 'rgba(25, 140, 255, 0.4)' : 'var(--ui-color-border-low)'}
>
<Stack
display="flex"
@@ -49,9 +49,10 @@ export function DriverEntryRow({
w="8"
h="8"
rounded="lg"
bg="bg-iron-gray"
color="text-gray-500"
style={{ fontWeight: 'bold', fontSize: '0.875rem' }}
bg="var(--ui-color-bg-surface-muted)"
color="var(--ui-color-text-low)"
weight="bold"
fontSize="0.875rem"
>
{index + 1}
</Stack>
@@ -63,7 +64,7 @@ export function DriverEntryRow({
rounded="full"
overflow="hidden"
border={isCurrentUser}
borderColor={isCurrentUser ? 'border-primary-blue' : ''}
borderColor={isCurrentUser ? 'var(--ui-color-intent-primary)' : ''}
>
<Image
src={avatarUrl}
@@ -81,11 +82,11 @@ export function DriverEntryRow({
w="5"
h="5"
rounded="full"
bg="bg-deep-graphite"
bg="var(--ui-color-bg-base)"
display="flex"
alignItems="center"
justifyContent="center"
style={{ fontSize: '0.625rem' }}
fontSize="0.625rem"
>
{CountryFlagDisplay.fromCountryCode(country).toString()}
</Stack>
@@ -96,14 +97,14 @@ export function DriverEntryRow({
<Text
weight="semibold"
size="sm"
color={isCurrentUser ? 'text-primary-blue' : 'text-white'}
variant={isCurrentUser ? 'primary' : 'high'}
truncate
>
{name}
</Text>
{isCurrentUser && <Badge variant="primary">You</Badge>}
</Stack>
<Text size="xs" color="text-gray-500">{country}</Text>
<Text size="xs" variant="low">{country}</Text>
</Stack>
{rating != null && (

View File

@@ -29,10 +29,10 @@ export function DriverHeaderPanel({
return (
<Stack
bg="bg-panel-gray"
bg="var(--ui-color-bg-surface)"
rounded="xl"
border
borderColor="border-charcoal-outline"
borderColor="var(--ui-color-border-low)"
overflow="hidden"
position="relative"
>
@@ -43,12 +43,12 @@ export function DriverHeaderPanel({
left={0}
right={0}
height="24"
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
bg="linear-gradient(to right, rgba(25, 140, 255, 0.2), transparent)"
opacity={0.5}
/>
<Stack p={6} position="relative">
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align={{ base: 'start', md: 'center' }}>
{/* Avatar */}
<Stack
width="32"
@@ -56,8 +56,8 @@ export function DriverHeaderPanel({
rounded="2xl"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black"
borderColor="var(--ui-color-border-low)"
bg="var(--ui-color-bg-base)"
flexShrink={0}
>
<Image
@@ -72,25 +72,25 @@ export function DriverHeaderPanel({
<Stack flexGrow={1}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3} wrap>
<Text as="h1" size="3xl" weight="bold" color="text-white">
<Text as="h1" size="3xl" weight="bold" variant="high">
{name}
</Text>
<RatingBadge rating={rating} ratingLabel={ratingLabel} size="lg" />
</Stack>
<Stack direction="row" align="center" gap={4} wrap>
<Text size="sm" color="text-gray-400">
<Text size="sm" variant="low">
{nationality}
</Text>
{globalRankLabel && (
<Text size="sm" color="text-gray-400">
Global Rank: <Text color="text-warning-amber" weight="semibold">{globalRankLabel}</Text>
<Text size="sm" variant="low">
Global Rank: <Text variant="warning" weight="semibold">{globalRankLabel}</Text>
</Text>
)}
</Stack>
{bio && (
<Text size="sm" color="text-gray-400" className="max-w-2xl mt-2" lineClamp={2}>
<Text size="sm" variant="low" maxWidth="2xl" mt={2} lineClamp={2}>
{bio}
</Text>
)}

View File

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

View File

@@ -2,7 +2,7 @@
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Table, TableHead, TableBody, TableRow, TableHeaderCell } from '@/ui/Table';
import { TrendingUp } from 'lucide-react';
import { Card } from '@/ui/Card';
@@ -15,16 +15,16 @@ interface DriverTableProps {
export function DriverTable({ children }: DriverTableProps) {
return (
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Group direction="column" gap={4} fullWidth>
<Group direction="row" align="center" gap={3}>
<Card variant="dark">
<Icon icon={TrendingUp} size={5} intent="primary" />
</Card>
<Stack>
<Group direction="column">
<Heading level={2}>Driver Rankings</Heading>
<Text size="xs" variant="low">Top performers by skill rating</Text>
</Stack>
</Stack>
</Group>
</Group>
<Table>
<TableHead>
@@ -40,6 +40,6 @@ export function DriverTable({ children }: DriverTableProps) {
{children}
</TableBody>
</Table>
</Stack>
</Group>
);
}

View File

@@ -1,9 +1,8 @@
'use client';
import { RatingBadge } from '@/components/drivers/RatingBadge';
import { Image } from '@/ui/Image';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { TableRow, TableCell } from '@/ui/Table';
import { Avatar } from '@/ui/Avatar';
@@ -41,7 +40,7 @@ export function DriverTableRow({
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Group direction="row" align="center" gap={3}>
<Avatar
src={avatarUrl || undefined}
alt={name}
@@ -54,7 +53,7 @@ export function DriverTableRow({
>
{name}
</Text>
</Stack>
</Group>
</TableCell>
<TableCell>
<Text size="xs" variant="low">{nationality}</Text>

View File

@@ -2,7 +2,7 @@
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
@@ -41,31 +41,31 @@ export function DriversDirectoryHeader({
return (
<Section variant="dark" padding="md">
<Container>
<Stack direction="row" align="center" justify="between" gap={8}>
<Stack gap={6}>
<Stack direction="row" align="center" gap={3}>
<Group direction="row" align="center" justify="between" gap={8} fullWidth>
<Group direction="column" gap={6}>
<Group direction="row" align="center" gap={3}>
<Card variant="dark">
<Icon icon={Users} size={6} intent="primary" />
</Card>
<Heading level={1}>Drivers</Heading>
</Stack>
</Group>
<Text size="lg" variant="low">
Meet the racers who make every lap count. From rookies to champions, track their journey and see who&apos;s dominating the grid.
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
</Text>
<Stack direction="row" gap={6} wrap>
<Group direction="row" gap={6} wrap>
{stats.map((stat, index) => (
<Stack key={index} direction="row" align="center" gap={2}>
<Group key={index} direction="row" align="center" gap={2}>
<Text size="sm" variant="low">
<Text as="span" weight="semibold" variant="high">{stat.value}</Text> {stat.label}
</Text>
</Stack>
</Group>
))}
</Stack>
</Stack>
</Group>
</Group>
<Stack gap={2} align="center">
<Group direction="column" gap={2} align="center">
<Button
variant="primary"
onClick={onViewLeaderboard}
@@ -76,8 +76,8 @@ export function DriversDirectoryHeader({
<Text size="xs" variant="low" align="center">
See full driver rankings
</Text>
</Stack>
</Stack>
</Group>
</Group>
</Container>
</Section>
);

View File

@@ -25,10 +25,10 @@ interface LiveryCardProps {
export function LiveryCard({ livery, onEdit, onDownload, onDelete }: LiveryCardProps) {
return (
<Card className="overflow-hidden hover:border-primary-blue/50 transition-colors">
<Card overflow="hidden" hoverBorderColor="var(--ui-color-intent-primary)" transition="all 0.3s ease">
{/* Livery Preview */}
<Stack height={48} backgroundColor="deep-graphite" rounded="lg" mb={4} display="flex" center border borderColor="charcoal-outline">
<Icon icon={Car} size={16} color="text-gray-600" />
<Stack height={48} bg="var(--ui-color-bg-base)" rounded="lg" mb={4} display="flex" center border borderColor="var(--ui-color-border-low)">
<Icon icon={Car} size={16} intent="low" />
</Stack>
{/* Livery Info */}

View File

@@ -27,7 +27,7 @@ export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
return (
<Card>
<Stack mb={6}>
<Heading level={2} icon={<Icon icon={Activity} size={5} color="#00f2ff" />}>
<Heading level={2} icon={<Icon icon={Activity} size={5} intent="telemetry" />}>
Performance Overview
</Heading>
</Stack>
@@ -39,13 +39,13 @@ export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
value={stats.wins}
max={stats.totalRaces}
label="Win Rate"
color="#10b981"
color="var(--ui-color-intent-success)"
/>
<CircularProgress
value={stats.podiums}
max={stats.totalRaces}
label="Podium Rate"
color="#f59e0b"
color="var(--ui-color-intent-warning)"
/>
</Stack>
<Stack direction="row" gap={6}>
@@ -53,13 +53,13 @@ export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
value={stats.consistency ?? 0}
max={100}
label="Consistency"
color="#3b82f6"
color="var(--ui-color-intent-primary)"
/>
<CircularProgress
value={stats.totalRaces - stats.dnfs}
max={stats.totalRaces}
label="Finish Rate"
color="#00f2ff"
color="var(--ui-color-intent-telemetry)"
/>
</Stack>
</Stack>
@@ -67,37 +67,37 @@ export function PerformanceOverview({ stats }: PerformanceOverviewProps) {
<GridItem colSpan={12} lgSpan={6}>
<Stack mb={4}>
<Heading level={3} icon={<Icon icon={BarChart3} size={4} color="#9ca3af" />}>
<Heading level={3} icon={<Icon icon={BarChart3} size={4} intent="low" />}>
Results Breakdown
</Heading>
</Stack>
<HorizontalBarChart
data={[
{ label: 'Wins', value: stats.wins, color: 'bg-performance-green' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'bg-warning-amber' },
{ label: 'DNFs', value: stats.dnfs, color: 'bg-red-500' },
{ label: 'Wins', value: stats.wins, color: 'var(--ui-color-intent-success)' },
{ label: 'Podiums (2nd-3rd)', value: stats.podiums - stats.wins, color: 'var(--ui-color-intent-warning)' },
{ label: 'DNFs', value: stats.dnfs, color: 'var(--ui-color-intent-critical)' },
]}
maxValue={stats.totalRaces}
/>
<Stack mt={6}>
<Grid cols={2} gap={4}>
<Stack p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack p={4} bg="var(--ui-color-bg-base)" rounded="xl" border borderColor="var(--ui-color-border-low)">
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={TrendingUp} size={4} color="#10b981" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Best Finish</Text>
<Icon icon={TrendingUp} size={4} intent="success" />
<Text size="xs" variant="low" uppercase>Best Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-performance-green">P{stats.bestFinish}</Text>
<Text size="2xl" weight="bold" variant="success">P{stats.bestFinish}</Text>
</Stack>
</Stack>
<Stack p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626' }}>
<Stack p={4} bg="var(--ui-color-bg-base)" rounded="xl" border borderColor="var(--ui-color-border-low)">
<Stack gap={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Target} size={4} color="#3b82f6" />
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase' }}>Avg Finish</Text>
<Icon icon={Target} size={4} intent="primary" />
<Text size="xs" variant="low" uppercase>Avg Finish</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-primary-blue">
<Text size="2xl" weight="bold" variant="primary">
P{(stats.avgFinish ?? 0).toFixed(1)}
</Text>
</Stack>

View File

@@ -9,6 +9,8 @@ import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
interface ProfileHeroProps {
@@ -54,25 +56,40 @@ export function ProfileHero({
friendRequestSent,
}: ProfileHeroProps) {
return (
<Surface variant="muted" rounded="2xl" border padding={6} style={{ background: 'linear-gradient(to bottom right, rgba(38, 38, 38, 0.8), rgba(38, 38, 38, 0.6), #0f1115)', borderColor: '#262626' }}>
<Surface
variant="muted"
rounded="2xl"
border
padding={6}
bg="linear-gradient(to bottom right, rgba(38, 38, 38, 0.8), rgba(38, 38, 38, 0.6), var(--ui-color-bg-base))"
borderColor="var(--ui-color-border-low)"
>
<Stack direction="row" align="start" gap={6} wrap>
{/* Avatar */}
<Stack style={{ position: 'relative' }}>
<Stack style={{ width: '7rem', height: '7rem', borderRadius: '1rem', background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)', padding: '0.25rem', boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)' }}>
<Stack style={{ width: '100%', height: '100%', borderRadius: '0.75rem', overflow: 'hidden', backgroundColor: '#262626' }}>
<Box position="relative">
<Stack
w="28"
h="28"
rounded="xl"
bg="linear-gradient(to bottom right, var(--ui-color-intent-primary), rgba(147, 51, 234, 1))"
padding={1}
shadow="xl"
>
<Stack fullWidth fullHeight rounded="lg" overflow="hidden" bg="var(--ui-color-bg-surface-muted)">
<Image
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
alt={driver.name}
width={144}
height={144}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
objectFit="cover"
fill
/>
</Stack>
</Stack>
</Stack>
</Box>
{/* Driver Info */}
<Stack style={{ flex: 1, minWidth: 0 }}>
<Stack flex={1} minWidth="0">
<Stack direction="row" align="center" gap={3} wrap mb={2}>
<Heading level={1}>{driver.name}</Heading>
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
@@ -84,18 +101,18 @@ export function ProfileHero({
<Stack direction="row" align="center" gap={4} wrap mb={4}>
{stats && (
<>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Surface variant="muted" rounded="lg" padding={1} bg="rgba(25, 140, 255, 0.1)" border borderColor="rgba(25, 140, 255, 0.3)" px={3}>
<Stack direction="row" align="center" gap={2}>
<Star style={{ width: '1rem', height: '1rem', color: '#3b82f6' }} />
<Text font="mono" weight="bold" color="text-primary-blue">{stats.ratingLabel}</Text>
<Text size="xs" color="text-gray-400">Rating</Text>
<Icon icon={Star} size={4} intent="primary" />
<Text font="mono" weight="bold" variant="primary">{stats.ratingLabel}</Text>
<Text size="xs" variant="low">Rating</Text>
</Stack>
</Surface>
<Surface variant="muted" rounded="lg" padding={1} style={{ backgroundColor: 'rgba(250, 204, 21, 0.1)', border: '1px solid rgba(250, 204, 21, 0.3)', paddingLeft: '0.75rem', paddingRight: '0.75rem' }}>
<Surface variant="muted" rounded="lg" padding={1} bg="rgba(250, 204, 21, 0.1)" border borderColor="rgba(250, 204, 21, 0.3)" px={3}>
<Stack direction="row" align="center" gap={2}>
<Trophy style={{ width: '1rem', height: '1rem', color: '#facc15' }} />
<Text font="mono" weight="bold" style={{ color: '#facc15' }}>{globalRankLabel}</Text>
<Text size="xs" color="text-gray-400">Global</Text>
<Icon icon={Trophy} size={4} intent="warning" />
<Text font="mono" weight="bold" color="rgba(250, 204, 21, 1)">{globalRankLabel}</Text>
<Text size="xs" variant="low">Global</Text>
</Stack>
</Surface>
</>
@@ -103,19 +120,19 @@ export function ProfileHero({
</Stack>
{/* Meta info */}
<Stack direction="row" align="center" gap={4} wrap style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
<Stack direction="row" align="center" gap={4} wrap color="var(--ui-color-text-low)">
<Stack direction="row" align="center" gap={1.5}>
<Globe style={{ width: '1rem', height: '1rem' }} />
<Icon icon={Globe} size={4} />
<Text size="sm">iRacing: {driver.iracingId}</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Calendar style={{ width: '1rem', height: '1rem' }} />
<Icon icon={Calendar} size={4} />
<Text size="sm">
Joined {driver.joinedAtLabel}
</Text>
</Stack>
<Stack direction="row" align="center" gap={1.5}>
<Clock style={{ width: '1rem', height: '1rem' }} />
<Icon icon={Clock} size={4} />
<Text size="sm">{timezone}</Text>
</Stack>
</Stack>
@@ -127,7 +144,7 @@ export function ProfileHero({
variant="primary"
onClick={onAddFriend}
disabled={friendRequestSent}
icon={<UserPlus style={{ width: '1rem', height: '1rem' }} />}
icon={<Icon icon={UserPlus} size={4} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
@@ -136,23 +153,22 @@ export function ProfileHero({
{/* Social Handles */}
{socialHandles.length > 0 && (
<Stack mt={6} pt={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.5)' }}>
<Stack mt={6} pt={6} borderTop borderColor="rgba(255, 255, 255, 0.05)">
<Stack direction="row" align="center" gap={2} wrap>
<Text size="sm" color="text-gray-500" style={{ marginRight: '0.5rem' }}>Connect:</Text>
<Text size="sm" variant="low" mr={2}>Connect:</Text>
{socialHandles.map((social) => {
const Icon = getSocialIcon(social.platform);
const SocialIcon = getSocialIcon(social.platform);
return (
<Stack key={social.platform}>
<Link
href={social.url}
target="_blank"
rel="noopener noreferrer"
variant="ghost"
>
<Surface variant="muted" rounded="lg" padding={1} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', paddingLeft: '0.75rem', paddingRight: '0.75rem', backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', color: '#9ca3af' }}>
<Icon style={{ width: '1rem', height: '1rem' }} />
<Surface variant="muted" rounded="lg" padding={1} display="flex" alignItems="center" gap={2} px={3} bg="rgba(255, 255, 255, 0.05)" border borderColor="var(--ui-color-border-low)" color="var(--ui-color-text-low)">
<Icon icon={SocialIcon} size={4} />
<Text size="sm">{social.handle}</Text>
<ExternalLink style={{ width: '0.75rem', height: '0.75rem', opacity: 0.5 }} />
<Icon icon={ExternalLink} size={3} opacity={0.5} />
</Surface>
</Link>
</Stack>

View File

@@ -7,10 +7,10 @@ import { Text } from '@/ui/Text';
import { BarChart3 } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' },
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' },
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' },
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' },
{ id: 'pro', label: 'Pro', icon: BarChart3, color: 'var(--ui-color-intent-warning)', bgColor: 'rgba(255, 190, 77, 0.1)', borderColor: 'rgba(255, 190, 77, 0.3)' },
{ id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'rgba(147, 51, 234, 1)', bgColor: 'rgba(147, 51, 234, 0.1)', borderColor: 'rgba(147, 51, 234, 0.3)' },
{ id: 'intermediate', label: 'Intermediate', icon: BarChart3, color: 'var(--ui-color-intent-primary)', bgColor: 'rgba(25, 140, 255, 0.1)', borderColor: 'rgba(25, 140, 255, 0.3)' },
{ id: 'beginner', label: 'Beginner', icon: BarChart3, color: 'var(--ui-color-intent-success)', bgColor: 'rgba(16, 185, 129, 0.1)', borderColor: 'rgba(16, 185, 129, 0.3)' },
];
interface SkillDistributionProps {
@@ -38,15 +38,15 @@ export function SkillDistribution({ drivers }: SkillDistributionProps) {
alignItems="center"
justifyContent="center"
rounded="xl"
bg="bg-neon-aqua/10"
bg="rgba(78, 212, 224, 0.1)"
border
borderColor="border-neon-aqua/20"
borderColor="rgba(78, 212, 224, 0.2)"
>
<Icon icon={BarChart3} size={5} color="var(--neon-aqua)" />
<Icon icon={BarChart3} size={5} intent="telemetry" />
</Box>
<Box>
<Heading level={2}>Skill Distribution</Heading>
<Text size="xs" color="text-gray-500">Driver population by skill level</Text>
<Text size="xs" variant="low">Driver population by skill level</Text>
</Box>
</Box>
@@ -58,28 +58,24 @@ export function SkillDistribution({ drivers }: SkillDistributionProps) {
p={4}
rounded="xl"
border
className={`${level.bgColor} ${level.borderColor}`}
bg={level.bgColor}
borderColor={level.borderColor}
>
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<Icon icon={level.icon} size={5} className={level.color} />
<Text size="2xl" weight="bold" className={level.color}>{level.count}</Text>
<Icon icon={level.icon} size={5} color={level.color} />
<Text size="2xl" weight="bold" color={level.color}>{level.count}</Text>
</Box>
<Text color="text-white" weight="medium" block mb={1}>{level.label}</Text>
<Box fullWidth h="2" rounded="full" bg="bg-deep-graphite/50" overflow="hidden">
<Text variant="high" weight="medium" block mb={1}>{level.label}</Text>
<Box fullWidth h="2" rounded="full" bg="var(--ui-color-bg-base)" overflow="hidden">
<Box
h="full"
rounded="full"
transition
className={
level.id === 'pro' ? 'bg-yellow-400' :
level.id === 'advanced' ? 'bg-purple-400' :
level.id === 'intermediate' ? 'bg-primary-blue' :
'bg-green-400'
}
bg={level.color}
style={{ width: `${level.percentage}%` }}
/>
</Box>
<Text size="xs" color="text-gray-500" block mt={1}>{level.percentage}% of drivers</Text>
<Text size="xs" variant="low" block mt={1}>{level.percentage}% of drivers</Text>
</Box>
);
})}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Accordion } from '@/ui/Accordion';
import { Accordion } from '@/components/shared/Accordion';
import { Copy } from 'lucide-react';
import React, { useState } from 'react';

View File

@@ -6,7 +6,7 @@ import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Accordion } from '@/ui/Accordion';
import { Accordion } from '@/components/shared/Accordion';
import { Copy } from 'lucide-react';
import React, { useState } from 'react';

View File

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

View File

@@ -3,7 +3,7 @@
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { Glow } from '@/ui/Glow';
import { Text } from '@/ui/Text';
import { FooterSection } from '@/ui/FooterSection';
import { Group } from '@/ui/Group';
import { NotFoundActions } from './NotFoundActions';
import { NotFoundDiagnostics } from './NotFoundDiagnostics';
import { NotFoundHelpLinks } from './NotFoundHelpLinks';
@@ -42,40 +42,40 @@ export function NotFoundScreen({
{/* Background Glow Accent */}
<Glow color="primary" size="xl" opacity={0.1} position="center" />
<NotFoundDiagnostics errorCode={errorCode} />
<Text
as="h1"
size="4xl"
weight="bold"
variant="high"
uppercase
block
align="center"
style={{ marginTop: '1rem', marginBottom: '2rem' }}
>
{title}
</Text>
<Group direction="column" align="center" gap={8} fullWidth>
<NotFoundDiagnostics errorCode={errorCode} />
<Group direction="column" align="center" gap={4} fullWidth>
<Text
as="h1"
size="4xl"
weight="bold"
variant="high"
uppercase
block
align="center"
>
{title}
</Text>
<Text
size="xl"
variant="med"
block
weight="medium"
align="center"
style={{ marginBottom: '3rem' }}
>
{message}
</Text>
<Text
size="xl"
variant="med"
block
weight="medium"
align="center"
>
{message}
</Text>
</Group>
<NotFoundActions
primaryLabel={actionLabel}
onPrimaryClick={onActionClick}
/>
<NotFoundActions
primaryLabel={actionLabel}
onPrimaryClick={onActionClick}
/>
<FooterSection>
<NotFoundHelpLinks links={helpLinks} />
</FooterSection>
</Group>
</ErrorPageContainer>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Accordion } from '@/components/shared/Accordion';
import { CardStack } from '@/ui/CardStack';
import { Center } from '@/ui/Center';
interface FAQItem {
question: string;
answer: string;
}
interface FAQSectionProps {
title: string;
subtitle: string;
faqs: FAQItem[];
}
/**
* FAQSection - A semantic component for the FAQ section.
*/
export function FAQSection({
title,
subtitle,
faqs,
}: FAQSectionProps) {
return (
<Section variant="dark" padding="lg">
<Container size="md">
<Center>
<CardStack gap={4}>
<Center>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="widest">
{subtitle}
</Text>
</Center>
<Heading level={2} weight="bold" align="center">
{title}
</Heading>
</CardStack>
</Center>
<CardStack gap={4}>
{faqs.map((faq, index) => (
<Accordion key={index} title={faq.question}>
<Text size="sm" variant="low" leading="relaxed">
{faq.answer}
</Text>
</Accordion>
))}
</CardStack>
</Container>
</Section>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { CardStack } from '@/ui/CardStack';
import { FeatureList } from '@/ui/FeatureList';
import { FeatureQuote } from '@/ui/FeatureQuote';
interface HomeFeatureDescriptionProps {
lead: string;
@@ -12,7 +14,7 @@ interface HomeFeatureDescriptionProps {
/**
* HomeFeatureDescription - A semantic component for feature descriptions on the home page.
* Refactored to use semantic HTML and Tailwind.
* Refactored to use semantic UI components.
*/
export function HomeFeatureDescription({
lead,
@@ -20,40 +22,21 @@ export function HomeFeatureDescription({
quote,
accentColor = 'primary',
}: HomeFeatureDescriptionProps) {
const borderColor = {
primary: 'primary-accent',
aqua: 'telemetry-aqua',
amber: 'warning-amber',
gray: 'border-gray',
}[accentColor];
const bgColor = {
primary: 'primary-accent/5',
aqua: 'telemetry-aqua/5',
amber: 'warning-amber/5',
gray: 'white/5',
}[accentColor];
const intent = accentColor === 'gray' ? 'low' : accentColor;
return (
<Stack gap={6}>
<Text size="lg" color="text-gray-400" weight="medium" leading="relaxed">
<CardStack gap={6}>
<Text size="lg" variant="med" leading="relaxed">
{lead}
</Text>
<Stack as="ul" gap={2}>
{items.map((item, index) => (
<Stack as="li" key={index} direction="row" align="start" gap={2}>
<Text color="text-primary-accent"></Text>
<Text size="sm" color="text-gray-500">{item}</Text>
</Stack>
))}
</Stack>
<FeatureList items={items} intent={intent} />
{quote && (
<Stack borderLeft borderStyle="solid" borderWidth="2px" borderColor={borderColor} pl={4} py={1} bg={bgColor}>
<Text color="text-gray-600" font="mono" size="xs" uppercase letterSpacing="widest" leading="relaxed">
{quote}
</Text>
</Stack>
<FeatureQuote intent={intent}>
{quote}
</FeatureQuote>
)}
</Stack>
</CardStack>
);
}

View File

@@ -1,13 +1,7 @@
'use client';
import React from 'react';
import { Panel } from '@/ui/Panel';
import { Glow } from '@/ui/Glow';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Section } from '@/ui/Section';
import { FeatureSection } from '@/ui/FeatureSection';
interface HomeFeatureSectionProps {
heading: string;
@@ -27,43 +21,13 @@ export function HomeFeatureSection({
layout,
accentColor = 'primary',
}: HomeFeatureSectionProps) {
const glowColor = ({
primary: 'primary',
aqua: 'aqua',
amber: 'amber',
}[accentColor] || 'primary') as 'primary' | 'aqua' | 'amber' | 'purple';
return (
<Section variant="dark" padding="lg">
<Glow
color={glowColor}
size="lg"
position={layout === 'text-left' ? 'bottom-left' : 'top-right'}
opacity={0.02}
/>
<Container>
<Grid cols={{ base: 1, lg: 2 }} gap={12}>
{/* Text Content */}
<Stack gap={8}>
<Stack gap={4}>
<Heading level={2} weight="bold">
{heading}
</Heading>
</Stack>
<Stack>
{description}
</Stack>
</Stack>
{/* Mockup Panel */}
<Panel variant="dark">
<Stack align="center" justify="center">
{mockup}
</Stack>
</Panel>
</Grid>
</Container>
</Section>
<FeatureSection
heading={heading}
description={description}
mockup={mockup}
layout={layout}
intent={accentColor}
/>
);
}

View File

@@ -1,109 +1,42 @@
'use client';
import React from 'react';
import { Button } from '@/ui/Button';
import { Glow } from '@/ui/Glow';
import { Icon } from '@/ui/Icon';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Card } from '@/ui/Card';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
import { DiscordCTA } from '@/ui/DiscordCTA';
import { Code, Lightbulb, MessageSquare, Users } from 'lucide-react';
export function HomeFooterCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
const benefits = [
{
icon: MessageSquare,
title: "Share your pain points",
description: "Tell us what frustrates you about league racing today."
},
{
icon: Lightbulb,
title: "Shape the product",
description: "Your ideas directly influence our roadmap."
},
{
icon: Users,
title: "Connect with racers",
description: "Join a community of like-minded competitive drivers."
},
{
icon: Code,
title: "Early Access",
description: "Test new features before they go public."
},
];
return (
<Section variant="dark" padding="lg">
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Container>
<Card variant="outline">
<Stack align="center" gap={12}>
{/* Header */}
<Stack align="center" gap={6}>
<DiscordIcon size={40} />
<Stack gap={4} align="center">
<Heading level={2} weight="bold" align="center">
Join the Grid on Discord
</Heading>
</Stack>
</Stack>
{/* Personal message */}
<Stack align="center" gap={6}>
<Text size="lg" variant="high" weight="medium" align="center">
GridPilot is a <Text as="span" variant="high" weight="bold">solo developer project</Text> built for the community.
</Text>
<Text size="base" variant="low" align="center">
We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap.
</Text>
</Stack>
{/* Benefits grid */}
<Grid cols={{ base: 1, md: 2 }} gap={6}>
<BenefitItem
icon={MessageSquare}
title="Share your pain points"
description="Tell us what frustrates you about league racing today."
/>
<BenefitItem
icon={Lightbulb}
title="Shape the product"
description="Your ideas directly influence our roadmap."
/>
<BenefitItem
icon={Users}
title="Connect with racers"
description="Join a community of like-minded competitive drivers."
/>
<BenefitItem
icon={Code}
title="Early Access"
description="Test new features before they go public."
/>
</Grid>
{/* CTA Button */}
<Stack gap={6} align="center">
<Button
as="a"
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
variant="primary"
size="lg"
icon={<DiscordIcon size={24} />}
>
Join Discord
</Button>
<Text size="xs" variant="primary" weight="bold" font="mono" uppercase>
Early Alpha Access Available
</Text>
</Stack>
</Stack>
</Card>
</Container>
</Section>
);
}
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
return (
<Card variant="dark">
<Stack align="start" gap={5}>
<Icon icon={icon} size={5} intent="primary" />
<Stack gap={2}>
<Text size="base" weight="bold" variant="high">{title}</Text>
<Text size="sm" variant="low">{description}</Text>
</Stack>
</Stack>
</Card>
<DiscordCTA
title="Join the Grid on Discord"
lead="GridPilot is a solo developer project built for the community."
description="We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap."
benefits={benefits}
discordUrl={discordUrl}
/>
);
}

View File

@@ -1,13 +1,6 @@
'use client';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Glow } from '@/ui/Glow';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Section } from '@/ui/Section';
import { ButtonGroup } from '@/ui/ButtonGroup';
import { LandingHero } from '@/ui/LandingHero';
interface HomeHeaderProps {
title: string;
@@ -26,51 +19,6 @@ interface HomeHeaderProps {
/**
* HomeHeader - Semantic hero section for the landing page.
*/
export function HomeHeader({
title,
subtitle,
description,
primaryAction,
secondaryAction,
}: HomeHeaderProps) {
return (
<Section variant="dark" padding="lg">
<Glow color="primary" size="xl" position="top-right" opacity={0.1} />
<Container>
<Stack gap={8}>
<Text size="xs" weight="bold" uppercase variant="primary">
{subtitle}
</Text>
<Heading level={1} weight="bold">
{title}
</Heading>
<Text size="lg" variant="low">
{description}
</Text>
<ButtonGroup gap={4}>
<Button
as="a"
href={primaryAction.href}
variant="primary"
size="lg"
>
{primaryAction.label}
</Button>
<Button
as="a"
href={secondaryAction.href}
variant="secondary"
size="lg"
>
{secondaryAction.label}
</Button>
</ButtonGroup>
</Stack>
</Container>
</Section>
);
export function HomeHeader(props: HomeHeaderProps) {
return <LandingHero {...props} />;
}

View File

@@ -1,43 +1,39 @@
'use client';
import React from 'react';
import { MetricCard } from '@/ui/MetricCard';
import { StatsStrip } from '@/ui/StatsStrip';
import { Activity, Users, Trophy, Calendar } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
/**
* HomeStatsStrip - A thin strip showing some status or quick info.
* Part of the "Telemetry-workspace" feel.
*/
export function HomeStatsStrip() {
return (
<Container>
<Grid cols={{ base: 2, md: 4 }} gap={4}>
<MetricCard
label="Active Drivers"
value="1,284"
icon={Users}
trend={{ value: 12, isPositive: true }}
/>
<MetricCard
label="Live Sessions"
value="42"
icon={Activity}
intent="telemetry"
/>
<MetricCard
label="Total Races"
value="15,402"
icon={Trophy}
intent="warning"
/>
<MetricCard
label="Next Event"
value="14:00"
icon={Calendar}
/>
</Grid>
</Container>
);
const stats = [
{
label: "Active Drivers",
value: "1,284",
icon: Users,
trend: { value: 12, isPositive: true }
},
{
label: "Live Sessions",
value: "42",
icon: Activity,
intent: "telemetry" as const
},
{
label: "Total Races",
value: "15,402",
icon: Trophy,
intent: "warning" as const
},
{
label: "Next Event",
value: "14:00",
icon: Calendar
},
];
return <StatsStrip stats={stats} />;
}

View File

@@ -2,9 +2,12 @@
import { LeagueCard } from '@/components/leagues/LeagueCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Panel } from '@/ui/Panel';
import { Box } from '@/ui/Box';
import { CardStack } from '@/ui/CardStack';
interface League {
id: string;
@@ -20,26 +23,26 @@ interface LeagueSummaryPanelProps {
* LeagueSummaryPanel - Semantic section for featured leagues.
*/
export function LeagueSummaryPanel({ leagues }: LeagueSummaryPanelProps) {
return (
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
FEATURED LEAGUES
</Heading>
<Link
href={routes.public.leagues}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
VIEW ALL
</Link>
</Box>
const actions = (
<Link
href={routes.public.leagues}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
>
VIEW ALL
</Link>
);
<Box display="flex" flexDirection="col" gap={4}>
return (
<Panel
variant="dark"
padding={6}
title="FEATURED LEAGUES"
actions={actions}
>
<CardStack gap={4}>
{leagues.slice(0, 2).map((league) => (
<LeagueCard
key={league.id}
@@ -54,7 +57,7 @@ export function LeagueSummaryPanel({ leagues }: LeagueSummaryPanelProps) {
openSlotsCount={6}
/>
))}
</Box>
</Box>
</CardStack>
</Panel>
);
}

View File

@@ -5,9 +5,13 @@ import { routes } from '@/lib/routing/RouteConfig';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Panel } from '@/ui/Panel';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { CardStack } from '@/ui/CardStack';
import { Center } from '@/ui/Center';
interface Race {
id: string;
track: string;
@@ -23,33 +27,33 @@ interface RecentRacesPanelProps {
* RecentRacesPanel - Semantic section for upcoming/recent races.
*/
export function RecentRacesPanel({ races }: RecentRacesPanelProps) {
return (
<Panel variant="dark" padding={6}>
<Stack direction="row" align="center" justify="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
UPCOMING RACES
</Heading>
<Link
href={routes.public.races}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
FULL SCHEDULE
</Link>
</Stack>
const actions = (
<Link
href={routes.public.races}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
>
FULL SCHEDULE
</Link>
);
<Stack gap={3}>
return (
<Panel
variant="dark"
padding={6}
title="UPCOMING RACES"
actions={actions}
>
<CardStack gap={3}>
{races.length === 0 ? (
<Panel variant="muted" padding={12} border>
<Stack center>
<Text size="xs" font="mono" uppercase letterSpacing="widest" color="text-gray-600">
<Center>
<Text size="xs" font="mono" uppercase letterSpacing="widest" variant="low">
No races scheduled
</Text>
</Stack>
</Center>
</Panel>
) : (
races.slice(0, 3).map((race) => (
@@ -63,7 +67,7 @@ export function RecentRacesPanel({ races }: RecentRacesPanelProps) {
/>
))
)}
</Stack>
</CardStack>
</Panel>
);
}

View File

@@ -2,9 +2,12 @@
import { TeamCard } from '@/components/teams/TeamCard';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Panel } from '@/ui/Panel';
import { Box } from '@/ui/Box';
import { CardStack } from '@/ui/CardStack';
interface Team {
id: string;
@@ -21,26 +24,26 @@ interface TeamSummaryPanelProps {
* TeamSummaryPanel - Semantic section for teams.
*/
export function TeamSummaryPanel({ teams }: TeamSummaryPanelProps) {
return (
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
TEAMS ON THE GRID
</Heading>
<Link
href={routes.public.teams}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
hoverColor="text-white"
transition
>
BROWSE TEAMS
</Link>
</Box>
const actions = (
<Link
href={routes.public.teams}
size="xs"
weight="bold"
letterSpacing="widest"
variant="primary"
>
BROWSE TEAMS
</Link>
);
<Box display="flex" flexDirection="col" gap={4}>
return (
<Panel
variant="dark"
padding={6}
title="TEAMS ON THE GRID"
actions={actions}
>
<CardStack gap={4}>
{teams.slice(0, 2).map((team) => (
<TeamCard
key={team.id}
@@ -51,7 +54,7 @@ export function TeamSummaryPanel({ teams }: TeamSummaryPanelProps) {
isRecruiting={true}
/>
))}
</Box>
</Box>
</CardStack>
</Panel>
);
}

View File

@@ -44,8 +44,13 @@ export function BenefitCard({
rounded="xl"
border={true}
padding={6}
className={`relative h-full transition-all duration-300 group ${isHighlight ? 'border-primary-blue/30' : 'border-charcoal-outline hover:border-charcoal-outline/80'}`}
style={isHighlight ? { background: 'linear-gradient(to bottom right, rgba(25, 140, 255, 0.1), rgba(25, 140, 255, 0.05))' } : {}}
borderColor={isHighlight ? 'rgba(25, 140, 255, 0.3)' : 'var(--ui-color-border-low)'}
hoverBorderColor={isHighlight ? 'rgba(25, 140, 255, 0.5)' : 'var(--ui-color-border-default)'}
transition="all 0.3s ease"
group
position="relative"
fullHeight
bg={isHighlight ? 'linear-gradient(to bottom right, rgba(25, 140, 255, 0.1), rgba(25, 140, 255, 0.05))' : undefined}
>
{/* Icon */}
<Box
@@ -55,25 +60,25 @@ export function BenefitCard({
display="flex"
center
mb={4}
bg={isHighlight ? 'bg-primary-blue/20' : 'bg-iron-gray'}
bg={isHighlight ? 'rgba(25, 140, 255, 0.2)' : 'var(--ui-color-bg-surface-muted)'}
border={!isHighlight}
borderColor="border-charcoal-outline"
borderColor="var(--ui-color-border-low)"
>
<Icon icon={icon} size={6} className={isHighlight ? 'text-primary-blue' : 'text-gray-400'} />
<Icon icon={icon} size={6} intent={isHighlight ? 'primary' : 'low'} />
</Box>
{/* Content */}
<Heading level={3} mb={2}>{title}</Heading>
<Text size="sm" color="text-gray-400" block style={{ lineHeight: 1.625 }}>{description}</Text>
<Text size="sm" variant="low" block leading="relaxed">{description}</Text>
{/* Stats */}
{stats && (
<Box mt={4} pt={4} borderTop={true} borderColor="border-charcoal-outline/50">
<Box mt={4} pt={4} borderTop={true} borderColor="var(--ui-color-border-low)">
<Box display="flex" alignItems="baseline" gap={2}>
<Text size="2xl" weight="bold" color={isHighlight ? 'text-primary-blue' : 'text-white'}>
<Text size="2xl" weight="bold" variant={isHighlight ? 'primary' : 'high'}>
{stats.value}
</Text>
<Text size="sm" color="text-gray-500">{stats.label}</Text>
<Text size="sm" variant="low">{stats.label}</Text>
</Box>
</Box>
)}
@@ -84,7 +89,11 @@ export function BenefitCard({
position="absolute"
inset="0"
rounded="xl"
className="bg-gradient-to-br from-primary-blue/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), transparent)"
opacity={0}
groupHoverOpacity={1}
transition="opacity 0.3s ease"
pointerEvents="none"
/>
)}
</Surface>

View File

@@ -24,11 +24,11 @@ export function DiscoverySection({ viewData }: DiscoverySectionProps) {
<Stack gap={16}>
<Stack maxWidth="2xl">
<Stack borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="0.2em">
Live Ecosystem
</Text>
</Stack>
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '4xl' }} className="tracking-tight">
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '4xl' }} letterSpacing="-0.025em">
Discover the Grid
</Heading>
<Text size="lg" color="text-gray-400" block mt={6} leading="relaxed">
@@ -39,10 +39,10 @@ export function DiscoverySection({ viewData }: DiscoverySectionProps) {
<Grid cols={1} lgCols={3} gap={12}>
{/* Top leagues */}
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" className="border-b border-border-gray/30 pb-4">
<Heading level={5} color="text-gray-400" weight="bold" className="tracking-widest">FEATURED LEAGUES</Heading>
<Stack direction="row" align="center" justify="between" borderBottom borderColor="var(--ui-color-border-low)" pb={4}>
<Heading level={5} color="var(--ui-color-text-low)" weight="bold" letterSpacing="widest" uppercase>FEATURED LEAGUES</Heading>
<Link href={routes.public.leagues}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-widest hover:text-white transition-colors">View all</Text>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="widest" hoverVariant="high">View all</Text>
</Link>
</Stack>
<Stack gap={4}>
@@ -65,10 +65,10 @@ export function DiscoverySection({ viewData }: DiscoverySectionProps) {
{/* Teams */}
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" className="border-b border-border-gray/30 pb-4">
<Heading level={5} color="text-gray-400" weight="bold" className="tracking-widest">TEAMS ON THE GRID</Heading>
<Stack direction="row" align="center" justify="between" borderBottom borderColor="var(--ui-color-border-low)" pb={4}>
<Heading level={5} color="var(--ui-color-text-low)" weight="bold" letterSpacing="widest" uppercase>TEAMS ON THE GRID</Heading>
<Link href={routes.public.teams}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-widest hover:text-white transition-colors">Browse</Text>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="widest" hoverVariant="high">Browse</Text>
</Link>
</Stack>
<Stack gap={4}>
@@ -87,10 +87,10 @@ export function DiscoverySection({ viewData }: DiscoverySectionProps) {
{/* Upcoming races */}
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" className="border-b border-border-gray/30 pb-4">
<Heading level={5} color="text-gray-400" weight="bold" className="tracking-widest">UPCOMING RACES</Heading>
<Stack direction="row" align="center" justify="between" borderBottom borderColor="var(--ui-color-border-low)" pb={4}>
<Heading level={5} color="var(--ui-color-text-low)" weight="bold" letterSpacing="widest" uppercase>UPCOMING RACES</Heading>
<Link href={routes.public.races}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-widest hover:text-white transition-colors">Schedule</Text>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="widest" hoverVariant="high">Schedule</Text>
</Link>
</Stack>
{viewData.upcomingRaces.length === 0 ? (

View File

@@ -1,12 +1,6 @@
'use client';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { motion } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { FAQSection } from '@/components/home/FAQSection';
const faqs = [
{
@@ -35,103 +29,12 @@ const faqs = [
}
];
function FAQItem({ faq, index }: { faq: typeof faqs[0]; index: number }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Stack
as={motion.div}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
group
>
<Stack rounded="none" bg="panel-gray/40" border borderColor="border-gray/50" transition hoverBorderColor="primary-accent/30">
<Stack
as="button"
onClick={() => setIsOpen(!isOpen)}
fullWidth
p={{ base: 4, md: 6 }}
textAlign="left"
rounded="none"
minHeight="44px"
className="relative overflow-hidden"
>
<Stack display="flex" alignItems="center" justifyContent="between" gap={{ base: 2, md: 4 }}>
<Stack direction="row" align="center" gap={4}>
<Stack w="1" h="4" bg={isOpen ? "primary-accent" : "border-gray"} transition className="group-hover:bg-primary-accent" />
<Heading level={3} fontSize={{ base: 'sm', md: 'base' }} weight="bold" color="text-white" groupHoverColor="primary-accent" transition className="tracking-wide">
{faq.question}
</Heading>
</Stack>
<Stack
as={motion.div}
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.15, ease: 'easeInOut' }}
w={{ base: "4", md: "5" }}
h={{ base: "4", md: "5" }}
color={isOpen ? "text-primary-accent" : "text-gray-500"}
flexShrink={0}
>
<Icon icon={ChevronDown} size="full" />
</Stack>
</Stack>
</Stack>
<Stack
as={motion.div}
initial={false}
animate={{
height: isOpen ? 'auto' : 0,
opacity: isOpen ? 1 : 0
}}
transition={{
height: { duration: 0.2, ease: 'easeInOut' },
opacity: { duration: 0.15, ease: 'easeInOut' }
}}
overflow="hidden"
>
<Stack px={{ base: 4, md: 6 }} pb={{ base: 4, md: 6 }} pt={0} ml={5}>
<Text size="sm" color="text-gray-400" weight="normal" leading="relaxed" className="max-w-2xl">
{faq.answer}
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
);
}
export function FAQ() {
return (
<Stack as="section" position="relative" py={{ base: 20, md: 32 }} bg="graphite-black" overflow="hidden" borderBottom borderColor="border-gray/50">
{/* Background image with mask */}
<Stack
position="absolute"
inset="0"
bg="url(/images/porsche.jpeg)"
backgroundSize="cover"
backgroundPosition="center"
opacity={0.03}
/>
<Stack maxWidth="4xl" mx="auto" px={{ base: 4, md: 6 }} position="relative" zIndex={10}>
<Stack textAlign="center" mb={{ base: 12, md: 16 }}>
<Stack borderLeft borderRight borderStyle="solid" borderColor="primary-accent" px={4} display="inline-block" mb={4}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
Support & Information
</Text>
</Stack>
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4} className="tracking-tight">
Frequently Asked Questions
</Heading>
</Stack>
<Stack display="flex" flexDirection="column" gap={4}>
{faqs.map((faq, index) => (
<FAQItem key={faq.question} faq={faq} index={index} />
))}
</Stack>
</Stack>
</Stack>
<FAQSection
title="Frequently Asked Questions"
subtitle="Support & Information"
faqs={faqs}
/>
);
}
}

View File

@@ -12,6 +12,9 @@ import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Section } from '@/ui/Section';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Box } from '@/ui/Box';
import { Grid } from '@/ui/Grid';
const features = [
{
@@ -48,68 +51,79 @@ const features = [
function FeatureCard({ feature, index }: { feature: typeof features[0], index: number }) {
return (
<Stack
display="flex"
flexDirection="column"
gap={6}
className="p-8 bg-panel-gray/20 border border-border-gray/20 rounded-none hover:border-primary-accent/20 transition-all duration-300 ease-smooth group relative overflow-hidden"
<Surface
variant="muted"
padding={8}
rounded="none"
border
borderColor="var(--ui-color-border-low)"
hoverBorderColor="var(--ui-color-intent-primary)"
transition="all 0.3s ease"
group
position="relative"
overflow="hidden"
>
<Stack aspectRatio="video" fullWidth position="relative" className="bg-graphite-black rounded-none overflow-hidden border border-border-gray/20">
<Stack aspectRatio="video" fullWidth position="relative" bg="var(--ui-color-bg-base)" rounded="none" overflow="hidden" border borderColor="var(--ui-color-border-low)">
<MockupStack index={index}>
<feature.MockupComponent />
</MockupStack>
</Stack>
<Stack gap={4}>
<Stack display="flex" alignItems="center" gap={3}>
<Stack w="1" h="3" bg="primary-accent" />
<Heading level={3} weight="bold" fontSize="lg" className="tracking-tighter uppercase">
<Stack gap={4} mt={6}>
<Stack direction="row" align="center" gap={3}>
<Box w="1" h="3" bg="var(--ui-color-intent-primary)" />
<Heading level={3} weight="bold" fontSize="lg" letterSpacing="-0.05em" uppercase>
{feature.title}
</Heading>
</Stack>
<Text size="sm" color="text-gray-500" leading="relaxed" weight="normal" className="group-hover:text-gray-400 transition-colors">
<Text size="sm" variant="low" leading="relaxed" weight="normal" groupHoverTextColor="var(--ui-color-text-med)">
{feature.description}
</Text>
</Stack>
{/* Subtle hover effect */}
<Stack
<Box
position="absolute"
bottom="0"
left="0"
w="full"
h="0.5"
bg="primary-accent"
className="scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left"
bg="var(--ui-color-intent-primary)"
transform="scaleX(0)"
groupHoverScale={true}
transition="transform 0.5s ease"
style={{ transformOrigin: 'left' }}
/>
</Stack>
</Surface>
);
}
export function FeatureGrid() {
return (
<Section className="bg-graphite-black border-b border-border-gray/20 py-32">
<Container position="relative" zIndex={10}>
<Box borderBottom borderColor="var(--ui-color-border-low)">
<Section variant="dark" py={32}>
<Container position="relative" zIndex={10}>
<Stack gap={16}>
<Stack maxWidth="2xl">
<Stack borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4} bg="primary-accent/5" py={1}>
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.3em]">
<Stack borderLeft borderStyle="solid" borderColor="var(--ui-color-intent-primary)" pl={4} mb={4} bg="rgba(25, 140, 255, 0.05)" py={1}>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="0.3em">
Engineered for Competition
</Text>
</Stack>
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '5xl' }} className="tracking-tighter uppercase leading-none">
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '5xl' }} letterSpacing="-0.05em" uppercase>
Building for League Racing
</Heading>
<Text size="lg" color="text-gray-500" block mt={6} leading="relaxed" className="border-l border-border-gray/20 pl-6">
<Text size="lg" variant="low" block mt={6} leading="relaxed" borderLeft borderColor="var(--ui-color-border-low)" pl={6}>
Every feature is designed to reduce friction and increase immersion. Join our Discord to help shape the future of the platform.
</Text>
</Stack>
<Stack display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
<Grid cols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{features.map((feature, index) => (
<FeatureCard key={feature.title} feature={feature} index={index} />
))}
</Stack>
</Grid>
</Stack>
</Container>
</Section>
</Section>
</Box>
);
}

View File

@@ -19,10 +19,11 @@ export function LandingHero() {
ref={sectionRef}
position="relative"
overflow="hidden"
bg="graphite-black"
bg="var(--ui-color-bg-base)"
pt={{ base: 24, md: 32, lg: 40 }}
pb={{ base: 16, md: 24, lg: 32 }}
className="border-b border-border-gray"
borderBottom
borderColor="var(--ui-color-border-low)"
>
{/* Background image layer with parallax */}
<Stack
@@ -54,8 +55,8 @@ export function LandingHero() {
<Container size="lg" position="relative" zIndex={10}>
<Stack gap={{ base: 8, md: 12 }}>
<Stack gap={6} maxWidth="3xl">
<Stack borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={2} bg="primary-accent/5" py={1}>
<Text size="xs" weight="bold" color="text-primary-accent" uppercase letterSpacing="0.3em">
<Stack borderLeft borderStyle="solid" borderColor="var(--ui-color-intent-primary)" pl={4} mb={2} bg="rgba(25, 140, 255, 0.05)" py={1}>
<Text size="xs" weight="bold" variant="primary" uppercase letterSpacing="0.3em">
Precision Racing Infrastructure
</Text>
</Stack>
@@ -63,13 +64,13 @@ export function LandingHero() {
level={1}
fontSize={{ base: '4xl', sm: '5xl', md: '6xl', lg: '8xl' }}
weight="bold"
color="text-white"
color="var(--ui-color-text-high)"
lineHeight="0.95"
letterSpacing="tighter"
>
MODERN MOTORSPORT INFRASTRUCTURE.
</Heading>
<Text size={{ base: 'lg', md: 'xl' }} color="text-gray-400" weight="normal" leading="relaxed" maxWidth="2xl" borderLeft borderStyle="solid" borderColor="border-gray" pl={6} opacity={0.3}>
<Text size={{ base: 'lg', md: 'xl' }} variant="low" weight="normal" leading="relaxed" maxWidth="2xl" borderLeft borderStyle="solid" borderColor="var(--ui-color-border-low)" pl={6} opacity={0.3}>
GridPilot gives your league racing a real home. Results, standings, teams, and career progression engineered for precision and control.
</Text>
</Stack>
@@ -91,8 +92,8 @@ export function LandingHero() {
variant="secondary"
size="lg"
px={12}
borderColor="border-gray"
hoverBorderColor="primary-accent/50"
borderColor="var(--ui-color-border-low)"
hoverBorderColor="var(--ui-color-intent-primary)"
letterSpacing="0.2em"
fontSize="xs"
h="14"
@@ -110,24 +111,24 @@ export function LandingHero() {
mt={12}
borderTop
borderStyle="solid"
borderColor="border-gray"
borderColor="var(--ui-color-border-low)"
opacity={0.2}
pt={12}
>
{[
{ label: 'IDENTITY', text: 'Your racing career in one place', color: 'primary' },
{ label: 'AUTOMATION', text: 'No more manual session setup', color: 'aqua' },
{ label: 'PRECISION', text: 'Real-time results and standings', color: 'amber' },
{ label: 'COMMUNITY', text: 'Built for teams and leagues', color: 'green' }
{ label: 'IDENTITY', text: 'Your racing career in one place', color: 'var(--ui-color-intent-primary)' },
{ label: 'AUTOMATION', text: 'No more manual session setup', color: 'var(--ui-color-intent-telemetry)' },
{ label: 'PRECISION', text: 'Real-time results and standings', color: 'var(--ui-color-intent-warning)' },
{ label: 'COMMUNITY', text: 'Built for teams and leagues', color: 'var(--ui-color-intent-success)' }
].map((item) => (
<Stack key={item.label} gap={3} group cursor="default">
<Stack display="flex" alignItems="center" gap={3}>
<Stack w="1" h="3" bg={item.color === 'primary' ? 'primary-accent' : item.color === 'aqua' ? 'telemetry-aqua' : item.color === 'amber' ? 'warning-amber' : 'success-green'} />
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="0.2em" groupHoverTextColor="white" transition>
<Stack w="1" h="3" bg={item.color} />
<Text size="xs" weight="bold" variant="low" uppercase letterSpacing="0.2em" groupHoverTextColor="var(--ui-color-text-high)" transition>
{item.label}
</Text>
</Stack>
<Text size="sm" color="text-gray-500" groupHoverTextColor="gray-300" transition leading="relaxed">
<Text size="sm" variant="low" groupHoverTextColor="var(--ui-color-text-med)" transition leading="relaxed">
{item.text}
</Text>
</Stack>

View File

@@ -15,6 +15,7 @@ export function AuthedNav({ pathname, direction = 'col' }: AuthedNavProps) {
const items = [
{ label: 'Dashboard', href: routes.protected.dashboard, icon: Home },
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
{ label: 'Drivers', href: routes.public.drivers, icon: Users },
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: Layout },
{ label: 'Teams', href: routes.public.teams, icon: Users },
{ label: 'Races', href: routes.public.races, icon: Calendar },

View File

@@ -15,6 +15,7 @@ export function PublicNav({ pathname, direction = 'col' }: PublicNavProps) {
const items = [
{ label: 'Home', href: routes.public.home, icon: Home },
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
{ label: 'Drivers', href: routes.public.drivers, icon: Users },
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: Layout },
{ label: 'Teams', href: routes.public.teams, icon: Users },
{ label: 'Races', href: routes.public.races, icon: Calendar },

View File

@@ -1,7 +1,7 @@
'use client';
import { InfoBanner } from '@/ui/InfoBanner';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { ModalIcon } from '@/ui/ModalIcon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';

View File

@@ -5,7 +5,7 @@ import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { getMembership } from '@/lib/leagueMembership';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { Text } from '@/ui/Text';
import { useState } from 'react';

View File

@@ -4,7 +4,7 @@ import { Text } from '@/ui/Text';
interface JoinRequestItemProps {
driverId: string;
formattedRequestedAt: string;
requestedAt: string;
onApprove: () => void;
onReject: () => void;
isApproving?: boolean;
@@ -13,7 +13,7 @@ interface JoinRequestItemProps {
export function JoinRequestItem({
driverId,
formattedRequestedAt,
requestedAt,
onApprove,
onReject,
isApproving,
@@ -47,7 +47,7 @@ export function JoinRequestItem({
<Stack flexGrow={1}>
<Text color="text-white" weight="medium" block>{driverId}</Text>
<Text size="sm" color="text-gray-400" block>
Requested {formattedRequestedAt}
Requested {requestedAt}
</Text>
</Stack>
</Stack>

View File

@@ -8,7 +8,7 @@ import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { InfoFlyout } from '@/ui/InfoFlyout';
import { InfoFlyout } from '@/components/shared/InfoFlyout';
import { Stepper } from '@/ui/Stepper';
import { Button } from '@/ui/Button';
import { IconButton } from '@/ui/IconButton';

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import { Modal } from "@/ui/Modal";
import { Modal } from "@/components/shared/Modal";
import { Button } from "@/ui/Button";
import { Card } from "@/ui/Card";
import { Stack } from "@/ui/Stack";

View File

@@ -2,7 +2,7 @@
import { IconButton } from '@/ui/IconButton';
import { Image } from '@/ui/Image';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { ChevronLeft, ChevronRight, Download } from 'lucide-react';

View File

@@ -1,7 +1,8 @@
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
interface MockupStackProps {
children: ReactNode;
@@ -28,9 +29,13 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
// On mobile or before mount, render without animations
if (!isMounted || isMobile) {
return (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<div
className="absolute rounded-none bg-panel-gray/80 border border-border-gray/50"
<Box position="relative" fullWidth fullHeight maxWidth="85vw" marginX="auto" marginY={{ base: 4, sm: 0 }} style={{ perspective: '1200px', transform: 'scale(var(--mockup-scale, 1))' }}>
<Surface
variant="muted"
position="absolute"
rounded="none"
border
borderColor="var(--ui-color-border-low)"
style={{
rotate: `${rotation1}deg`,
zIndex: 1,
@@ -43,8 +48,12 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
}}
/>
<div
className="absolute rounded-none bg-panel-gray/90 border border-border-gray/50"
<Surface
variant="muted"
position="absolute"
rounded="none"
border
borderColor="var(--ui-color-border-low)"
style={{
rotate: `${rotation2}deg`,
zIndex: 2,
@@ -57,24 +66,34 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
}}
/>
<div
className="relative z-10 w-full h-full rounded-none overflow-hidden border border-border-gray/30"
<Box
position="relative"
zIndex={10}
fullWidth
fullHeight
rounded="none"
overflow="hidden"
border
borderColor="var(--ui-color-border-low)"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
>
{children}
</div>
</div>
</Box>
</Box>
);
}
// Desktop: render with animations
return (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<Box position="relative" fullWidth fullHeight maxWidth="85vw" marginX="auto" marginY={{ base: 4, sm: 0 }} style={{ perspective: '1200px', transform: 'scale(var(--mockup-scale, 1))' }}>
<motion.div
className="absolute rounded-none bg-panel-gray/80 border border-border-gray/50"
style={{
position: 'absolute',
borderRadius: '0',
backgroundColor: 'rgba(20, 22, 25, 0.8)',
border: '1px solid rgba(35, 39, 43, 0.5)',
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
@@ -89,8 +108,11 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
/>
<motion.div
className="absolute rounded-none bg-panel-gray/90 border border-border-gray/50"
style={{
position: 'absolute',
borderRadius: '0',
backgroundColor: 'rgba(20, 22, 25, 0.9)',
border: '1px solid rgba(35, 39, 43, 0.5)',
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
@@ -105,8 +127,14 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
/>
<motion.div
className="relative z-10 w-full h-full rounded-none overflow-hidden border border-border-gray/30"
style={{
position: 'relative',
zIndex: 10,
width: '100%',
height: '100%',
borderRadius: '0',
overflow: 'hidden',
border: '1px solid rgba(35, 39, 43, 0.3)',
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
whileHover={
@@ -129,7 +157,12 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
transition={{ duration: 0.4, delay: 0.2 }}
>
<motion.div
className="absolute inset-0 pointer-events-none rounded-none"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: '0',
}}
whileHover={
shouldReduceMotion
? {}
@@ -141,6 +174,6 @@ export function MockupStack({ children, index = 0 }: MockupStackProps) {
/>
{children}
</motion.div>
</div>
</Box>
);
}
}

View File

@@ -35,18 +35,9 @@ export function ProtestWorkflowMockup() {
},
];
const getStatusStyles = (status: string) => {
switch (status) {
case 'pending': return 'bg-panel-gray border-gray-700 text-gray-600';
case 'active': return 'bg-warning-amber/10 border-warning-amber text-warning-amber';
case 'resolved': return 'bg-success-green/10 border-success-green text-success-green';
default: return 'bg-panel-gray border-gray-700 text-gray-600';
}
};
if (isMobile) {
return (
<Box position="relative" fullWidth fullHeight bg="graphite-black" rounded="none" overflow="hidden" p={3} display="flex" flexDirection="col" justifyContent="center" gap={4}>
<Box position="relative" fullWidth fullHeight bg="var(--ui-color-bg-base)" rounded="none" overflow="hidden" p={3} display="flex" flexDirection="col" justifyContent="center" gap={4}>
<Box display="flex" alignItems="center" justifyContent="center" gap={3}>
{steps.map((step, i) => (
<Box key={step.name} display="flex" alignItems="center">
@@ -61,17 +52,19 @@ export function ProtestWorkflowMockup() {
mb={1}
border
borderWidth="1px"
className={getStatusStyles(step.status)}
bg={step.status === 'pending' ? 'var(--ui-color-bg-surface)' : step.status === 'active' ? 'rgba(255, 190, 77, 0.1)' : 'rgba(16, 185, 129, 0.1)'}
borderColor={step.status === 'pending' ? 'var(--ui-color-border-default)' : step.status === 'active' ? 'var(--ui-color-intent-warning)' : 'var(--ui-color-intent-success)'}
color={step.status === 'pending' ? 'var(--ui-color-text-low)' : step.status === 'active' ? 'var(--ui-color-intent-warning)' : 'var(--ui-color-intent-success)'}
>
<Box as="svg" w="5" h="5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={step.icon} />
</Box>
</Box>
<Text size="xs" color="text-white" weight="bold" textAlign="center" className="uppercase tracking-widest">{step.name}</Text>
<Text size="xs" color="text-white" weight="bold" textAlign="center" uppercase letterSpacing="widest">{step.name}</Text>
</Box>
{i < steps.length - 1 && (
<Box as="svg" w="4" h="4" mx={1} viewBox="0 0 24 24" fill="none">
<path d="M5 12h14m-7-7l7 7-7 7" stroke="#198CFF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M5 12h14m-7-7l7 7-7 7" stroke="var(--ui-color-intent-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</Box>
)}
</Box>
@@ -83,7 +76,7 @@ export function ProtestWorkflowMockup() {
position="absolute"
insetY="0"
left="0"
bg="primary-accent"
bg="var(--ui-color-intent-primary)"
style={{ width: `${((activeStep + 1) / steps.length) * 100}%` }}
/>
</Box>
@@ -104,7 +97,7 @@ export function ProtestWorkflowMockup() {
};
return (
<Box position="relative" fullWidth fullHeight bg="graphite-black" rounded="none" overflow="hidden" p={{ base: 1.5, sm: 3, md: 5, lg: 8 }} display="flex" flexDirection="col" justifyContent="center" gap={{ base: 2, sm: 4, md: 6, lg: 8 }}>
<Box position="relative" fullWidth fullHeight bg="var(--ui-color-bg-base)" rounded="none" overflow="hidden" p={{ base: 1.5, sm: 3, md: 5, lg: 8 }} display="flex" flexDirection="col" justifyContent="center" gap={{ base: 2, sm: 4, md: 6, lg: 8 }}>
<Box display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems="center" justifyContent="center" gap={{ base: 2, sm: 3, md: 4 }}>
{steps.map((step, i) => (
<Box key={step.name} display="flex" alignItems="center" flexShrink={0}>
@@ -131,10 +124,12 @@ export function ProtestWorkflowMockup() {
mb={{ base: 1, sm: 1.5, md: 2 }}
border
borderWidth="1px"
className={getStatusStyles(step.status)}
bg={step.status === 'pending' ? 'var(--ui-color-bg-surface)' : step.status === 'active' ? 'rgba(255, 190, 77, 0.1)' : 'rgba(16, 185, 129, 0.1)'}
borderColor={step.status === 'pending' ? 'var(--ui-color-border-default)' : step.status === 'active' ? 'var(--ui-color-intent-warning)' : 'var(--ui-color-intent-success)'}
color={step.status === 'pending' ? 'var(--ui-color-text-low)' : step.status === 'active' ? 'var(--ui-color-intent-warning)' : 'var(--ui-color-intent-success)'}
whileHover={shouldReduceMotion ? {} : {
scale: 1.05,
borderColor: '#198CFF',
borderColor: 'var(--ui-color-intent-primary)',
transition: { duration: 0.2 }
}}
>
@@ -142,7 +137,7 @@ export function ProtestWorkflowMockup() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={step.icon} />
</Box>
{step.status === 'active' && (
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="warning-amber" />
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="var(--ui-color-intent-warning)" />
)}
</Box>
<Text
@@ -151,7 +146,8 @@ export function ProtestWorkflowMockup() {
color="text-white"
weight="bold"
textAlign="center"
className="uppercase tracking-widest"
uppercase
letterSpacing="widest"
>
{step.name}
</Text>
@@ -159,13 +155,13 @@ export function ProtestWorkflowMockup() {
{i < steps.length - 1 && (
<Box
className="hidden md:block"
display={{ base: 'none', md: 'block' }}
position="relative"
ml={2}
mr={2}
>
<Box as="svg" w={{ base: 3, sm: 4, md: 5 }} h={{ base: 3, sm: 4, md: 5 }} viewBox="0 0 24 24" fill="none">
<path d="M5 12h14m-7-7l7 7-7 7" stroke="#198CFF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity={0.5} />
<path d="M5 12h14m-7-7l7 7-7 7" stroke="var(--ui-color-intent-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity={0.5} />
</Box>
</Box>
)}
@@ -189,7 +185,7 @@ export function ProtestWorkflowMockup() {
position="absolute"
insetY="0"
left="0"
bg="primary-accent"
bg="var(--ui-color-intent-primary)"
initial={{ width: '0%' }}
animate={{ width: `${((activeStep + 1) / steps.length) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}

View File

@@ -0,0 +1,174 @@
'use client';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { CheckCircle2, LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
export interface WorkflowStep {
id: number;
icon: LucideIcon;
title: string;
description: string;
intent: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
}
interface WorkflowMockupProps {
steps: WorkflowStep[];
}
export function WorkflowMockup({ steps }: WorkflowMockupProps) {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [activeStep, setActiveStep] = useState(0);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % steps.length);
}, 3000);
return () => clearInterval(interval);
}, [isMounted, steps.length]);
if (!isMounted) {
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="none" padding={6}>
<Box display="flex" justifyContent="between" gap={2}>
{steps.map((step) => (
<Box key={step.id} display="flex" alignItems="center" justifyContent="center" flexDirection="col">
<Box width="10" height="10" rounded="none" bg="var(--ui-color-bg-base)" style={{ border: '1px solid var(--ui-color-border-default)' }} display="flex" alignItems="center" justifyContent="center" mb={2}>
<Text variant={step.intent}>
<Icon icon={step.icon} size={4} />
</Text>
</Box>
<Text size="xs" weight="bold" variant="high" uppercase>{step.title}</Text>
</Box>
))}
</Box>
</Surface>
</Box>
);
}
return (
<Box position="relative" fullWidth>
<Surface variant="muted" rounded="none" padding={6} overflow="hidden">
{/* Connection Lines */}
<Box position="absolute" top="3.5rem" left="8%" right="8%" display={{ base: 'none', sm: 'block' }}>
<Box height="0.5" bg="rgba(255,255,255,0.05)" position="relative">
<Box
as={motion.div}
position="absolute"
fullHeight
bg="var(--ui-color-intent-primary)"
initial={{ width: '0%' }}
animate={{ width: `${(activeStep / (steps.length - 1)) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
/>
</Box>
</Box>
{/* Steps */}
<Box display="flex" justifyContent="between" gap={2} position="relative">
{steps.map((step, index) => {
const isActive = index === activeStep;
const isCompleted = index < activeStep;
const StepIcon = step.icon;
return (
<Box
as={motion.div}
key={step.id}
display="flex"
flexDirection="col"
alignItems="center"
justifyContent="center"
cursor="pointer"
flexGrow={1}
onClick={() => setActiveStep(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Box
as={motion.div}
w={{ base: '10', sm: '12' }}
h={{ base: '10', sm: '12' }}
display="flex"
alignItems="center"
justifyContent="center"
mb={2}
transition
style={{
backgroundColor: isActive ? 'rgba(25, 140, 255, 0.1)' : isCompleted ? 'rgba(16, 185, 129, 0.1)' : 'var(--ui-color-bg-base)',
opacity: isActive ? 1 : isCompleted ? 0.8 : 0.5,
border: `1px solid ${isActive ? 'var(--ui-color-intent-primary)' : isCompleted ? 'var(--ui-color-intent-success)' : 'var(--ui-color-border-default)'}`
}}
animate={isActive && !shouldReduceMotion ? {
opacity: [0.7, 1, 0.7],
transition: { duration: 1.5, repeat: Infinity }
} : {}}
className="relative"
>
{isActive && (
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" style={{ borderTop: '1px solid var(--ui-color-intent-primary)', borderLeft: '1px solid var(--ui-color-intent-primary)' }} />
)}
{isCompleted ? (
<Icon icon={CheckCircle2} size={5} intent="success" />
) : (
<Text variant={isActive ? 'primary' : 'low'}>
<Icon icon={StepIcon} size={5} />
</Text>
)}
</Box>
<Text
size="xs"
weight="bold"
variant={isActive ? 'high' : 'low'}
display={{ base: 'none', sm: 'block' }}
uppercase
>
{step.title}
</Text>
</Box>
);
})}
</Box>
{/* Active Step Preview - Mobile */}
<AnimatePresence mode="wait">
<Box
as={motion.div}
key={activeStep}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
marginTop={4}
paddingTop={4}
style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}
display={{ base: 'block', sm: 'none' }}
>
<Box textAlign="center">
<Text size="xs" variant="med" block marginBottom={1} font="mono" weight="bold" uppercase>
STEP {activeStep + 1}: {steps[activeStep]?.title || ''}
</Text>
<Text size="xs" variant="low" block font="mono" uppercase>
{steps[activeStep]?.description || ''}
</Text>
</Box>
</Box>
</AnimatePresence>
</Surface>
</Box>
);
}

View File

@@ -2,7 +2,7 @@
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Grid } from '@/ui/Grid';

View File

@@ -1,5 +1,6 @@
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface OnboardingShellProps {
children: React.ReactNode;
@@ -16,40 +17,40 @@ interface OnboardingShellProps {
*/
export function OnboardingShell({ children, header, footer, sidebar }: OnboardingShellProps) {
return (
<Stack minHeight="screen" bg="bg-near-black" color="text-white">
<Box minHeight="100vh" bg="rgba(10,10,10,1)" color="white">
{header && (
<Stack borderBottom borderColor="border-charcoal-outline" py={4} bg="bg-deep-charcoal">
<Box borderBottom borderColor="rgba(255,255,255,0.1)" py={4} bg="rgba(20,22,25,1)">
<Container size="md">
{header}
</Container>
</Stack>
</Box>
)}
<Stack flex={1} py={12}>
<Box flexGrow={1} py={12}>
<Container size="md">
<Stack direction="row" gap={12}>
<Stack flex={1}>
<Box flexGrow={1}>
<Stack gap={8}>
{children}
</Stack>
</Stack>
</Box>
{sidebar && (
<Stack w="80" display={{ base: 'none', lg: 'block' }}>
<Box w="80" display={{ base: 'none', lg: 'block' }}>
{sidebar}
</Stack>
</Box>
)}
</Stack>
</Container>
</Stack>
</Box>
{footer && (
<Stack borderTop borderColor="border-charcoal-outline" py={6} bg="bg-deep-charcoal">
<Box borderTop borderColor="rgba(255,255,255,0.1)" py={6} bg="rgba(20,22,25,1)">
<Container size="md">
{footer}
</Container>
</Stack>
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -5,7 +5,7 @@ import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { InfoBox } from '@/ui/InfoBox';
import { Input } from '@/ui/Input';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';

View File

@@ -3,7 +3,7 @@
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Modal } from '@/ui/Modal';
import { Modal } from '@/components/shared/Modal';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text';

View File

@@ -0,0 +1,64 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
export interface AccordionProps {
title: string;
children: ReactNode;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: () => void;
}
export const Accordion = ({
title,
children,
defaultOpen = false,
isOpen: controlledIsOpen,
onToggle
}: AccordionProps) => {
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
const isControlled = controlledIsOpen !== undefined;
const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
const handleToggle = () => {
if (onToggle) {
onToggle();
}
if (!isControlled) {
setInternalIsOpen(!internalIsOpen);
}
};
return (
<Surface variant="muted" rounded="lg" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
<Box
as="button"
onClick={handleToggle}
fullWidth
display="flex"
alignItems="center"
justifyContent="between"
paddingX={4}
paddingY={3}
hoverBg="rgba(255,255,255,0.05)"
transition
>
<Text weight="bold" size="sm" variant="high">
{title}
</Text>
<Icon icon={isOpen ? ChevronUp : ChevronDown} size={4} intent="low" />
</Box>
{isOpen && (
<Box padding={4} borderTop>
{children}
</Box>
)}
</Surface>
);
};

View File

@@ -0,0 +1,47 @@
import { Button } from '@/ui/Button';
import { Modal } from '@/components/shared/Modal';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { AlertCircle } from 'lucide-react';
import React from 'react';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'primary';
isLoading?: boolean;
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'primary',
isLoading = false,
}: ConfirmDialogProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
primaryActionLabel={isLoading ? 'Processing...' : confirmLabel}
onPrimaryAction={onConfirm}
secondaryActionLabel={cancelLabel}
onSecondaryAction={onClose}
icon={variant === 'danger' ? <Icon icon={AlertCircle} size={5} intent="critical" /> : undefined}
>
<Text variant="low" size="sm">
{description}
</Text>
</Modal>
);
}

View File

@@ -1,6 +1,3 @@
/* eslint-disable gridpilot-rules/no-raw-html-in-app */
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { CountryFlag } from '@/ui/CountryFlag';
@@ -10,6 +7,7 @@ import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
export interface Country {
code: string;
@@ -115,32 +113,31 @@ export function CountrySelect({
return (
<Box ref={containerRef} position="relative">
{/* Trigger Button */}
<button
<Box
as="button"
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
borderRadius: 'var(--ui-radius-md)',
border: 'none',
padding: '0.75rem 1rem',
backgroundColor: 'var(--ui-color-bg-surface-muted)',
color: 'white',
boxShadow: 'var(--ui-shadow-sm)',
transition: 'all 150ms',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
outline: 'none',
ring: '1px inset',
borderColor: error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'
} as any}
className={error ? 'ring-warning-amber' : 'ring-charcoal-outline focus:ring-primary-blue'}
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
rounded="md"
paddingX={4}
paddingY={3}
bg="var(--ui-color-bg-surface-muted)"
color="white"
shadow="sm"
transition="all 150ms"
cursor={disabled ? 'not-allowed' : 'pointer'}
opacity={disabled ? 0.5 : 1}
outline="none"
border
borderColor={error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'}
hoverBorderColor={!disabled ? 'var(--ui-color-intent-primary)' : undefined}
>
<Group gap={3}>
<Globe className="w-4 h-4 text-gray-500" />
<Icon icon={Globe} size={4} intent="low" />
{selectedCountry ? (
<Group gap={2}>
<CountryFlag countryCode={selectedCountry.code} size="md" />
@@ -150,8 +147,16 @@ export function CountrySelect({
<Text variant="low">{placeholder}</Text>
)}
</Group>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
<Icon
icon={ChevronDown}
size={4}
intent="low"
style={{
transition: 'transform 200ms',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)'
}}
/>
</Box>
{/* Dropdown */}
{isOpen && (
@@ -167,60 +172,49 @@ export function CountrySelect({
overflow="hidden"
>
{/* Search Input */}
<Box padding={2} borderBottom="1px solid var(--ui-color-border-muted)">
<Box position="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search countries..."
style={{
width: '100%',
borderRadius: 'var(--ui-radius-md)',
border: 'none',
padding: '0.5rem 1rem 0.5rem 2.25rem',
backgroundColor: 'var(--ui-color-bg-base)',
color: 'white',
fontSize: '0.875rem',
outline: 'none'
}}
/>
</Box>
<Box padding={2} borderBottom>
<Input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search countries..."
fullWidth
size="sm"
icon={<Icon icon={Search} size={4} intent="low" />}
/>
</Box>
{/* Country List */}
<Box overflowY="auto" maxHeight="15rem">
{filteredCountries.length > 0 ? (
filteredCountries.map((country) => (
<button
<Box
as="button"
key={country.code}
type="button"
onClick={() => handleSelect(country.code)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '0.625rem 1rem',
textAlign: 'left',
fontSize: '0.875rem',
transition: 'colors 150ms',
border: 'none',
backgroundColor: value === country.code ? 'rgba(25, 140, 255, 0.2)' : 'transparent',
color: value === country.code ? 'white' : 'var(--ui-color-text-med)',
cursor: 'pointer'
}}
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
paddingX={4}
paddingY={2.5}
textAlign="left"
transition="colors 150ms"
border="none"
bg={value === country.code ? 'rgba(25, 140, 255, 0.2)' : 'transparent'}
color={value === country.code ? 'white' : 'var(--ui-color-text-med)'}
cursor="pointer"
hoverBg="rgba(255, 255, 255, 0.05)"
>
<Group gap={3}>
<CountryFlag countryCode={country.code} size="md" />
<Text as="span">{country.name}</Text>
</Group>
{value === country.code && (
<Check className="w-4 h-4 text-primary-blue" />
<Icon icon={Check} size={4} intent="primary" />
)}
</button>
</Box>
))
) : (
<Box paddingX={4} paddingY={6} textAlign="center">

View File

@@ -0,0 +1,159 @@
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 { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Copy, RefreshCw, Terminal, X } from 'lucide-react';
import { useEffect, useState } from 'react';
interface DevErrorPanelProps {
error: ApiError;
onReset: () => void;
}
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(() => {
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
}
};
const getSeverityVariant = (): 'critical' | 'warning' | 'primary' | 'default' => {
switch (error.getSeverity()) {
case 'error': return 'critical';
case 'warn': return 'warning';
case 'info': return 'primary';
default: return 'default';
}
};
return (
<Box position="fixed" inset={0} zIndex={100} bg="var(--ui-color-bg-base)" padding={4} style={{ overflowY: 'auto' }}>
<Box maxWidth="80rem" marginX="auto" fullWidth>
<Box display="flex" flexDirection="col" gap={4}>
{/* Header */}
<Card variant="dark" padding={4}>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={3}>
<Icon icon={Terminal} size={5} intent="primary" />
<Heading level={2}>API Error Debug Panel</Heading>
<Badge variant={getSeverityVariant() as any}>
{error.type}
</Badge>
</Box>
<Box display="flex" 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>
</Box>
</Box>
</Card>
{/* Details Grid */}
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
<Box display="flex" flexDirection="col" gap={4}>
<Card title="Error Details" variant="outline">
<Box display="flex" flexDirection="col" gap={2}>
<DetailRow label="Type" value={error.type} intent="critical" />
<DetailRow label="Message" value={error.message} />
<DetailRow label="Endpoint" value={error.context.endpoint || 'N/A'} intent="primary" />
<DetailRow label="Method" value={error.context.method || 'N/A'} intent="warning" />
<DetailRow label="Status" value={error.context.statusCode || 'N/A'} />
</Box>
</Card>
<Card title="Connection Health" variant="outline">
<Box display="flex" flexDirection="col" gap={2}>
<DetailRow label="Status" value={connectionStatus.status.toUpperCase()} intent={connectionStatus.status === 'connected' ? 'success' : 'critical'} />
<DetailRow label="Reliability" value={`${connectionMonitor.getReliability().toFixed(2)}%`} />
<DetailRow label="Total Requests" value={connectionStatus.totalRequests} />
</Box>
</Card>
</Box>
<Box display="flex" flexDirection="col" gap={4}>
<Card title="Actions" variant="outline">
<Box display="flex" flexDirection="col" gap={2}>
<Button variant="primary" onClick={() => connectionMonitor.performHealthCheck()} fullWidth icon={<Icon icon={RefreshCw} size={4} />}>
Run Health Check
</Button>
<Button variant="secondary" onClick={() => CircuitBreakerRegistry.getInstance().resetAll()} fullWidth>
Reset Circuit Breakers
</Button>
</Box>
</Card>
<Card title="Raw Error" variant="outline">
<Box padding={2} bg="var(--ui-color-bg-surface-muted)" rounded="md" style={{ maxHeight: '10rem', overflow: 'auto' }}>
<Text size="xs" variant="low" font="mono">
{JSON.stringify(error.context, null, 2)}
</Text>
</Box>
</Card>
</Box>
</Box>
</Box>
</Box>
</Box>
);
}
const DetailRow = ({ label, value, intent = 'low' }: { label: string; value: any; intent?: any }) => (
<Box display="grid" gridCols={3} gap={2}>
<Text size="xs" variant="low" weight="bold">{label}:</Text>
<Box style={{ gridColumn: 'span 2' }}>
<Text size="xs" variant={intent} weight="bold">{String(value)}</Text>
</Box>
</Box>
);

View File

@@ -0,0 +1,240 @@
import { ApiError } from '@/lib/api/base/ApiError';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
actions = [],
showRetry = true,
showNavigation = true,
hideTechnicalDetails = false,
}: ErrorDisplayProps) {
const getErrorInfo = () => {
const isApiError = error instanceof ApiError;
return {
title: isApiError ? 'API Error' : 'Unexpected Error',
message: error.message || 'Something went wrong',
statusCode: isApiError ? error.context.statusCode : undefined,
details: isApiError ? error.context.responseText : undefined,
isApiError,
};
};
const errorInfo = getErrorInfo();
const defaultActions: ErrorDisplayAction[] = [
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
...(showNavigation ? [
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
] : []),
...actions,
];
const ErrorIcon = () => (
<Box
display="flex"
width={20}
height={20}
alignItems="center"
justifyContent="center"
rounded="xl"
bg="rgba(227, 92, 92, 0.1)"
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
>
<Icon icon={AlertCircle} size={10} intent="critical" />
</Box>
);
switch (variant) {
case 'full-screen':
return (
<Box
position="fixed"
inset={0}
zIndex={100}
bg="var(--ui-color-bg-base)"
display="flex"
alignItems="center"
justifyContent="center"
padding={6}
role="alert"
>
<Box maxWidth="32rem" fullWidth textAlign="center">
<Box display="flex" justifyContent="center" marginBottom={6}>
<ErrorIcon />
</Box>
<Heading level={2} marginBottom={3}>
{errorInfo.title}
</Heading>
<Text size="lg" variant="low" block marginBottom={6} leading="relaxed">
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Box marginBottom={6} display="inline-flex" alignItems="center" gap={2} paddingX={4} paddingY={2} bg="var(--ui-color-bg-surface-muted)" rounded="lg">
<Text size="sm" variant="med" font="mono">HTTP {errorInfo.statusCode}</Text>
{errorInfo.details && !hideTechnicalDetails && (
<Text size="sm" variant="low">- {errorInfo.details}</Text>
)}
</Box>
)}
{defaultActions.length > 0 && (
<Box display="flex" flexDirection={{ base: 'col', md: 'row' }} gap={3} justifyContent="center">
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
icon={action.icon && <Icon icon={action.icon} size={4} />}
>
{action.label}
</Button>
))}
</Box>
)}
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
<Box marginTop={8} textAlign="left">
<details style={{ cursor: 'pointer' }}>
<summary>
<Text as="span" size="sm" variant="low">Technical Details</Text>
</summary>
<Box marginTop={2} padding={4} bg="var(--ui-color-bg-surface-muted)" rounded="lg" style={{ overflowX: 'auto' }}>
<Text as="pre" size="xs" variant="low" font="mono">
{error.stack}
</Text>
</Box>
</details>
</Box>
)}
</Box>
</Box>
);
case 'card':
return (
<Surface
variant="muted"
rounded="xl"
padding={6}
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
role="alert"
>
<Box display="flex" gap={4} alignItems="start">
<Icon icon={AlertCircle} size={6} intent="critical" />
<Box flex={1}>
<Heading level={3} marginBottom={1}>
{errorInfo.title}
</Heading>
<Text size="sm" variant="low" block marginBottom={3}>
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Text size="xs" variant="low" font="mono" block marginBottom={3}>
HTTP {errorInfo.statusCode}
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
</Text>
)}
{defaultActions.length > 0 && (
<Box display="flex" gap={2}>
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
size="sm"
>
{action.label}
</Button>
))}
</Box>
)}
</Box>
</Box>
</Surface>
);
case 'inline':
return (
<Box
display="inline-flex"
alignItems="center"
gap={2}
paddingX={3}
paddingY={2}
bg="rgba(227, 92, 92, 0.1)"
rounded="lg"
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
role="alert"
>
<Icon icon={AlertCircle} size={4} intent="critical" />
<Text size="sm" variant="critical">{errorInfo.message}</Text>
{onRetry && showRetry && (
<Button
variant="ghost"
onClick={onRetry}
size="sm"
style={{ marginLeft: '0.5rem', padding: 0, height: 'auto' }}
>
Retry
</Button>
)}
</Box>
);
default:
return null;
}
}
export function ApiErrorDisplay({
error,
onRetry,
variant = 'full-screen',
hideTechnicalDetails = false,
}: {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
hideTechnicalDetails?: boolean;
}) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant={variant}
hideTechnicalDetails={hideTechnicalDetails}
/>
);
}
export function NetworkErrorDisplay({
onRetry,
variant = 'full-screen',
}: {
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
}) {
return (
<ErrorDisplay
error={new Error('Network connection failed. Please check your internet connection.')}
onRetry={onRetry}
variant={variant}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { HelpCircle, X } from 'lucide-react';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Surface } from '@/ui/Surface';
export interface InfoFlyoutProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
anchorRef: React.RefObject<HTMLElement>;
}
export const InfoFlyout = ({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) => {
const [position, setPosition] = useState({ top: 0, left: 0 });
const flyoutRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && anchorRef.current) {
const rect = anchorRef.current.getBoundingClientRect();
const flyoutWidth = 380;
const padding = 16;
let left = rect.right + 12;
let top = rect.top;
if (left + flyoutWidth > window.innerWidth - padding) {
left = rect.left - flyoutWidth - 12;
}
setPosition({ top, left });
}
}, [isOpen, anchorRef]);
if (!isOpen) return null;
return createPortal(
<Box
ref={flyoutRef as any}
position="fixed"
zIndex={100}
style={{ top: position.top, left: position.left, width: '24rem' }}
>
<Surface variant="muted" rounded="xl" shadow="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
<Box display="flex" alignItems="center" justifyContent="between" padding={4} bg="var(--ui-color-bg-surface-muted)" style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={HelpCircle} size={4} intent="primary" />
<Heading level={6}>{title}</Heading>
</Box>
<IconButton icon={X} size="sm" variant="ghost" onClick={onClose} title="Close" />
</Box>
<Box padding={4} style={{ maxHeight: '20rem', overflowY: 'auto' }}>
{children}
</Box>
</Surface>
</Box>,
document.body
);
};

View File

@@ -0,0 +1,151 @@
import { X } from 'lucide-react';
import { ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { IconButton } from '@/ui/IconButton';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
export interface ModalProps {
children: ReactNode;
isOpen: boolean;
onClose?: () => void;
onOpenChange?: (isOpen: boolean) => void;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
primaryActionLabel?: string;
onPrimaryAction?: () => void;
secondaryActionLabel?: string;
onSecondaryAction?: () => void;
footer?: ReactNode;
description?: string;
icon?: ReactNode;
actions?: ReactNode;
}
export const Modal = ({
children,
isOpen,
onClose,
onOpenChange,
title,
size = 'md',
primaryActionLabel,
onPrimaryAction,
secondaryActionLabel,
onSecondaryAction,
footer,
description,
icon,
actions
}: ModalProps) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const sizeMap = {
sm: '24rem',
md: '32rem',
lg: '48rem',
xl: '64rem',
full: '100%',
};
const handleClose = () => {
if (onClose) onClose();
if (onOpenChange) onOpenChange(false);
};
return createPortal(
<Box
position="fixed"
inset={0}
zIndex={100}
display="flex"
alignItems="center"
justifyContent="center"
padding={4}
bg="rgba(0, 0, 0, 0.8)"
>
<Box
position="absolute"
inset={0}
onClick={handleClose}
/>
<Surface
variant="default"
rounded="lg"
shadow="xl"
style={{
width: '100%',
maxWidth: sizeMap[size],
maxHeight: '90vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: '1px solid var(--ui-color-border-default)'
}}
>
<Box
display="flex"
alignItems="center"
justifyContent="between"
padding={4}
borderBottom
>
<Box display="flex" alignItems="center" gap={3}>
{icon}
<Box>
{title && <Heading level={3}>{title}</Heading>}
{description && <Box marginTop={1}><Text size="sm" variant="low">{description}</Text></Box>}
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
{actions}
<IconButton icon={X} onClick={handleClose} variant="ghost" title="Close modal" />
</Box>
</Box>
<Box flex={1} overflow="auto" padding={6}>
{children}
</Box>
{(footer || primaryActionLabel || secondaryActionLabel) && (
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)" display="flex" justifyContent="end" gap={3}>
{footer}
{secondaryActionLabel && (
<Button
onClick={onSecondaryAction || handleClose}
variant="ghost"
>
{secondaryActionLabel}
</Button>
)}
{primaryActionLabel && (
<Button
onClick={onPrimaryAction}
variant="primary"
>
{primaryActionLabel}
</Button>
)}
</Box>
)}
</Surface>
</Box>,
document.body
);
};

View File

@@ -0,0 +1,41 @@
'use client';
import { Box } from '@/ui/Box';
import { motion } from 'framer-motion';
interface ProgressLineProps {
isLoading: boolean;
}
export function ProgressLine({ isLoading }: ProgressLineProps) {
if (!isLoading) return null;
return (
<Box
fullWidth
height="2px"
bg="var(--ui-color-bg-surface-muted)"
style={{ overflow: 'hidden', position: 'relative' }}
>
<motion.div
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
backgroundColor: 'var(--ui-color-intent-primary)'
}}
initial={{ width: '0%', left: '0%' }}
animate={{
width: ['20%', '50%', '20%'],
left: ['-20%', '100%', '-20%'],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'linear',
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import React, { createContext, useContext, ReactNode } from 'react';
import { Theme } from '@/ui/theme/Theme';
import { defaultTheme } from '@/ui/theme/themes/default';
interface ThemeContextType {
theme: Theme;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
// For now, we only have the default theme.
// In the future, this could be driven by state, cookies, or user preferences.
const value = {
theme: defaultTheme,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -3,7 +3,7 @@ import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import { ConfirmDialog } from '@/components/shared/ConfirmDialog';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
@@ -17,7 +17,7 @@ import { Skeleton } from '@/ui/Skeleton';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Badge } from '@/ui/Badge';
import { ProgressLine } from '@/ui/ProgressLine';
import { ProgressLine } from '@/components/shared/ProgressLine';
import { SharedEmptyState } from './SharedEmptyState';
export {

View File

@@ -0,0 +1,174 @@
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { ListItem, ListItemActions, ListItemInfo } from '@/ui/ListItem';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { AlertCircle, CheckCircle2, Upload, X } from 'lucide-react';
import React, { useRef, useState } from 'react';
export interface UploadDropzoneProps {
onFilesSelected: (files: File[]) => void;
accept?: string;
multiple?: boolean;
maxSize?: number; // in bytes
isLoading?: boolean;
error?: string;
}
export function UploadDropzone({
onFilesSelected,
accept,
multiple = false,
maxSize,
isLoading,
error,
}: UploadDropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
validateAndSelectFiles(files);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
validateAndSelectFiles(files);
}
};
const validateAndSelectFiles = (files: File[]) => {
let filteredFiles = files;
if (accept) {
const acceptedTypes = accept.split(',').map(t => t.trim());
filteredFiles = filteredFiles.filter(file => {
return acceptedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.endsWith(type);
}
if (type.endsWith('/*')) {
return file.type.startsWith(type.replace('/*', ''));
}
return file.type === type;
});
});
}
if (maxSize) {
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
}
if (!multiple) {
filteredFiles = filteredFiles.slice(0, 1);
}
setSelectedFiles(filteredFiles);
onFilesSelected(filteredFiles);
};
const removeFile = (index: number) => {
const newFiles = [...selectedFiles];
newFiles.splice(index, 1);
setSelectedFiles(newFiles);
onFilesSelected(newFiles);
};
return (
<Box
fullWidth
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Surface
variant="muted"
rounded="xl"
padding={8}
onClick={() => fileInputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? 'var(--ui-color-intent-primary)' : (error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)')}`,
backgroundColor: isDragging ? 'rgba(25, 140, 255, 0.05)' : 'var(--ui-color-bg-surface-muted)',
textAlign: 'center',
cursor: 'pointer'
}}
>
<Box
as="input"
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept={accept}
multiple={multiple}
style={{ display: 'none' }}
/>
<Box display="flex" flexDirection="col" alignItems="center" gap={4}>
<Icon
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
size={10}
intent={isDragging ? 'primary' : (error ? 'critical' : 'low')}
animate={isLoading ? 'pulse' : 'none'}
/>
<Box>
<Text weight="bold" variant="high" size="lg" block marginBottom={1}>
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
</Text>
<Text size="sm" variant="low" block>
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
</Text>
</Box>
{error && (
<Box display="flex" alignItems="center" gap={2} marginTop={2}>
<Icon icon={AlertCircle} size={4} intent="warning" />
<Text size="sm" variant="warning" weight="medium">{error}</Text>
</Box>
)}
</Box>
</Surface>
{selectedFiles.length > 0 && (
<Box marginTop={4} display="flex" flexDirection="col" gap={2}>
{selectedFiles.map((file, index) => (
<ListItem key={`${file.name}-${index}`}>
<ListItemInfo
title={file.name}
description={`${Math.round(file.size / 1024)} KB`}
/>
<ListItemActions>
<IconButton
icon={X}
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
title="Remove"
/>
</ListItemActions>
</ListItem>
))}
</Box>
)}
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import { EmptyState } from '@/ui/EmptyState';
import { ErrorDisplay } from '@/ui/ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { ApiError } from '@/lib/api/base/ApiError';
import { Inbox, List, LucideIcon } from 'lucide-react';

View File

@@ -1,7 +1,7 @@
'use client';
import { EmptyState } from '@/ui/EmptyState';
import { ErrorDisplay } from '@/ui/ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';

View File

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

View File

@@ -1,10 +1,10 @@
'use client';
import { ReactNode } from 'react';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot';
interface TeamsDirectoryProps {
children: ReactNode;
@@ -14,32 +14,33 @@ interface TeamsDirectoryProps {
export function TeamsDirectory({ children, title, subtitle }: TeamsDirectoryProps) {
return (
<Box as="main" bg="base-black" minHeight="screen">
<Container size="lg">
<Box paddingY={12}>
<Stack gap={10}>
{title && (
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg="primary-accent" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
</Stack>
)}
{children}
</Stack>
</Box>
</Container>
</Box>
<Container size="lg" py={12}>
<Group direction="column" gap={10} fullWidth>
{title && (
<Group direction="row" align="center" gap={2}>
<StatusDot intent="primary" size="md" />
<Text size="xs" weight="bold" variant="low" uppercase>{title}</Text>
</Group>
)}
{children}
</Group>
</Container>
);
}
export function TeamsDirectorySection({ children, title, accentColor = "primary-accent" }: { children: ReactNode, title: string, accentColor?: string }) {
const intentMap: Record<string, 'primary' | 'success' | 'warning' | 'critical' | 'telemetry'> = {
'primary-accent': 'primary',
'telemetry-aqua': 'telemetry',
};
return (
<Box>
<Stack direction="row" align="center" gap={2} mb={6}>
<Box w="2" h="2" bg={accentColor} />
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
</Stack>
<Group direction="column" gap={6} fullWidth>
<Group direction="row" align="center" gap={2}>
<StatusDot intent={intentMap[accentColor] || 'primary'} size="md" />
<Text size="xs" weight="bold" variant="low" uppercase>{title}</Text>
</Group>
{children}
</Box>
</Group>
);
}