website refactor
This commit is contained in:
16
apps/website/ui/AuthContainer.tsx
Normal file
16
apps/website/ui/AuthContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/website/ui/AuthError.tsx
Normal file
18
apps/website/ui/AuthError.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/AuthLoading.tsx
Normal file
22
apps/website/ui/AuthLoading.tsx
Normal 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
25
apps/website/ui/Badge.tsx
Normal 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
93
apps/website/ui/Box.tsx
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/website/ui/Container.tsx
Normal file
50
apps/website/ui/Container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
apps/website/ui/CountryFlag.tsx
Normal file
108
apps/website/ui/CountryFlag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
apps/website/ui/CountrySelect.tsx
Normal file
191
apps/website/ui/CountrySelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/website/ui/DecorativeBlur.tsx
Normal file
46
apps/website/ui/DecorativeBlur.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
apps/website/ui/DurationField.tsx
Normal file
71
apps/website/ui/DurationField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/website/ui/ErrorBanner.tsx
Normal file
37
apps/website/ui/ErrorBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/website/ui/FormField.tsx
Normal file
45
apps/website/ui/FormField.tsx
Normal 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
52
apps/website/ui/Grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/website/ui/GridItem.tsx
Normal file
25
apps/website/ui/GridItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Container from '@/components/ui/Container';
|
||||
import Container from '@/ui/Container';
|
||||
|
||||
interface HeaderProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
34
apps/website/ui/Heading.tsx
Normal file
34
apps/website/ui/Heading.tsx
Normal 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
30
apps/website/ui/Hero.tsx
Normal 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
37
apps/website/ui/Icon.tsx
Normal 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
23
apps/website/ui/Image.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
apps/website/ui/InfoBanner.tsx
Normal file
85
apps/website/ui/InfoBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/website/ui/InfoBox.tsx
Normal file
65
apps/website/ui/InfoBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/website/ui/LoadingSpinner.tsx
Normal file
27
apps/website/ui/LoadingSpinner.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
146
apps/website/ui/MockupStack.tsx
Normal file
146
apps/website/ui/MockupStack.tsx
Normal 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
185
apps/website/ui/Modal.tsx
Normal 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;
|
||||
}
|
||||
49
apps/website/ui/PageHeader.tsx
Normal file
49
apps/website/ui/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/PlaceholderImage.tsx
Normal file
22
apps/website/ui/PlaceholderImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/website/ui/PresetCard.tsx
Normal file
122
apps/website/ui/PresetCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface QuickActionLinkProps {
|
||||
href: string;
|
||||
|
||||
271
apps/website/ui/RangeField.tsx
Normal file
271
apps/website/ui/RangeField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
apps/website/ui/SectionHeader.tsx
Normal file
47
apps/website/ui/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/website/ui/SegmentedControl.tsx
Normal file
72
apps/website/ui/SegmentedControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
26
apps/website/ui/Skeleton.tsx
Normal file
26
apps/website/ui/Skeleton.tsx
Normal 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..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
95
apps/website/ui/Stack.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
70
apps/website/ui/Surface.tsx
Normal file
70
apps/website/ui/Surface.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/website/ui/TabContent.tsx
Normal file
9
apps/website/ui/TabContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/website/ui/TabNavigation.tsx
Normal file
39
apps/website/ui/TabNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
74
apps/website/ui/Toggle.tsx
Normal file
74
apps/website/ui/Toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user