website refactor
This commit is contained in:
36
apps/website/ui/AppWrapper.tsx
Normal file
36
apps/website/ui/AppWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/website/ui/Button.tsx
Normal file
57
apps/website/ui/Button.tsx
Normal 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
35
apps/website/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/website/ui/DashboardLayoutWrapper.tsx
Normal file
19
apps/website/ui/DashboardLayoutWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/website/ui/ErrorActionButtons.tsx
Normal file
55
apps/website/ui/ErrorActionButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/website/ui/ErrorPageContainer.tsx
Normal file
29
apps/website/ui/ErrorPageContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/website/ui/Header.tsx
Normal file
16
apps/website/ui/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/website/ui/HeaderContent.tsx
Normal file
26
apps/website/ui/HeaderContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/website/ui/Layout.tsx
Normal file
63
apps/website/ui/Layout.tsx
Normal 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
45
apps/website/ui/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/website/ui/MainContent.tsx
Normal file
9
apps/website/ui/MainContent.tsx
Normal 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>;
|
||||
}
|
||||
34
apps/website/ui/QuickActionLink.tsx
Normal file
34
apps/website/ui/QuickActionLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/website/ui/Section.tsx
Normal file
43
apps/website/ui/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/website/ui/Select.tsx
Normal file
40
apps/website/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/website/ui/StatCard.tsx
Normal file
53
apps/website/ui/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/website/ui/StatusBadge.tsx
Normal file
33
apps/website/ui/StatusBadge.tsx
Normal 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
64
apps/website/ui/Text.tsx
Normal 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>;
|
||||
}
|
||||
5
apps/website/ui/onboarding/OnboardingCardAccent.tsx
Normal file
5
apps/website/ui/onboarding/OnboardingCardAccent.tsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
11
apps/website/ui/onboarding/OnboardingContainer.tsx
Normal file
11
apps/website/ui/onboarding/OnboardingContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/website/ui/onboarding/OnboardingError.tsx
Normal file
12
apps/website/ui/onboarding/OnboardingError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/website/ui/onboarding/OnboardingForm.tsx
Normal file
12
apps/website/ui/onboarding/OnboardingForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/website/ui/onboarding/OnboardingHeader.tsx
Normal file
17
apps/website/ui/onboarding/OnboardingHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/website/ui/onboarding/OnboardingHelpText.tsx
Normal file
7
apps/website/ui/onboarding/OnboardingHelpText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/website/ui/onboarding/OnboardingNavigation.tsx
Normal file
58
apps/website/ui/onboarding/OnboardingNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
apps/website/ui/onboarding/PersonalInfoStep.tsx
Normal file
151
apps/website/ui/onboarding/PersonalInfoStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user