import * as React from 'react'; import { AlertTriangle, AlertCircle, Info, X, ChevronDown, ChevronUp } 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 '@/types/api'; import { formatUserFriendlyError } from '@/utils/errorMessages'; import { parseApiError } from '@/utils/apiErrorHandler'; /** * ErrorDisplayProps - Props for the ErrorDisplay component * * Standardizes error presentation across the application with support for * different variants, severities, and recovery actions. */ export interface ErrorDisplayProps { /** * The error to display * Can be Error object, ApiError, string, or error-like object */ error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record; }; /** * Callback when user clicks retry button * If not provided, retry button is hidden */ onRetry?: () => void | Promise; /** * Callback when user dismisses the error * If not provided, dismiss button is hidden */ onDismiss?: () => void; /** * Whether to show detailed error information * Default: false (only shows user-friendly message) * In development, defaults to true */ showDetails?: boolean; /** * Additional context about where/why the error occurred * Used for logging and debugging */ context?: { action?: string; // e.g., "fetching tracks", "uploading file" resource?: string; // e.g., "tracks", "playlist" resourceId?: string; // e.g., track ID, playlist ID [key: string]: any; // Additional context }; /** * Variant/style of error display * - "inline": Inline error within content (default) * - "banner": Full-width banner at top of page * - "modal": Modal dialog overlay * - "card": Card-style error display */ variant?: 'inline' | 'banner' | 'modal' | 'card'; /** * Error severity/category * Affects visual styling (color, icon) * - "error": Critical errors (default) * - "warning": Warnings that don't block functionality * - "info": Informational messages */ severity?: 'error' | 'warning' | 'info'; /** * Size of the error display * - "sm": Small, compact * - "md": Medium (default) * - "lg": Large, prominent */ size?: 'sm' | 'md' | 'lg'; /** * Custom className for styling */ className?: string; /** * Whether error is dismissible * Default: true (if onDismiss provided) */ dismissible?: boolean; /** * Custom title override * If not provided, uses default title based on error type */ title?: string; /** * Custom icon override * If not provided, uses default icon based on severity */ icon?: React.ReactNode; /** * Additional actions to display alongside retry/dismiss * Array of { label, onClick, variant } objects */ actions?: Array<{ label: string; onClick: () => void; variant?: 'default' | 'outline' | 'ghost'; }>; } /** * Normalizes different error types into a consistent structure */ function normalizeError( error: Error | ApiError | string | { message: string; code?: string | number; status?: number; details?: Record } ): { message: string; code?: string | number; status?: number; details?: any; stack?: string; } { // String error if (typeof error === 'string') { return { message: error }; } // Standard Error object if (error instanceof Error) { return { message: error.message, stack: error.stack, }; } // ApiError or error-like object if (error && typeof error === 'object') { // Try to parse as ApiError if it has the structure 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 { // Fallback to direct object access 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) }; } /** * ErrorDisplay - Standardized error display component * * Provides consistent error UI across the application with support for * different variants, severities, and recovery actions. * * @example * ```tsx * refetch()} * /> * ``` */ 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); // Normalize error const normalizedError = React.useMemo(() => normalizeError(error), [error]); // Determine if details should be shown const isDev = import.meta.env.DEV; const showDetails = showDetailsProp ?? isDev; // Extract user-friendly message const userMessage = React.useMemo(() => { if (normalizedError.message) { // Try to format using utility if it's an ApiError-like structure try { return formatUserFriendlyError( normalizedError as ApiError, context?.resource as any, false ); } catch { return normalizedError.message; } } return 'An unexpected error occurred'; }, [normalizedError, context]); // Determine title 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]); // Icon configuration 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]); // Color styles based on severity const colorStyles = React.useMemo(() => { switch (severity) { case 'error': return { bg: 'bg-kodo-red/10', border: 'border-kodo-red/30', text: 'text-kodo-red', icon: 'text-kodo-red', }; case 'warning': return { bg: 'bg-kodo-gold/10', border: 'border-kodo-gold/30', text: 'text-kodo-gold', icon: 'text-kodo-gold', }; case 'info': return { bg: 'bg-kodo-cyan/10', border: 'border-kodo-cyan/30', text: 'text-kodo-cyan', icon: 'text-kodo-cyan', }; default: return { bg: 'bg-kodo-red/10', border: 'border-kodo-red/30', text: 'text-kodo-red', icon: 'text-kodo-red', }; } }, [severity]); // Size styles const sizeStyles = React.useMemo(() => { switch (size) { case 'sm': return { padding: 'p-3', 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-3', }; } }, [size]); // Handle retry const handleRetry = React.useCallback(async () => { if (!onRetry || isRetrying) return; setIsRetrying(true); try { await onRetry(); } finally { setIsRetrying(false); } }, [onRetry, isRetrying]); // Handle dismiss const handleDismiss = React.useCallback(() => { if (onDismiss) { onDismiss(); } if (variant === 'modal') { setIsModalOpen(false); } }, [onDismiss, variant]); // Determine if dismissible const isDismissible = dismissible ?? (onDismiss !== undefined || variant === 'modal'); // Render error details const renderDetails = () => { if (!showDetails || !isDetailsExpanded) return null; const details: Array<{ label: string; value: any }> = []; 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)}
                
))}
); }; // Base error content const errorContent = (
{iconConfig}
{title}
{userMessage}
{renderDetails()} {(onRetry || actions.length > 0 || (showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context))) && (
{onRetry && ( )} {actions.map((action, idx) => ( ))} {showDetails && (normalizedError.code || normalizedError.details || normalizedError.stack || context) && ( )}
)}
{isDismissible && ( )}
); // Render based on variant 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';