2026-01-11 15:58:54 +00:00
|
|
|
import * as React from 'react';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
Info,
|
|
|
|
|
X,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
Bug,
|
|
|
|
|
} from 'lucide-react';
|
2026-01-11 15:58:54 +00:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { Button } from './button';
|
|
|
|
|
import { Card, CardContent } from './card';
|
|
|
|
|
import { Dialog } from './dialog';
|
2026-01-15 16:03:35 +00:00
|
|
|
import type { ApiError } from '@/schemas/apiSchemas';
|
2026-01-11 15:58:54 +00:00
|
|
|
import { formatUserFriendlyError } from '@/utils/errorMessages';
|
2026-01-11 16:29:11 +00:00
|
|
|
import { parseApiError, getErrorCategory } from '@/utils/apiErrorHandler';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
formatIssueReport,
|
|
|
|
|
copyIssueReportToClipboard,
|
|
|
|
|
openGitHubIssue,
|
|
|
|
|
} from '@/utils/reportIssue';
|
2026-01-15 16:03:35 +00:00
|
|
|
// CRITICAL FIX: Utiliser le wrapper lazy pour éviter les collisions de noms de variables
|
|
|
|
|
import toast from '@/utils/toast';
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ErrorDisplayProps - Props for the ErrorDisplay component
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-11 15:58:54 +00:00
|
|
|
* 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
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
error:
|
|
|
|
|
| Error
|
|
|
|
|
| ApiError
|
|
|
|
|
| string
|
|
|
|
|
| {
|
|
|
|
|
message: string;
|
|
|
|
|
code?: string | number;
|
|
|
|
|
status?: number;
|
|
|
|
|
details?: Record<string, any>;
|
|
|
|
|
};
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Callback when user clicks retry button
|
|
|
|
|
* If not provided, retry button is hidden
|
|
|
|
|
*/
|
|
|
|
|
onRetry?: () => void | Promise<void>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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?: {
|
2026-01-13 18:47:57 +00:00
|
|
|
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
|
2026-01-11 15:58:54 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(
|
2026-01-13 18:47:57 +00:00
|
|
|
error:
|
|
|
|
|
| Error
|
|
|
|
|
| ApiError
|
|
|
|
|
| string
|
|
|
|
|
| {
|
|
|
|
|
message: string;
|
|
|
|
|
code?: string | number;
|
|
|
|
|
status?: number;
|
|
|
|
|
details?: Record<string, any>;
|
|
|
|
|
},
|
2026-01-11 15:58:54 +00:00
|
|
|
): {
|
|
|
|
|
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
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-11 15:58:54 +00:00
|
|
|
* Provides consistent error UI across the application with support for
|
|
|
|
|
* different variants, severities, and recovery actions.
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-11 15:58:54 +00:00
|
|
|
* @example
|
|
|
|
|
* ```tsx
|
|
|
|
|
* <ErrorDisplay
|
|
|
|
|
* error={error}
|
|
|
|
|
* onRetry={() => refetch()}
|
|
|
|
|
* />
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
export const ErrorDisplay = React.forwardRef<HTMLDivElement, ErrorDisplayProps>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
error,
|
|
|
|
|
onRetry,
|
|
|
|
|
onDismiss,
|
|
|
|
|
showDetails: showDetailsProp,
|
|
|
|
|
context,
|
|
|
|
|
variant = 'inline',
|
|
|
|
|
severity = 'error',
|
|
|
|
|
size = 'md',
|
|
|
|
|
className,
|
|
|
|
|
dismissible,
|
|
|
|
|
title: titleProp,
|
|
|
|
|
icon: iconProp,
|
|
|
|
|
actions = [],
|
|
|
|
|
...props
|
|
|
|
|
},
|
2026-01-13 18:47:57 +00:00
|
|
|
ref,
|
2026-01-11 15:58:54 +00:00
|
|
|
) => {
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-11 16:29:11 +00:00
|
|
|
// Parse error to ApiError for category detection
|
|
|
|
|
const apiError = React.useMemo(() => parseApiError(error), [error]);
|
2026-01-13 18:47:57 +00:00
|
|
|
const errorCategory = React.useMemo(
|
|
|
|
|
() => getErrorCategory(apiError),
|
|
|
|
|
[apiError],
|
|
|
|
|
);
|
2026-01-11 16:29:11 +00:00
|
|
|
|
|
|
|
|
// Determine if this is a server error that should show report issue button
|
|
|
|
|
const isServerError = React.useMemo(() => {
|
2026-01-13 18:47:57 +00:00
|
|
|
return (
|
|
|
|
|
errorCategory === 'server_error' ||
|
|
|
|
|
(apiError.status !== undefined && apiError.status >= 500)
|
|
|
|
|
);
|
2026-01-11 16:29:11 +00:00
|
|
|
}, [errorCategory, apiError]);
|
|
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
// 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,
|
2026-01-13 18:47:57 +00:00
|
|
|
false,
|
2026-01-11 15:58:54 +00:00
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
return normalizedError.message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 'An unexpected error occurred';
|
|
|
|
|
}, [normalizedError, context]);
|
|
|
|
|
|
|
|
|
|
// Determine title
|
|
|
|
|
const title = React.useMemo(() => {
|
|
|
|
|
if (titleProp) return titleProp;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
if (context?.action) {
|
|
|
|
|
return `Error ${context.action}`;
|
|
|
|
|
}
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
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;
|
|
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
const iconSize =
|
|
|
|
|
size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-6 h-6' : 'w-5 h-5';
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
switch (severity) {
|
|
|
|
|
case 'error':
|
|
|
|
|
return <AlertCircle className={cn(iconSize, 'text-kodo-red')} />;
|
|
|
|
|
case 'warning':
|
|
|
|
|
return <AlertTriangle className={cn(iconSize, 'text-kodo-gold')} />;
|
|
|
|
|
case 'info':
|
|
|
|
|
return <Info className={cn(iconSize, 'text-kodo-cyan')} />;
|
|
|
|
|
default:
|
|
|
|
|
return <AlertCircle className={cn(iconSize, 'text-kodo-red')} />;
|
|
|
|
|
}
|
|
|
|
|
}, [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;
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-01-11 16:29:11 +00:00
|
|
|
// Handle report issue
|
|
|
|
|
const handleReportIssue = React.useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const report = formatIssueReport(error, {
|
|
|
|
|
component: context?.resource,
|
|
|
|
|
action: context?.action,
|
|
|
|
|
userId: context?.userId,
|
|
|
|
|
additionalInfo: context,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Try to open GitHub issue first, fallback to clipboard
|
|
|
|
|
try {
|
|
|
|
|
openGitHubIssue(report);
|
|
|
|
|
toast.success('Opening GitHub issue...');
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback to clipboard
|
|
|
|
|
await copyIssueReportToClipboard(report);
|
|
|
|
|
toast.success('Issue report copied to clipboard');
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error('Failed to generate issue report');
|
|
|
|
|
}
|
|
|
|
|
}, [error, context]);
|
|
|
|
|
|
2026-01-11 15:58:54 +00:00
|
|
|
// Determine if dismissible
|
2026-01-13 18:47:57 +00:00
|
|
|
const isDismissible =
|
|
|
|
|
dismissible ?? (onDismiss !== undefined || variant === 'modal');
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
// Render error details
|
|
|
|
|
const renderDetails = () => {
|
|
|
|
|
if (!showDetails || !isDetailsExpanded) return null;
|
|
|
|
|
|
|
|
|
|
const details: Array<{ label: string; value: any }> = [];
|
|
|
|
|
|
2026-01-11 16:29:11 +00:00
|
|
|
// Show request ID prominently for server errors
|
|
|
|
|
if (apiError.request_id) {
|
|
|
|
|
details.push({ label: 'Request ID', value: apiError.request_id });
|
|
|
|
|
}
|
2026-01-11 15:58:54 +00:00
|
|
|
if (normalizedError.code) {
|
2026-01-13 18:47:57 +00:00
|
|
|
details.push({
|
|
|
|
|
label: 'Error Code',
|
|
|
|
|
value: String(normalizedError.code),
|
|
|
|
|
});
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
|
|
|
|
if (normalizedError.status) {
|
2026-01-13 18:47:57 +00:00
|
|
|
details.push({
|
|
|
|
|
label: 'HTTP Status',
|
|
|
|
|
value: String(normalizedError.status),
|
|
|
|
|
});
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
|
|
|
|
if (normalizedError.details) {
|
2026-01-13 18:47:57 +00:00
|
|
|
details.push({
|
|
|
|
|
label: 'Details',
|
|
|
|
|
value: JSON.stringify(normalizedError.details, null, 2),
|
|
|
|
|
});
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
|
|
|
|
if (normalizedError.stack) {
|
|
|
|
|
details.push({ label: 'Stack Trace', value: normalizedError.stack });
|
|
|
|
|
}
|
|
|
|
|
if (context) {
|
2026-01-13 18:47:57 +00:00
|
|
|
details.push({
|
|
|
|
|
label: 'Context',
|
|
|
|
|
value: JSON.stringify(context, null, 2),
|
|
|
|
|
});
|
2026-01-11 15:58:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (details.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-white/10">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{details.map((detail, idx) => (
|
|
|
|
|
<div key={idx} className="text-xs">
|
2026-01-13 18:47:57 +00:00
|
|
|
<span className="font-semibold opacity-70">
|
|
|
|
|
{detail.label}:
|
|
|
|
|
</span>
|
2026-01-11 15:58:54 +00:00
|
|
|
<pre className="mt-1 p-2 bg-black/20 rounded text-xs overflow-x-auto">
|
2026-01-13 18:47:57 +00:00
|
|
|
{typeof detail.value === 'string'
|
|
|
|
|
? detail.value
|
|
|
|
|
: JSON.stringify(detail.value, null, 2)}
|
2026-01-11 15:58:54 +00:00
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Base error content
|
|
|
|
|
const errorContent = (
|
|
|
|
|
<div
|
|
|
|
|
ref={ref}
|
|
|
|
|
role="alert"
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-lg border flex',
|
|
|
|
|
colorStyles.bg,
|
|
|
|
|
colorStyles.border,
|
|
|
|
|
colorStyles.text,
|
|
|
|
|
sizeStyles.padding,
|
|
|
|
|
sizeStyles.gap,
|
2026-01-13 18:47:57 +00:00
|
|
|
className,
|
2026-01-11 15:58:54 +00:00
|
|
|
)}
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex-shrink-0">{iconConfig}</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className={cn('font-semibold mb-1', sizeStyles.title)}>
|
|
|
|
|
{title}
|
|
|
|
|
</div>
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className={cn('opacity-90', sizeStyles.text)}>{userMessage}</div>
|
2026-01-11 15:58:54 +00:00
|
|
|
{renderDetails()}
|
2026-01-13 18:47:57 +00:00
|
|
|
{(onRetry ||
|
|
|
|
|
actions.length > 0 ||
|
|
|
|
|
isServerError ||
|
|
|
|
|
(showDetails &&
|
|
|
|
|
(normalizedError.code ||
|
|
|
|
|
normalizedError.details ||
|
|
|
|
|
normalizedError.stack ||
|
|
|
|
|
context))) && (
|
2026-01-11 15:58:54 +00:00
|
|
|
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
|
|
|
|
{onRetry && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size={size === 'sm' ? 'sm' : 'default'}
|
|
|
|
|
onClick={handleRetry}
|
|
|
|
|
disabled={isRetrying}
|
|
|
|
|
className="border-current text-current hover:bg-current/10"
|
|
|
|
|
>
|
|
|
|
|
{isRetrying ? 'Retrying...' : 'Retry'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-11 16:29:11 +00:00
|
|
|
{isServerError && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size={size === 'sm' ? 'sm' : 'default'}
|
|
|
|
|
onClick={handleReportIssue}
|
|
|
|
|
className="border-current text-current hover:bg-current/10"
|
|
|
|
|
>
|
|
|
|
|
<Bug className="w-4 h-4 mr-1.5" />
|
|
|
|
|
Report Issue
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-11 15:58:54 +00:00
|
|
|
{actions.map((action, idx) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={idx}
|
|
|
|
|
variant={action.variant || 'outline'}
|
|
|
|
|
size={size === 'sm' ? 'sm' : 'default'}
|
|
|
|
|
onClick={action.onClick}
|
|
|
|
|
className="border-current text-current hover:bg-current/10"
|
|
|
|
|
>
|
|
|
|
|
{action.label}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
2026-01-13 18:47:57 +00:00
|
|
|
{showDetails &&
|
|
|
|
|
(normalizedError.code ||
|
|
|
|
|
normalizedError.details ||
|
|
|
|
|
normalizedError.stack ||
|
|
|
|
|
context) && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size={size === 'sm' ? 'sm' : 'default'}
|
|
|
|
|
onClick={() => setIsDetailsExpanded(!isDetailsExpanded)}
|
|
|
|
|
className="text-current hover:bg-current/10"
|
|
|
|
|
>
|
|
|
|
|
{isDetailsExpanded ? (
|
|
|
|
|
<>
|
|
|
|
|
<ChevronUp className="w-4 h-4 mr-1" />
|
|
|
|
|
Hide Details
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<ChevronDown className="w-4 h-4 mr-1" />
|
|
|
|
|
Show Details
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-11 15:58:54 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{isDismissible && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleDismiss}
|
|
|
|
|
className="opacity-70 hover:opacity-100 transition-opacity flex-shrink-0"
|
|
|
|
|
aria-label="Dismiss error"
|
|
|
|
|
>
|
|
|
|
|
<X className={cn(size === 'sm' ? 'w-4 h-4' : 'w-5 h-5')} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Render based on variant
|
|
|
|
|
switch (variant) {
|
|
|
|
|
case 'banner':
|
2026-01-13 18:47:57 +00:00
|
|
|
return <div className="w-full">{errorContent}</div>;
|
2026-01-11 15:58:54 +00:00
|
|
|
|
|
|
|
|
case 'card':
|
|
|
|
|
return (
|
|
|
|
|
<Card className={cn(colorStyles.border, className)}>
|
|
|
|
|
<CardContent className={cn(sizeStyles.padding, 'pt-6')}>
|
|
|
|
|
{errorContent}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'modal':
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={isModalOpen}
|
|
|
|
|
onClose={handleDismiss}
|
|
|
|
|
title={title}
|
2026-01-13 18:47:57 +00:00
|
|
|
variant={
|
|
|
|
|
severity === 'error'
|
|
|
|
|
? 'alert'
|
|
|
|
|
: severity === 'warning'
|
|
|
|
|
? 'alert'
|
|
|
|
|
: 'info'
|
|
|
|
|
}
|
2026-01-11 15:58:54 +00:00
|
|
|
footer={
|
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
|
|
|
{onRetry && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handleRetry}
|
|
|
|
|
disabled={isRetrying}
|
|
|
|
|
>
|
|
|
|
|
{isRetrying ? 'Retrying...' : 'Retry'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-11 16:29:11 +00:00
|
|
|
{isServerError && (
|
2026-01-13 18:47:57 +00:00
|
|
|
<Button variant="outline" onClick={handleReportIssue}>
|
2026-01-11 16:29:11 +00:00
|
|
|
<Bug className="w-4 h-4 mr-1.5" />
|
|
|
|
|
Report Issue
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-11 15:58:54 +00:00
|
|
|
{actions.map((action, idx) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={idx}
|
|
|
|
|
variant={action.variant || 'outline'}
|
|
|
|
|
onClick={action.onClick}
|
|
|
|
|
>
|
|
|
|
|
{action.label}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
2026-01-13 18:47:57 +00:00
|
|
|
<Button variant="default" onClick={handleDismiss}>
|
2026-01-11 15:58:54 +00:00
|
|
|
{onRetry ? 'Close' : 'Dismiss'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className={cn(colorStyles.text)}>
|
|
|
|
|
<div className={cn('opacity-90 mb-4', sizeStyles.text)}>
|
|
|
|
|
{userMessage}
|
|
|
|
|
{isServerError && apiError.request_id && (
|
|
|
|
|
<div className="mt-2 text-xs opacity-75">
|
|
|
|
|
Request ID:{' '}
|
|
|
|
|
<code className="bg-black/20 px-1.5 py-0.5 rounded">
|
|
|
|
|
{apiError.request_id}
|
|
|
|
|
</code>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{renderDetails()}
|
2026-01-11 15:58:54 +00:00
|
|
|
</div>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case 'inline':
|
|
|
|
|
default:
|
|
|
|
|
return errorContent;
|
|
|
|
|
}
|
2026-01-13 18:47:57 +00:00
|
|
|
},
|
2026-01-11 15:58:54 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ErrorDisplay.displayName = 'ErrorDisplay';
|