website refactor

This commit is contained in:
2026-01-14 10:51:05 +01:00
parent 4522d41aef
commit 0d89ad027e
291 changed files with 6887 additions and 3685 deletions

View File

@@ -0,0 +1,36 @@
'use client';
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';
import { QueryClientProvider } from '@/lib/providers/QueryClientProvider';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider';
import NotificationProvider from '@/components/notifications/NotificationProvider';
import { NotificationIntegration } from '@/components/errors/NotificationIntegration';
import { EnhancedErrorBoundary } from '@/components/errors/EnhancedErrorBoundary';
import DevToolbar from '@/components/dev/DevToolbar';
import React from 'react';
interface AppWrapperProps {
children: React.ReactNode;
enabledFlags: string[];
}
export function AppWrapper({ children, enabledFlags }: AppWrapperProps) {
return (
<ContainerProvider>
<QueryClientProvider>
<AuthProvider>
<FeatureFlagProvider flags={enabledFlags}>
<NotificationProvider>
<NotificationIntegration />
<EnhancedErrorBoundary enableDevOverlay={process.env.NODE_ENV === 'development'}>
{children}
{process.env.NODE_ENV === 'development' && <DevToolbar />}
</EnhancedErrorBoundary>
</NotificationProvider>
</FeatureFlagProvider>
</AuthProvider>
</QueryClientProvider>
</ContainerProvider>
);
}

View File

@@ -0,0 +1,57 @@
import React, { ReactNode, MouseEventHandler } from 'react';
interface ButtonProps {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string;
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}
export function Button({
children,
onClick,
className = '',
variant = 'primary',
size = 'md',
disabled = false,
type = 'button'
}: ButtonProps) {
const baseClasses = 'inline-flex items-center rounded-lg transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2';
const variantClasses = {
primary: 'bg-primary-blue text-white hover:bg-primary-blue/80 focus-visible:outline-primary-blue',
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'
};
const sizeClasses = {
sm: 'min-h-[36px] px-3 py-1.5 text-xs',
md: 'min-h-[44px] px-4 py-2 text-sm',
lg: 'min-h-[52px] px-6 py-3 text-base'
};
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
const classes = [
baseClasses,
variantClasses[variant],
sizeClasses[size],
disabledClasses,
className
].filter(Boolean).join(' ');
return (
<button
type={type}
className={classes}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}

35
apps/website/ui/Card.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React, { ReactNode, MouseEventHandler } from 'react';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
variant?: 'default' | 'highlight';
}
export function Card({
children,
className = '',
onClick,
variant = 'default'
}: CardProps) {
const baseClasses = 'rounded-lg p-6 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 classes = [
baseClasses,
variantClasses[variant],
onClick ? 'cursor-pointer hover:scale-[1.02]' : '',
className
].filter(Boolean).join(' ');
return (
<div className={classes} onClick={onClick}>
{children}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
interface DashboardLayoutWrapperProps {
children: ReactNode;
}
/**
* DashboardLayoutWrapper
*
* Full-screen layout wrapper for dashboard pages.
* Provides the base container with background styling.
*/
export function DashboardLayoutWrapper({ children }: DashboardLayoutWrapperProps) {
return (
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
interface ErrorActionButtonsProps {
onRetry?: () => void;
onHomeClick: () => void;
showRetry?: boolean;
homeLabel?: string;
}
/**
* ErrorActionButtons
*
* Action buttons for error pages (Try Again, Go Home)
* Provides consistent styling and behavior.
* All navigation callbacks must be provided by the caller.
*/
export function ErrorActionButtons({
onRetry,
onHomeClick,
showRetry = false,
homeLabel = 'Drive home',
}: ErrorActionButtonsProps) {
if (showRetry && onRetry) {
return (
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={onRetry}
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
>
Try again
</button>
<button
type="button"
onClick={onHomeClick}
className="inline-flex items-center justify-center rounded-md bg-iron-gray px-4 py-2 text-sm font-medium text-white hover:bg-iron-gray/80 transition-colors"
>
Go home
</button>
</div>
);
}
return (
<div className="pt-2">
<button
type="button"
onClick={onHomeClick}
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
>
{homeLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React, { ReactNode } from 'react';
interface ErrorPageContainerProps {
children: ReactNode;
errorCode: string;
description: string;
}
/**
* ErrorPageContainer
*
* A reusable container for error pages (404, 500, etc.)
* Provides consistent styling and layout for error states.
*/
export function ErrorPageContainer({
children,
errorCode,
description,
}: ErrorPageContainerProps) {
return (
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
<div className="max-w-md text-center space-y-4">
<h1 className="text-3xl font-semibold">{errorCode}</h1>
<p className="text-sm text-gray-400">{description}</p>
{children}
</div>
</main>
);
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
import Container from '@/components/ui/Container';
interface HeaderProps {
children: React.ReactNode;
}
export function Header({ children }: HeaderProps) {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
<Container>
{children}
</Container>
</header>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Text } from './Text';
export function HeaderContent() {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Link href="/" className="inline-flex items-center">
<Image
src="/images/logos/wordmark-rectangle-dark.svg"
alt="GridPilot"
width={160}
height={30}
className="h-6 w-auto md:h-8"
priority
/>
</Link>
<Text size="sm" color="text-gray-400" className="hidden sm:block font-light">
Making league racing less chaotic
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import React, { ReactNode } from 'react';
interface LayoutProps {
children: ReactNode;
className?: string;
padding?: string;
gap?: string;
grid?: boolean;
gridCols?: 1 | 2 | 3 | 4;
flex?: boolean;
flexCol?: boolean;
items?: 'start' | 'center' | 'end' | 'stretch';
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
}
export function Layout({
children,
className = '',
padding = 'p-6',
gap = 'gap-4',
grid = false,
gridCols = 1,
flex = false,
flexCol = false,
items = 'start',
justify = 'start'
}: LayoutProps) {
const baseClasses = [padding, gap, className];
if (grid) {
const gridColsMap = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4'
};
baseClasses.push('grid', gridColsMap[gridCols]);
} else if (flex) {
baseClasses.push('flex');
if (flexCol) baseClasses.push('flex-col');
const itemsMap = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch'
};
baseClasses.push(itemsMap[items]);
const justifyMap = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around'
};
baseClasses.push(justifyMap[justify]);
}
const classes = baseClasses.filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
}

45
apps/website/ui/Link.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React, { ReactNode } from 'react';
import NextLink from 'next/link';
interface LinkProps {
href: string;
children: ReactNode;
className?: string;
variant?: 'primary' | 'secondary' | 'ghost';
target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string;
}
export function Link({
href,
children,
className = '',
variant = 'primary',
target = '_self',
rel = ''
}: LinkProps) {
const baseClasses = 'inline-flex items-center transition-colors';
const variantClasses = {
primary: 'text-primary-blue hover:text-primary-blue/80',
secondary: 'text-purple-300 hover:text-purple-400',
ghost: 'text-gray-400 hover:text-gray-300'
};
const classes = [
baseClasses,
variantClasses[variant],
className
].filter(Boolean).join(' ');
return (
<NextLink
href={href}
className={classes}
target={target}
rel={rel}
>
{children}
</NextLink>
);
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
interface MainContentProps {
children: React.ReactNode;
}
export function MainContent({ children }: MainContentProps) {
return <div className="pt-16">{children}</div>;
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Text } from './Text';
interface QuickActionLinkProps {
href: string;
children: React.ReactNode;
variant?: 'blue' | 'purple' | 'orange';
className?: string;
}
export function QuickActionLink({
href,
children,
variant = 'blue',
className = ''
}: QuickActionLinkProps) {
const variantClasses = {
blue: 'bg-primary-blue/20 border-primary-blue/30 text-primary-blue hover:bg-primary-blue/30',
purple: 'bg-purple-500/20 border-purple-500/30 text-purple-300 hover:bg-purple-500/30',
orange: 'bg-orange-500/20 border-orange-500/30 text-orange-300 hover:bg-orange-500/30'
};
const classes = [
'px-4 py-3 border rounded-lg transition-colors text-sm font-medium text-center inline-block w-full',
variantClasses[variant],
className
].filter(Boolean).join(' ');
return (
<a href={href} className={classes}>
{children}
</a>
);
}

View File

@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
interface SectionProps {
children: ReactNode;
className?: string;
title?: string;
description?: string;
variant?: 'default' | 'card' | 'highlight';
}
export function Section({
children,
className = '',
title,
description,
variant = 'default'
}: 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'
};
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>
);
}

View File

@@ -0,0 +1,40 @@
import React, { ChangeEvent } from 'react';
interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
id?: string;
'aria-label'?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
options: SelectOption[];
className?: string;
}
export function Select({
id,
'aria-label': ariaLabel,
value,
onChange,
options,
className = '',
}: SelectProps) {
return (
<select
id={id}
aria-label={ariaLabel}
value={value}
onChange={onChange}
className={className}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,53 @@
import React, { ReactNode } from 'react';
import { Card } from './Card';
import { Text } from './Text';
interface StatCardProps {
label: string;
value: string | number;
icon?: ReactNode;
variant?: 'blue' | 'purple' | 'green' | 'orange';
className?: string;
}
export function StatCard({
label,
value,
icon,
variant = 'blue',
className = ''
}: StatCardProps) {
const variantClasses = {
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30',
purple: 'bg-gradient-to-br from-purple-900/20 to-purple-700/10 border-purple-500/30',
green: 'bg-gradient-to-br from-green-900/20 to-green-700/10 border-green-500/30',
orange: 'bg-gradient-to-br from-orange-900/20 to-orange-700/10 border-orange-500/30'
};
const iconColorClasses = {
blue: 'text-blue-400',
purple: 'text-purple-400',
green: 'text-green-400',
orange: 'text-orange-400'
};
return (
<Card className={`${variantClasses[variant]} ${className}`}>
<div className="flex items-center justify-between">
<div>
<Text size="sm" color="text-gray-400" className="mb-1">
{label}
</Text>
<Text size="3xl" weight="bold" color="text-white">
{value}
</Text>
</div>
{icon && (
<div className={iconColorClasses[variant]}>
{icon}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Text } from './Text';
interface StatusBadgeProps {
children: React.ReactNode;
variant?: 'success' | 'warning' | 'error' | 'info';
className?: string;
}
export function StatusBadge({
children,
variant = 'success',
className = ''
}: 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'
};
const classes = [
'px-2 py-1 text-xs rounded-full',
variantClasses[variant],
className
].filter(Boolean).join(' ');
return (
<Text size="xs" className={classes}>
{children}
</Text>
);
}

64
apps/website/ui/Text.tsx Normal file
View File

@@ -0,0 +1,64 @@
import React, { ReactNode } from 'react';
interface TextProps {
children: ReactNode;
className?: string;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
color?: string;
font?: 'mono' | 'sans';
align?: 'left' | 'center' | 'right';
truncate?: boolean;
}
export function Text({
children,
className = '',
size = 'base',
weight = 'normal',
color = '',
font = 'sans',
align = 'left',
truncate = false
}: TextProps) {
const sizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl'
};
const weightClasses = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold'
};
const fontClasses = {
mono: 'font-mono',
sans: 'font-sans'
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right'
};
const classes = [
sizeClasses[size],
weightClasses[weight],
fontClasses[font],
alignClasses[align],
color,
truncate ? 'truncate' : '',
className
].filter(Boolean).join(' ');
return <span className={classes}>{children}</span>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,151 @@
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>
);
}