error and load state
This commit is contained in:
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user