page wrapper

This commit is contained in:
2026-01-07 12:40:52 +01:00
parent e589c30bf8
commit 0db80fa98d
128 changed files with 7386 additions and 8096 deletions

View File

@@ -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}
/>
);
}