website refactor

This commit is contained in:
2026-01-15 17:12:24 +01:00
parent c3b308e960
commit f035cfe7ce
468 changed files with 24378 additions and 17324 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { ReactNode } from 'react';
import { useCapability } from "@/lib/hooks/useCapability";
import { useCapability } from "@/hooks/useCapability";
import { useInject } from '@/lib/di/hooks/useInject';
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';

View File

@@ -1,117 +0,0 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
interface HeroSectionProps {
title: string;
description?: string;
icon?: LucideIcon;
backgroundPattern?: React.ReactNode;
stats?: Array<{
icon?: LucideIcon;
value: string | number;
label: string;
color?: string;
animate?: boolean;
}>;
actions?: Array<{
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
icon?: LucideIcon;
description?: string;
}>;
children?: React.ReactNode;
className?: string;
}
export const HeroSection = ({
title,
description,
icon: Icon,
backgroundPattern,
stats,
actions,
children,
className = ''
}: HeroSectionProps) => (
<section className={`relative overflow-hidden rounded-2xl bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60 border border-charcoal-outline/50 ${className}`}>
{/* Background Pattern */}
{backgroundPattern || (
<>
<div className="absolute top-0 right-0 w-96 h-96 bg-primary-blue/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-neon-aqua/5 rounded-full blur-3xl" />
</>
)}
<div className="relative max-w-7xl mx-auto px-8 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Main Content */}
<div className="max-w-2xl">
{Icon && (
<div className="flex items-center gap-3 mb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Icon className="w-6 h-6 text-primary-blue" />
</div>
<Heading level={1} className="text-3xl lg:text-4xl">
{title}
</Heading>
</div>
)}
{!Icon && (
<Heading level={1} className="text-3xl lg:text-4xl mb-4">
{title}
</Heading>
)}
{description && (
<p className="text-gray-400 text-lg leading-relaxed">
{description}
</p>
)}
{/* Stats */}
{stats && stats.length > 0 && (
<div className="flex flex-wrap gap-6 mt-6">
{stats.map((stat, index) => (
<div key={index} className="flex items-center gap-2">
{stat.icon ? (
<stat.icon className={`w-4 h-4 ${stat.color || 'text-primary-blue'}`} />
) : (
<div className={`w-2 h-2 rounded-full ${stat.color || 'bg-primary-blue'} ${stat.animate ? 'animate-pulse' : ''}`} />
)}
<span className="text-sm text-gray-400">
<span className="text-white font-semibold">{stat.value}</span> {stat.label}
</span>
</div>
))}
</div>
)}
</div>
{/* Actions or Custom Content */}
{actions && actions.length > 0 && (
<div className="flex flex-col gap-4">
{actions.map((action, index) => (
<div key={index} className="flex flex-col gap-2">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="flex items-center gap-2 px-6 py-3"
>
{action.icon && <action.icon className="w-5 h-5" />}
{action.label}
</Button>
{action.description && (
<p className="text-xs text-gray-500 text-center">{action.description}</p>
)}
</div>
))}
</div>
)}
{children}
</div>
</div>
</section>
);

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { StatusBadge as UIStatusBadge } from '@/ui/StatusBadge';
interface StatusBadgeProps {
status: string;
config?: {
icon: LucideIcon;
color: string;
bg: string;
border: string;
label: string;
};
className?: string;
}
export const StatusBadge = ({ status, config, className = '' }: StatusBadgeProps) => {
const defaultConfig = {
scheduled: {
icon: undefined,
variant: 'info' as const,
label: 'Scheduled',
},
running: {
icon: undefined,
variant: 'success' as const,
label: 'LIVE',
},
completed: {
icon: undefined,
variant: 'neutral' as const,
label: 'Completed',
},
cancelled: {
icon: undefined,
variant: 'warning' as const,
label: 'Cancelled',
},
};
const badgeConfig = config
? { ...config, variant: 'info' as const } // Fallback variant if config is provided
: defaultConfig[status as keyof typeof defaultConfig] || {
icon: undefined,
variant: 'neutral' as const,
label: status,
};
return (
<UIStatusBadge
variant={badgeConfig.variant}
icon={badgeConfig.icon}
className={className}
>
{badgeConfig.label}
</UIStatusBadge>
);
};

View File

@@ -1,330 +0,0 @@
'use client';
import React from 'react';
import { EmptyStateProps, EmptyStateAction } from './types';
import { Button } from '@/ui/Button';
// Illustration components (simple SVG representations)
const Illustrations = {
racing: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
<circle cx="35" cy="65" r="3" fill="currentColor"/>
<circle cx="65" cy="65" r="3" fill="currentColor"/>
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
league: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
team: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
</svg>
),
sponsor: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
</svg>
),
driver: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
<circle cx="40" cy="72" r="3" fill="currentColor"/>
<circle cx="60" cy="72" r="3" fill="currentColor"/>
</svg>
),
} as const;
/**
* EmptyState Component
*
* Provides consistent empty/placeholder states with 3 variants:
* - default: Standard empty state with icon, title, description, and action
* - minimal: Simple version without extra styling
* - full-page: Full page empty state with centered layout
*
* Supports both icons and illustrations for visual appeal.
*/
export function EmptyState({
icon: Icon,
title,
description,
action,
variant = 'default',
className = '',
illustration,
ariaLabel = 'Empty state',
}: EmptyStateProps) {
// Render illustration if provided
const IllustrationComponent = illustration ? Illustrations[illustration] : null;
// Common content
const Content = () => (
<>
{/* Visual - Icon or Illustration */}
<div className="flex justify-center mb-4">
{IllustrationComponent ? (
<div className="text-gray-500">
<IllustrationComponent />
</div>
) : Icon ? (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-8 h-8 text-gray-500" />
</div>
) : null}
</div>
{/* Title */}
<h3 className="text-xl font-semibold text-white mb-2 text-center">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-gray-400 mb-6 text-center leading-relaxed">
{description}
</p>
)}
{/* Action Button */}
{action && (
<div className="flex justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[140px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
</>
);
// Render different variants
switch (variant) {
case 'default':
return (
<div
className={`text-center py-12 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-md mx-auto">
<Content />
</div>
</div>
);
case 'minimal':
return (
<div
className={`text-center py-8 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-sm mx-auto space-y-3">
{/* Minimal icon */}
{Icon && (
<div className="flex justify-center">
<Icon className="w-10 h-10 text-gray-600" />
</div>
)}
<h3 className="text-lg font-medium text-gray-300">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500">
{description}
</p>
)}
{action && (
<button
onClick={action.onClick}
className="text-sm text-primary-blue hover:text-blue-400 font-medium mt-2 inline-flex items-center gap-1"
>
{action.label}
{action.icon && <action.icon className="w-3 h-3" />}
</button>
)}
</div>
</div>
);
case 'full-page':
return (
<div
className={`fixed inset-0 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-lg w-full text-center">
<div className="mb-6">
{IllustrationComponent ? (
<div className="text-gray-500 flex justify-center">
<IllustrationComponent />
</div>
) : Icon ? (
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-10 h-10 text-gray-500" />
</div>
</div>
) : null}
</div>
<h2 className="text-3xl font-bold text-white mb-4">
{title}
</h2>
{description && (
<p className="text-gray-400 text-lg mb-8 leading-relaxed">
{description}
</p>
)}
{action && (
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[160px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
{/* Additional helper text for full-page variant */}
<div className="mt-8 text-sm text-gray-500">
Need help? Contact us at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
);
default:
return null;
}
}
/**
* Convenience component for default empty state
*/
export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="default"
className={className}
illustration={illustration}
/>
);
}
/**
* Convenience component for minimal empty state
*/
export function MinimalEmptyState({ icon, title, description, action, className }: Omit<EmptyStateProps, 'variant'>) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="minimal"
className={className}
/>
);
}
/**
* Convenience component for full-page empty state
*/
export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="full-page"
className={className}
illustration={illustration}
/>
);
}
/**
* Pre-configured empty states for common scenarios
*/
import { Activity, Search, Lock } from 'lucide-react';
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={Activity}
title="No data available"
description="There is nothing to display here at the moment"
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={Search}
title="No results found"
description="Try adjusting your search or filters"
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
return (
<EmptyState
icon={Lock}
title="Access denied"
description="You don't have permission to view this content"
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
variant="full-page"
/>
);
}

View File

@@ -1,252 +0,0 @@
'use client';
import React from 'react';
import { AlertCircle, RefreshCw, ArrowLeft, Home, Bug } from 'lucide-react';
import { ErrorDisplayProps } from './types';
import { ApiError } from '@/lib/api/base/ApiError';
/**
* ErrorDisplay Component
*
* Provides consistent error state handling with multiple variants:
* - full-screen: Centered error page with full viewport
* - card: Compact card-style error display
* - inline: Small inline error message
*
* Features:
* - Automatic error message extraction
* - Retry functionality
* - Navigation options (back, home)
* - Technical details toggle
* - API error specific handling
*/
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
actions = [],
showRetry = true,
showNavigation = true,
hideTechnicalDetails = false,
className = '',
}: ErrorDisplayProps) {
// Extract error information
const getErrorInfo = () => {
const isApiError = error instanceof ApiError;
return {
title: isApiError ? 'API Error' : 'Unexpected Error',
message: error.message || 'Something went wrong',
statusCode: isApiError ? error.context.statusCode : undefined,
details: isApiError ? error.context.responseText : undefined,
isApiError,
};
};
const errorInfo = getErrorInfo();
// Default actions
const defaultActions = [
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const }] : []),
...(showNavigation ? [
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const },
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'outline' as const },
] : []),
...actions,
];
// Render different variants
switch (variant) {
case 'full-screen':
return (
<div
className={`fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
role="alert"
aria-live="assertive"
>
<div className="max-w-lg w-full text-center">
{/* Icon */}
<div className="flex justify-center mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-red-500/10 border border-red-500/30">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
</div>
{/* Title */}
<h2 className="text-3xl font-bold text-white mb-3">
{errorInfo.title}
</h2>
{/* Message */}
<p className="text-gray-400 text-lg mb-6 leading-relaxed">
{errorInfo.message}
</p>
{/* API Error Details */}
{errorInfo.isApiError && errorInfo.statusCode && (
<div className="mb-6 inline-flex items-center gap-2 px-4 py-2 bg-iron-gray/40 rounded-lg text-sm text-gray-300">
<span className="font-mono">HTTP {errorInfo.statusCode}</span>
{errorInfo.details && !hideTechnicalDetails && (
<span className="text-gray-500">- {errorInfo.details}</span>
)}
</div>
)}
{/* Actions */}
{defaultActions.length > 0 && (
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{defaultActions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
className={`
px-6 py-3 rounded-lg font-medium transition-all
${action.variant === 'primary' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
${action.variant === 'secondary' ? 'bg-iron-gray hover:bg-iron-gray/80 text-white' : ''}
${action.variant === 'outline' ? 'border border-gray-600 hover:border-gray-500 text-gray-300' : ''}
${action.variant === 'ghost' ? 'text-gray-400 hover:text-gray-300' : ''}
`}
>
{action.label}
</button>
))}
</div>
)}
{/* Technical Details Toggle (for development) */}
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
<details className="mt-8 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-400">
Technical Details
</summary>
<pre className="mt-2 p-4 bg-black/50 rounded-lg text-xs text-gray-400 overflow-x-auto">
{error.stack}
</pre>
</details>
)}
</div>
</div>
);
case 'card':
return (
<div
className={`bg-iron-gray/40 border border-red-500/30 rounded-xl p-6 ${className}`}
role="alert"
aria-live="assertive"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
{/* Content */}
<div className="flex-1">
<h3 className="font-semibold text-white mb-1">
{errorInfo.title}
</h3>
<p className="text-sm text-gray-400 mb-3">
{errorInfo.message}
</p>
{/* API Error Details */}
{errorInfo.isApiError && errorInfo.statusCode && (
<div className="text-xs font-mono text-gray-500 mb-3">
HTTP {errorInfo.statusCode}
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
</div>
)}
{/* Actions */}
{defaultActions.length > 0 && (
<div className="flex gap-2">
{defaultActions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
className={`
px-3 py-1.5 rounded text-sm font-medium
${action.variant === 'primary' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
${action.variant === 'secondary' ? 'bg-deep-graphite hover:bg-black/60 text-white' : ''}
${action.variant === 'outline' ? 'border border-gray-600 hover:border-gray-500 text-gray-300' : ''}
${action.variant === 'ghost' ? 'text-gray-400 hover:text-gray-300' : ''}
`}
>
{action.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`inline-flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg ${className}`}
role="alert"
aria-live="assertive"
>
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-sm text-red-400">{errorInfo.message}</span>
{onRetry && showRetry && (
<button
onClick={onRetry}
className="ml-2 text-xs text-red-300 hover:text-red-200 underline"
>
Retry
</button>
)}
</div>
);
default:
return null;
}
}
/**
* Convenience component for API error display
*/
export function ApiErrorDisplay({
error,
onRetry,
variant = 'full-screen',
hideTechnicalDetails = false,
}: {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
hideTechnicalDetails?: boolean;
}) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant={variant}
hideTechnicalDetails={hideTechnicalDetails}
/>
);
}
/**
* Convenience component for network error display
*/
export function NetworkErrorDisplay({
onRetry,
variant = 'full-screen',
}: {
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
}) {
return (
<ErrorDisplay
error={new Error('Network connection failed. Please check your internet connection.')}
onRetry={onRetry}
variant={variant}
/>
);
}

View File

@@ -1,199 +0,0 @@
'use client';
import React from 'react';
import { LoadingWrapperProps } from './types';
/**
* LoadingWrapper Component
*
* Provides consistent loading states with multiple variants:
* - spinner: Traditional loading spinner (default)
* - skeleton: Skeleton screens for better UX
* - full-screen: Centered in viewport
* - inline: Compact inline loading
* - card: Loading card placeholders
*
* All variants are fully accessible with ARIA labels and keyboard support.
*/
export function LoadingWrapper({
variant = 'spinner',
message = 'Loading...',
className = '',
size = 'md',
skeletonCount = 3,
cardConfig,
ariaLabel = 'Loading content',
}: LoadingWrapperProps) {
// Size mappings for different variants
const sizeClasses = {
sm: {
spinner: 'w-4 h-4 border-2',
inline: 'text-xs',
card: 'h-24',
},
md: {
spinner: 'w-10 h-10 border-2',
inline: 'text-sm',
card: 'h-32',
},
lg: {
spinner: 'w-16 h-16 border-4',
inline: 'text-base',
card: 'h-40',
},
};
const spinnerSize = sizeClasses[size].spinner;
const inlineSize = sizeClasses[size].inline;
const cardHeight = cardConfig?.height || sizeClasses[size].card;
// Render different variants
switch (variant) {
case 'spinner':
return (
<div
className={`flex items-center justify-center min-h-[200px] ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="flex flex-col items-center gap-3">
<div
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
/>
<p className="text-gray-400 text-sm">{message}</p>
</div>
</div>
);
case 'skeleton':
return (
<div
className={`space-y-3 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: skeletonCount }).map((_, index) => (
<div
key={index}
className="w-full bg-iron-gray/40 rounded-lg animate-pulse"
style={{ height: cardHeight }}
/>
))}
</div>
);
case 'full-screen':
return (
<div
className="fixed inset-0 z-50 bg-deep-graphite/90 backdrop-blur-sm flex items-center justify-center p-4"
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="text-center max-w-md">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg font-medium">{message}</p>
<p className="text-gray-400 text-sm">This may take a moment...</p>
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`inline-flex items-center gap-2 ${inlineSize} ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<span className="text-gray-400">{message}</span>
</div>
);
case 'card':
const cardCount = cardConfig?.count || 3;
const cardClassName = cardConfig?.className || '';
return (
<div
className={`grid gap-4 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: cardCount }).map((_, index) => (
<div
key={index}
className={`bg-iron-gray/40 rounded-xl overflow-hidden border border-charcoal-outline/50 ${cardClassName}`}
style={{ height: cardHeight }}
>
<div className="h-full w-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</div>
</div>
))}
</div>
);
default:
return null;
}
}
/**
* Convenience component for full-screen loading
*/
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
return (
<LoadingWrapper
variant="full-screen"
message={message}
className={className}
/>
);
}
/**
* Convenience component for inline loading
*/
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
return (
<LoadingWrapper
variant="inline"
message={message}
size={size}
className={className}
/>
);
}
/**
* Convenience component for skeleton loading
*/
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
return (
<LoadingWrapper
variant="skeleton"
skeletonCount={skeletonCount}
className={className}
/>
);
}
/**
* Convenience component for card loading
*/
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
return (
<LoadingWrapper
variant="card"
cardConfig={cardConfig}
className={className}
/>
);
}

View File

@@ -1,276 +0,0 @@
import React, { ReactNode } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
import { Inbox, List, LucideIcon } from 'lucide-react';
// ==================== PAGEWRAPPER TYPES ====================
export interface PageWrapperLoadingConfig {
variant?: 'skeleton' | 'full-screen';
message?: string;
}
export interface PageWrapperErrorConfig {
variant?: 'full-screen' | 'card';
card?: {
title?: string;
description?: string;
};
}
export interface PageWrapperEmptyConfig {
icon?: LucideIcon;
title?: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export interface PageWrapperProps<TData> {
/** Data to be rendered */
data: TData | undefined;
/** Loading state (default: false) */
isLoading?: boolean;
/** Error state (default: null) */
error?: Error | null;
/** Retry function for errors */
retry?: () => void;
/** Template component that receives the data */
Template: React.ComponentType<{ data: TData }>;
/** Loading configuration */
loading?: PageWrapperLoadingConfig;
/** Error configuration */
errorConfig?: PageWrapperErrorConfig;
/** Empty configuration */
empty?: PageWrapperEmptyConfig;
/** Children for flexible content rendering */
children?: ReactNode;
/** Additional CSS classes */
className?: string;
}
/**
* PageWrapper Component
*
* A comprehensive wrapper component that handles all page states:
* - Loading states (skeleton or full-screen)
* - Error states (full-screen or card)
* - Empty states (with icon, title, description, and action)
* - Success state (renders Template component with data)
* - Flexible children support for custom content
*
* Usage Example:
* ```typescript
* <PageWrapper
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* Template={MyTemplateComponent}
* loading={{ variant: 'skeleton', message: 'Loading...' }}
* error={{ variant: 'full-screen' }}
* empty={{
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }}
* >
* <AdditionalContent />
* </PageWrapper>
* ```
*/
export function PageWrapper<TData>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
}: PageWrapperProps<TData>) {
// Priority order: Loading > Error > Empty > Success
// 1. Loading State
if (isLoading) {
const loadingVariant = loading?.variant || 'skeleton';
const loadingMessage = loading?.message || 'Loading...';
if (loadingVariant === 'full-screen') {
return (
<LoadingWrapper
variant="full-screen"
message={loadingMessage}
/>
);
}
// Default to skeleton
return (
<div className={className}>
<LoadingWrapper
variant="skeleton"
message={loadingMessage}
skeletonCount={3}
/>
{children}
</div>
);
}
// 2. Error State
if (error) {
const errorVariant = errorConfig?.variant || 'full-screen';
if (errorVariant === 'card') {
const cardTitle = errorConfig?.card?.title || 'Error';
const cardDescription = errorConfig?.card?.description || 'Something went wrong';
return (
<div className={className}>
<ErrorDisplay
error={error as ApiError}
onRetry={retry}
variant="card"
/>
{children}
</div>
);
}
// Default to full-screen
return (
<ErrorDisplay
error={error as ApiError}
onRetry={retry}
variant="full-screen"
/>
);
}
// 3. Empty State
if (!data || (Array.isArray(data) && data.length === 0)) {
if (empty) {
const Icon = empty.icon;
const hasAction = empty.action && retry;
return (
<div className={className}>
<EmptyState
icon={Icon || Inbox}
title={empty.title || 'No data available'}
description={empty.description}
action={hasAction ? {
label: empty.action!.label,
onClick: empty.action!.onClick,
} : undefined}
variant="default"
/>
{children}
</div>
);
}
// If no empty config provided but data is empty, show nothing
return (
<div className={className}>
{children}
</div>
);
}
// 4. Success State - Render Template with data
return (
<div className={className}>
<Template data={data} />
{children}
</div>
);
}
/**
* Convenience component for list data with automatic empty state handling
*/
export function ListPageWrapper<TData extends any[]>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
}: PageWrapperProps<TData>) {
const listEmpty = empty || {
icon: List,
title: 'No items found',
description: 'This list is currently empty',
};
return (
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
Template={Template}
loading={loading}
errorConfig={errorConfig}
empty={listEmpty}
className={className}
>
{children}
</PageWrapper>
);
}
/**
* Convenience component for detail pages with enhanced error handling
*/
export function DetailPageWrapper<TData>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
onBack,
onRefresh,
}: PageWrapperProps<TData> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
// Create enhanced error config with additional actions
const enhancedErrorConfig: PageWrapperErrorConfig = {
...errorConfig,
};
return (
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
Template={Template}
loading={loading}
errorConfig={enhancedErrorConfig}
empty={empty}
className={className}
>
{children}
</PageWrapper>
);
}

View File

@@ -1,390 +0,0 @@
'use client';
import React, { ReactNode } from 'react';
import { StateContainerProps, StateContainerConfig } from './types';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
import { Inbox, AlertCircle, Grid, List } from 'lucide-react';
/**
* StateContainer Component
*
* Combined wrapper that automatically handles all states (loading, error, empty, success)
* based on the provided data and state values.
*
* Features:
* - Automatic state detection and rendering
* - Customizable configuration for each state
* - Custom render functions for advanced use cases
* - Consistent behavior across all pages
*
* Usage Example:
* ```typescript
* <StateContainer
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* config={{
* loading: { variant: 'skeleton', message: 'Loading...' },
* error: { variant: 'full-screen' },
* empty: {
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }
* }}
* >
* {(content) => <MyContent data={content} />}
* </StateContainer>
* ```
*/
export function StateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
showEmpty = true,
isEmpty,
}: StateContainerProps<T>) {
// Determine if data is empty
const isDataEmpty = (data: T | null | undefined): boolean => {
if (data === null || data === undefined) return true;
if (isEmpty) return isEmpty(data);
// Default empty checks
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object' && data !== null) {
return Object.keys(data).length === 0;
}
return false;
};
// Priority order: Loading > Error > Empty > Success
if (isLoading) {
const loadingConfig = config?.loading || {};
// Custom render
if (config?.customRender?.loading) {
return <>{config.customRender.loading()}</>;
}
return (
<div className={className}>
<LoadingWrapper
variant={loadingConfig.variant || 'spinner'}
message={loadingConfig.message || 'Loading...'}
size={loadingConfig.size || 'md'}
skeletonCount={loadingConfig.skeletonCount}
/>
</div>
);
}
if (error) {
const errorConfig = config?.error || {};
// Custom render
if (config?.customRender?.error) {
return <>{config.customRender.error(error)}</>;
}
return (
<div className={className}>
<ErrorDisplay
error={error}
onRetry={retry}
variant={errorConfig.variant || 'full-screen'}
actions={errorConfig.actions}
showRetry={errorConfig.showRetry}
showNavigation={errorConfig.showNavigation}
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
/>
</div>
);
}
if (showEmpty && isDataEmpty(data)) {
const emptyConfig = config?.empty;
// Custom render
if (config?.customRender?.empty) {
return <>{config.customRender.empty()}</>;
}
// If no empty config provided, show nothing (or could show default empty state)
if (!emptyConfig) {
return (
<div className={className}>
<EmptyState
icon={Inbox}
title="No data available"
description="There is nothing to display here"
/>
</div>
);
}
return (
<div className={className}>
<EmptyState
icon={emptyConfig.icon}
title={emptyConfig.title || 'No data available'}
description={emptyConfig.description}
action={emptyConfig.action}
variant="default"
/>
</div>
);
}
// Success state - render children with data
if (data === null || data === undefined) {
// This shouldn't happen if we've handled all cases above, but as a fallback
return (
<div className={className}>
<EmptyState
icon={AlertCircle}
title="Unexpected state"
description="No data available but no error or loading state"
/>
</div>
);
}
// At this point, data is guaranteed to be non-null and non-undefined
return <>{children(data as T)}</>;
}
/**
* ListStateContainer - Specialized for list data
* Automatically handles empty arrays with appropriate messaging
*/
export function ListStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const listConfig: StateContainerConfig<T[]> = {
...config,
empty: emptyConfig || {
icon: List,
title: 'No items found',
description: 'This list is currently empty',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={listConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}
/**
* DetailStateContainer - Specialized for detail pages
* Includes back/refresh functionality
*/
export function DetailStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
onBack,
onRefresh,
}: StateContainerProps<T> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
const detailConfig: StateContainerConfig<T> = {
...config,
error: {
...config?.error,
actions: [
...(config?.error?.actions || []),
...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
],
showNavigation: config?.error?.showNavigation ?? true,
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={detailConfig}
className={className}
>
{children}
</StateContainer>
);
}
/**
* PageStateContainer - Full page state management
* Wraps content in proper page structure
*/
export function PageStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
title,
description,
}: StateContainerProps<T> & {
title?: string;
description?: string;
}) {
const pageConfig: StateContainerConfig<T> = {
loading: {
variant: 'full-screen',
message: title ? `Loading ${title}...` : 'Loading...',
...config?.loading,
},
error: {
variant: 'full-screen',
...config?.error,
},
empty: config?.empty,
};
if (isLoading) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (error) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
if (config?.empty) {
return (
<div className="min-h-screen bg-deep-graphite py-12">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
}
return (
<div className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-6">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
/**
* GridStateContainer - Specialized for grid layouts
* Handles card-based empty states
*/
export function GridStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const gridConfig: StateContainerConfig<T[]> = {
loading: {
variant: 'card',
...config?.loading,
},
...config,
empty: emptyConfig || {
icon: Grid,
title: 'No items to display',
description: 'Try adjusting your filters or search',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={gridConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}

View File

@@ -1,61 +0,0 @@
'use client';
import React from 'react';
import { PageWrapper, PageWrapperProps } from './PageWrapper';
/**
* Stateful Page Wrapper - CLIENT SIDE ONLY
* Adds loading/error state management for client-side fetching
*
* Usage:
* ```typescript
* 'use client';
*
* export default function ProfilePage() {
* const { data, isLoading, error, refetch } = usePageData(...);
*
* return (
* <StatefulPageWrapper
* data={data}
* isLoading={isLoading}
* error={error}
* retry={refetch}
* Template={ProfileTemplate}
* loading={{ variant: 'skeleton', message: 'Loading profile...' }}
* />
* );
* }
* ```
*/
export function StatefulPageWrapper<TData>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
}: PageWrapperProps<TData>) {
// Same implementation but with 'use client' for CSR-specific features
return (
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
Template={Template}
loading={loading}
errorConfig={errorConfig}
empty={empty}
className={className}
>
{children}
</PageWrapper>
);
}
// Re-export types for convenience
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';

View File

@@ -1,136 +0,0 @@
'use client';
import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
// ==================== EMPTY STATE TYPES ====================
export interface EmptyStateAction {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
icon?: LucideIcon;
}
export interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
variant?: 'default' | 'minimal' | 'full-page';
className?: string;
illustration?: 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
ariaLabel?: string;
}
// ==================== LOADING WRAPPER TYPES ====================
export interface LoadingWrapperCardConfig {
count?: number;
height?: string;
className?: string;
}
export interface LoadingWrapperProps {
variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
message?: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
skeletonCount?: number;
cardConfig?: LoadingWrapperCardConfig;
ariaLabel?: string;
}
// ==================== ERROR DISPLAY TYPES ====================
export interface ErrorDisplayAction {
label: string;
onClick: () => void;
variant: 'primary' | 'secondary' | 'outline' | 'ghost';
}
export interface ErrorDisplayProps {
error: ApiError | Error;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
className?: string;
}
// ==================== STATE CONTAINER TYPES ====================
export interface StateContainerConfig<T> {
loading?: Pick<LoadingWrapperProps, 'variant' | 'message' | 'size' | 'skeletonCount'>;
error?: {
variant?: 'full-screen' | 'card' | 'inline';
actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
};
empty?: {
icon?: LucideIcon;
title?: string;
description?: string;
action?: EmptyStateAction;
};
customRender?: {
loading?: () => ReactNode;
error?: (error: Error) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps<T> {
data: T | undefined;
isLoading?: boolean;
error?: Error | null;
retry?: () => void;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
className?: string;
showEmpty?: boolean;
isEmpty?: (data: T) => boolean;
}
// ==================== PAGE WRAPPER TYPES ====================
export interface PageWrapperLoadingConfig {
variant?: 'skeleton' | 'full-screen';
message?: string;
}
export interface PageWrapperErrorConfig {
variant?: 'full-screen' | 'card';
card?: {
title?: string;
description?: string;
};
}
export interface PageWrapperEmptyConfig {
icon?: LucideIcon;
title?: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export interface PageWrapperProps<TData> {
data: TData | undefined;
isLoading?: boolean;
error?: Error | null;
retry?: () => void;
Template: React.ComponentType<{ data: TData }>;
loading?: PageWrapperLoadingConfig;
errorConfig?: PageWrapperErrorConfig;
empty?: PageWrapperEmptyConfig;
children?: ReactNode;
className?: string;
}