280 lines
9.3 KiB
TypeScript
280 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
import { ApiError } from '@/lib/api/base/ApiError';
|
|
import { getErrorSeverity, isConnectivityError, isRetryable, parseApiError } from '@/lib/utils/errorUtils';
|
|
import { Button } from '@/ui/Button';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { IconButton } from '@/ui/IconButton';
|
|
import { Stack } from '@/ui/Stack';
|
|
import { Text } from '@/ui/Text';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import {
|
|
AlertCircle,
|
|
AlertTriangle,
|
|
Bug,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Info,
|
|
RefreshCw,
|
|
Wifi,
|
|
X
|
|
} from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
interface EnhancedFormErrorProps {
|
|
error: unknown;
|
|
onRetry?: () => void;
|
|
onDismiss?: () => void;
|
|
showDeveloperDetails?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Enhanced Form Error Component
|
|
*
|
|
* Shows user-friendly error messages with optional developer details.
|
|
* Handles validation errors, network errors, and general errors.
|
|
*/
|
|
export function EnhancedFormError({
|
|
error,
|
|
onRetry,
|
|
onDismiss,
|
|
showDeveloperDetails = process.env.NODE_ENV === 'development',
|
|
}: EnhancedFormErrorProps) {
|
|
const [showDetails, setShowDetails] = useState(false);
|
|
const parsed = parseApiError(error);
|
|
const severity = getErrorSeverity(error);
|
|
const retryable = isRetryable(error);
|
|
const connectivity = isConnectivityError(error);
|
|
|
|
const getIcon = () => {
|
|
if (connectivity) return Wifi;
|
|
if (severity === 'error') return AlertTriangle;
|
|
if (severity === 'warning') return AlertCircle;
|
|
return Info;
|
|
};
|
|
|
|
const getColor = () => {
|
|
switch (severity) {
|
|
case 'error': return 'red';
|
|
case 'warning': return 'amber';
|
|
case 'info': return 'blue';
|
|
default: return 'gray';
|
|
}
|
|
};
|
|
|
|
const color = getColor();
|
|
|
|
return (
|
|
<Stack
|
|
as={motion.div}
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
>
|
|
<Stack
|
|
bg={`bg-${color}-500/10`}
|
|
border
|
|
borderColor={`border-${color}-500/30`}
|
|
rounded="lg"
|
|
overflow="hidden"
|
|
>
|
|
{/* Main Error Message */}
|
|
<Stack p={4} display="flex" alignItems="start" gap={3}>
|
|
<Stack color={`text-${color}-400`} flexShrink={0} mt={0.5}>
|
|
<Icon icon={getIcon()} size={5} />
|
|
</Stack>
|
|
|
|
<Stack flexGrow={1} minWidth="0">
|
|
<Stack display="flex" alignItems="center" justifyContent="between" gap={2}>
|
|
<Text size="sm" weight="medium" color={`text-${color}-200`}>
|
|
{parsed.userMessage}
|
|
</Text>
|
|
|
|
<Stack display="flex" alignItems="center" gap={2}>
|
|
{retryable && onRetry && (
|
|
<IconButton
|
|
icon={RefreshCw}
|
|
onClick={onRetry}
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Retry"
|
|
/>
|
|
)}
|
|
|
|
{onDismiss && (
|
|
<IconButton
|
|
icon={X}
|
|
onClick={onDismiss}
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Dismiss"
|
|
/>
|
|
)}
|
|
|
|
{showDeveloperDetails && (
|
|
<IconButton
|
|
icon={showDetails ? ChevronUp : ChevronDown}
|
|
onClick={() => setShowDetails(!showDetails)}
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Toggle technical details"
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Validation Errors List */}
|
|
{parsed.isValidationError && parsed.validationErrors.length > 0 && (
|
|
<Stack gap={1} mt={2}>
|
|
{parsed.validationErrors.map((validationError, index) => (
|
|
<Text key={index} size="xs" color={`text-${color}-300/80`} block>
|
|
• {validationError.field}: {validationError.message}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Action Hint */}
|
|
<Stack mt={2}>
|
|
<Text size="xs" color="text-gray-400">
|
|
{connectivity && "Check your internet connection and try again"}
|
|
{parsed.isValidationError && "Please review your input and try again"}
|
|
{retryable && !connectivity && !parsed.isValidationError && "Please try again in a moment"}
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Developer Details */}
|
|
<AnimatePresence>
|
|
{showDetails && (
|
|
<Stack
|
|
as={motion.div}
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
>
|
|
<Stack borderTop borderColor={`border-${color}-500/20`} bg="bg-black/20" p={4}>
|
|
<Stack gap={3} fontSize="0.75rem">
|
|
<Stack display="flex" alignItems="center" gap={2} color="text-gray-400">
|
|
<Icon icon={Bug} size={3} />
|
|
<Text weight="semibold">Developer Details</Text>
|
|
</Stack>
|
|
|
|
<Stack gap={1}>
|
|
<Text color="text-gray-500">Error Type:</Text>
|
|
<Text color="text-white">{error instanceof ApiError ? error.type : 'Unknown'}</Text>
|
|
</Stack>
|
|
|
|
<Stack gap={1}>
|
|
<Text color="text-gray-500">Developer Message:</Text>
|
|
<Text color="text-white" transform="break-all">{parsed.developerMessage}</Text>
|
|
</Stack>
|
|
|
|
{error instanceof ApiError && error.context.endpoint && (
|
|
<Stack gap={1}>
|
|
<Text color="text-gray-500">Endpoint:</Text>
|
|
<Text color="text-white">{error.context.method} {error.context.endpoint}</Text>
|
|
</Stack>
|
|
)}
|
|
|
|
{error instanceof ApiError && error.context.statusCode && (
|
|
<Stack gap={1}>
|
|
<Text color="text-gray-500">Status Code:</Text>
|
|
<Text color="text-white">{error.context.statusCode}</Text>
|
|
</Stack>
|
|
)}
|
|
|
|
<Stack pt={2} borderTop borderColor="border-charcoal-outline/50">
|
|
<Text color="text-gray-500" block mb={1}>Quick Actions:</Text>
|
|
<Stack display="flex" gap={2}>
|
|
{retryable && onRetry && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={onRetry}
|
|
size="sm"
|
|
bg="bg-blue-600/20"
|
|
color="text-primary-blue"
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
if (error instanceof Error) {
|
|
console.error('Full error details:', error);
|
|
if (error.stack) {
|
|
console.log('Stack trace:', error.stack);
|
|
}
|
|
}
|
|
}}
|
|
size="sm"
|
|
bg="bg-purple-600/20"
|
|
color="text-purple-400"
|
|
>
|
|
Log to Console
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
)}
|
|
</AnimatePresence>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Form Error Summary Component
|
|
*
|
|
* Shows a compact error summary for form submission failures
|
|
*/
|
|
export function FormErrorSummary({
|
|
error,
|
|
onDismiss
|
|
}: {
|
|
error: unknown;
|
|
onDismiss?: () => void;
|
|
}) {
|
|
const parsed = parseApiError(error);
|
|
const summary = {
|
|
title: 'Submission Failed',
|
|
description: parsed.userMessage,
|
|
action: 'Please try again'
|
|
};
|
|
|
|
return (
|
|
<Stack
|
|
as={motion.div}
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
>
|
|
<Stack bg="bg-red-500/10" border borderColor="border-red-500/30" rounded="lg" p={3} display="flex" alignItems="start" gap={2}>
|
|
<Icon icon={AlertCircle} size={4} color="rgb(239, 68, 68)" mt={0.5} />
|
|
<Stack flexGrow={1} minWidth="0">
|
|
<Stack display="flex" alignItems="center" justifyContent="between" gap={2}>
|
|
<Stack>
|
|
<Text size="sm" weight="medium" color="text-red-200" block>{summary.title}</Text>
|
|
<Text size="xs" color="text-red-300/80" block mt={0.5}>{summary.description}</Text>
|
|
<Text size="xs" color="text-gray-400" block mt={1}>{summary.action}</Text>
|
|
</Stack>
|
|
{onDismiss && (
|
|
<IconButton
|
|
icon={X}
|
|
onClick={onDismiss}
|
|
variant="ghost"
|
|
size="sm"
|
|
color="rgb(239, 68, 68)"
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
}
|