page wrapper
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { EmptyStateProps } from './types';
|
||||
import { EmptyStateProps, EmptyStateAction } from './types';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
@@ -81,11 +81,11 @@ export function EmptyState({
|
||||
<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 */}
|
||||
@@ -144,9 +144,11 @@ export function EmptyState({
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
@@ -182,13 +184,13 @@ export function EmptyState({
|
||||
<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">
|
||||
@@ -289,10 +291,12 @@ export function FullPageEmptyState({ icon, title, description, action, className
|
||||
* Pre-configured empty states for common scenarios
|
||||
*/
|
||||
|
||||
import { Activity, Search, Lock } from 'lucide-react';
|
||||
|
||||
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={require('lucide-react').Activity}
|
||||
icon={Activity}
|
||||
title="No data available"
|
||||
description="There is nothing to display here at the moment"
|
||||
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
|
||||
@@ -304,7 +308,7 @@ export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={require('lucide-react').Search}
|
||||
icon={Search}
|
||||
title="No results found"
|
||||
description="Try adjusting your search or filters"
|
||||
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
|
||||
@@ -316,7 +320,7 @@ export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={require('lucide-react').Lock}
|
||||
icon={Lock}
|
||||
title="Access denied"
|
||||
description="You don't have permission to view this content"
|
||||
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
|
||||
|
||||
@@ -1,196 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { AlertCircle, RefreshCw, ArrowLeft, Home, Bug } from 'lucide-react';
|
||||
import { ErrorDisplayProps } from './types';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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:
|
||||
* - Auto-detects retryable errors
|
||||
* - Shows user-friendly messages
|
||||
* - Provides navigation options
|
||||
* - Displays technical details in development
|
||||
* - Fully accessible with ARIA labels and keyboard navigation
|
||||
* - 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',
|
||||
showRetry: showRetryProp,
|
||||
showNavigation = true,
|
||||
actions = [],
|
||||
className = '',
|
||||
showRetry = true,
|
||||
showNavigation = true,
|
||||
hideTechnicalDetails = false,
|
||||
ariaLabel = 'Error notification',
|
||||
className = '',
|
||||
}: 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);
|
||||
}
|
||||
}
|
||||
// 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 handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
const errorInfo = getErrorInfo();
|
||||
|
||||
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;
|
||||
// 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="min-h-screen bg-deep-graphite flex items-center justify-center p-4"
|
||||
className={`fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
|
||||
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 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{isRetryable && onRetry && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="w-full"
|
||||
>
|
||||
{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
|
||||
variant="secondary"
|
||||
onClick={handleGoBack}
|
||||
className="flex-1"
|
||||
{/* 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' : ''}
|
||||
`}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleGoHome}
|
||||
className="flex-1"
|
||||
>
|
||||
<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) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'secondary'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className="w-full"
|
||||
>
|
||||
{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>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,145 +186,20 @@ export function ErrorDisplay({
|
||||
case 'inline':
|
||||
return (
|
||||
<div
|
||||
className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className}`}
|
||||
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-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>
|
||||
<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={handleClose}
|
||||
className="text-gray-400 hover:text-gray-200 p-1 rounded hover:bg-iron-gray/50"
|
||||
aria-label="Dismiss error"
|
||||
onClick={onRetry}
|
||||
className="ml-2 text-xs text-red-300 hover:text-red-200 underline"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Retry
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -347,57 +209,44 @@ export function ErrorDisplay({
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-screen error display
|
||||
* Convenience component for API error display
|
||||
*/
|
||||
export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
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="full-screen"
|
||||
{...props}
|
||||
variant={variant}
|
||||
hideTechnicalDetails={hideTechnicalDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for inline error display
|
||||
* Convenience component for network error display
|
||||
*/
|
||||
export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
|
||||
export function NetworkErrorDisplay({
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
}: {
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
error={new Error('Network connection failed. Please check your internet connection.')}
|
||||
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}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
276
apps/website/components/shared/state/PageWrapper.tsx
Normal file
276
apps/website/components/shared/state/PageWrapper.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -121,7 +122,7 @@ export function StateContainer<T>({
|
||||
return (
|
||||
<div className={className}>
|
||||
<EmptyState
|
||||
icon={require('lucide-react').Inbox}
|
||||
icon={Inbox}
|
||||
title="No data available"
|
||||
description="There is nothing to display here"
|
||||
/>
|
||||
@@ -133,7 +134,7 @@ export function StateContainer<T>({
|
||||
<div className={className}>
|
||||
<EmptyState
|
||||
icon={emptyConfig.icon}
|
||||
title={emptyConfig.title}
|
||||
title={emptyConfig.title || 'No data available'}
|
||||
description={emptyConfig.description}
|
||||
action={emptyConfig.action}
|
||||
variant="default"
|
||||
@@ -148,7 +149,7 @@ export function StateContainer<T>({
|
||||
return (
|
||||
<div className={className}>
|
||||
<EmptyState
|
||||
icon={require('lucide-react').AlertCircle}
|
||||
icon={AlertCircle}
|
||||
title="Unexpected state"
|
||||
description="No data available but no error or loading state"
|
||||
/>
|
||||
@@ -187,7 +188,7 @@ export function ListStateContainer<T>({
|
||||
const listConfig: StateContainerConfig<T[]> = {
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: require('lucide-react').List,
|
||||
icon: List,
|
||||
title: 'No items found',
|
||||
description: 'This list is currently empty',
|
||||
},
|
||||
@@ -367,7 +368,7 @@ export function GridStateContainer<T>({
|
||||
},
|
||||
...config,
|
||||
empty: emptyConfig || {
|
||||
icon: require('lucide-react').Grid,
|
||||
icon: Grid,
|
||||
title: 'No items to display',
|
||||
description: 'Try adjusting your filters or search',
|
||||
},
|
||||
|
||||
61
apps/website/components/shared/state/StatefulPageWrapper.tsx
Normal file
61
apps/website/components/shared/state/StatefulPageWrapper.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'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';
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
// ==================== EMPTY STATE TYPES ====================
|
||||
@@ -9,12 +9,12 @@ import { ApiError } from '@/lib/api/base/ApiError';
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: EmptyStateAction;
|
||||
@@ -24,9 +24,9 @@ export interface EmptyStateProps {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ==================== LOADING STATE TYPES ====================
|
||||
// ==================== LOADING WRAPPER TYPES ====================
|
||||
|
||||
export interface LoadingCardConfig {
|
||||
export interface LoadingWrapperCardConfig {
|
||||
count?: number;
|
||||
height?: string;
|
||||
className?: string;
|
||||
@@ -38,67 +38,58 @@ export interface LoadingWrapperProps {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
skeletonCount?: number;
|
||||
cardConfig?: LoadingCardConfig;
|
||||
cardConfig?: LoadingWrapperCardConfig;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// ==================== ERROR STATE TYPES ====================
|
||||
// ==================== ERROR DISPLAY TYPES ====================
|
||||
|
||||
export interface ErrorAction {
|
||||
export interface ErrorDisplayAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
icon?: LucideIcon;
|
||||
disabled?: boolean;
|
||||
variant: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
}
|
||||
|
||||
export interface ErrorDisplayProps {
|
||||
error: ApiError;
|
||||
error: ApiError | Error;
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'inline' | 'card' | 'toast';
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
actions?: ErrorDisplayAction[];
|
||||
showRetry?: boolean;
|
||||
showNavigation?: boolean;
|
||||
actions?: ErrorAction[];
|
||||
className?: string;
|
||||
hideTechnicalDetails?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ==================== STATE CONTAINER TYPES ====================
|
||||
|
||||
export interface StateContainerConfig<T> {
|
||||
loading?: {
|
||||
variant?: LoadingWrapperProps['variant'];
|
||||
message?: string;
|
||||
size?: LoadingWrapperProps['size'];
|
||||
skeletonCount?: number;
|
||||
};
|
||||
loading?: Pick<LoadingWrapperProps, 'variant' | 'message' | 'size' | 'skeletonCount'>;
|
||||
error?: {
|
||||
variant?: ErrorDisplayProps['variant'];
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
actions?: ErrorDisplayAction[];
|
||||
showRetry?: boolean;
|
||||
showNavigation?: boolean;
|
||||
hideTechnicalDetails?: boolean;
|
||||
actions?: ErrorAction[];
|
||||
};
|
||||
empty?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: EmptyStateAction;
|
||||
illustration?: EmptyStateProps['illustration'];
|
||||
};
|
||||
customRender?: {
|
||||
loading?: () => ReactNode;
|
||||
error?: (error: ApiError) => ReactNode;
|
||||
error?: (error: Error) => ReactNode;
|
||||
empty?: () => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StateContainerProps<T> {
|
||||
data: T | null | undefined;
|
||||
isLoading: boolean;
|
||||
error: ApiError | null;
|
||||
retry: () => void;
|
||||
data: T | undefined;
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
retry?: () => void;
|
||||
children: (data: T) => ReactNode;
|
||||
config?: StateContainerConfig<T>;
|
||||
className?: string;
|
||||
@@ -106,11 +97,40 @@ export interface StateContainerProps<T> {
|
||||
isEmpty?: (data: T) => boolean;
|
||||
}
|
||||
|
||||
// ==================== CONVENIENCE PROP TYPES ====================
|
||||
// ==================== PAGE WRAPPER TYPES ====================
|
||||
|
||||
// For components that only need specific subsets of props
|
||||
export type MinimalEmptyStateProps = Omit<EmptyStateProps, 'variant'>;
|
||||
export type MinimalLoadingProps = Pick<LoadingWrapperProps, 'message' | 'className'>;
|
||||
export type InlineLoadingProps = Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>;
|
||||
export type SkeletonLoadingProps = Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>;
|
||||
export type CardLoadingProps = Pick<LoadingWrapperProps, 'cardConfig' | 'className'>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user