error and load state
This commit is contained in:
326
apps/website/components/shared/state/EmptyState.tsx
Normal file
326
apps/website/components/shared/state/EmptyState.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyStateProps } from '../types/state.types';
|
||||
import Button from '@/components/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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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 */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
*/
|
||||
|
||||
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={require('lucide-react').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={require('lucide-react').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={require('lucide-react').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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
418
apps/website/components/shared/state/ErrorDisplay.tsx
Normal file
418
apps/website/components/shared/state/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
|
||||
import { ErrorDisplayProps } from '../types/state.types';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* ErrorDisplay Component
|
||||
*
|
||||
* Provides standardized error display with 4 variants:
|
||||
* - full-screen: Full page error with navigation
|
||||
* - inline: Compact inline error message
|
||||
* - card: Error card for grid layouts
|
||||
* - toast: Toast notification style
|
||||
*
|
||||
* Features:
|
||||
* - Auto-detects retryable errors
|
||||
* - Shows user-friendly messages
|
||||
* - Provides navigation options
|
||||
* - Displays technical details in development
|
||||
* - Fully accessible with ARIA labels and keyboard navigation
|
||||
*/
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
showRetry: showRetryProp,
|
||||
showNavigation = true,
|
||||
actions = [],
|
||||
className = '',
|
||||
hideTechnicalDetails = false,
|
||||
ariaLabel = 'Error notification',
|
||||
}: ErrorDisplayProps) {
|
||||
const router = useRouter();
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
// Auto-detect retry capability
|
||||
const isRetryable = showRetryProp ?? error.isRetryable();
|
||||
const isConnectivity = error.isConnectivityIssue();
|
||||
const userMessage = error.getUserMessage();
|
||||
const isDev = process.env.NODE_ENV === 'development' && !hideTechnicalDetails;
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (onRetry) {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await onRetry();
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// For toast variant, this would typically close the notification
|
||||
// Implementation depends on how toast notifications are managed
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
// Icon based on error type
|
||||
const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
|
||||
|
||||
// Common button styles
|
||||
const buttonBase = 'flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50';
|
||||
const primaryButton = `${buttonBase} bg-red-500 hover:bg-red-600 text-white`;
|
||||
const secondaryButton = `${buttonBase} bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline`;
|
||||
const ghostButton = `${buttonBase} hover:bg-iron-gray/50 text-gray-300`;
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'full-screen':
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-deep-graphite flex items-center justify-center p-4"
|
||||
role="alert"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-500/20 rounded-lg">
|
||||
<ErrorIcon className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
|
||||
|
||||
{/* Technical Details (Development Only) */}
|
||||
{isDev && (
|
||||
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
|
||||
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>Type: {error.type}</div>
|
||||
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
|
||||
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
|
||||
{error.context.retryCount !== undefined && (
|
||||
<div>Retries: {error.context.retryCount}</div>
|
||||
)}
|
||||
{error.context.wasRetry && <div>Was Retry: true</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{isRetryable && onRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className={primaryButton}
|
||||
aria-label={isRetrying ? 'Retrying...' : 'Try again'}
|
||||
>
|
||||
{isRetrying ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try Again
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showNavigation && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleGoBack}
|
||||
className={`${secondaryButton} flex-1`}
|
||||
aria-label="Go back to previous page"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Go Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGoHome}
|
||||
className={`${secondaryButton} flex-1`}
|
||||
aria-label="Go to home page"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Actions */}
|
||||
{actions.length > 0 && (
|
||||
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
|
||||
{actions.map((action, index) => {
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-blue hover:bg-blue-600 text-white',
|
||||
secondary: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
ghost: 'hover:bg-iron-gray/50 text-gray-300',
|
||||
}[action.variant || 'secondary'];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`${buttonBase} ${variantClasses} ${action.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
|
||||
If this persists, please contact support at{' '}
|
||||
<a
|
||||
href="mailto:support@gridpilot.com"
|
||||
className="text-primary-blue hover:underline"
|
||||
aria-label="Email support"
|
||||
>
|
||||
support@gridpilot.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<div
|
||||
className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className}`}
|
||||
role="alert"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-red-200 font-medium">{userMessage}</p>
|
||||
{isDev && (
|
||||
<p className="text-xs text-red-300/70 mt-1 font-mono">
|
||||
[{error.type}] {error.context.statusCode || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{isRetryable && onRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isRetrying ? 'Retrying...' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="text-xs px-3 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-200 p-1 rounded hover:bg-iron-gray/50"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<div
|
||||
className={`bg-iron-gray border border-red-500/30 rounded-xl overflow-hidden ${className}`}
|
||||
role="alert"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ErrorIcon className="w-5 h-5 text-red-400" />
|
||||
<h3 className="text-white font-semibold">Error</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-gray-300 text-sm">{userMessage}</p>
|
||||
{isDev && (
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{error.type} | Status: {error.context.statusCode || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{isRetryable && onRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
{isRetrying ? 'Retrying' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
{actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="text-xs px-3 py-1.5 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'toast':
|
||||
return (
|
||||
<div
|
||||
className={`bg-iron-gray border-l-4 border-red-500 rounded-r-lg shadow-lg p-4 flex items-start gap-3 ${className}`}
|
||||
role="alert"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="assertive"
|
||||
>
|
||||
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium text-sm">{userMessage}</p>
|
||||
{isDev && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
[{error.type}] {error.context.statusCode || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{isRetryable && onRetry && (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="text-xs px-2.5 py-1 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isRetrying ? '...' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
{actions.slice(0, 2).map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="text-xs px-2.5 py-1 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-white p-1 rounded hover:bg-iron-gray/50 flex-shrink-0"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-screen error display
|
||||
*/
|
||||
export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant="full-screen"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for inline error display
|
||||
*/
|
||||
export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant="inline"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for card error display
|
||||
*/
|
||||
export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant="card"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for toast error display
|
||||
*/
|
||||
export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant="toast"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
199
apps/website/components/shared/state/LoadingWrapper.tsx
Normal file
199
apps/website/components/shared/state/LoadingWrapper.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LoadingWrapperProps } from '../types/state.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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
389
apps/website/components/shared/state/StateContainer.tsx
Normal file
389
apps/website/components/shared/state/StateContainer.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { StateContainerProps, StateContainerConfig } from '../types/state.types';
|
||||
import { LoadingWrapper } from './LoadingWrapper';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
/**
|
||||
* 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): 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={require('lucide-react').Inbox}
|
||||
title="No data available"
|
||||
description="There is nothing to display here"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<EmptyState
|
||||
icon={emptyConfig.icon}
|
||||
title={emptyConfig.title}
|
||||
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={require('lucide-react').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
|
||||
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: require('lucide-react').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: require('lucide-react').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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Basic test file to verify state components are properly exported and typed
|
||||
*/
|
||||
|
||||
import { LoadingWrapper } from '../LoadingWrapper';
|
||||
import { ErrorDisplay } from '../ErrorDisplay';
|
||||
import { EmptyState } from '../EmptyState';
|
||||
import { StateContainer } from '../StateContainer';
|
||||
import { useDataFetching } from '../../hooks/useDataFetching';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
// This file just verifies that all components can be imported and are properly typed
|
||||
// Full testing would be done in separate test files
|
||||
|
||||
describe('State Components - Basic Type Checking', () => {
|
||||
it('should export all components', () => {
|
||||
expect(LoadingWrapper).toBeDefined();
|
||||
expect(ErrorDisplay).toBeDefined();
|
||||
expect(EmptyState).toBeDefined();
|
||||
expect(StateContainer).toBeDefined();
|
||||
expect(useDataFetching).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have proper component signatures', () => {
|
||||
// LoadingWrapper accepts props
|
||||
const loadingProps = {
|
||||
variant: 'spinner' as const,
|
||||
message: 'Loading...',
|
||||
size: 'md' as const,
|
||||
};
|
||||
expect(loadingProps).toBeDefined();
|
||||
|
||||
// ErrorDisplay accepts ApiError
|
||||
const mockError = new ApiError(
|
||||
'Test error',
|
||||
'NETWORK_ERROR',
|
||||
{ timestamp: new Date().toISOString() }
|
||||
);
|
||||
expect(mockError).toBeDefined();
|
||||
expect(mockError.isRetryable()).toBe(true);
|
||||
|
||||
// EmptyState accepts icon and title
|
||||
const emptyProps = {
|
||||
icon: require('lucide-react').Activity,
|
||||
title: 'No data',
|
||||
};
|
||||
expect(emptyProps).toBeDefined();
|
||||
|
||||
// StateContainer accepts data and state
|
||||
const stateProps = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
retry: async () => {},
|
||||
children: (data: any) => <div>{JSON.stringify(data)}</div>,
|
||||
};
|
||||
expect(stateProps).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user