import * as React from 'react'; import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp, Bug, Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from './button'; import { Card, CardContent } from './card'; import { Dialog } from './dialog'; import type { ApiError } from '@/schemas/apiSchemas'; import { formatUserFriendlyError } from '@/utils/errorMessages'; import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler'; import { formatIssueReport, copyIssueReportToClipboard, openGitHubIssue, } from '@/utils/reportIssue'; import toast from '@/utils/toast'; export interface ErrorDisplayProps { error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record }; onRetry?: () => void | Promise; onDismiss?: () => void; showDetails?: boolean; context?: { action?: string; resource?: string; resourceId?: string; [key: string]: any; }; variant?: 'inline' | 'banner' | 'modal' | 'card'; severity?: 'error' | 'warning' | 'info'; size?: 'sm' | 'md' | 'lg'; className?: string; dismissible?: boolean; title?: string; icon?: React.ReactNode; actions?: Array<{ label: string; onClick: () => void; variant?: 'default' | 'outline' | 'ghost' }>; } function normalizeError(error: any): { message: string; code?: string | number; status?: number; details?: any; stack?: string } { if (typeof error === 'string') return { message: error }; if (error instanceof Error) return { message: error.message, stack: error.stack }; if (error && typeof error === 'object') { try { const apiError = parseApiError(error); return { message: apiError.message || 'An error occurred', code: apiError.code, status: typeof apiError.code === 'number' ? apiError.code : undefined, details: apiError.details, }; } catch { return { message: (error as any).message || String(error), code: (error as any).code, status: (error as any).status, details: (error as any).details, }; } } return { message: String(error) }; } export const ErrorDisplay = React.forwardRef(({ error, onRetry, onDismiss, showDetails: showDetailsProp, context, variant = 'inline', severity = 'error', size = 'md', className, dismissible, title: titleProp, icon: iconProp, actions = [], ...props }, ref) => { const [isDetailsExpanded, setIsDetailsExpanded] = React.useState(false); const [isRetrying, setIsRetrying] = React.useState(false); const [isModalOpen, setIsModalOpen] = React.useState(true); const normalizedError = React.useMemo(() => normalizeError(error), [error]); const apiError = React.useMemo(() => parseApiError(error), [error]); const errorCategory = React.useMemo(() => getErrorCategory(apiError), [apiError]); const isServerError = React.useMemo(() => errorCategory === 'server_error' || (normalizedError.status !== undefined && normalizedError.status >= 500), [errorCategory, error]); const isDev = import.meta.env.DEV; const showDetails = showDetailsProp ?? isDev; const userMessage = React.useMemo(() => { if (normalizedError.message) { try { return formatUserFriendlyError(normalizedError as ApiError, context?.resource as any, false); } catch { return normalizedError.message; } } return 'An unexpected error occurred'; }, [normalizedError, context]); const title = React.useMemo(() => { if (titleProp) return titleProp; if (context?.action) return `Error ${context.action}`; switch (severity) { case 'error': return 'Error'; case 'warning': return 'Warning'; case 'info': return 'Information'; default: return 'Error'; } }, [titleProp, context, severity]); const iconConfig = React.useMemo(() => { if (iconProp) return iconProp; const iconSize = size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-6 h-6' : 'w-5 h-5'; switch (severity) { case 'error': return ; case 'warning': return ; case 'info': return ; default: return ; } }, [iconProp, severity, size]); const colorStyles = React.useMemo(() => { switch (severity) { case 'error': return { bg: 'bg-destructive/10', border: 'border-destructive/30', text: 'text-destructive', icon: 'text-destructive' }; case 'warning': return { bg: 'bg-warning/10', border: 'border-warning/30', text: 'text-warning', icon: 'text-warning' }; case 'info': return { bg: 'bg-info/10', border: 'border-info/30', text: 'text-info', icon: 'text-info' }; default: return { bg: 'bg-destructive/10', border: 'border-destructive/30', text: 'text-destructive', icon: 'text-destructive' }; } }, [severity]); const sizeStyles = React.useMemo(() => { switch (size) { case 'sm': return { padding: 'p-4', text: 'text-xs', title: 'text-sm', gap: 'gap-2' }; case 'lg': return { padding: 'p-6', text: 'text-base', title: 'text-lg', gap: 'gap-4' }; default: return { padding: 'p-4', text: 'text-sm', title: 'text-base', gap: 'gap-4' }; } }, [size]); const handleRetry = React.useCallback(async () => { if (!onRetry || isRetrying) return; setIsRetrying(true); try { await onRetry(); } finally { setIsRetrying(false); } }, [onRetry, isRetrying]); const handleDismiss = React.useCallback(() => { if (onDismiss) onDismiss(); if (variant === 'modal') setIsModalOpen(false); }, [onDismiss, variant]); const handleReportIssue = React.useCallback(async () => { try { const report = formatIssueReport(error, { component: context?.resource, action: context?.action, userId: context?.userId, additionalInfo: context }); try { openGitHubIssue(report); toast.success('Opening GitHub issue...'); } catch { await copyIssueReportToClipboard(report); toast.success('Issue report copied to clipboard'); } } catch { toast.error('Failed to generate issue report'); } }, [error, context]); const handleCopyRequestId = React.useCallback(async () => { if (!apiError.request_id) return; try { await navigator.clipboard.writeText(apiError.request_id); toast.success('Request ID copied to clipboard'); } catch { toast.error('Failed to copy request ID'); } }, [apiError.request_id]); const isDismissible = dismissible ?? (onDismiss !== undefined || variant === 'modal'); const renderDetails = () => { if (!showDetails || !isDetailsExpanded) return null; const details = []; if (apiError.request_id) details.push({ label: 'Request ID', value: apiError.request_id }); if (normalizedError.code) details.push({ label: 'Error Code', value: String(normalizedError.code) }); if (normalizedError.status) details.push({ label: 'HTTP Status', value: String(normalizedError.status) }); if (normalizedError.details) details.push({ label: 'Details', value: JSON.stringify(normalizedError.details, null, 2) }); if (normalizedError.stack) details.push({ label: 'Stack Trace', value: normalizedError.stack }); if (context) details.push({ label: 'Context', value: JSON.stringify(context, null, 2) }); if (details.length === 0) return null; return (
{details.map((detail, idx) => (
{detail.label}:
                {typeof detail.value === 'string' ? detail.value : JSON.stringify(detail.value, null, 2)}
              
))}
); }; const errorContent = (
{iconConfig}
{title}
{userMessage}
{renderDetails()} {(onRetry || actions.length > 0 || isServerError || (showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context))) && (
{onRetry && } {isServerError && apiError.request_id && ( <> )} {actions.map((action, idx) => ( ))} {showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context) && ( )}
)}
{isDismissible && ( )}
); switch (variant) { case 'banner': return
{errorContent}
; case 'card': return {errorContent}; case 'modal': return ( {onRetry && } {actions.map((action, idx) => )} }>
{userMessage}
{renderDetails()}
); case 'inline': default: return errorContent; } }); ErrorDisplay.displayName = 'ErrorDisplay';