WellNuo/components/errors/ErrorToast.tsx
Sergei a238b7e35f Add comprehensive error handling system
- Add extended error types with severity levels, retry policies,
  and contextual information (types/errors.ts)
- Create centralized error handler service with user-friendly
  message translation and classification (services/errorHandler.ts)
- Add ErrorContext for global error state management with
  auto-dismiss and error queue support (contexts/ErrorContext.tsx)
- Create error UI components: ErrorToast, FieldError,
  FieldErrorSummary, FullScreenError, EmptyState, OfflineState
- Add useError hook with retry strategies and API response handling
- Add useAsync hook for async operations with comprehensive state
- Create error message utilities with validation helpers
- Add tests for errorHandler and errorMessages (88 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:43:07 -08:00

216 lines
5.1 KiB
TypeScript

/**
* ErrorToast - Animated toast notification for errors
*
* Features:
* - Slides in from top
* - Auto-dismiss for non-critical errors
* - Retry and dismiss actions
* - Severity-based styling
*/
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { AppError, ErrorSeverity } from '@/types/errors';
interface ErrorToastProps {
error: AppError & { onRetry?: () => void };
onDismiss: () => void;
visible: boolean;
}
// Severity colors
const severityColors: Record<ErrorSeverity, { bg: string; border: string; icon: string }> = {
critical: { bg: '#FEE2E2', border: '#EF4444', icon: '#DC2626' },
error: { bg: '#FEE2E2', border: '#EF4444', icon: '#DC2626' },
warning: { bg: '#FEF3C7', border: '#F59E0B', icon: '#D97706' },
info: { bg: '#DBEAFE', border: '#3B82F6', icon: '#2563EB' },
};
// Severity icons
const severityIcons: Record<ErrorSeverity, keyof typeof Ionicons.glyphMap> = {
critical: 'alert-circle',
error: 'close-circle',
warning: 'warning',
info: 'information-circle',
};
export function ErrorToast({ error, onDismiss, visible }: ErrorToastProps) {
const insets = useSafeAreaInsets();
const slideAnim = useRef(new Animated.Value(-200)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
const colors = severityColors[error.severity];
const icon = severityIcons[error.severity];
useEffect(() => {
if (visible) {
// Slide in
Animated.parallel([
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 50,
friction: 8,
}),
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
// Slide out
Animated.parallel([
Animated.timing(slideAnim, {
toValue: -200,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideAnim, opacityAnim]);
// Component always renders when not visible - animations handle visibility
if (!visible) {
// We still render to allow the slide-out animation to complete
}
return (
<Animated.View
style={[
styles.container,
{
paddingTop: insets.top + 8,
transform: [{ translateY: slideAnim }],
opacity: opacityAnim,
},
]}
pointerEvents="box-none"
>
<View
style={[
styles.toast,
{
backgroundColor: colors.bg,
borderColor: colors.border,
},
]}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name={icon} size={24} color={colors.icon} />
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.message} numberOfLines={2}>
{error.userMessage}
</Text>
{error.actionHint && (
<Text style={styles.hint} numberOfLines={1}>
{error.actionHint}
</Text>
)}
</View>
{/* Actions */}
<View style={styles.actions}>
{error.retry.isRetryable && error.onRetry && (
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
onDismiss();
error.onRetry?.();
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="refresh" size={20} color={colors.icon} />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.dismissButton}
onPress={onDismiss}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
paddingHorizontal: 16,
},
toast: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
borderWidth: 1,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
},
android: {
elevation: 6,
},
}),
},
iconContainer: {
marginRight: 12,
},
content: {
flex: 1,
marginRight: 8,
},
message: {
fontSize: 14,
fontWeight: '600',
color: '#1F2937',
lineHeight: 20,
},
hint: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
retryButton: {
padding: 4,
},
dismissButton: {
padding: 4,
},
});
export default ErrorToast;