website refactor

This commit is contained in:
2026-01-14 23:31:57 +01:00
parent fbae5e6185
commit c1a86348d7
93 changed files with 7268 additions and 9088 deletions

View File

@@ -0,0 +1,16 @@
'use client';
import React from 'react';
import { Box } from './Box';
interface AuthContainerProps {
children: React.ReactNode;
}
export function AuthContainer({ children }: AuthContainerProps) {
return (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', backgroundColor: '#0f1115' }}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import React from 'react';
import { ErrorBanner } from './ErrorBanner';
interface AuthErrorProps {
action: string;
}
export function AuthError({ action }: AuthErrorProps) {
return (
<ErrorBanner
message={`Failed to load ${action} page`}
title="Error"
variant="error"
/>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { LoadingSpinner } from './LoadingSpinner';
interface AuthLoadingProps {
message?: string;
}
export function AuthLoading({ message = 'Authenticating...' }: AuthLoadingProps) {
return (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#0f1115' }}>
<Stack align="center" gap={4}>
<LoadingSpinner size={12} />
<Text color="text-gray-400">{message}</Text>
</Stack>
</Box>
);
}

25
apps/website/ui/Badge.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
interface BadgeProps {
children: ReactNode;
className?: string;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
}
export function Badge({ children, className = '', variant = 'default' }: BadgeProps) {
const baseClasses = 'flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium';
const variantClasses = {
default: 'bg-gray-500/10 border-gray-500/30 text-gray-400',
primary: 'bg-primary-blue/10 border-primary-blue/30 text-primary-blue',
success: 'bg-performance-green/10 border-performance-green/30 text-performance-green',
warning: 'bg-warning-amber/10 border-warning-amber/30 text-warning-amber',
danger: 'bg-red-600/10 border-red-600/30 text-red-500',
info: 'bg-neon-aqua/10 border-neon-aqua/30 text-neon-aqua'
};
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
return <Box className={classes}>{children}</Box>;
}

93
apps/website/ui/Box.tsx Normal file
View File

@@ -0,0 +1,93 @@
import React, { forwardRef, ForwardedRef, ElementType, ComponentPropsWithoutRef } from 'react';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface BoxProps<T extends ElementType> {
as?: T;
children?: React.ReactNode;
className?: string;
center?: boolean;
fullWidth?: boolean;
fullHeight?: boolean;
m?: Spacing;
mt?: Spacing;
mb?: Spacing;
ml?: Spacing;
mr?: Spacing;
mx?: Spacing | 'auto';
my?: Spacing;
p?: Spacing;
pt?: Spacing;
pb?: Spacing;
pl?: Spacing;
pr?: Spacing;
px?: Spacing;
py?: Spacing;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto';
maxWidth?: string;
}
export const Box = forwardRef(<T extends ElementType = 'div'>(
{
as,
children,
className = '',
center = false,
fullWidth = false,
fullHeight = false,
m, mt, mb, ml, mr, mx, my,
p, pt, pb, pl, pr, px, py,
display,
position,
overflow,
maxWidth,
...props
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<any>
) => {
const Tag = (as as any) || 'div';
const spacingMap: Record<string | number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96',
'auto': 'auto'
};
const classes = [
center ? 'flex items-center justify-center' : '',
fullWidth ? 'w-full' : '',
fullHeight ? 'h-full' : '',
m !== undefined ? `m-${spacingMap[m]}` : '',
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
mx !== undefined ? `mx-${spacingMap[mx]}` : '',
my !== undefined ? `my-${spacingMap[my]}` : '',
p !== undefined ? `p-${spacingMap[p]}` : '',
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
px !== undefined ? `px-${spacingMap[px]}` : '',
py !== undefined ? `py-${spacingMap[py]}` : '',
display ? display : '',
position ? position : '',
overflow ? `overflow-${overflow}` : '',
className
].filter(Boolean).join(' ');
const style = maxWidth ? { maxWidth, ...((props as any).style || {}) } : (props as any).style;
return (
<Tag ref={ref} className={classes} {...props} style={style}>
{children}
</Tag>
);
});
Box.displayName = 'Box';

View File

@@ -1,13 +1,18 @@
import React, { ReactNode, MouseEventHandler } from 'react';
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes } from 'react';
import { Stack } from './Stack';
interface ButtonProps {
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-performance' | 'race-final';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
icon?: ReactNode;
fullWidth?: boolean;
as?: 'button' | 'a';
href?: string;
}
export function Button({
@@ -17,15 +22,22 @@ export function Button({
variant = 'primary',
size = 'md',
disabled = false,
type = 'button'
type = 'button',
icon,
fullWidth = false,
as = 'button',
href,
...props
}: ButtonProps) {
const baseClasses = 'inline-flex items-center rounded-lg transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2';
const baseClasses = 'inline-flex items-center rounded-lg transition-all duration-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 hover:scale-[1.02] active:scale-95';
const variantClasses = {
primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue',
primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue shadow-[0_0_15px_rgba(25,140,255,0.4)]',
secondary: 'bg-iron-gray text-white border border-charcoal-outline hover:bg-iron-gray/80 focus-visible:outline-primary-blue',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400'
ghost: 'bg-transparent text-gray-400 hover:bg-gray-800 focus-visible:outline-gray-400',
'race-performance': 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white shadow-[0_0_15px_rgba(251,191,36,0.4)] hover:from-yellow-500 hover:to-orange-600 focus-visible:outline-yellow-400',
'race-final': 'bg-gradient-to-r from-purple-400 to-pink-500 text-white shadow-[0_0_15px_rgba(168,85,247,0.4)] hover:from-purple-500 hover:to-pink-600 focus-visible:outline-purple-400'
};
const sizeClasses = {
@@ -35,23 +47,45 @@ export function Button({
};
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
const widthClasses = fullWidth ? 'w-full' : '';
const classes = [
baseClasses,
variantClasses[variant],
sizeClasses[size],
disabledClasses,
widthClasses,
className
].filter(Boolean).join(' ');
const content = icon ? (
<Stack direction="row" align="center" gap={2} center={fullWidth}>
{icon}
{children}
</Stack>
) : children;
if (as === 'a') {
return (
<a
href={href}
className={classes}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{content}
</a>
);
}
return (
<button
type={type}
className={classes}
onClick={onClick}
disabled={disabled}
{...props}
>
{children}
{content}
</button>
);
}
}

View File

@@ -1,35 +1,60 @@
import React, { ReactNode, MouseEventHandler } from 'react';
import React, { ReactNode, MouseEventHandler, HTMLAttributes } from 'react';
interface CardProps {
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
variant?: 'default' | 'highlight';
p?: Spacing;
px?: Spacing;
py?: Spacing;
pt?: Spacing;
pb?: Spacing;
pl?: Spacing;
pr?: Spacing;
}
export function Card({
children,
className = '',
onClick,
variant = 'default'
variant = 'default',
p, px, py, pt, pb, pl, pr,
...props
}: CardProps) {
const baseClasses = 'rounded-lg p-6 shadow-card border duration-200';
const baseClasses = 'rounded-lg shadow-card border duration-200';
const variantClasses = {
default: 'bg-iron-gray border-charcoal-outline',
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 border-blue-500/30'
};
const spacingMap: Record<number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
};
const classes = [
baseClasses,
variantClasses[variant],
onClick ? 'cursor-pointer hover:scale-[1.02]' : '',
p !== undefined ? `p-${spacingMap[p]}` : (px === undefined && py === undefined && pt === undefined && pb === undefined && pl === undefined && pr === undefined ? 'p-6' : ''),
px !== undefined ? `px-${spacingMap[px]}` : '',
py !== undefined ? `py-${spacingMap[py]}` : '',
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
className
].filter(Boolean).join(' ');
return (
<div className={classes} onClick={onClick}>
<div className={classes} onClick={onClick} {...props}>
{children}
</div>
);
}
}

View File

@@ -0,0 +1,50 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface ContainerProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
padding?: boolean;
className?: string;
py?: Spacing;
}
export function Container({
children,
size = 'lg',
padding = true,
className = '',
py,
...props
}: ContainerProps) {
const sizeClasses = {
sm: 'max-w-2xl',
md: 'max-w-4xl',
lg: 'max-w-7xl',
xl: 'max-w-[1400px]',
full: 'max-w-full'
};
const spacingMap: Record<number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
};
const classes = [
'mx-auto',
sizeClasses[size],
padding ? 'px-4 sm:px-6 lg:px-8' : '',
py !== undefined ? `py-${spacingMap[py]}` : '',
className
].filter(Boolean).join(' ');
return (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import React, { useState } from 'react';
// ISO 3166-1 alpha-2 country code to full country name mapping
const countryNames: Record<string, string> = {
'US': 'United States',
'GB': 'United Kingdom',
'CA': 'Canada',
'AU': 'Australia',
'NZ': 'New Zealand',
'DE': 'Germany',
'FR': 'France',
'IT': 'Italy',
'ES': 'Spain',
'NL': 'Netherlands',
'BE': 'Belgium',
'SE': 'Sweden',
'NO': 'Norway',
'DK': 'Denmark',
'FI': 'Finland',
'PL': 'Poland',
'CZ': 'Czech Republic',
'AT': 'Austria',
'CH': 'Switzerland',
'PT': 'Portugal',
'IE': 'Ireland',
'BR': 'Brazil',
'MX': 'Mexico',
'AR': 'Argentina',
'JP': 'Japan',
'CN': 'China',
'KR': 'South Korea',
'IN': 'India',
'SG': 'Singapore',
'TH': 'Thailand',
'MY': 'Malaysia',
'ID': 'Indonesia',
'PH': 'Philippines',
'ZA': 'South Africa',
'RU': 'Russia',
'MC': 'Monaco',
'TR': 'Turkey',
'GR': 'Greece',
'HU': 'Hungary',
'RO': 'Romania',
'BG': 'Bulgaria',
'HR': 'Croatia',
'SI': 'Slovenia',
'SK': 'Slovakia',
'LT': 'Lithuania',
'LV': 'Latvia',
'EE': 'Estonia',
};
// ISO 3166-1 alpha-2 country code to flag emoji conversion
const countryCodeToFlag = (countryCode: string): string => {
if (!countryCode || countryCode.length !== 2) return '🏁';
// Convert ISO 3166-1 alpha-2 to regional indicator symbols
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
interface CountryFlagProps {
countryCode: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
showTooltip?: boolean;
}
export function CountryFlag({
countryCode,
size = 'md',
className = '',
showTooltip = true
}: CountryFlagProps) {
const [showTooltipState, setShowTooltipState] = useState(false);
const sizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
};
const flag = countryCodeToFlag(countryCode);
const countryName = countryNames[countryCode.toUpperCase()] || countryCode;
return (
<span
className={`inline-flex items-center relative ${sizeClasses[size]} ${className}`}
onMouseEnter={() => setShowTooltipState(true)}
onMouseLeave={() => setShowTooltipState(false)}
title={showTooltip ? countryName : undefined}
>
<span className="select-none">{flag}</span>
{showTooltip && showTooltipState && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs font-medium text-white bg-deep-graphite border border-charcoal-outline rounded shadow-lg whitespace-nowrap z-50">
{countryName}
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-charcoal-outline"></span>
</span>
)}
</span>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Globe, Search, ChevronDown, Check } from 'lucide-react';
import { CountryFlag } from './CountryFlag';
export interface Country {
code: string;
name: string;
}
export const COUNTRIES: Country[] = [
{ code: 'US', name: 'United States' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'DE', name: 'Germany' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'AU', name: 'Australia' },
{ code: 'CA', name: 'Canada' },
{ code: 'BR', name: 'Brazil' },
{ code: 'JP', name: 'Japan' },
{ code: 'BE', name: 'Belgium' },
{ code: 'AT', name: 'Austria' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'FI', name: 'Finland' },
{ code: 'PL', name: 'Poland' },
{ code: 'PT', name: 'Portugal' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'HU', name: 'Hungary' },
{ code: 'RU', name: 'Russia' },
{ code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' },
{ code: 'CL', name: 'Chile' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'IN', name: 'India' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'TH', name: 'Thailand' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'IE', name: 'Ireland' },
{ code: 'GR', name: 'Greece' },
{ code: 'TR', name: 'Turkey' },
{ code: 'RO', name: 'Romania' },
{ code: 'UA', name: 'Ukraine' },
];
interface CountrySelectProps {
value: string;
onChange: (value: string) => void;
error?: boolean;
errorMessage?: string;
disabled?: boolean;
placeholder?: string;
}
export function CountrySelect({
value,
onChange,
error,
errorMessage,
disabled,
placeholder = 'Select country',
}: CountrySelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedCountry = COUNTRIES.find(c => c.code === value);
const filteredCountries = COUNTRIES.filter(country =>
country.name.toLowerCase().includes(search.toLowerCase()) ||
country.code.toLowerCase().includes(search.toLowerCase())
);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearch('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleSelect = (code: string) => {
onChange(code);
setIsOpen(false);
setSearch('');
};
return (
<div ref={containerRef} className="relative">
{/* Trigger Button */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
error
? 'ring-warning-amber focus:ring-warning-amber'
: 'ring-charcoal-outline focus:ring-primary-blue'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
>
<div className="flex items-center gap-3">
<Globe className="w-4 h-4 text-gray-500" />
{selectedCountry ? (
<span className="flex items-center gap-2">
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
<span>{selectedCountry.name}</span>
</span>
) : (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
{/* Search Input */}
<div className="p-2 border-b border-charcoal-outline">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search countries..."
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
/>
</div>
</div>
{/* Country List */}
<div className="overflow-y-auto max-h-60">
{filteredCountries.length > 0 ? (
filteredCountries.map((country) => (
<button
key={country.code}
type="button"
onClick={() => handleSelect(country.code)}
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
value === country.code
? 'bg-primary-blue/20 text-white'
: 'text-gray-300 hover:bg-deep-graphite'
}`}
>
<span className="flex items-center gap-3">
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
<span>{country.name}</span>
</span>
{value === country.code && (
<Check className="w-4 h-4 text-primary-blue" />
)}
</button>
))
) : (
<div className="px-4 py-6 text-center text-gray-500 text-sm">
No countries found
</div>
)}
</div>
</div>
)}
{/* Error Message */}
{error && errorMessage && (
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Box } from './Box';
interface DecorativeBlurProps {
color?: 'blue' | 'green' | 'purple' | 'yellow' | 'red';
size?: 'sm' | 'md' | 'lg' | 'xl';
position?: 'top-right' | 'bottom-left' | 'center';
opacity?: number;
}
export function DecorativeBlur({
color = 'blue',
size = 'md',
position = 'center',
opacity = 10
}: DecorativeBlurProps) {
const colorClasses = {
blue: 'bg-primary-blue',
green: 'bg-performance-green',
purple: 'bg-purple-600',
yellow: 'bg-yellow-400',
red: 'bg-racing-red'
};
const sizeClasses = {
sm: 'w-32 h-32 blur-xl',
md: 'w-48 h-48 blur-2xl',
lg: 'w-64 h-64 blur-3xl',
xl: 'w-96 h-96 blur-[64px]'
};
const positionClasses = {
'top-right': 'absolute top-0 right-0',
'bottom-left': 'absolute bottom-0 left-0',
'center': 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
};
const opacityStyle = { opacity: opacity / 100 };
return (
<Box
className={`${colorClasses[color]} ${sizeClasses[size]} ${positionClasses[position]} rounded-full pointer-events-none`}
style={opacityStyle}
/>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import Input from '@/ui/Input';
interface DurationFieldProps {
label: string;
value: number | '';
onChange: (value: number | '') => void;
helperText?: string;
required?: boolean;
disabled?: boolean;
unit?: 'minutes' | 'laps';
error?: string;
}
export default function DurationField({
label,
value,
onChange,
helperText,
required,
disabled,
unit = 'minutes',
error,
}: DurationFieldProps) {
const handleChange = (raw: string) => {
if (raw.trim() === '') {
onChange('');
return;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
onChange('');
return;
}
onChange(parsed);
};
const unitLabel = unit === 'laps' ? 'laps' : 'min';
return (
<div className="space-y-1">
<label className="block text-sm font-medium text-gray-300">
{label}
{required && <span className="text-warning-amber ml-1">*</span>}
</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
type="number"
value={value === '' ? '' : String(value)}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled}
min={1}
className="pr-16"
error={!!error}
/>
</div>
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
</div>
{helperText && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-warning-amber mt-1">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Surface } from './Surface';
export interface ErrorBannerProps {
message: string;
title?: string;
variant?: 'error' | 'warning' | 'info';
}
export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) {
const variantColors = {
error: { bg: 'rgba(239, 68, 68, 0.1)', border: '#ef4444', text: '#ef4444' },
warning: { bg: 'rgba(245, 158, 11, 0.1)', border: '#f59e0b', text: '#fcd34d' },
info: { bg: 'rgba(59, 130, 246, 0.1)', border: '#3b82f6', text: '#3b82f6' },
};
const colors = variantColors[variant];
return (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
>
<Box style={{ flex: 1 }}>
{title && <Text weight="medium" style={{ color: colors.text }} block mb={1}>{title}</Text>}
<Text size="sm" style={{ color: colors.text, opacity: 0.9 }} block>{message}</Text>
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Stack } from './Stack';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface FormFieldProps {
label: string;
icon?: LucideIcon;
children: React.ReactNode;
required?: boolean;
error?: string;
hint?: string;
}
export function FormField({
label,
icon,
children,
required = false,
error,
hint,
}: FormFieldProps) {
return (
<Stack gap={2}>
<label className="block text-sm font-medium text-gray-300">
<Stack direction="row" align="center" gap={2}>
{icon && <Icon icon={icon} size={4} color="#6b7280" />}
<Text size="sm" weight="medium" color="text-gray-300">{label}</Text>
{required && <Text color="text-error-red">*</Text>}
</Stack>
</label>
{children}
{error && (
<Text size="xs" color="text-error-red" block mt={1}>{error}</Text>
)}
{hint && !error && (
<Text size="xs" color="text-gray-500" block mt={1}>{hint}</Text>
)}
</Stack>
);
}

52
apps/website/ui/Grid.tsx Normal file
View File

@@ -0,0 +1,52 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
interface GridProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
gap?: number;
className?: string;
}
export function Grid({
children,
cols = 1,
gap = 4,
className = '',
...props
}: GridProps) {
const colClasses: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-4',
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
12: 'grid-cols-12'
};
const gapClasses: Record<number, string> = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
6: 'gap-6',
8: 'gap-8',
12: 'gap-12',
16: 'gap-16'
};
const classes = [
'grid',
colClasses[cols] || 'grid-cols-1',
gapClasses[gap] || 'gap-4',
className
].filter(Boolean).join(' ');
return (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Box } from './Box';
interface GridItemProps {
children: React.ReactNode;
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
mdSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
lgSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
className?: string;
}
export function GridItem({ children, colSpan, mdSpan, lgSpan, className = '' }: GridItemProps) {
const spanClasses = [
colSpan ? `col-span-${colSpan}` : '',
mdSpan ? `md:col-span-${mdSpan}` : '',
lgSpan ? `lg:col-span-${lgSpan}` : '',
className
].filter(Boolean).join(' ');
return (
<Box className={spanClasses}>
{children}
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Container from '@/components/ui/Container';
import Container from '@/ui/Container';
interface HeaderProps {
children: React.ReactNode;

View File

@@ -0,0 +1,34 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Stack } from './Stack';
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: ReactNode;
className?: string;
style?: React.CSSProperties;
icon?: ReactNode;
}
export function Heading({ level, children, className = '', style, icon, ...props }: HeadingProps) {
const Tag = `h${level}` as 'h1';
const levelClasses = {
1: 'text-3xl md:text-4xl font-bold text-white',
2: 'text-xl font-semibold text-white',
3: 'text-lg font-semibold text-white',
4: 'text-base font-semibold text-white',
5: 'text-sm font-semibold text-white',
6: 'text-xs font-semibold text-white',
};
const classes = [levelClasses[level], className].filter(Boolean).join(' ');
const content = icon ? (
<Stack direction="row" align="center" gap={2}>
{icon}
{children}
</Stack>
) : children;
return <Tag className={classes} style={style} {...props}>{content}</Tag>;
}

30
apps/website/ui/Hero.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
interface HeroProps {
children: ReactNode;
className?: string;
variant?: 'default' | 'primary' | 'secondary';
}
export function Hero({ children, className = '', variant = 'default' }: HeroProps) {
const baseClasses = 'relative overflow-hidden rounded-2xl border p-8';
const variantClasses = {
default: 'bg-iron-gray border-charcoal-outline',
primary: 'bg-gradient-to-br from-iron-gray via-iron-gray to-charcoal-outline border-charcoal-outline',
secondary: 'bg-gradient-to-br from-primary-blue/10 to-purple-600/10 border-primary-blue/20'
};
const classes = [baseClasses, variantClasses[variant], className].filter(Boolean).join(' ');
return (
<Box className={classes}>
<Box className="absolute top-0 right-0 w-64 h-64 bg-primary-blue/5 rounded-full blur-3xl" />
<Box className="absolute bottom-0 left-0 w-48 h-48 bg-performance-green/5 rounded-full blur-3xl" />
<Box className="relative z-10">
{children}
</Box>
</Box>
);
}

37
apps/website/ui/Icon.tsx Normal file
View File

@@ -0,0 +1,37 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface IconProps {
icon: LucideIcon;
size?: number | string;
color?: string;
className?: string;
style?: React.CSSProperties;
}
export function Icon({ icon: LucideIcon, size = 4, color, className = '', style, ...props }: IconProps) {
const sizeMap: Record<string | number, string> = {
3: 'w-3 h-3',
3.5: 'w-3.5 h-3.5',
4: 'w-4 h-4',
5: 'w-5 h-5',
6: 'w-6 h-6',
7: 'w-7 h-7',
8: 'w-8 h-8',
10: 'w-10 h-10',
12: 'w-12 h-12',
16: 'w-16 h-16'
};
const sizeClass = sizeMap[size] || 'w-4 h-4';
const combinedStyle = color ? { color, ...style } : style;
return (
<LucideIcon
className={`${sizeClass} ${className}`}
style={combinedStyle}
{...props}
/>
);
}

23
apps/website/ui/Image.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { ImgHTMLAttributes } from 'react';
interface ImageProps extends ImgHTMLAttributes<HTMLImageElement> {
src: string;
alt: string;
width?: number;
height?: number;
className?: string;
}
export function Image({ src, alt, width, height, className = '', ...props }: ImageProps) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
width={width}
height={height}
className={className}
{...props}
/>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import React from 'react';
import { Info, AlertTriangle, CheckCircle, XCircle, LucideIcon } from 'lucide-react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Surface } from './Surface';
import { Icon } from './Icon';
type BannerType = 'info' | 'warning' | 'success' | 'error';
interface InfoBannerProps {
type?: BannerType;
title?: string;
children: React.ReactNode;
icon?: LucideIcon;
}
export function InfoBanner({
type = 'info',
title,
children,
icon: CustomIcon,
}: InfoBannerProps) {
const bannerConfig: Record<BannerType, {
icon: LucideIcon;
bg: string;
border: string;
titleColor: string;
iconColor: string;
}> = {
info: {
icon: Info,
bg: 'rgba(38, 38, 38, 0.3)',
border: 'rgba(38, 38, 38, 0.5)',
titleColor: 'text-gray-300',
iconColor: '#9ca3af',
},
warning: {
icon: AlertTriangle,
bg: 'rgba(245, 158, 11, 0.1)',
border: 'rgba(245, 158, 11, 0.3)',
titleColor: 'text-warning-amber',
iconColor: '#f59e0b',
},
success: {
icon: CheckCircle,
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.3)',
titleColor: 'text-performance-green',
iconColor: '#10b981',
},
error: {
icon: XCircle,
bg: 'rgba(239, 68, 68, 0.1)',
border: 'rgba(239, 68, 68, 0.3)',
titleColor: 'text-error-red',
iconColor: '#ef4444',
},
};
const config = bannerConfig[type];
const BannerIcon = CustomIcon || config.icon;
return (
<Surface
variant="muted"
rounded="lg"
border
padding={4}
style={{ backgroundColor: config.bg, borderColor: config.border }}
>
<Stack direction="row" align="start" gap={3}>
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
<Box style={{ flex: 1 }}>
{title && (
<Text weight="medium" color={config.titleColor as any} block mb={1}>{title}</Text>
)}
<Text size="sm" color="text-gray-400" block>{children}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Surface } from './Surface';
import { Stack } from './Stack';
import { Box } from './Box';
import { Icon } from './Icon';
import { Text } from './Text';
import { LucideIcon } from 'lucide-react';
interface InfoBoxProps {
icon: LucideIcon;
title: string;
description: string;
variant?: 'primary' | 'success' | 'warning' | 'default';
}
export function InfoBox({ icon, title, description, variant = 'default' }: InfoBoxProps) {
const variantColors = {
primary: {
bg: 'rgba(59, 130, 246, 0.1)',
border: '#3b82f6',
text: '#3b82f6',
icon: '#3b82f6'
},
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: '#10b981',
text: '#10b981',
icon: '#10b981'
},
warning: {
bg: 'rgba(245, 158, 11, 0.1)',
border: '#f59e0b',
text: '#f59e0b',
icon: '#f59e0b'
},
default: {
bg: 'rgba(38, 38, 38, 0.3)',
border: '#262626',
text: 'white',
icon: '#9ca3af'
}
};
const colors = variantColors[variant];
return (
<Surface
variant="muted"
rounded="xl"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}>
<Icon icon={icon} size={5} color={colors.icon} />
</Surface>
<Box>
<Text weight="medium" style={{ color: colors.text }} block>{title}</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{description}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -1,16 +1,28 @@
import { forwardRef } from 'react';
import { Text } from './Text';
import { Box } from './Box';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
errorMessage?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', variant = 'default', ...props }, ref) => {
const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors';
const variantClasses = variant === 'error' ? 'border-racing-red' : 'border-charcoal-outline';
({ className = '', variant = 'default', errorMessage, ...props }, ref) => {
const baseClasses = 'px-3 py-2 border rounded-lg text-white bg-deep-graphite focus:outline-none focus:border-primary-blue transition-colors w-full';
const variantClasses = (variant === 'error' || errorMessage) ? 'border-racing-red' : 'border-charcoal-outline';
const classes = `${baseClasses} ${variantClasses} ${className}`;
return <input ref={ref} className={classes} {...props} />;
return (
<Box fullWidth>
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-error-red" block mt={1}>
{errorMessage}
</Text>
)}
</Box>
);
}
);

View File

@@ -1,13 +1,14 @@
import React, { ReactNode } from 'react';
import NextLink from 'next/link';
import React, { ReactNode, AnchorHTMLAttributes } from 'react';
interface LinkProps {
interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
children: ReactNode;
className?: string;
variant?: 'primary' | 'secondary' | 'ghost';
target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string;
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
style?: React.CSSProperties;
}
export function Link({
@@ -16,7 +17,10 @@ export function Link({
className = '',
variant = 'primary',
target = '_self',
rel = ''
rel = '',
onClick,
style,
...props
}: LinkProps) {
const baseClasses = 'inline-flex items-center transition-colors';
@@ -33,13 +37,16 @@ export function Link({
].filter(Boolean).join(' ');
return (
<NextLink
<a
href={href}
className={classes}
target={target}
rel={rel}
onClick={onClick}
style={style}
{...props}
>
{children}
</NextLink>
</a>
);
}
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: number;
color?: string;
className?: string;
}
export function LoadingSpinner({ size = 8, color = '#3b82f6', className = '' }: LoadingSpinnerProps) {
const style: React.CSSProperties = {
width: `${size * 0.25}rem`,
height: `${size * 0.25}rem`,
border: '2px solid transparent',
borderTopColor: color,
borderLeftColor: color,
borderRadius: '9999px',
};
return (
<div
className={`animate-spin ${className}`}
style={style}
role="status"
aria-label="Loading"
/>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { motion, useReducedMotion } from 'framer-motion';
import { ReactNode, useEffect, useState } from 'react';
interface MockupStackProps {
children: ReactNode;
index?: number;
}
export default function MockupStack({ children, index = 0 }: MockupStackProps) {
const shouldReduceMotion = useReducedMotion();
const [isMounted, setIsMounted] = useState(false);
const [isMobile, setIsMobile] = useState(true); // Default to mobile (no animations)
useEffect(() => {
setIsMounted(true);
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const seed = index * 1337;
const rotation1 = ((seed * 17) % 80 - 40) / 20;
const rotation2 = ((seed * 23) % 80 - 40) / 20;
// On mobile or before mount, render without animations
if (!isMounted || isMobile) {
return (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
right: '-8px',
bottom: '-8px',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
opacity: 0.5,
}}
/>
<div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
opacity: 0.7,
}}
/>
<div
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
>
{children}
</div>
</div>
);
}
// Desktop: render with animations
return (
<div className="relative w-full h-full scale-60 sm:scale-70 md:scale-85 lg:scale-95 max-w-[85vw] mx-auto my-4 sm:my-0" style={{ perspective: '1200px' }}>
<motion.div
className="absolute rounded-lg bg-iron-gray/80 border border-charcoal-outline"
style={{
rotate: `${rotation1}deg`,
zIndex: 1,
top: '-8px',
left: '-8px',
right: '-8px',
bottom: '-8px',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 0.5, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
<motion.div
className="absolute rounded-lg bg-iron-gray/90 border border-charcoal-outline"
style={{
rotate: `${rotation2}deg`,
zIndex: 2,
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 0.7, scale: 1 }}
transition={{ duration: 0.3, delay: 0.15 }}
/>
<motion.div
className="relative z-10 w-full h-full rounded-lg overflow-hidden"
style={{
boxShadow: '0 20px 60px rgba(0,0,0,0.45)',
}}
whileHover={
shouldReduceMotion
? {}
: {
scale: 1.02,
rotateY: 3,
rotateX: -2,
y: -12,
transition: {
type: 'spring',
stiffness: 200,
damping: 20,
},
}
}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<motion.div
className="absolute inset-0 pointer-events-none rounded-lg"
whileHover={
shouldReduceMotion
? {}
: {
boxShadow: '0 0 40px rgba(25, 140, 255, 0.4)',
transition: { duration: 0.2 },
}
}
/>
{children}
</motion.div>
</div>
);
}

185
apps/website/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,185 @@
'use client';
import React, {
useEffect,
useRef,
type ReactNode,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Heading } from './Heading';
import { Button } from './Button';
interface ModalProps {
title: string;
description?: string;
children?: ReactNode;
primaryActionLabel?: string;
secondaryActionLabel?: string;
onPrimaryAction?: () => void | Promise<void>;
onSecondaryAction?: () => void;
onOpenChange?: (open: boolean) => void;
isOpen: boolean;
}
export function Modal({
title,
description,
children,
primaryActionLabel,
secondaryActionLabel,
onPrimaryAction,
onSecondaryAction,
onOpenChange,
isOpen,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElementRef = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
previouslyFocusedElementRef.current = document.activeElement;
const focusable = getFirstFocusable(dialogRef.current);
if (focusable) {
focusable.focus();
} else if (dialogRef.current) {
dialogRef.current.focus();
}
return;
}
if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) {
previouslyFocusedElementRef.current.focus();
}
}, [isOpen]);
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onOpenChange) {
onOpenChange(false);
}
return;
}
if (event.key === 'Tab') {
const focusable = getFocusableElements(dialogRef.current);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1] ?? first;
if (!first || !last) {
return;
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
}
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && onOpenChange) {
onOpenChange(false);
}
};
if (!isOpen) {
return null;
}
return (
<Box
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: '0 1rem' }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-description' : undefined}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
>
<Box
ref={dialogRef}
style={{ width: '100%', maxWidth: '28rem', borderRadius: '1rem', backgroundColor: '#0f1115', border: '1px solid #262626', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', outline: 'none' }}
tabIndex={-1}
>
<Box p={6} style={{ borderBottom: '1px solid rgba(38, 38, 38, 0.8)' }}>
<Heading level={2} id="modal-title">{title}</Heading>
{description && (
<Text
id="modal-description"
size="sm"
color="text-gray-400"
block
mt={2}
>
{description}
</Text>
)}
</Box>
<Box p={6}>
<Text size="sm" color="text-gray-100">{children}</Text>
</Box>
{(primaryActionLabel || secondaryActionLabel) && (
<Box p={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.8)', display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
{secondaryActionLabel && (
<Button
type="button"
onClick={() => {
onSecondaryAction?.();
onOpenChange?.(false);
}}
variant="secondary"
size="sm"
>
{secondaryActionLabel}
</Button>
)}
{primaryActionLabel && (
<Button
type="button"
onClick={async () => {
if (onPrimaryAction) {
await onPrimaryAction();
}
}}
variant="primary"
size="sm"
>
{primaryActionLabel}
</Button>
)}
</Box>
)}
</Box>
</Box>
);
}
function getFocusableElements(root: HTMLElement | null): HTMLElement[] {
if (!root) return [];
const selectors = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
];
const nodes = Array.from(
root.querySelectorAll<HTMLElement>(selectors.join(',')),
);
return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
}
function getFirstFocusable(root: HTMLElement | null): HTMLElement | null {
const elements = getFocusableElements(root);
return elements[0] ?? null;
}

View File

@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Heading } from './Heading';
import { Surface } from './Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
iconGradient?: string;
iconBorder?: string;
}
export function PageHeader({
icon,
title,
description,
action,
iconGradient = 'from-iron-gray to-deep-graphite',
iconBorder = 'border-charcoal-outline',
}: PageHeaderProps) {
return (
<Box mb={8}>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Stack direction="row" align="center" gap={4}>
<Surface variant="muted" rounded="xl" border padding={3} className={`bg-gradient-to-br ${iconGradient} ${iconBorder}`}>
<Icon icon={icon} size={7} color="#d1d5db" />
</Surface>
<Box>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1}>{description}</Text>
)}
</Box>
</Stack>
</Box>
{action && <Box>{action}</Box>}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import React from 'react';
import { User } from 'lucide-react';
import { Box } from './Box';
import { Icon } from './Icon';
export interface PlaceholderImageProps {
size?: number;
className?: string;
}
export function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) {
return (
<Box
className={`rounded-full bg-charcoal-outline flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<Icon icon={User} size={6} color="#9ca3af" />
</Box>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import type { MouseEventHandler, ReactNode } from 'react';
import Card from './Card';
interface PresetCardStat {
label: string;
value: string;
}
export interface PresetCardProps {
title: string;
subtitle?: string;
primaryTag?: string;
description?: string;
stats?: PresetCardStat[];
selected?: boolean;
disabled?: boolean;
onSelect?: () => void;
className?: string;
children?: ReactNode;
}
export default function PresetCard({
title,
subtitle,
primaryTag,
description,
stats,
selected,
disabled,
onSelect,
className = '',
children,
}: PresetCardProps) {
const isInteractive = typeof onSelect === 'function' && !disabled;
const handleClick: MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (event) => {
if (!isInteractive) {
return;
}
event.preventDefault();
onSelect?.();
};
const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline';
const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray';
const baseRing = selected ? 'ring-2 ring-primary-blue/40' : '';
const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : '';
const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : '';
const content = (
<div className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle && (
<div className="mt-0.5 text-xs text-gray-400">{subtitle}</div>
)}
</div>
<div className="flex flex-col items-end gap-1">
{primaryTag && (
<span className="inline-flex rounded-full bg-primary-blue/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary-blue">
{primaryTag}
</span>
)}
{selected && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-blue/10 px-2 py-0.5 text-[10px] font-medium text-primary-blue">
<span className="h-1.5 w-1.5 rounded-full bg-primary-blue" />
Selected
</span>
)}
</div>
</div>
{description && (
<p className="text-xs text-gray-300">{description}</p>
)}
{children}
{stats && stats.length > 0 && (
<div className="mt-1 border-t border-charcoal-outline/70 pt-2">
<dl className="grid grid-cols-1 gap-2 text-[11px] text-gray-400 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="space-y-0.5">
<dt className="font-medium text-gray-500">{stat.label}</dt>
<dd className="text-xs text-gray-200">{stat.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);
const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`;
if (isInteractive) {
return (
<button
type="button"
onClick={handleClick as MouseEventHandler<HTMLButtonElement>}
disabled={disabled}
className={`group block w-full rounded-lg text-left text-sm shadow-card outline-none transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-blue ${commonClasses}`}
>
<div className="p-4">
{content}
</div>
</button>
);
}
return (
<Card
className={commonClasses}
onClick={handleClick as MouseEventHandler<HTMLDivElement>}
>
{content}
</Card>
);
}

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { Text } from './Text';
interface QuickActionLinkProps {
href: string;

View File

@@ -0,0 +1,271 @@
'use client';
import React, { useCallback, useRef, useState, useEffect } from 'react';
interface RangeFieldProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
helperText?: string;
error?: string | undefined;
disabled?: boolean;
unitLabel?: string;
rangeHint?: string;
/** Show large value display above slider */
showLargeValue?: boolean;
/** Compact mode - single line */
compact?: boolean;
}
export default function RangeField({
label,
value,
min,
max,
step = 1,
onChange,
helperText,
error,
disabled,
unitLabel = 'min',
rangeHint,
showLargeValue = false,
compact = false,
}: RangeFieldProps) {
const [localValue, setLocalValue] = useState(value);
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local value with prop when not dragging
useEffect(() => {
if (!isDragging) {
setLocalValue(value);
}
}, [value, isDragging]);
const clampedValue = Number.isFinite(localValue)
? Math.min(Math.max(localValue, min), max)
: min;
const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
const effectiveRangeHint =
rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}${max} ${unitLabel}`);
const calculateValueFromPosition = useCallback(
(clientX: number) => {
if (!sliderRef.current) return clampedValue;
const rect = sliderRef.current.getBoundingClientRect();
const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
const rawValue = min + percent * (max - min);
const steppedValue = Math.round(rawValue / step) * step;
return Math.min(Math.max(steppedValue, min), max);
},
[min, max, step, clampedValue]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled) return;
e.preventDefault();
setIsDragging(true);
const newValue = calculateValueFromPosition(e.clientX);
setLocalValue(newValue);
onChange(newValue);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[disabled, calculateValueFromPosition, onChange]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging || disabled) return;
const newValue = calculateValueFromPosition(e.clientX);
setLocalValue(newValue);
onChange(newValue);
},
[isDragging, disabled, calculateValueFromPosition, onChange]
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === '') {
setLocalValue(min);
return;
}
const parsed = parseInt(raw, 10);
if (!Number.isNaN(parsed)) {
const clamped = Math.min(Math.max(parsed, min), max);
setLocalValue(clamped);
onChange(clamped);
}
};
const handleInputBlur = () => {
// Ensure value is synced on blur
onChange(clampedValue);
};
// Quick preset buttons for common values
const quickPresets = [
Math.round(min + (max - min) * 0.25),
Math.round(min + (max - min) * 0.5),
Math.round(min + (max - min) * 0.75),
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
if (compact) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
<div
ref={sliderRef}
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
{/* Track fill */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
style={{ width: `${rangePercent}%` }}
/>
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
bg-white border-2 border-primary-blue shadow-md
transition-transform duration-75
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
<span className="text-[10px] text-gray-500">{unitLabel}</span>
</div>
</div>
</div>
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">{label}</label>
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
</div>
{showLargeValue && (
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
<span className="text-sm text-gray-400">{unitLabel}</span>
</div>
)}
{/* Custom slider */}
<div
ref={sliderRef}
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{/* Track background */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
{/* Track fill with gradient */}
<div
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
style={{ width: `${rangePercent}%` }}
/>
{/* Tick marks */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className={`w-0.5 h-1 rounded-full transition-colors ${
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
}`}
/>
))}
</div>
{/* Thumb */}
<div
className={`
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
transition-all duration-75
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
`}
style={{ left: `${rangePercent}%` }}
/>
</div>
{/* Value input and quick presets */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="number"
min={min}
max={max}
step={step}
value={clampedValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
disabled={disabled}
className={`
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
bg-iron-gray border border-charcoal-outline text-white
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
transition-colors
${error ? 'border-warning-amber' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
/>
<span className="text-xs text-gray-400">{unitLabel}</span>
</div>
{quickPresets.length > 0 && (
<div className="flex gap-1">
{quickPresets.slice(0, 3).map((preset) => (
<button
key={preset}
type="button"
onClick={() => {
setLocalValue(preset);
onChange(preset);
}}
disabled={disabled}
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
>
{preset}
</button>
))}
</div>
)}
</div>
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
{error && <p className="text-xs text-warning-amber">{error}</p>}
</div>
);
}

View File

@@ -1,11 +1,18 @@
'use client';
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Text } from './Text';
interface SectionProps {
children: ReactNode;
className?: string;
title?: string;
description?: string;
variant?: 'default' | 'card' | 'highlight';
variant?: 'default' | 'card' | 'highlight' | 'dark' | 'light';
id?: string;
py?: number;
}
export function Section({
@@ -13,31 +20,34 @@ export function Section({
className = '',
title,
description,
variant = 'default'
variant = 'default',
id,
py = 16
}: SectionProps) {
const baseClasses = 'space-y-4';
const variantClasses = {
default: '',
card: 'bg-iron-gray rounded-lg p-6 border border-charcoal-outline',
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30'
highlight: 'bg-gradient-to-r from-blue-900/20 to-blue-700/10 rounded-lg p-6 border border-blue-500/30',
dark: 'bg-iron-gray',
light: 'bg-charcoal-outline'
};
const classes = [
baseClasses,
variantClasses[variant],
className
].filter(Boolean).join(' ');
return (
<section className={classes}>
{title && (
<h2 className="text-xl font-semibold text-white">{title}</h2>
)}
{description && (
<p className="text-sm text-gray-400">{description}</p>
)}
{children}
</section>
<Box as="section" id={id} className={classes} py={py as 0} px={4}>
<Box className="mx-auto max-w-7xl">
{(title || description) && (
<Box mb={8}>
{title && <Heading level={2}>{title}</Heading>}
{description && <Text color="text-gray-400" block mt={2}>{description}</Text>}
</Box>
)}
{children}
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,47 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Heading } from './Heading';
import { Surface } from './Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface SectionHeaderProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
color?: string;
}
export function SectionHeader({
icon,
title,
description,
action,
color = '#3b82f6'
}: SectionHeaderProps) {
return (
<Box p={5} style={{ borderBottom: '1px solid #262626', background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)' }}>
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Heading level={2}>{title}</Heading>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
)}
</Box>
</Stack>
</Box>
{action && <Box>{action}</Box>}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
interface SegmentedControlOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
interface SegmentedControlProps {
options: SegmentedControlOption[];
value: string;
onChange?: (value: string) => void;
}
export function SegmentedControl({
options,
value,
onChange,
}: SegmentedControlProps) {
const handleSelect = (optionValue: string, optionDisabled?: boolean) => {
if (!onChange || optionDisabled) return;
if (optionValue === value) return;
onChange(optionValue);
};
return (
<Box style={{ display: 'inline-flex', width: '100%', flexWrap: 'wrap', gap: '0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(38, 38, 38, 0.6)', padding: '0.25rem' }}>
{options.map((option) => {
const isSelected = option.value === value;
return (
<Box
key={option.value}
as="button"
type="button"
onClick={() => handleSelect(option.value, option.disabled)}
aria-pressed={isSelected}
disabled={option.disabled}
style={{
flex: 1,
minWidth: '140px',
padding: '0.375rem 0.75rem',
borderRadius: '9999px',
transition: 'all 0.2s',
textAlign: 'left',
backgroundColor: isSelected ? '#3b82f6' : 'transparent',
color: isSelected ? 'white' : '#d1d5db',
opacity: option.disabled ? 0.5 : 1,
cursor: option.disabled ? 'not-allowed' : 'pointer',
border: 'none'
}}
>
<Stack gap={0.5}>
<Text size="xs" weight="medium" color="inherit">{option.label}</Text>
{option.description && (
<Text size="xs" color={isSelected ? 'text-white' : 'text-gray-400'} style={{ fontSize: '10px', opacity: isSelected ? 0.8 : 1 }}>
{option.description}
</Text>
)}
</Stack>
</Box>
);
})}
</Box>
);
}

View File

@@ -5,13 +5,14 @@ interface SelectOption {
label: string;
}
interface SelectProps {
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
id?: string;
'aria-label'?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
options: SelectOption[];
className?: string;
style?: React.CSSProperties;
}
export function Select({
@@ -21,6 +22,8 @@ export function Select({
onChange,
options,
className = '',
style,
...props
}: SelectProps) {
const defaultClasses = 'w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors';
const classes = className ? `${defaultClasses} ${className}` : defaultClasses;
@@ -32,6 +35,8 @@ export function Select({
value={value}
onChange={onChange}
className={classes}
style={style}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
@@ -40,4 +45,4 @@ export function Select({
))}
</select>
);
}
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
interface SkeletonProps {
width?: string | number;
height?: string | number;
circle?: boolean;
className?: string;
}
export function Skeleton({ width, height, circle, className = '' }: SkeletonProps) {
const style: React.CSSProperties = {
width: width,
height: height,
borderRadius: circle ? '9999px' : '0.375rem',
backgroundColor: 'rgba(38, 38, 38, 0.4)',
};
return (
<div
className={`animate-pulse ${className}`}
style={style}
role="status"
aria-label="Loading..."
/>
);
}

View File

@@ -1,30 +0,0 @@
/**
* SponsorLogo
*
* Pure UI component for displaying sponsor logos.
* Renders an optimized image with fallback on error.
*/
import Image from 'next/image';
export interface SponsorLogoProps {
sponsorId: string;
alt: string;
className?: string;
}
export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) {
return (
<Image
src={`/media/sponsors/${sponsorId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
}}
/>
);
}

95
apps/website/ui/Stack.tsx Normal file
View File

@@ -0,0 +1,95 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface StackProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
className?: string;
direction?: 'row' | 'col';
gap?: number;
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
wrap?: boolean;
center?: boolean;
m?: Spacing;
mt?: Spacing;
mb?: Spacing;
ml?: Spacing;
mr?: Spacing;
p?: Spacing;
pt?: Spacing;
pb?: Spacing;
pl?: Spacing;
pr?: Spacing;
px?: Spacing;
py?: Spacing;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
}
export function Stack({
children,
className = '',
direction = 'col',
gap = 4,
align = 'stretch',
justify = 'start',
wrap = false,
center = false,
m, mt, mb, ml, mr,
p, pt, pb, pl, pr, px, py,
rounded,
...props
}: StackProps) {
const gapClasses: Record<number, string> = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
6: 'gap-6',
8: 'gap-8',
12: 'gap-12'
};
const spacingMap: Record<number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
};
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full'
};
const classes = [
'flex',
direction === 'col' ? 'flex-col' : 'flex-row',
gapClasses[gap] || 'gap-4',
center ? 'items-center justify-center' : `items-${align} justify-${justify}`,
wrap ? 'flex-wrap' : '',
m !== undefined ? `m-${spacingMap[m]}` : '',
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
p !== undefined ? `p-${spacingMap[p]}` : '',
pt !== undefined ? `pt-${spacingMap[pt]}` : '',
pb !== undefined ? `pb-${spacingMap[pb]}` : '',
pl !== undefined ? `pl-${spacingMap[pl]}` : '',
pr !== undefined ? `pr-${spacingMap[pr]}` : '',
px !== undefined ? `px-${spacingMap[px]}` : '',
py !== undefined ? `py-${spacingMap[py]}` : '',
rounded ? roundedClasses[rounded] : '',
className
].filter(Boolean).join(' ');
return <Box className={classes} {...props}>{children}</Box>;
}

View File

@@ -1,21 +1,30 @@
import React, { ReactNode } from 'react';
import React from 'react';
import { Card } from './Card';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
icon?: ReactNode;
subValue?: string;
icon?: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange';
className?: string;
trend?: {
value: number;
isPositive: boolean;
};
}
export function StatCard({
label,
value,
subValue,
icon,
variant = 'blue',
className = ''
className = '',
trend,
}: StatCardProps) {
const variantClasses = {
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30',
@@ -25,28 +34,38 @@ export function StatCard({
};
const iconColorClasses = {
blue: 'text-blue-400',
purple: 'text-purple-400',
green: 'text-green-400',
orange: 'text-orange-400'
blue: '#60a5fa',
purple: '#a78bfa',
green: '#34d399',
orange: '#fb923c'
};
return (
<Card className={`${variantClasses[variant]} ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">
<Text size="sm" color="text-gray-400" className="mb-1" block>
{label}
</Text>
<Text size="3xl" weight="bold" color="text-white">
<Text size="3xl" weight="bold" color="text-white" block>
{value}
</Text>
{subValue && (
<Text size="xs" color="text-gray-500" className="mt-1" block>
{subValue}
</Text>
)}
</div>
<div className="flex flex-col items-end gap-2">
{icon && (
<Icon icon={icon} size={8} color={iconColorClasses[variant]} />
)}
{trend && (
<Text size="sm" color={trend.isPositive ? 'text-performance-green' : 'text-error-red'}>
{trend.isPositive ? '↑' : '↓'}{Math.abs(trend.value)}%
</Text>
)}
</div>
{icon && (
<div className={iconColorClasses[variant]}>
{icon}
</div>
)}
</div>
</Card>
);

View File

@@ -1,33 +1,46 @@
import React from 'react';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
import { Stack } from './Stack';
interface StatusBadgeProps {
children: React.ReactNode;
variant?: 'success' | 'warning' | 'error' | 'info';
variant?: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
className?: string;
icon?: LucideIcon;
}
export function StatusBadge({
children,
variant = 'success',
className = ''
className = '',
icon,
}: StatusBadgeProps) {
const variantClasses = {
success: 'bg-performance-green/20 text-performance-green',
warning: 'bg-warning-amber/20 text-warning-amber',
error: 'bg-red-600/20 text-red-400',
info: 'bg-blue-500/20 text-blue-400'
success: 'bg-performance-green/20 text-performance-green border-performance-green/30',
warning: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
error: 'bg-red-600/20 text-red-400 border-red-600/30',
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
neutral: 'bg-iron-gray text-gray-400 border-charcoal-outline',
pending: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30',
};
const classes = [
'px-2 py-1 text-xs rounded-full',
'px-2 py-0.5 text-xs rounded-full border font-medium inline-flex items-center',
variantClasses[variant],
className
].filter(Boolean).join(' ');
return (
<Text size="xs" className={classes}>
const content = icon ? (
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={icon} size={3} />
{children}
</Text>
</Stack>
) : children;
return (
<span className={classes}>
{content}
</span>
);
}

View File

@@ -0,0 +1,70 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import { Box } from './Box';
interface SurfaceProps extends HTMLAttributes<HTMLElement> {
children: ReactNode;
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple';
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
padding?: number;
className?: string;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none';
}
export function Surface({
children,
variant = 'default',
rounded = 'lg',
border = false,
padding = 0,
className = '',
display,
...props
}: SurfaceProps) {
const variantClasses = {
default: 'bg-iron-gray',
muted: 'bg-iron-gray/50',
dark: 'bg-deep-graphite',
glass: 'bg-deep-graphite/60 backdrop-blur-md',
'gradient-blue': 'bg-gradient-to-br from-primary-blue/20 via-iron-gray/80 to-deep-graphite',
'gradient-gold': 'bg-gradient-to-br from-yellow-600/20 via-iron-gray/80 to-deep-graphite',
'gradient-purple': 'bg-gradient-to-br from-purple-600/20 via-iron-gray/80 to-deep-graphite'
};
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full'
};
const paddingClasses: Record<number, string> = {
0: 'p-0',
1: 'p-1',
2: 'p-2',
3: 'p-3',
4: 'p-4',
6: 'p-6',
8: 'p-8',
10: 'p-10',
12: 'p-12'
};
const classes = [
variantClasses[variant],
roundedClasses[rounded],
border ? 'border border-charcoal-outline' : '',
paddingClasses[padding] || 'p-0',
display ? display : '',
className
].filter(Boolean).join(' ');
return (
<Box className={classes} {...props}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
export function TabContent({ children, className = '' }: { children: React.ReactNode, className?: string }) {
return (
<div className={className}>
{children}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
interface Tab {
id: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
}
interface TabNavigationProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
}
export function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
return (
<div className={`flex items-center gap-1 p-1.5 rounded-xl bg-iron-gray/50 border border-charcoal-outline w-fit relative z-10 ${className}`}>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all cursor-pointer select-none ${
activeTab === tab.id
? 'bg-primary-blue text-white shadow-lg shadow-primary-blue/25'
: 'text-gray-400 hover:text-white hover:bg-iron-gray/80'
}`}
>
{Icon && <Icon className="w-4 h-4" />}
{tab.label}
</button>
);
})}
</div>
);
}

View File

@@ -1,88 +1,88 @@
import { ReactNode } from 'react';
import React, { ReactNode, HTMLAttributes } from 'react';
interface TableProps {
interface TableProps extends HTMLAttributes<HTMLTableElement> {
children: ReactNode;
className?: string;
}
export function Table({ children, className = '' }: TableProps) {
export function Table({ children, className = '', ...props }: TableProps) {
return (
<div className={`overflow-x-auto ${className}`}>
<table className="w-full">
<div style={{ overflowX: 'auto' }}>
<table className={`w-full ${className}`} {...props}>
{children}
</table>
</div>
);
}
interface TableHeadProps {
interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableHead({ children }: TableHeadProps) {
export function TableHead({ children, ...props }: TableHeadProps) {
return (
<thead>
<thead {...props}>
{children}
</thead>
);
}
interface TableBodyProps {
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableBody({ children }: TableBodyProps) {
export function TableBody({ children, ...props }: TableBodyProps) {
return (
<tbody>
<tbody {...props}>
{children}
</tbody>
);
}
interface TableRowProps {
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
children: ReactNode;
className?: string;
}
export function TableRow({ children, className = '' }: TableRowProps) {
export function TableRow({ children, className = '', ...props }: TableRowProps) {
const baseClasses = 'border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
<tr className={classes}>
<tr className={classes} {...props}>
{children}
</tr>
);
}
interface TableHeaderProps {
interface TableHeaderProps extends HTMLAttributes<HTMLTableCellElement> {
children: ReactNode;
className?: string;
}
export function TableHeader({ children, className = '' }: TableHeaderProps) {
export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
const baseClasses = 'text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
<th className={classes}>
<th className={classes} {...props}>
{children}
</th>
);
}
interface TableCellProps {
interface TableCellProps extends HTMLAttributes<HTMLTableCellElement> {
children: ReactNode;
className?: string;
}
export function TableCell({ children, className = '' }: TableCellProps) {
export function TableCell({ children, className = '', ...props }: TableCellProps) {
const baseClasses = 'py-3 px-4';
const classes = className ? `${baseClasses} ${className}` : baseClasses;
return (
<td className={classes}>
<td className={classes} {...props}>
{children}
</td>
);
}
}

View File

@@ -1,6 +1,8 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, HTMLAttributes } from 'react';
interface TextProps {
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface TextProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
className?: string;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
@@ -9,6 +11,12 @@ interface TextProps {
font?: 'mono' | 'sans';
align?: 'left' | 'center' | 'right';
truncate?: boolean;
style?: React.CSSProperties;
block?: boolean;
ml?: Spacing;
mr?: Spacing;
mt?: Spacing;
mb?: Spacing;
}
export function Text({
@@ -19,7 +27,11 @@ export function Text({
color = '',
font = 'sans',
align = 'left',
truncate = false
truncate = false,
style,
block = false,
ml, mr, mt, mb,
...props
}: TextProps) {
const sizeClasses = {
xs: 'text-xs',
@@ -49,16 +61,28 @@ export function Text({
center: 'text-center',
right: 'text-right'
};
const spacingMap: Record<number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
};
const classes = [
block ? 'block' : 'inline',
sizeClasses[size],
weightClasses[weight],
fontClasses[font],
alignClasses[align],
color,
truncate ? 'truncate' : '',
ml !== undefined ? `ml-${spacingMap[ml]}` : '',
mr !== undefined ? `mr-${spacingMap[mr]}` : '',
mt !== undefined ? `mt-${spacingMap[mt]}` : '',
mb !== undefined ? `mb-${spacingMap[mb]}` : '',
className
].filter(Boolean).join(' ');
return <span className={classes}>{children}</span>;
}
return <span className={classes} style={style} {...props}>{children}</span>;
}

View File

@@ -0,0 +1,74 @@
'use client';
import React from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Box } from './Box';
import { Text } from './Text';
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description?: string;
disabled?: boolean;
}
export function Toggle({
checked,
onChange,
label,
description,
disabled = false,
}: ToggleProps) {
const shouldReduceMotion = useReducedMotion();
return (
<label className={`flex items-start justify-between cursor-pointer py-3 border-b border-charcoal-outline/50 last:border-b-0 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<Box style={{ flex: 1, paddingRight: '1rem' }}>
<Text weight="medium" color="text-gray-200" block>{label}</Text>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
)}
</Box>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative w-12 h-6 rounded-full transition-colors duration-200 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-primary-blue/50 ${
checked
? 'bg-primary-blue'
: 'bg-iron-gray'
} ${disabled ? 'cursor-not-allowed' : ''}`}
>
{/* Glow effect when active */}
{checked && (
<motion.div
className="absolute inset-0 rounded-full bg-primary-blue"
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
animate={{ boxShadow: '0 0 12px rgba(25, 140, 255, 0.4)' }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
/>
)}
{/* Knob */}
<motion.span
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
initial={false}
animate={{
x: checked ? 24 : 2,
scale: 1,
}}
whileTap={{ scale: disabled ? 1 : 0.9 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
duration: shouldReduceMotion ? 0 : undefined,
}}
/>
</button>
</label>
);
}

View File

@@ -1,5 +0,0 @@
export function OnboardingCardAccent() {
return (
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
);
}

View File

@@ -1,11 +0,0 @@
interface OnboardingContainerProps {
children: React.ReactNode;
}
export function OnboardingContainer({ children }: OnboardingContainerProps) {
return (
<div className="max-w-3xl mx-auto px-4 py-10">
{children}
</div>
);
}

View File

@@ -1,12 +0,0 @@
interface OnboardingErrorProps {
message: string;
}
export function OnboardingError({ message }: OnboardingErrorProps) {
return (
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<span className="text-red-400 flex-shrink-0 mt-0.5"></span>
<p className="text-sm text-red-400">{message}</p>
</div>
);
}

View File

@@ -1,12 +0,0 @@
interface OnboardingFormProps {
children: React.ReactNode;
onSubmit: (e: React.FormEvent) => void | Promise<void>;
}
export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) {
return (
<form onSubmit={onSubmit} className="relative">
{children}
</form>
);
}

View File

@@ -1,17 +0,0 @@
interface OnboardingHeaderProps {
title: string;
subtitle: string;
emoji: string;
}
export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) {
return (
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<span className="text-2xl">{emoji}</span>
</div>
<h1 className="text-4xl font-bold mb-2">{title}</h1>
<p className="text-gray-400">{subtitle}</p>
</div>
);
}

View File

@@ -1,7 +0,0 @@
export function OnboardingHelpText() {
return (
<p className="text-center text-xs text-gray-500 mt-6">
Your avatar will be AI-generated based on your photo and chosen suit color
</p>
);
}

View File

@@ -1,58 +0,0 @@
import Button from '@/components/ui/Button';
interface OnboardingNavigationProps {
onBack: () => void;
onNext?: () => void;
isLastStep: boolean;
canSubmit: boolean;
loading: boolean;
}
export function OnboardingNavigation({ onBack, onNext, isLastStep, canSubmit, loading }: OnboardingNavigationProps) {
return (
<div className="mt-8 flex items-center justify-between">
<Button
type="button"
variant="secondary"
onClick={onBack}
disabled={loading}
className="flex items-center gap-2"
>
<span></span>
Back
</Button>
{!isLastStep ? (
<Button
type="button"
variant="primary"
onClick={onNext}
disabled={loading}
className="flex items-center gap-2"
>
Continue
<span></span>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading || !canSubmit}
className="flex items-center gap-2"
>
{loading ? (
<>
<span className="animate-spin"></span>
Creating Profile...
</>
) : (
<>
<span></span>
Complete Setup
</>
)}
</Button>
)}
</div>
);
}

View File

@@ -1,151 +0,0 @@
import { User, Clock, ChevronRight } from 'lucide-react';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import CountrySelect from '@/components/ui/CountrySelect';
export interface PersonalInfo {
firstName: string;
lastName: string;
displayName: string;
country: string;
timezone: string;
}
interface FormErrors {
[key: string]: string | undefined;
}
interface PersonalInfoStepProps {
personalInfo: PersonalInfo;
setPersonalInfo: (info: PersonalInfo) => void;
errors: FormErrors;
loading: boolean;
}
const TIMEZONES = [
{ value: 'America/New_York', label: 'Eastern Time (ET)' },
{ value: 'America/Chicago', label: 'Central Time (CT)' },
{ value: 'America/Denver', label: 'Mountain Time (MT)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
{ value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' },
{ value: 'Europe/Berlin', label: 'Central European Time (CET)' },
{ value: 'Europe/Paris', label: 'Central European Time (CET)' },
{ value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' },
{ value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' },
{ value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' },
];
export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loading }: PersonalInfoStepProps) {
return (
<div className="space-y-6">
<div>
<Heading level={2} className="text-xl mb-1 flex items-center gap-2">
<User className="w-5 h-5 text-primary-blue" />
Personal Information
</Heading>
<p className="text-sm text-gray-400">
Tell us a bit about yourself
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-300 mb-2">
First Name *
</label>
<Input
id="firstName"
type="text"
value={personalInfo.firstName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, firstName: e.target.value })
}
error={!!errors.firstName}
errorMessage={errors.firstName}
placeholder="John"
disabled={loading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-300 mb-2">
Last Name *
</label>
<Input
id="lastName"
type="text"
value={personalInfo.lastName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, lastName: e.target.value })
}
error={!!errors.lastName}
errorMessage={errors.lastName}
placeholder="Racer"
disabled={loading}
/>
</div>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-gray-300 mb-2">
Display Name * <span className="text-gray-500 font-normal">(shown publicly)</span>
</label>
<Input
id="displayName"
type="text"
value={personalInfo.displayName}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, displayName: e.target.value })
}
error={!!errors.displayName}
errorMessage={errors.displayName}
placeholder="SpeedyRacer42"
disabled={loading}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-300 mb-2">
Country *
</label>
<CountrySelect
value={personalInfo.country}
onChange={(value) =>
setPersonalInfo({ ...personalInfo, country: value })
}
error={!!errors.country}
errorMessage={errors.country ?? ''}
disabled={loading}
/>
</div>
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-300 mb-2">
Timezone
</label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
<select
id="timezone"
value={personalInfo.timezone}
onChange={(e) =>
setPersonalInfo({ ...personalInfo, timezone: e.target.value })
}
className="block w-full rounded-md border-0 px-4 py-3 pl-10 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm appearance-none cursor-pointer"
disabled={loading}
>
<option value="">Select timezone</option>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
<ChevronRight className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 rotate-90" />
</div>
</div>
</div>
</div>
);
}