WellNuo/components/ui/ErrorMessage.tsx
Sergei 7feca4d54b Add debouncing for refresh buttons to prevent duplicate API calls
Implemented a reusable useDebounce hook to prevent rapid-fire clicks
on refresh buttons throughout the application.

Changes:
- Created hooks/useDebounce.ts with configurable delay and leading/trailing edge options
- Added comprehensive unit tests in hooks/__tests__/useDebounce.test.ts
- Applied debouncing to dashboard WebView refresh button (app/(tabs)/dashboard.tsx)
- Applied debouncing to beneficiary detail pull-to-refresh (app/(tabs)/beneficiaries/[id]/index.tsx)
- Applied debouncing to equipment screen refresh (app/(tabs)/beneficiaries/[id]/equipment.tsx)
- Applied debouncing to all error retry buttons (components/ui/ErrorMessage.tsx)
- Fixed jest.setup.js to properly mock React Native modules
- Added implementation documentation in docs/DEBOUNCE_IMPLEMENTATION.md

Technical details:
- Default 1-second debounce delay
- Leading edge execution (immediate first call, then debounce)
- Type-safe with TypeScript generics
- Automatic cleanup on component unmount

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 11:44:16 -08:00

198 lines
5.4 KiB
TypeScript

import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { useDebounce } from '@/hooks/useDebounce';
interface ErrorMessageProps {
message: string;
onRetry?: () => void;
onSkip?: () => void;
onDismiss?: () => void;
}
export function ErrorMessage({ message, onRetry, onSkip, onDismiss }: ErrorMessageProps) {
const { debouncedFn: debouncedRetry } = useDebounce(onRetry || (() => {}), { delay: 1000 });
return (
<View style={styles.container}>
<View style={styles.content}>
<Ionicons name="alert-circle" size={24} color={AppColors.error} />
<Text style={styles.message}>{message}</Text>
</View>
<View style={styles.actions}>
{onRetry && (
<TouchableOpacity onPress={debouncedRetry} style={styles.button}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
)}
{onSkip && (
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
<Ionicons name="arrow-forward" size={18} color={AppColors.textSecondary} />
<Text style={styles.skipButtonText}>Skip</Text>
</TouchableOpacity>
)}
{onDismiss && (
<TouchableOpacity onPress={onDismiss} style={styles.dismissButton}>
<Ionicons name="close" size={18} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
);
}
interface FullScreenErrorProps {
title?: string;
message: string;
onRetry?: () => void;
onSkip?: () => void;
}
export function FullScreenError({
title = 'Something went wrong',
message,
onRetry,
onSkip
}: FullScreenErrorProps) {
const { debouncedFn: debouncedRetry } = useDebounce(onRetry || (() => {}), { delay: 1000 });
return (
<View style={styles.fullScreenContainer}>
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.fullScreenTitle}>{title}</Text>
<Text style={styles.fullScreenMessage}>{message}</Text>
<View style={styles.fullScreenActions}>
{onRetry && (
<TouchableOpacity onPress={debouncedRetry} style={styles.retryButton}>
<Ionicons name="refresh" size={20} color={AppColors.white} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
)}
{onSkip && (
<TouchableOpacity onPress={onSkip} style={styles.fullScreenSkipButton}>
<Ionicons name="arrow-forward" size={20} color={AppColors.textSecondary} />
<Text style={styles.fullScreenSkipButtonText}>Skip</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#FEE2E2',
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginVertical: Spacing.sm,
},
content: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
message: {
color: AppColors.error,
fontSize: FontSizes.sm,
marginLeft: Spacing.sm,
flex: 1,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
},
button: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
},
buttonText: {
color: AppColors.primary,
fontSize: FontSizes.sm,
fontWeight: '500',
marginLeft: Spacing.xs,
},
skipButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
marginLeft: Spacing.xs,
},
skipButtonText: {
color: AppColors.textSecondary,
fontSize: FontSizes.sm,
fontWeight: '500',
marginLeft: Spacing.xs,
},
dismissButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
// Full Screen Error
fullScreenContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.background,
},
fullScreenTitle: {
fontSize: FontSizes.xl,
fontWeight: '600',
color: AppColors.textPrimary,
marginTop: Spacing.lg,
textAlign: 'center',
},
fullScreenMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.sm,
textAlign: 'center',
},
fullScreenActions: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
marginTop: Spacing.xl,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.sm + 4,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
},
retryButtonText: {
color: AppColors.white,
fontSize: FontSizes.base,
fontWeight: '600',
marginLeft: Spacing.sm,
},
fullScreenSkipButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surfaceSecondary,
paddingVertical: Spacing.sm + 4,
paddingHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.border,
},
fullScreenSkipButtonText: {
color: AppColors.textSecondary,
fontSize: FontSizes.base,
fontWeight: '600',
marginLeft: Spacing.sm,
},
});