Files
gridpilot.gg/apps/website/components/shared/state/ErrorDisplay.tsx
2026-01-18 16:43:32 +01:00

248 lines
7.6 KiB
TypeScript

import { ApiError } from '@/lib/api/base/ApiError';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Surface } from '@/ui/primitives/Surface';
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
import { Text } from '@/ui/Text';
import { AlertCircle, ArrowLeft, Box, Home, RefreshCw } from 'lucide-react';
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
actions = [],
showRetry = true,
showNavigation = true,
hideTechnicalDetails = false,
className = '',
}: ErrorDisplayProps) {
const getErrorInfo = () => {
const isApiError = error instanceof ApiError;
return {
title: isApiError ? 'API Error' : 'Unexpected Error',
message: error.message || 'Something went wrong',
statusCode: isApiError ? error.context.statusCode : undefined,
details: isApiError ? error.context.responseText : undefined,
isApiError,
};
};
const errorInfo = getErrorInfo();
const defaultActions: ErrorDisplayAction[] = [
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
...(showNavigation ? [
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
] : []),
...actions,
];
switch (variant) {
case 'full-screen':
return (
<Box
position="fixed"
inset="0"
zIndex={50}
bg="bg-deep-graphite"
display="flex"
alignItems="center"
justifyContent="center"
p={6}
className={className}
role="alert"
aria-live="assertive"
>
<Box maxWidth="lg" fullWidth textAlign="center">
<Box display="flex" justifyContent="center" mb={6}>
<Box
display="flex"
h="20"
w="20"
alignItems="center"
justifyContent="center"
rounded="3xl"
bg="bg-red-500/10"
border={true}
borderColor="border-red-500/30"
>
<Icon icon={AlertCircle} size={10} color="text-red-500" />
</Box>
</Box>
<Heading level={2} mb={3}>
{errorInfo.title}
</Heading>
<Text size="lg" color="text-gray-400" block mb={6} style={{ lineHeight: 1.625 }}>
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Box mb={6} display="inline-flex" alignItems="center" gap={2} px={4} py={2} bg="bg-iron-gray/40" rounded="lg">
<Text size="sm" color="text-gray-300" font="mono">HTTP {errorInfo.statusCode}</Text>
{errorInfo.details && !hideTechnicalDetails && (
<Text size="sm" color="text-gray-500">- {errorInfo.details}</Text>
)}
</Box>
)}
{defaultActions.length > 0 && (
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
icon={action.icon && <Icon icon={action.icon} size={4} />}
className="px-6 py-3"
>
{action.label}
</Button>
))}
</Stack>
)}
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
<Box mt={8} textAlign="left">
<details className="cursor-pointer">
<summary className="text-sm text-gray-500 hover:text-gray-400">
Technical Details
</summary>
<Box as="pre" mt={2} p={4} bg="bg-black/50" rounded="lg" color="text-gray-400" style={{ fontSize: '0.75rem', overflowX: 'auto' }}>
{error.stack}
</Box>
</details>
</Box>
)}
</Box>
</Box>
);
case 'card':
return (
<Surface
variant="muted"
border={true}
borderColor="border-red-500/30"
rounded="xl"
p={6}
className={className}
role="alert"
aria-live="assertive"
>
<Stack direction="row" gap={4} align="start">
<Icon icon={AlertCircle} size={6} color="text-red-500" />
<Box flexGrow={1}>
<Heading level={3} mb={1}>
{errorInfo.title}
</Heading>
<Text size="sm" color="text-gray-400" block mb={3}>
{errorInfo.message}
</Text>
{errorInfo.isApiError && errorInfo.statusCode && (
<Text size="xs" font="mono" color="text-gray-500" block mb={3}>
HTTP {errorInfo.statusCode}
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
</Text>
)}
{defaultActions.length > 0 && (
<Stack direction="row" gap={2}>
{defaultActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
size="sm"
>
{action.label}
</Button>
))}
</Stack>
)}
</Box>
</Stack>
</Surface>
);
case 'inline':
return (
<Box
display="inline-flex"
alignItems="center"
gap={2}
px={3}
py={2}
bg="bg-red-500/10"
border={true}
borderColor="border-red-500/30"
rounded="lg"
className={className}
role="alert"
aria-live="assertive"
>
<Icon icon={AlertCircle} size={4} color="text-red-500" />
<Text size="sm" color="text-red-400">{errorInfo.message}</Text>
{onRetry && showRetry && (
<Button
variant="ghost"
onClick={onRetry}
size="sm"
className="ml-2 text-xs text-red-300 hover:text-red-200 underline p-0 h-auto"
>
Retry
</Button>
)}
</Box>
);
default:
return null;
}
}
export function ApiErrorDisplay({
error,
onRetry,
variant = 'full-screen',
hideTechnicalDetails = false,
}: {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
hideTechnicalDetails?: boolean;
}) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant={variant}
hideTechnicalDetails={hideTechnicalDetails}
/>
);
}
export function NetworkErrorDisplay({
onRetry,
variant = 'full-screen',
}: {
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
}) {
return (
<ErrorDisplay
error={new Error('Network connection failed. Please check your internet connection.')}
onRetry={onRetry}
variant={variant}
/>
);
}