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>
This commit is contained in:
parent
9d5a40944f
commit
a238b7e35f
371
__tests__/services/errorHandler.test.ts
Normal file
371
__tests__/services/errorHandler.test.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Error Handler Service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createErrorFromApi,
|
||||||
|
createErrorFromException,
|
||||||
|
createNetworkError,
|
||||||
|
createValidationError,
|
||||||
|
createErrorFromUnknown,
|
||||||
|
createError,
|
||||||
|
calculateRetryDelay,
|
||||||
|
ErrorCodes,
|
||||||
|
} from '@/services/errorHandler';
|
||||||
|
import { ErrorCategory, ErrorSeverity, isAppError, isRetryableError, isAuthError } from '@/types/errors';
|
||||||
|
|
||||||
|
describe('errorHandler', () => {
|
||||||
|
describe('createErrorFromApi', () => {
|
||||||
|
it('should create error from API error with code', () => {
|
||||||
|
const apiError = {
|
||||||
|
message: 'Invalid email address',
|
||||||
|
code: 'INVALID_EMAIL',
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError);
|
||||||
|
|
||||||
|
expect(error.message).toBe('Invalid email address');
|
||||||
|
expect(error.code).toBe('INVALID_EMAIL');
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
expect(error.category).toBe('validation');
|
||||||
|
expect(error.severity).toBe('error');
|
||||||
|
expect(error.errorId).toBeDefined();
|
||||||
|
expect(error.timestamp).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify 401 status as authentication error', () => {
|
||||||
|
const apiError = {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
status: 401,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError);
|
||||||
|
|
||||||
|
expect(error.category).toBe('authentication');
|
||||||
|
expect(error.retry.isRetryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify 404 status as notFound error', () => {
|
||||||
|
const apiError = {
|
||||||
|
message: 'Not found',
|
||||||
|
status: 404,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError);
|
||||||
|
|
||||||
|
expect(error.category).toBe('notFound');
|
||||||
|
expect(error.retry.isRetryable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify 500 status as server error with retry', () => {
|
||||||
|
const apiError = {
|
||||||
|
message: 'Internal server error',
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError);
|
||||||
|
|
||||||
|
expect(error.category).toBe('server');
|
||||||
|
expect(error.retry.isRetryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify 429 status as rate limit error', () => {
|
||||||
|
const apiError = {
|
||||||
|
message: 'Too many requests',
|
||||||
|
status: 429,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError);
|
||||||
|
|
||||||
|
expect(error.category).toBe('rateLimit');
|
||||||
|
expect(error.retry.isRetryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include context in error', () => {
|
||||||
|
const apiError = { message: 'Error', code: 'TEST' };
|
||||||
|
const context = { userId: 123, action: 'test' };
|
||||||
|
|
||||||
|
const error = createErrorFromApi(apiError, context);
|
||||||
|
|
||||||
|
expect(error.context).toEqual(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createErrorFromException', () => {
|
||||||
|
it('should create error from JavaScript Error', () => {
|
||||||
|
const jsError = new Error('Something went wrong');
|
||||||
|
|
||||||
|
const error = createErrorFromException(jsError);
|
||||||
|
|
||||||
|
expect(error.message).toBe('Something went wrong');
|
||||||
|
expect(error.code).toBe(ErrorCodes.EXCEPTION);
|
||||||
|
expect(error.originalError).toBe(jsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom code when provided', () => {
|
||||||
|
const jsError = new Error('Network failed');
|
||||||
|
|
||||||
|
const error = createErrorFromException(jsError, ErrorCodes.NETWORK_ERROR);
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(error.category).toBe('network');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNetworkError', () => {
|
||||||
|
it('should create network error with default message', () => {
|
||||||
|
const error = createNetworkError();
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(error.category).toBe('network');
|
||||||
|
expect(error.severity).toBe('warning');
|
||||||
|
expect(error.retry.isRetryable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create timeout error when specified', () => {
|
||||||
|
const error = createNetworkError('Request timed out', true);
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCodes.NETWORK_TIMEOUT);
|
||||||
|
expect(error.category).toBe('timeout');
|
||||||
|
expect(error.retry.isRetryable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createValidationError', () => {
|
||||||
|
it('should create validation error with field errors', () => {
|
||||||
|
const fieldErrors = [
|
||||||
|
{ field: 'email', message: 'Invalid email' },
|
||||||
|
{ field: 'phone', message: 'Invalid phone' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const error = createValidationError('Validation failed', fieldErrors);
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR);
|
||||||
|
expect(error.category).toBe('validation');
|
||||||
|
expect(error.fieldErrors).toEqual(fieldErrors);
|
||||||
|
expect(error.retry.isRetryable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createErrorFromUnknown', () => {
|
||||||
|
it('should handle string errors', () => {
|
||||||
|
const error = createErrorFromUnknown('Something bad');
|
||||||
|
|
||||||
|
expect(error.message).toBe('Something bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Error objects', () => {
|
||||||
|
const jsError = new Error('Test error');
|
||||||
|
const error = createErrorFromUnknown(jsError);
|
||||||
|
|
||||||
|
expect(error.message).toBe('Test error');
|
||||||
|
expect(error.originalError).toBe(jsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API-like objects', () => {
|
||||||
|
const apiLike = { message: 'API error', code: 'TEST_CODE' };
|
||||||
|
const error = createErrorFromUnknown(apiLike);
|
||||||
|
|
||||||
|
expect(error.message).toBe('API error');
|
||||||
|
expect(error.code).toBe('TEST_CODE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined', () => {
|
||||||
|
const error = createErrorFromUnknown(null);
|
||||||
|
|
||||||
|
expect(error.message).toBe('An unknown error occurred');
|
||||||
|
expect(error.code).toBe(ErrorCodes.UNKNOWN_ERROR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through AppError objects', () => {
|
||||||
|
const original = createError(ErrorCodes.NETWORK_ERROR);
|
||||||
|
const result = createErrorFromUnknown(original);
|
||||||
|
|
||||||
|
expect(result.errorId).toBe(original.errorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createError', () => {
|
||||||
|
it('should create error with predefined code', () => {
|
||||||
|
const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED);
|
||||||
|
|
||||||
|
expect(error.code).toBe(ErrorCodes.SUBSCRIPTION_REQUIRED);
|
||||||
|
expect(error.category).toBe('subscription');
|
||||||
|
expect(error.userMessage).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom message when provided', () => {
|
||||||
|
const error = createError(ErrorCodes.NOT_FOUND, 'User not found');
|
||||||
|
|
||||||
|
expect(error.message).toBe('User not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateRetryDelay', () => {
|
||||||
|
it('should return base delay without exponential backoff', () => {
|
||||||
|
const policy = {
|
||||||
|
isRetryable: true,
|
||||||
|
retryDelayMs: 1000,
|
||||||
|
useExponentialBackoff: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(calculateRetryDelay(policy, 0)).toBe(1000);
|
||||||
|
expect(calculateRetryDelay(policy, 1)).toBe(1000);
|
||||||
|
expect(calculateRetryDelay(policy, 2)).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increase delay with exponential backoff', () => {
|
||||||
|
const policy = {
|
||||||
|
isRetryable: true,
|
||||||
|
retryDelayMs: 1000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const delay0 = calculateRetryDelay(policy, 0);
|
||||||
|
const delay1 = calculateRetryDelay(policy, 1);
|
||||||
|
const delay2 = calculateRetryDelay(policy, 2);
|
||||||
|
|
||||||
|
// With jitter, delays should be approximately 1000, 2000, 4000
|
||||||
|
expect(delay0).toBeGreaterThanOrEqual(1000);
|
||||||
|
expect(delay0).toBeLessThan(1300); // 1000 + 30% jitter
|
||||||
|
expect(delay1).toBeGreaterThanOrEqual(2000);
|
||||||
|
expect(delay1).toBeLessThan(2600);
|
||||||
|
expect(delay2).toBeGreaterThanOrEqual(4000);
|
||||||
|
expect(delay2).toBeLessThan(5200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cap delay at 30 seconds', () => {
|
||||||
|
const policy = {
|
||||||
|
isRetryable: true,
|
||||||
|
retryDelayMs: 10000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const delay = calculateRetryDelay(policy, 5); // Would be 320000ms without cap
|
||||||
|
|
||||||
|
expect(delay).toBeLessThanOrEqual(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 1000ms when no delay specified', () => {
|
||||||
|
const policy = { isRetryable: true };
|
||||||
|
|
||||||
|
expect(calculateRetryDelay(policy, 0)).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('type guards', () => {
|
||||||
|
describe('isAppError', () => {
|
||||||
|
it('should return true for valid AppError', () => {
|
||||||
|
const error = createError(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(isAppError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for plain objects', () => {
|
||||||
|
expect(isAppError({ message: 'test' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null/undefined', () => {
|
||||||
|
expect(isAppError(null)).toBe(false);
|
||||||
|
expect(isAppError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRetryableError', () => {
|
||||||
|
it('should return true for network errors', () => {
|
||||||
|
const error = createNetworkError();
|
||||||
|
expect(isRetryableError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for auth errors', () => {
|
||||||
|
const error = createError(ErrorCodes.UNAUTHORIZED);
|
||||||
|
expect(isRetryableError(error)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for validation errors', () => {
|
||||||
|
const error = createValidationError('Invalid input');
|
||||||
|
expect(isRetryableError(error)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAuthError', () => {
|
||||||
|
it('should return true for unauthorized errors', () => {
|
||||||
|
const error = createError(ErrorCodes.UNAUTHORIZED);
|
||||||
|
expect(isAuthError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for token expired errors', () => {
|
||||||
|
const error = createError(ErrorCodes.TOKEN_EXPIRED);
|
||||||
|
expect(isAuthError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other errors', () => {
|
||||||
|
const error = createNetworkError();
|
||||||
|
expect(isAuthError(error)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error code classification', () => {
|
||||||
|
const testCases: [string, ErrorCategory][] = [
|
||||||
|
[ErrorCodes.NETWORK_ERROR, 'network'],
|
||||||
|
[ErrorCodes.NETWORK_TIMEOUT, 'timeout'],
|
||||||
|
[ErrorCodes.UNAUTHORIZED, 'authentication'],
|
||||||
|
[ErrorCodes.TOKEN_EXPIRED, 'authentication'],
|
||||||
|
[ErrorCodes.FORBIDDEN, 'permission'],
|
||||||
|
[ErrorCodes.NOT_FOUND, 'notFound'],
|
||||||
|
[ErrorCodes.BENEFICIARY_NOT_FOUND, 'notFound'],
|
||||||
|
[ErrorCodes.CONFLICT, 'conflict'],
|
||||||
|
[ErrorCodes.DUPLICATE_ENTRY, 'conflict'],
|
||||||
|
[ErrorCodes.VALIDATION_ERROR, 'validation'],
|
||||||
|
[ErrorCodes.INVALID_EMAIL, 'validation'],
|
||||||
|
[ErrorCodes.RATE_LIMITED, 'rateLimit'],
|
||||||
|
[ErrorCodes.SERVER_ERROR, 'server'],
|
||||||
|
[ErrorCodes.BLE_NOT_AVAILABLE, 'ble'],
|
||||||
|
[ErrorCodes.SENSOR_OFFLINE, 'sensor'],
|
||||||
|
[ErrorCodes.SUBSCRIPTION_REQUIRED, 'subscription'],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(testCases)('should classify %s as %s category', (code, expectedCategory) => {
|
||||||
|
const error = createError(code as any);
|
||||||
|
expect(error.category).toBe(expectedCategory);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user messages', () => {
|
||||||
|
it('should provide user-friendly message for network error', () => {
|
||||||
|
const error = createError(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(error.userMessage).toContain('internet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide user-friendly message for auth error', () => {
|
||||||
|
const error = createError(ErrorCodes.UNAUTHORIZED);
|
||||||
|
expect(error.userMessage).toContain('session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide user-friendly message for subscription error', () => {
|
||||||
|
const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED);
|
||||||
|
expect(error.userMessage).toContain('subscription');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('action hints', () => {
|
||||||
|
it('should provide action hint for network error', () => {
|
||||||
|
const error = createError(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(error.actionHint).toBeDefined();
|
||||||
|
expect(error.actionHint).toContain('connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide action hint for auth error', () => {
|
||||||
|
const error = createError(ErrorCodes.UNAUTHORIZED);
|
||||||
|
expect(error.actionHint).toBeDefined();
|
||||||
|
expect(error.actionHint).toContain('log in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide action hint for BLE error', () => {
|
||||||
|
const error = createError(ErrorCodes.BLE_NOT_ENABLED);
|
||||||
|
expect(error.actionHint).toBeDefined();
|
||||||
|
expect(error.actionHint).toContain('Bluetooth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
226
__tests__/utils/errorMessages.test.ts
Normal file
226
__tests__/utils/errorMessages.test.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Error Message Utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getErrorMessage,
|
||||||
|
getActionHint,
|
||||||
|
validateEmail,
|
||||||
|
validatePhone,
|
||||||
|
validateRequired,
|
||||||
|
validateMinLength,
|
||||||
|
validateMaxLength,
|
||||||
|
validateOtp,
|
||||||
|
formatErrorMessage,
|
||||||
|
combineFieldErrors,
|
||||||
|
getErrorCountText,
|
||||||
|
ErrorMessages,
|
||||||
|
} from '@/utils/errorMessages';
|
||||||
|
import { ErrorCodes } from '@/types/errors';
|
||||||
|
|
||||||
|
describe('errorMessages', () => {
|
||||||
|
describe('getErrorMessage', () => {
|
||||||
|
it('should return network error message', () => {
|
||||||
|
const message = getErrorMessage(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(message).toBe(ErrorMessages.networkError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return session expired message for unauthorized', () => {
|
||||||
|
const message = getErrorMessage(ErrorCodes.UNAUTHORIZED);
|
||||||
|
expect(message).toBe(ErrorMessages.sessionExpired);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for unknown code', () => {
|
||||||
|
const message = getErrorMessage('UNKNOWN_CODE', 'Custom fallback');
|
||||||
|
expect(message).toBe('Custom fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return generic error for unknown code without fallback', () => {
|
||||||
|
const message = getErrorMessage('UNKNOWN_CODE');
|
||||||
|
expect(message).toBe(ErrorMessages.genericError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActionHint', () => {
|
||||||
|
it('should return hint for network error', () => {
|
||||||
|
const hint = getActionHint(ErrorCodes.NETWORK_ERROR);
|
||||||
|
expect(hint).toContain('connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return hint for BLE error', () => {
|
||||||
|
const hint = getActionHint(ErrorCodes.BLE_NOT_ENABLED);
|
||||||
|
expect(hint).toContain('Bluetooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for code without hint', () => {
|
||||||
|
const hint = getActionHint('UNKNOWN_CODE');
|
||||||
|
expect(hint).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateEmail', () => {
|
||||||
|
it('should return null for valid email', () => {
|
||||||
|
expect(validateEmail('test@example.com')).toBeNull();
|
||||||
|
expect(validateEmail('user.name@domain.co.uk')).toBeNull();
|
||||||
|
expect(validateEmail('user+tag@example.org')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for empty email', () => {
|
||||||
|
expect(validateEmail('')).toBe(ErrorMessages.required);
|
||||||
|
expect(validateEmail(' ')).toBe(ErrorMessages.required);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for invalid email', () => {
|
||||||
|
expect(validateEmail('notanemail')).toBe(ErrorMessages.invalidEmail);
|
||||||
|
expect(validateEmail('missing@domain')).toBe(ErrorMessages.invalidEmail);
|
||||||
|
expect(validateEmail('@nodomain.com')).toBe(ErrorMessages.invalidEmail);
|
||||||
|
expect(validateEmail('spaces in@email.com')).toBe(ErrorMessages.invalidEmail);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePhone', () => {
|
||||||
|
it('should return null for valid phone numbers', () => {
|
||||||
|
expect(validatePhone('+14155551234')).toBeNull();
|
||||||
|
expect(validatePhone('14155551234')).toBeNull();
|
||||||
|
expect(validatePhone('+7 (999) 123-45-67')).toBeNull();
|
||||||
|
expect(validatePhone('999.123.4567')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty phone (optional)', () => {
|
||||||
|
expect(validatePhone('')).toBeNull();
|
||||||
|
expect(validatePhone(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for invalid phone', () => {
|
||||||
|
expect(validatePhone('123')).toBe(ErrorMessages.invalidPhone);
|
||||||
|
expect(validatePhone('abcdefghij')).toBe(ErrorMessages.invalidPhone);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateRequired', () => {
|
||||||
|
it('should return null for non-empty value', () => {
|
||||||
|
expect(validateRequired('test')).toBeNull();
|
||||||
|
expect(validateRequired(' test ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for empty value', () => {
|
||||||
|
expect(validateRequired('')).toBe(ErrorMessages.required);
|
||||||
|
expect(validateRequired(' ')).toBe(ErrorMessages.required);
|
||||||
|
expect(validateRequired(null)).toBe(ErrorMessages.required);
|
||||||
|
expect(validateRequired(undefined)).toBe(ErrorMessages.required);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include field name in error when provided', () => {
|
||||||
|
const error = validateRequired('', 'Email');
|
||||||
|
expect(error).toBe('Email is required.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateMinLength', () => {
|
||||||
|
it('should return null when value meets minimum', () => {
|
||||||
|
expect(validateMinLength('abc', 3)).toBeNull();
|
||||||
|
expect(validateMinLength('abcde', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when value is too short', () => {
|
||||||
|
expect(validateMinLength('ab', 3)).toBe(ErrorMessages.tooShort(3));
|
||||||
|
expect(validateMinLength('', 1)).toBe(ErrorMessages.tooShort(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateMaxLength', () => {
|
||||||
|
it('should return null when value meets maximum', () => {
|
||||||
|
expect(validateMaxLength('abc', 3)).toBeNull();
|
||||||
|
expect(validateMaxLength('ab', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when value is too long', () => {
|
||||||
|
expect(validateMaxLength('abcd', 3)).toBe(ErrorMessages.tooLong(3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateOtp', () => {
|
||||||
|
it('should return null for valid OTP', () => {
|
||||||
|
expect(validateOtp('123456')).toBeNull();
|
||||||
|
expect(validateOtp('000000')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for empty OTP', () => {
|
||||||
|
expect(validateOtp('')).toBe(ErrorMessages.required);
|
||||||
|
expect(validateOtp(' ')).toBe(ErrorMessages.required);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for invalid OTP', () => {
|
||||||
|
expect(validateOtp('12345')).toBe(ErrorMessages.invalidOtp); // Too short
|
||||||
|
expect(validateOtp('1234567')).toBe(ErrorMessages.invalidOtp); // Too long
|
||||||
|
expect(validateOtp('12345a')).toBe(ErrorMessages.invalidOtp); // Contains letter
|
||||||
|
expect(validateOtp('12 345')).toBe(ErrorMessages.invalidOtp); // Contains space
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect custom length', () => {
|
||||||
|
expect(validateOtp('1234', 4)).toBeNull();
|
||||||
|
expect(validateOtp('123456', 4)).toBe(ErrorMessages.invalidOtp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatErrorMessage', () => {
|
||||||
|
it('should capitalize first letter', () => {
|
||||||
|
expect(formatErrorMessage('error message')).toBe('Error message.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add period if missing', () => {
|
||||||
|
expect(formatErrorMessage('Error message')).toBe('Error message.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add period if already ends with punctuation', () => {
|
||||||
|
expect(formatErrorMessage('Error message.')).toBe('Error message.');
|
||||||
|
expect(formatErrorMessage('Error message!')).toBe('Error message!');
|
||||||
|
expect(formatErrorMessage('Error message?')).toBe('Error message?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return generic error for empty input', () => {
|
||||||
|
expect(formatErrorMessage('')).toBe(ErrorMessages.genericError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
expect(formatErrorMessage(' error ')).toBe('Error.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combineFieldErrors', () => {
|
||||||
|
it('should return empty string for no errors', () => {
|
||||||
|
expect(combineFieldErrors([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return single message for one error', () => {
|
||||||
|
const errors = [{ field: 'email', message: 'Invalid email' }];
|
||||||
|
expect(combineFieldErrors(errors)).toBe('Invalid email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine multiple errors', () => {
|
||||||
|
const errors = [
|
||||||
|
{ field: 'email', message: 'Invalid email' },
|
||||||
|
{ field: 'phone', message: 'Invalid phone' },
|
||||||
|
];
|
||||||
|
const result = combineFieldErrors(errors);
|
||||||
|
expect(result).toContain('2 errors');
|
||||||
|
expect(result).toContain('Invalid email');
|
||||||
|
expect(result).toContain('Invalid phone');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getErrorCountText', () => {
|
||||||
|
it('should return "No errors" for 0', () => {
|
||||||
|
expect(getErrorCountText(0)).toBe('No errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "1 error" for 1', () => {
|
||||||
|
expect(getErrorCountText(1)).toBe('1 error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return plural for multiple errors', () => {
|
||||||
|
expect(getErrorCountText(2)).toBe('2 errors');
|
||||||
|
expect(getErrorCountText(5)).toBe('5 errors');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
215
components/errors/ErrorToast.tsx
Normal file
215
components/errors/ErrorToast.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
184
components/errors/FieldError.tsx
Normal file
184
components/errors/FieldError.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* FieldError - Inline field validation error display
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Animated appearance
|
||||||
|
* - Icon with error message
|
||||||
|
* - Compact inline design
|
||||||
|
* - Accessible labels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Animated,
|
||||||
|
AccessibilityInfo,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
interface FieldErrorProps {
|
||||||
|
message: string;
|
||||||
|
visible?: boolean;
|
||||||
|
animated?: boolean;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldError({
|
||||||
|
message,
|
||||||
|
visible = true,
|
||||||
|
animated = true,
|
||||||
|
accessibilityLabel,
|
||||||
|
}: FieldErrorProps) {
|
||||||
|
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const translateYAnim = useRef(new Animated.Value(-10)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
if (animated) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacityAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.spring(translateYAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
tension: 100,
|
||||||
|
friction: 10,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
opacityAnim.setValue(1);
|
||||||
|
translateYAnim.setValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce error to screen readers
|
||||||
|
AccessibilityInfo.announceForAccessibility(
|
||||||
|
accessibilityLabel || `Error: ${message}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Animated.timing(opacityAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}, [visible, message, animated, opacityAnim, translateYAnim, accessibilityLabel]);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
opacity: opacityAnim,
|
||||||
|
transform: [{ translateY: translateYAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
accessibilityRole="alert"
|
||||||
|
accessibilityLabel={accessibilityLabel || message}
|
||||||
|
>
|
||||||
|
<Ionicons name="alert-circle" size={14} color="#DC2626" style={styles.icon} />
|
||||||
|
<Text style={styles.message}>{message}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FieldErrorSummary - Summary of multiple field errors
|
||||||
|
*/
|
||||||
|
interface FieldErrorSummaryProps {
|
||||||
|
errors: { field: string; message: string }[];
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldErrorSummary({ errors, visible = true }: FieldErrorSummaryProps) {
|
||||||
|
if (!visible || errors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.summaryContainer} accessibilityRole="alert">
|
||||||
|
<View style={styles.summaryHeader}>
|
||||||
|
<Ionicons name="alert-circle" size={18} color="#DC2626" />
|
||||||
|
<Text style={styles.summaryTitle}>
|
||||||
|
Please fix the following {errors.length === 1 ? 'error' : 'errors'}:
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryList}>
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<View key={`${error.field}-${index}`} style={styles.summaryItem}>
|
||||||
|
<Text style={styles.summaryBullet}>•</Text>
|
||||||
|
<Text style={styles.summaryMessage}>{error.message}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#DC2626',
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
// Summary styles
|
||||||
|
summaryContainer: {
|
||||||
|
backgroundColor: '#FEE2E2',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FECACA',
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
summaryHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
summaryTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#991B1B',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
summaryList: {
|
||||||
|
paddingLeft: 26,
|
||||||
|
},
|
||||||
|
summaryItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
summaryBullet: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#991B1B',
|
||||||
|
marginRight: 8,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
summaryMessage: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#991B1B',
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FieldError;
|
||||||
272
components/errors/FullScreenError.tsx
Normal file
272
components/errors/FullScreenError.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* FullScreenError - Full screen error state component
|
||||||
|
*
|
||||||
|
* Used for:
|
||||||
|
* - Critical errors that block the UI
|
||||||
|
* - Data loading failures
|
||||||
|
* - Network offline states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { AppError, ErrorCategory } from '@/types/errors';
|
||||||
|
|
||||||
|
interface FullScreenErrorProps {
|
||||||
|
error?: AppError;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
showDismiss?: boolean;
|
||||||
|
retryLabel?: string;
|
||||||
|
dismissLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-specific icons
|
||||||
|
const categoryIcons: Record<ErrorCategory, keyof typeof Ionicons.glyphMap> = {
|
||||||
|
network: 'cloud-offline',
|
||||||
|
timeout: 'time-outline',
|
||||||
|
authentication: 'lock-closed',
|
||||||
|
permission: 'shield-outline',
|
||||||
|
notFound: 'search-outline',
|
||||||
|
validation: 'warning',
|
||||||
|
conflict: 'git-merge',
|
||||||
|
rateLimit: 'hourglass',
|
||||||
|
server: 'server-outline',
|
||||||
|
client: 'alert-circle',
|
||||||
|
ble: 'bluetooth',
|
||||||
|
sensor: 'hardware-chip',
|
||||||
|
subscription: 'card',
|
||||||
|
unknown: 'help-circle',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FullScreenError({
|
||||||
|
error,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
|
showDismiss = false,
|
||||||
|
retryLabel = 'Try Again',
|
||||||
|
dismissLabel = 'Dismiss',
|
||||||
|
}: FullScreenErrorProps) {
|
||||||
|
const displayTitle = title || (error?.category === 'network' ? 'Connection Error' : 'Something Went Wrong');
|
||||||
|
const displayMessage = message || error?.userMessage || 'An unexpected error occurred. Please try again.';
|
||||||
|
const icon = error?.category ? categoryIcons[error.category] : 'alert-circle';
|
||||||
|
const isRetryable = error?.retry.isRetryable ?? !!onRetry;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name={icon} size={64} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text style={styles.title}>{displayTitle}</Text>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<Text style={styles.message}>{displayMessage}</Text>
|
||||||
|
|
||||||
|
{/* Action hint */}
|
||||||
|
{error?.actionHint && (
|
||||||
|
<Text style={styles.hint}>{error.actionHint}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={styles.buttons}>
|
||||||
|
{isRetryable && onRetry && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={onRetry}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
|
||||||
|
<Text style={styles.retryButtonText}>{retryLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDismiss && onDismiss && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dismissButton}
|
||||||
|
onPress={onDismiss}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.dismissButtonText}>{dismissLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error ID for support */}
|
||||||
|
{error?.errorId && __DEV__ && (
|
||||||
|
<Text style={styles.errorId}>Error ID: {error.errorId}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState - Component for empty data states (not strictly an error)
|
||||||
|
*/
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon = 'folder-open-outline',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name={icon} size={64} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{message && <Text style={styles.message}>{message}</Text>}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={onAction}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryButtonText}>{actionLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OfflineState - Specific state for offline/no connection
|
||||||
|
*/
|
||||||
|
interface OfflineStateProps {
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineState({ onRetry }: OfflineStateProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name="cloud-offline" size={64} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>No Internet Connection</Text>
|
||||||
|
<Text style={styles.message}>
|
||||||
|
Please check your connection and try again.
|
||||||
|
</Text>
|
||||||
|
{onRetry && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={onRetry}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={20} color="#fff" style={styles.buttonIcon} />
|
||||||
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
buttons: {
|
||||||
|
marginTop: 16,
|
||||||
|
gap: 12,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
dismissButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
dismissButtonText: {
|
||||||
|
color: '#6B7280',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
errorId: {
|
||||||
|
marginTop: 24,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#D1D5DB',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FullScreenError;
|
||||||
7
components/errors/index.ts
Normal file
7
components/errors/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Error Components Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ErrorToast } from './ErrorToast';
|
||||||
|
export { FieldError, FieldErrorSummary } from './FieldError';
|
||||||
|
export { FullScreenError, EmptyState, OfflineState } from './FullScreenError';
|
||||||
384
contexts/ErrorContext.tsx
Normal file
384
contexts/ErrorContext.tsx
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* ErrorContext - Global Error State Management
|
||||||
|
*
|
||||||
|
* Provides centralized error handling with:
|
||||||
|
* - Error queue for multiple errors
|
||||||
|
* - Toast notifications for transient errors
|
||||||
|
* - Persistent errors that require user action
|
||||||
|
* - Error dismissal and retry callbacks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useCallback, useReducer, useMemo, useRef, ReactNode } from 'react';
|
||||||
|
import { AppError, ErrorSeverity, isAuthError, isCriticalError } from '@/types/errors';
|
||||||
|
import { logError } from '@/services/errorHandler';
|
||||||
|
|
||||||
|
// Maximum number of errors to keep in history
|
||||||
|
const MAX_ERROR_HISTORY = 50;
|
||||||
|
|
||||||
|
// Auto-dismiss timeout for non-critical errors (ms)
|
||||||
|
const AUTO_DISMISS_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
// Error with display metadata
|
||||||
|
interface DisplayableError extends AppError {
|
||||||
|
isVisible: boolean;
|
||||||
|
autoDismiss: boolean;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State shape
|
||||||
|
interface ErrorContextState {
|
||||||
|
// Current visible error (top of queue)
|
||||||
|
currentError: DisplayableError | null;
|
||||||
|
|
||||||
|
// All pending errors
|
||||||
|
errorQueue: DisplayableError[];
|
||||||
|
|
||||||
|
// Error history (for debugging)
|
||||||
|
errorHistory: AppError[];
|
||||||
|
|
||||||
|
// Global loading state for error recovery
|
||||||
|
isRecovering: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
type ErrorAction =
|
||||||
|
| { type: 'ADD_ERROR'; payload: DisplayableError }
|
||||||
|
| { type: 'DISMISS_CURRENT' }
|
||||||
|
| { type: 'DISMISS_ALL' }
|
||||||
|
| { type: 'DISMISS_BY_ID'; payload: string }
|
||||||
|
| { type: 'SET_RECOVERING'; payload: boolean }
|
||||||
|
| { type: 'CLEAR_HISTORY' };
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: ErrorContextState = {
|
||||||
|
currentError: null,
|
||||||
|
errorQueue: [],
|
||||||
|
errorHistory: [],
|
||||||
|
isRecovering: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
function errorReducer(state: ErrorContextState, action: ErrorAction): ErrorContextState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_ERROR': {
|
||||||
|
const newError = action.payload;
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
const newHistory = [newError, ...state.errorHistory].slice(0, MAX_ERROR_HISTORY);
|
||||||
|
|
||||||
|
// If no current error, set this as current
|
||||||
|
if (!state.currentError) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentError: { ...newError, isVisible: true },
|
||||||
|
errorHistory: newHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errorQueue: [...state.errorQueue, newError],
|
||||||
|
errorHistory: newHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DISMISS_CURRENT': {
|
||||||
|
// Get next error from queue
|
||||||
|
const [nextError, ...remainingQueue] = state.errorQueue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentError: nextError ? { ...nextError, isVisible: true } : null,
|
||||||
|
errorQueue: remainingQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DISMISS_ALL': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentError: null,
|
||||||
|
errorQueue: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DISMISS_BY_ID': {
|
||||||
|
const errorId = action.payload;
|
||||||
|
|
||||||
|
// If current error matches, dismiss it
|
||||||
|
if (state.currentError?.errorId === errorId) {
|
||||||
|
const [nextError, ...remainingQueue] = state.errorQueue;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentError: nextError ? { ...nextError, isVisible: true } : null,
|
||||||
|
errorQueue: remainingQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errorQueue: state.errorQueue.filter((e) => e.errorId !== errorId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_RECOVERING': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isRecovering: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLEAR_HISTORY': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
errorHistory: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context value shape
|
||||||
|
interface ErrorContextValue {
|
||||||
|
// State
|
||||||
|
currentError: DisplayableError | null;
|
||||||
|
errorQueue: DisplayableError[];
|
||||||
|
errorHistory: AppError[];
|
||||||
|
isRecovering: boolean;
|
||||||
|
hasErrors: boolean;
|
||||||
|
errorCount: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
showError: (
|
||||||
|
error: AppError,
|
||||||
|
options?: {
|
||||||
|
autoDismiss?: boolean;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
dismissCurrent: () => void;
|
||||||
|
dismissAll: () => void;
|
||||||
|
dismissById: (errorId: string) => void;
|
||||||
|
clearHistory: () => void;
|
||||||
|
setRecovering: (recovering: boolean) => void;
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
getErrorsByCategory: (category: string) => AppError[];
|
||||||
|
getErrorsBySeverity: (severity: ErrorSeverity) => AppError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
const ErrorContext = createContext<ErrorContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
interface ErrorProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onAuthError?: (error: AppError) => void;
|
||||||
|
onCriticalError?: (error: AppError) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorProvider({
|
||||||
|
children,
|
||||||
|
onAuthError,
|
||||||
|
onCriticalError,
|
||||||
|
}: ErrorProviderProps) {
|
||||||
|
const [state, dispatch] = useReducer(errorReducer, initialState);
|
||||||
|
|
||||||
|
// Timer refs for auto-dismiss
|
||||||
|
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
const clearDismissTimer = useCallback(() => {
|
||||||
|
if (dismissTimerRef.current) {
|
||||||
|
clearTimeout(dismissTimerRef.current);
|
||||||
|
dismissTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Schedule auto-dismiss
|
||||||
|
const scheduleAutoDismiss = useCallback((timeout: number = AUTO_DISMISS_TIMEOUT) => {
|
||||||
|
clearDismissTimer();
|
||||||
|
dismissTimerRef.current = setTimeout(() => {
|
||||||
|
dispatch({ type: 'DISMISS_CURRENT' });
|
||||||
|
}, timeout);
|
||||||
|
}, [clearDismissTimer]);
|
||||||
|
|
||||||
|
// Show error
|
||||||
|
const showError = useCallback(
|
||||||
|
(
|
||||||
|
error: AppError,
|
||||||
|
options?: {
|
||||||
|
autoDismiss?: boolean;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
// Log error
|
||||||
|
logError(error);
|
||||||
|
|
||||||
|
// Handle auth errors specially
|
||||||
|
if (isAuthError(error) && onAuthError) {
|
||||||
|
onAuthError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle critical errors specially
|
||||||
|
if (isCriticalError(error) && onCriticalError) {
|
||||||
|
onCriticalError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine auto-dismiss behavior
|
||||||
|
const shouldAutoDismiss =
|
||||||
|
options?.autoDismiss ??
|
||||||
|
(error.severity !== 'critical' && error.severity !== 'error');
|
||||||
|
|
||||||
|
const displayableError: DisplayableError = {
|
||||||
|
...error,
|
||||||
|
isVisible: true,
|
||||||
|
autoDismiss: shouldAutoDismiss,
|
||||||
|
onRetry: options?.onRetry,
|
||||||
|
onDismiss: options?.onDismiss,
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch({ type: 'ADD_ERROR', payload: displayableError });
|
||||||
|
|
||||||
|
// Schedule auto-dismiss if applicable
|
||||||
|
if (shouldAutoDismiss && !state.currentError) {
|
||||||
|
scheduleAutoDismiss();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.currentError, onAuthError, onCriticalError, scheduleAutoDismiss]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dismiss current error
|
||||||
|
const dismissCurrent = useCallback(() => {
|
||||||
|
clearDismissTimer();
|
||||||
|
|
||||||
|
// Call onDismiss callback if provided
|
||||||
|
state.currentError?.onDismiss?.();
|
||||||
|
|
||||||
|
dispatch({ type: 'DISMISS_CURRENT' });
|
||||||
|
|
||||||
|
// Schedule auto-dismiss for next error if applicable
|
||||||
|
const nextError = state.errorQueue[0];
|
||||||
|
if (nextError?.autoDismiss) {
|
||||||
|
scheduleAutoDismiss();
|
||||||
|
}
|
||||||
|
}, [state.currentError, state.errorQueue, clearDismissTimer, scheduleAutoDismiss]);
|
||||||
|
|
||||||
|
// Dismiss all errors
|
||||||
|
const dismissAll = useCallback(() => {
|
||||||
|
clearDismissTimer();
|
||||||
|
dispatch({ type: 'DISMISS_ALL' });
|
||||||
|
}, [clearDismissTimer]);
|
||||||
|
|
||||||
|
// Dismiss by ID
|
||||||
|
const dismissById = useCallback(
|
||||||
|
(errorId: string) => {
|
||||||
|
if (state.currentError?.errorId === errorId) {
|
||||||
|
dismissCurrent();
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'DISMISS_BY_ID', payload: errorId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.currentError, dismissCurrent]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear history
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
dispatch({ type: 'CLEAR_HISTORY' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set recovering state
|
||||||
|
const setRecovering = useCallback((recovering: boolean) => {
|
||||||
|
dispatch({ type: 'SET_RECOVERING', payload: recovering });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get errors by category
|
||||||
|
const getErrorsByCategory = useCallback(
|
||||||
|
(category: string) => {
|
||||||
|
return state.errorHistory.filter((e) => e.category === category);
|
||||||
|
},
|
||||||
|
[state.errorHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get errors by severity
|
||||||
|
const getErrorsBySeverity = useCallback(
|
||||||
|
(severity: ErrorSeverity) => {
|
||||||
|
return state.errorHistory.filter((e) => e.severity === severity);
|
||||||
|
},
|
||||||
|
[state.errorHistory]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Context value
|
||||||
|
const value = useMemo<ErrorContextValue>(
|
||||||
|
() => ({
|
||||||
|
currentError: state.currentError,
|
||||||
|
errorQueue: state.errorQueue,
|
||||||
|
errorHistory: state.errorHistory,
|
||||||
|
isRecovering: state.isRecovering,
|
||||||
|
hasErrors: state.currentError !== null || state.errorQueue.length > 0,
|
||||||
|
errorCount: (state.currentError ? 1 : 0) + state.errorQueue.length,
|
||||||
|
showError,
|
||||||
|
dismissCurrent,
|
||||||
|
dismissAll,
|
||||||
|
dismissById,
|
||||||
|
clearHistory,
|
||||||
|
setRecovering,
|
||||||
|
getErrorsByCategory,
|
||||||
|
getErrorsBySeverity,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state.currentError,
|
||||||
|
state.errorQueue,
|
||||||
|
state.errorHistory,
|
||||||
|
state.isRecovering,
|
||||||
|
showError,
|
||||||
|
dismissCurrent,
|
||||||
|
dismissAll,
|
||||||
|
dismissById,
|
||||||
|
clearHistory,
|
||||||
|
setRecovering,
|
||||||
|
getErrorsByCategory,
|
||||||
|
getErrorsBySeverity,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorContext.Provider value={value}>{children}</ErrorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to use error context
|
||||||
|
export function useErrorContext(): ErrorContextValue {
|
||||||
|
const context = useContext(ErrorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useErrorContext must be used within an ErrorProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience hook for showing errors
|
||||||
|
export function useShowError() {
|
||||||
|
const { showError } = useErrorContext();
|
||||||
|
return showError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe hook that returns undefined if not in ErrorProvider (for optional use)
|
||||||
|
export function useShowErrorSafe() {
|
||||||
|
const context = useContext(ErrorContext);
|
||||||
|
return context?.showError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience hook for current error
|
||||||
|
export function useCurrentError() {
|
||||||
|
const { currentError, dismissCurrent } = useErrorContext();
|
||||||
|
return { error: currentError, dismiss: dismissCurrent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorContext;
|
||||||
466
hooks/useAsync.ts
Normal file
466
hooks/useAsync.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* useAsync - Enhanced async state management hook
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Loading, success, error states
|
||||||
|
* - Automatic error handling
|
||||||
|
* - Retry with exponential backoff
|
||||||
|
* - Optimistic updates
|
||||||
|
* - Abort/cancel support
|
||||||
|
* - Stale data indication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { AppError } from '@/types/errors';
|
||||||
|
import { ApiResponse } from '@/types';
|
||||||
|
import {
|
||||||
|
createErrorFromApi,
|
||||||
|
createErrorFromUnknown,
|
||||||
|
calculateRetryDelay,
|
||||||
|
logError,
|
||||||
|
} from '@/services/errorHandler';
|
||||||
|
|
||||||
|
// Async operation states
|
||||||
|
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
// Async state shape
|
||||||
|
export interface AsyncState<T> {
|
||||||
|
status: AsyncStatus;
|
||||||
|
data: T | null;
|
||||||
|
error: AppError | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isIdle: boolean;
|
||||||
|
isStale: boolean;
|
||||||
|
lastUpdated: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for useAsync
|
||||||
|
interface UseAsyncOptions<T> {
|
||||||
|
// Initial data
|
||||||
|
initialData?: T | null;
|
||||||
|
// Run immediately on mount
|
||||||
|
immediate?: boolean;
|
||||||
|
// Keep previous data while loading
|
||||||
|
keepPreviousData?: boolean;
|
||||||
|
// Stale time in ms (after which data is considered stale)
|
||||||
|
staleTime?: number;
|
||||||
|
// Auto-retry on error
|
||||||
|
autoRetry?: boolean;
|
||||||
|
// Max retry attempts
|
||||||
|
maxRetries?: number;
|
||||||
|
// Callback on success
|
||||||
|
onSuccess?: (data: T) => void;
|
||||||
|
// Callback on error
|
||||||
|
onError?: (error: AppError) => void;
|
||||||
|
// Callback on settle (success or error)
|
||||||
|
onSettled?: (data: T | null, error: AppError | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return type
|
||||||
|
interface UseAsyncReturn<T, Args extends unknown[]> {
|
||||||
|
// State
|
||||||
|
state: AsyncState<T>;
|
||||||
|
data: T | null;
|
||||||
|
error: AppError | null;
|
||||||
|
status: AsyncStatus;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSuccess: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isIdle: boolean;
|
||||||
|
isStale: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
execute: (...args: Args) => Promise<T | null>;
|
||||||
|
reset: () => void;
|
||||||
|
setData: (data: T | ((prev: T | null) => T)) => void;
|
||||||
|
setError: (error: AppError | null) => void;
|
||||||
|
retry: () => Promise<T | null>;
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial state
|
||||||
|
function createInitialState<T>(initialData?: T | null): AsyncState<T> {
|
||||||
|
return {
|
||||||
|
status: initialData !== undefined && initialData !== null ? 'success' : 'idle',
|
||||||
|
data: initialData ?? null,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: initialData !== undefined && initialData !== null,
|
||||||
|
isError: false,
|
||||||
|
isIdle: initialData === undefined || initialData === null,
|
||||||
|
isStale: false,
|
||||||
|
lastUpdated: initialData !== undefined && initialData !== null ? new Date() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsync<T, Args extends unknown[] = []>(
|
||||||
|
asyncFn: (...args: Args) => Promise<ApiResponse<T>>,
|
||||||
|
options: UseAsyncOptions<T> = {}
|
||||||
|
): UseAsyncReturn<T, Args> {
|
||||||
|
const {
|
||||||
|
initialData,
|
||||||
|
immediate = false,
|
||||||
|
keepPreviousData = true,
|
||||||
|
staleTime,
|
||||||
|
autoRetry = false,
|
||||||
|
maxRetries = 3,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onSettled,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [state, setState] = useState<AsyncState<T>>(() =>
|
||||||
|
createInitialState(initialData)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const lastArgsRef = useRef<Args | null>(null);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
|
||||||
|
// Check if data is stale
|
||||||
|
useEffect(() => {
|
||||||
|
if (!staleTime || !state.lastUpdated || state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkStale = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const elapsed = now.getTime() - state.lastUpdated!.getTime();
|
||||||
|
if (elapsed > staleTime && !state.isStale) {
|
||||||
|
setState((prev) => ({ ...prev, isStale: true }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
checkStale();
|
||||||
|
|
||||||
|
// Set up interval
|
||||||
|
const interval = setInterval(checkStale, Math.min(staleTime / 2, 30000));
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [staleTime, state.lastUpdated, state.isLoading, state.isStale]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Execute the async function
|
||||||
|
const execute = useCallback(
|
||||||
|
async (...args: Args): Promise<T | null> => {
|
||||||
|
// Cancel previous request
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
// Store args for retry
|
||||||
|
lastArgsRef.current = args;
|
||||||
|
retryCountRef.current = 0;
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'loading',
|
||||||
|
isLoading: true,
|
||||||
|
isIdle: false,
|
||||||
|
error: keepPreviousData ? null : prev.error,
|
||||||
|
data: keepPreviousData ? prev.data : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const executeWithRetry = async (attempt: number): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
const response = await asyncFn(...args);
|
||||||
|
|
||||||
|
// Check if aborted or unmounted
|
||||||
|
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok && response.data !== undefined) {
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
status: 'success',
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: true,
|
||||||
|
isError: false,
|
||||||
|
isIdle: false,
|
||||||
|
isStale: false,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.(data);
|
||||||
|
onSettled?.(data, null);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error
|
||||||
|
if (response.error) {
|
||||||
|
const appError = createErrorFromApi(response.error);
|
||||||
|
|
||||||
|
// Check if should retry
|
||||||
|
if (
|
||||||
|
autoRetry &&
|
||||||
|
appError.retry.isRetryable &&
|
||||||
|
attempt < maxRetries
|
||||||
|
) {
|
||||||
|
const delay = calculateRetryDelay(appError.retry, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeWithRetry(attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log and set error
|
||||||
|
logError(appError);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
status: 'error',
|
||||||
|
data: keepPreviousData ? state.data : null,
|
||||||
|
error: appError,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: false,
|
||||||
|
isError: true,
|
||||||
|
isIdle: false,
|
||||||
|
isStale: false,
|
||||||
|
lastUpdated: state.lastUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
|
onError?.(appError);
|
||||||
|
onSettled?.(null, appError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
// Check if aborted
|
||||||
|
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appError = createErrorFromUnknown(error);
|
||||||
|
|
||||||
|
// Check if should retry
|
||||||
|
if (
|
||||||
|
autoRetry &&
|
||||||
|
appError.retry.isRetryable &&
|
||||||
|
attempt < maxRetries
|
||||||
|
) {
|
||||||
|
const delay = calculateRetryDelay(appError.retry, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeWithRetry(attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log and set error
|
||||||
|
logError(appError);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
status: 'error',
|
||||||
|
data: keepPreviousData ? state.data : null,
|
||||||
|
error: appError,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: false,
|
||||||
|
isError: true,
|
||||||
|
isIdle: false,
|
||||||
|
isStale: false,
|
||||||
|
lastUpdated: state.lastUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
|
onError?.(appError);
|
||||||
|
onSettled?.(null, appError);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeWithRetry(0);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
asyncFn,
|
||||||
|
keepPreviousData,
|
||||||
|
autoRetry,
|
||||||
|
maxRetries,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
onSettled,
|
||||||
|
state.data,
|
||||||
|
state.lastUpdated,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset to initial state
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
setState(createInitialState(initialData));
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// Set data manually
|
||||||
|
const setData = useCallback((data: T | ((prev: T | null) => T)) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const newData = typeof data === 'function' ? (data as (prev: T | null) => T)(prev.data) : data;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
status: 'success',
|
||||||
|
data: newData,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: true,
|
||||||
|
isError: false,
|
||||||
|
isIdle: false,
|
||||||
|
isStale: false,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set error manually
|
||||||
|
const setError = useCallback((error: AppError | null) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: error ? 'error' : 'idle',
|
||||||
|
error,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: false,
|
||||||
|
isError: !!error,
|
||||||
|
isIdle: !error,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Retry last execution
|
||||||
|
const retry = useCallback(async (): Promise<T | null> => {
|
||||||
|
if (!lastArgsRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return execute(...lastArgsRef.current);
|
||||||
|
}, [execute]);
|
||||||
|
|
||||||
|
// Cancel current execution
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Execute immediately if specified
|
||||||
|
useEffect(() => {
|
||||||
|
if (immediate) {
|
||||||
|
execute(...([] as unknown as Args));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
data: state.data,
|
||||||
|
error: state.error,
|
||||||
|
status: state.status,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
isSuccess: state.isSuccess,
|
||||||
|
isError: state.isError,
|
||||||
|
isIdle: state.isIdle,
|
||||||
|
isStale: state.isStale,
|
||||||
|
execute,
|
||||||
|
reset,
|
||||||
|
setData,
|
||||||
|
setError,
|
||||||
|
retry,
|
||||||
|
cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAsyncCallback - Similar to useAsync but for one-off operations
|
||||||
|
* (like form submissions) rather than data fetching
|
||||||
|
*/
|
||||||
|
export function useAsyncCallback<T, Args extends unknown[] = []>(
|
||||||
|
asyncFn: (...args: Args) => Promise<T>,
|
||||||
|
options: Omit<UseAsyncOptions<T>, 'immediate'> = {}
|
||||||
|
) {
|
||||||
|
const { onSuccess, onError, onSettled, autoRetry = false, maxRetries = 3 } = options;
|
||||||
|
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
isLoading: boolean;
|
||||||
|
error: AppError | null;
|
||||||
|
}>({
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
async (...args: Args): Promise<T | null> => {
|
||||||
|
setState({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const executeWithRetry = async (attempt: number): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
const result = await asyncFn(...args);
|
||||||
|
|
||||||
|
if (!mountedRef.current) return null;
|
||||||
|
|
||||||
|
setState({ isLoading: false, error: null });
|
||||||
|
onSuccess?.(result);
|
||||||
|
onSettled?.(result, null);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (!mountedRef.current) return null;
|
||||||
|
|
||||||
|
const appError = createErrorFromUnknown(error);
|
||||||
|
|
||||||
|
if (autoRetry && appError.retry.isRetryable && attempt < maxRetries) {
|
||||||
|
const delay = calculateRetryDelay(appError.retry, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
if (!mountedRef.current) return null;
|
||||||
|
return executeWithRetry(attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(appError);
|
||||||
|
setState({ isLoading: false, error: appError });
|
||||||
|
onError?.(appError);
|
||||||
|
onSettled?.(null, appError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return executeWithRetry(0);
|
||||||
|
},
|
||||||
|
[asyncFn, autoRetry, maxRetries, onSuccess, onError, onSettled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({ isLoading: false, error: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
execute,
|
||||||
|
reset,
|
||||||
|
isLoading: state.isLoading,
|
||||||
|
error: state.error,
|
||||||
|
isError: state.error !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAsync;
|
||||||
368
hooks/useError.ts
Normal file
368
hooks/useError.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* useError - Hook for error handling with recovery strategies
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Handle API errors consistently
|
||||||
|
* - Automatic retry with exponential backoff
|
||||||
|
* - User-friendly error display
|
||||||
|
* - Error state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import {
|
||||||
|
AppError,
|
||||||
|
ErrorState,
|
||||||
|
initialErrorState,
|
||||||
|
isRetryableError,
|
||||||
|
} from '@/types/errors';
|
||||||
|
import {
|
||||||
|
createErrorFromApi,
|
||||||
|
createErrorFromUnknown,
|
||||||
|
calculateRetryDelay,
|
||||||
|
logError,
|
||||||
|
} from '@/services/errorHandler';
|
||||||
|
import { ApiError, ApiResponse } from '@/types';
|
||||||
|
import { useShowErrorSafe } from '@/contexts/ErrorContext';
|
||||||
|
|
||||||
|
interface UseErrorOptions {
|
||||||
|
// Show error in global toast
|
||||||
|
showToast?: boolean;
|
||||||
|
// Show native alert
|
||||||
|
showAlert?: boolean;
|
||||||
|
// Custom alert title
|
||||||
|
alertTitle?: string;
|
||||||
|
// Auto-retry on failure
|
||||||
|
autoRetry?: boolean;
|
||||||
|
// Max retry attempts (overrides error default)
|
||||||
|
maxRetries?: number;
|
||||||
|
// Callback when error occurs
|
||||||
|
onError?: (error: AppError) => void;
|
||||||
|
// Callback when retry starts
|
||||||
|
onRetryStart?: (attempt: number) => void;
|
||||||
|
// Callback when all retries exhausted
|
||||||
|
onRetryExhausted?: (error: AppError) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseErrorReturn {
|
||||||
|
// Current error state
|
||||||
|
errorState: ErrorState;
|
||||||
|
|
||||||
|
// Set error from API response
|
||||||
|
setApiError: (error: ApiError, context?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
// Set error from any value
|
||||||
|
setError: (error: unknown, context?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
// Clear current error
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// Clear all errors
|
||||||
|
clearAllErrors: () => void;
|
||||||
|
|
||||||
|
// Handle API response (returns data if ok, sets error if not)
|
||||||
|
handleApiResponse: <T>(
|
||||||
|
response: ApiResponse<T>,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
) => T | null;
|
||||||
|
|
||||||
|
// Wrap async function with error handling
|
||||||
|
wrapAsync: <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
) => Promise<T | null>;
|
||||||
|
|
||||||
|
// Execute with retry
|
||||||
|
executeWithRetry: <T>(
|
||||||
|
fn: () => Promise<ApiResponse<T>>,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
) => Promise<T | null>;
|
||||||
|
|
||||||
|
// Check if currently has error
|
||||||
|
hasError: boolean;
|
||||||
|
|
||||||
|
// Current error (if any)
|
||||||
|
error: AppError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useError(options: UseErrorOptions = {}): UseErrorReturn {
|
||||||
|
const {
|
||||||
|
showToast = true,
|
||||||
|
showAlert = false,
|
||||||
|
alertTitle = 'Error',
|
||||||
|
autoRetry = false,
|
||||||
|
maxRetries,
|
||||||
|
onError,
|
||||||
|
onRetryStart,
|
||||||
|
onRetryExhausted,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [errorState, setErrorState] = useState<ErrorState>(initialErrorState);
|
||||||
|
const retryCountRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Get global show error function (may not be available if not in ErrorProvider)
|
||||||
|
const showGlobalError = useShowErrorSafe();
|
||||||
|
|
||||||
|
// Internal function to handle error
|
||||||
|
const handleError = useCallback(
|
||||||
|
(appError: AppError, retryFn?: () => void) => {
|
||||||
|
// Log error
|
||||||
|
logError(appError);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setErrorState({
|
||||||
|
hasError: true,
|
||||||
|
error: appError,
|
||||||
|
errors: [appError],
|
||||||
|
lastErrorAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call callback
|
||||||
|
onError?.(appError);
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
if (showToast && showGlobalError) {
|
||||||
|
showGlobalError(appError, { onRetry: retryFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show alert
|
||||||
|
if (showAlert) {
|
||||||
|
const buttons: { text: string; onPress?: () => void; style?: 'cancel' | 'default' | 'destructive' }[] = [
|
||||||
|
{ text: 'OK', style: 'cancel' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isRetryableError(appError) && retryFn) {
|
||||||
|
buttons.unshift({
|
||||||
|
text: 'Retry',
|
||||||
|
onPress: retryFn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert(alertTitle, appError.userMessage, buttons);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast, showAlert, alertTitle, onError, showGlobalError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set error from API error
|
||||||
|
const setApiError = useCallback(
|
||||||
|
(error: ApiError, context?: Record<string, unknown>) => {
|
||||||
|
const appError = createErrorFromApi(error, context);
|
||||||
|
handleError(appError);
|
||||||
|
},
|
||||||
|
[handleError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set error from any value
|
||||||
|
const setError = useCallback(
|
||||||
|
(error: unknown, context?: Record<string, unknown>) => {
|
||||||
|
const appError = createErrorFromUnknown(error, undefined, context);
|
||||||
|
handleError(appError);
|
||||||
|
},
|
||||||
|
[handleError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear current error
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setErrorState(initialErrorState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear all errors
|
||||||
|
const clearAllErrors = useCallback(() => {
|
||||||
|
setErrorState(initialErrorState);
|
||||||
|
retryCountRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle API response
|
||||||
|
const handleApiResponse = useCallback(
|
||||||
|
<T>(response: ApiResponse<T>, context?: Record<string, unknown>): T | null => {
|
||||||
|
if (response.ok && response.data !== undefined) {
|
||||||
|
clearError();
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
setApiError(response.error, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[clearError, setApiError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap async function with error handling
|
||||||
|
const wrapAsync = useCallback(
|
||||||
|
async <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
clearError();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
setError(error, context);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearError, setError]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute with retry
|
||||||
|
const executeWithRetry = useCallback(
|
||||||
|
async <T>(
|
||||||
|
fn: () => Promise<ApiResponse<T>>,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): Promise<T | null> => {
|
||||||
|
const operationId = Date.now().toString();
|
||||||
|
let attempt = 0;
|
||||||
|
const maxAttempts = maxRetries ?? 3;
|
||||||
|
|
||||||
|
const execute = async (): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fn();
|
||||||
|
|
||||||
|
if (response.ok && response.data !== undefined) {
|
||||||
|
clearError();
|
||||||
|
retryCountRef.current.delete(operationId);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
const appError = createErrorFromApi(response.error, context);
|
||||||
|
|
||||||
|
// Check if should retry
|
||||||
|
const shouldRetry =
|
||||||
|
autoRetry &&
|
||||||
|
isRetryableError(appError) &&
|
||||||
|
attempt < maxAttempts;
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
attempt++;
|
||||||
|
onRetryStart?.(attempt);
|
||||||
|
|
||||||
|
const delay = calculateRetryDelay(appError.retry, attempt - 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more retries
|
||||||
|
if (attempt > 0) {
|
||||||
|
onRetryExhausted?.(appError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError(appError, autoRetry ? () => execute() : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
const appError = createErrorFromUnknown(error, undefined, context);
|
||||||
|
|
||||||
|
// Check if should retry
|
||||||
|
const shouldRetry =
|
||||||
|
autoRetry &&
|
||||||
|
isRetryableError(appError) &&
|
||||||
|
attempt < maxAttempts;
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
attempt++;
|
||||||
|
onRetryStart?.(attempt);
|
||||||
|
|
||||||
|
const delay = calculateRetryDelay(appError.retry, attempt - 1);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more retries
|
||||||
|
if (attempt > 0) {
|
||||||
|
onRetryExhausted?.(appError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError(appError, autoRetry ? () => execute() : undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return execute();
|
||||||
|
},
|
||||||
|
[autoRetry, maxRetries, clearError, handleError, onRetryStart, onRetryExhausted]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorState,
|
||||||
|
setApiError,
|
||||||
|
setError,
|
||||||
|
clearError,
|
||||||
|
clearAllErrors,
|
||||||
|
handleApiResponse,
|
||||||
|
wrapAsync,
|
||||||
|
executeWithRetry,
|
||||||
|
hasError: errorState.hasError,
|
||||||
|
error: errorState.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFieldErrors - Hook for managing field-level validation errors
|
||||||
|
*/
|
||||||
|
interface FieldErrors {
|
||||||
|
[field: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFieldErrorsReturn {
|
||||||
|
errors: FieldErrors;
|
||||||
|
setFieldError: (field: string, message: string) => void;
|
||||||
|
clearFieldError: (field: string) => void;
|
||||||
|
clearAllFieldErrors: () => void;
|
||||||
|
hasErrors: boolean;
|
||||||
|
getError: (field: string) => string | undefined;
|
||||||
|
getErrorList: () => { field: string; message: string }[];
|
||||||
|
setErrors: (errors: FieldErrors) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFieldErrors(): UseFieldErrorsReturn {
|
||||||
|
const [errors, setErrors] = useState<FieldErrors>({});
|
||||||
|
|
||||||
|
const setFieldError = useCallback((field: string, message: string) => {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: message }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFieldError = useCallback((field: string) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const { [field]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllFieldErrors = useCallback(() => {
|
||||||
|
setErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getError = useCallback(
|
||||||
|
(field: string) => errors[field],
|
||||||
|
[errors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getErrorList = useCallback(() => {
|
||||||
|
return Object.entries(errors)
|
||||||
|
.filter(([_, message]) => message !== undefined)
|
||||||
|
.map(([field, message]) => ({ field, message: message as string }));
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
|
const hasErrors = Object.values(errors).some((v) => v !== undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
setFieldError,
|
||||||
|
clearFieldError,
|
||||||
|
clearAllFieldErrors,
|
||||||
|
hasErrors,
|
||||||
|
getError,
|
||||||
|
getErrorList,
|
||||||
|
setErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useError;
|
||||||
378
services/errorHandler.ts
Normal file
378
services/errorHandler.ts
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Error Handler Service
|
||||||
|
*
|
||||||
|
* Provides error classification, user-friendly message translation,
|
||||||
|
* and recovery strategy recommendations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppError,
|
||||||
|
ErrorCategory,
|
||||||
|
ErrorCode,
|
||||||
|
ErrorCodes,
|
||||||
|
ErrorSeverity,
|
||||||
|
FieldError,
|
||||||
|
RetryPolicy,
|
||||||
|
DefaultRetryPolicies,
|
||||||
|
} from '@/types/errors';
|
||||||
|
import { ApiError } from '@/types';
|
||||||
|
|
||||||
|
// Generate unique error ID
|
||||||
|
function generateErrorId(): string {
|
||||||
|
return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-friendly messages for error codes
|
||||||
|
const userMessages: Record<string, string> = {
|
||||||
|
// Network
|
||||||
|
[ErrorCodes.NETWORK_ERROR]: 'Unable to connect. Please check your internet connection.',
|
||||||
|
[ErrorCodes.NETWORK_TIMEOUT]: 'The request timed out. Please try again.',
|
||||||
|
[ErrorCodes.NETWORK_OFFLINE]: 'You appear to be offline. Please check your connection.',
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
[ErrorCodes.UNAUTHORIZED]: 'Your session has expired. Please log in again.',
|
||||||
|
[ErrorCodes.TOKEN_EXPIRED]: 'Your session has expired. Please log in again.',
|
||||||
|
[ErrorCodes.INVALID_CREDENTIALS]: 'Invalid email or password.',
|
||||||
|
[ErrorCodes.SESSION_EXPIRED]: 'Your session has expired. Please log in again.',
|
||||||
|
|
||||||
|
// Permission
|
||||||
|
[ErrorCodes.FORBIDDEN]: 'You don\'t have permission to perform this action.',
|
||||||
|
[ErrorCodes.INSUFFICIENT_PERMISSIONS]: 'You don\'t have sufficient permissions.',
|
||||||
|
|
||||||
|
// Resource
|
||||||
|
[ErrorCodes.NOT_FOUND]: 'The requested resource was not found.',
|
||||||
|
[ErrorCodes.BENEFICIARY_NOT_FOUND]: 'Beneficiary not found.',
|
||||||
|
[ErrorCodes.DEPLOYMENT_NOT_FOUND]: 'Deployment not found.',
|
||||||
|
[ErrorCodes.USER_NOT_FOUND]: 'User not found.',
|
||||||
|
|
||||||
|
// Conflict
|
||||||
|
[ErrorCodes.CONFLICT]: 'A conflict occurred. Please refresh and try again.',
|
||||||
|
[ErrorCodes.DUPLICATE_ENTRY]: 'This entry already exists.',
|
||||||
|
[ErrorCodes.VERSION_MISMATCH]: 'Data has changed. Please refresh and try again.',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
[ErrorCodes.VALIDATION_ERROR]: 'Please check your input and try again.',
|
||||||
|
[ErrorCodes.INVALID_INPUT]: 'Invalid input. Please check and try again.',
|
||||||
|
[ErrorCodes.MISSING_REQUIRED_FIELD]: 'Please fill in all required fields.',
|
||||||
|
[ErrorCodes.INVALID_EMAIL]: 'Please enter a valid email address.',
|
||||||
|
[ErrorCodes.INVALID_PHONE]: 'Please enter a valid phone number.',
|
||||||
|
[ErrorCodes.INVALID_OTP]: 'Invalid verification code. Please try again.',
|
||||||
|
|
||||||
|
// Server
|
||||||
|
[ErrorCodes.SERVER_ERROR]: 'Something went wrong on our end. Please try again later.',
|
||||||
|
[ErrorCodes.SERVICE_UNAVAILABLE]: 'Service is temporarily unavailable. Please try again later.',
|
||||||
|
[ErrorCodes.MAINTENANCE]: 'We\'re currently performing maintenance. Please try again soon.',
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
[ErrorCodes.RATE_LIMITED]: 'Too many requests. Please wait a moment and try again.',
|
||||||
|
[ErrorCodes.TOO_MANY_REQUESTS]: 'Too many requests. Please wait a moment and try again.',
|
||||||
|
|
||||||
|
// BLE
|
||||||
|
[ErrorCodes.BLE_NOT_AVAILABLE]: 'Bluetooth is not available on this device.',
|
||||||
|
[ErrorCodes.BLE_NOT_ENABLED]: 'Please enable Bluetooth to continue.',
|
||||||
|
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Bluetooth permission is required.',
|
||||||
|
[ErrorCodes.BLE_CONNECTION_FAILED]: 'Failed to connect to device. Please try again.',
|
||||||
|
[ErrorCodes.BLE_DEVICE_NOT_FOUND]: 'Device not found. Make sure it\'s powered on.',
|
||||||
|
[ErrorCodes.BLE_TIMEOUT]: 'Connection timed out. Please try again.',
|
||||||
|
|
||||||
|
// Sensor
|
||||||
|
[ErrorCodes.SENSOR_SETUP_FAILED]: 'Sensor setup failed. Please try again.',
|
||||||
|
[ErrorCodes.SENSOR_OFFLINE]: 'Sensor is offline. Check the device connection.',
|
||||||
|
[ErrorCodes.SENSOR_NOT_RESPONDING]: 'Sensor is not responding. Please try again.',
|
||||||
|
[ErrorCodes.SENSOR_WIFI_FAILED]: 'Failed to configure WiFi. Please check credentials.',
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'A subscription is required to access this feature.',
|
||||||
|
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Your subscription has expired.',
|
||||||
|
[ErrorCodes.PAYMENT_FAILED]: 'Payment failed. Please try again or use a different method.',
|
||||||
|
[ErrorCodes.PAYMENT_CANCELED]: 'Payment was canceled.',
|
||||||
|
|
||||||
|
// General
|
||||||
|
[ErrorCodes.UNKNOWN_ERROR]: 'Something went wrong. Please try again.',
|
||||||
|
[ErrorCodes.API_ERROR]: 'An error occurred. Please try again.',
|
||||||
|
[ErrorCodes.PARSE_ERROR]: 'Failed to process response. Please try again.',
|
||||||
|
[ErrorCodes.EXCEPTION]: 'An unexpected error occurred.',
|
||||||
|
[ErrorCodes.NOT_AUTHENTICATED]: 'Please log in to continue.',
|
||||||
|
[ErrorCodes.NO_BENEFICIARY_SELECTED]: 'Please select a beneficiary.',
|
||||||
|
[ErrorCodes.NO_DEPLOYMENT]: 'No deployment configured.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Action hints for error codes
|
||||||
|
const actionHints: Record<string, string> = {
|
||||||
|
[ErrorCodes.NETWORK_ERROR]: 'Check your Wi-Fi or cellular connection',
|
||||||
|
[ErrorCodes.NETWORK_OFFLINE]: 'Connect to the internet to continue',
|
||||||
|
[ErrorCodes.UNAUTHORIZED]: 'Tap here to log in again',
|
||||||
|
[ErrorCodes.TOKEN_EXPIRED]: 'Tap here to log in again',
|
||||||
|
[ErrorCodes.BLE_NOT_ENABLED]: 'Enable Bluetooth in Settings',
|
||||||
|
[ErrorCodes.BLE_PERMISSION_DENIED]: 'Grant Bluetooth permission in Settings',
|
||||||
|
[ErrorCodes.SUBSCRIPTION_REQUIRED]: 'View subscription options',
|
||||||
|
[ErrorCodes.SUBSCRIPTION_EXPIRED]: 'Renew your subscription',
|
||||||
|
[ErrorCodes.PAYMENT_FAILED]: 'Try a different payment method',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classify HTTP status to error category
|
||||||
|
function classifyHttpStatus(status: number): ErrorCategory {
|
||||||
|
if (status === 401) return 'authentication';
|
||||||
|
if (status === 403) return 'permission';
|
||||||
|
if (status === 404) return 'notFound';
|
||||||
|
if (status === 409) return 'conflict';
|
||||||
|
if (status === 429) return 'rateLimit';
|
||||||
|
if (status === 422 || status === 400) return 'validation';
|
||||||
|
if (status >= 500) return 'server';
|
||||||
|
if (status >= 400) return 'client';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify error code to category
|
||||||
|
function classifyErrorCode(code: string): ErrorCategory {
|
||||||
|
// Check timeout first before network (NETWORK_TIMEOUT should be timeout, not network)
|
||||||
|
if (code.includes('TIMEOUT')) return 'timeout';
|
||||||
|
if (code.startsWith('NETWORK') || code === 'NETWORK_ERROR') return 'network';
|
||||||
|
if (code.includes('AUTH') || code.includes('TOKEN') || code.includes('SESSION') || code === 'UNAUTHORIZED') {
|
||||||
|
return 'authentication';
|
||||||
|
}
|
||||||
|
if (code.includes('PERMISSION') || code === 'FORBIDDEN') return 'permission';
|
||||||
|
if (code.includes('NOT_FOUND')) return 'notFound';
|
||||||
|
if (code.includes('CONFLICT') || code.includes('DUPLICATE') || code.includes('MISMATCH')) return 'conflict';
|
||||||
|
if (code.includes('VALIDATION') || code.includes('INVALID') || code.includes('MISSING')) return 'validation';
|
||||||
|
if (code.includes('RATE') || code.includes('TOO_MANY')) return 'rateLimit';
|
||||||
|
if (code.includes('SERVER') || code.includes('UNAVAILABLE') || code.includes('MAINTENANCE')) return 'server';
|
||||||
|
if (code.startsWith('BLE')) return 'ble';
|
||||||
|
if (code.startsWith('SENSOR')) return 'sensor';
|
||||||
|
if (code.includes('SUBSCRIPTION') || code.includes('PAYMENT')) return 'subscription';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine severity from category and status
|
||||||
|
function determineSeverity(category: ErrorCategory, status?: number): ErrorSeverity {
|
||||||
|
// Critical - app cannot continue
|
||||||
|
if (category === 'authentication') return 'error';
|
||||||
|
if (status && status >= 500) return 'error';
|
||||||
|
if (category === 'server') return 'error';
|
||||||
|
|
||||||
|
// Warning - recoverable
|
||||||
|
if (category === 'network' || category === 'timeout') return 'warning';
|
||||||
|
if (category === 'rateLimit') return 'warning';
|
||||||
|
|
||||||
|
// Error - operation failed
|
||||||
|
if (category === 'validation' || category === 'permission' || category === 'notFound') return 'error';
|
||||||
|
|
||||||
|
// Default to error
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user message for error
|
||||||
|
function getUserMessage(code: string, message?: string): string {
|
||||||
|
// Use predefined message if available
|
||||||
|
if (userMessages[code]) {
|
||||||
|
return userMessages[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to provided message or generic
|
||||||
|
return message || 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get action hint for error
|
||||||
|
function getActionHint(code: string): string | undefined {
|
||||||
|
return actionHints[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retry policy for category
|
||||||
|
function getRetryPolicy(category: ErrorCategory): RetryPolicy {
|
||||||
|
return DefaultRetryPolicies[category] || DefaultRetryPolicies.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AppError from API error
|
||||||
|
*/
|
||||||
|
export function createErrorFromApi(apiError: ApiError, context?: Record<string, unknown>): AppError {
|
||||||
|
const code = apiError.code || ErrorCodes.API_ERROR;
|
||||||
|
const status = apiError.status;
|
||||||
|
const category = status ? classifyHttpStatus(status) : classifyErrorCode(code);
|
||||||
|
const severity = determineSeverity(category, status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: apiError.message,
|
||||||
|
code,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
status,
|
||||||
|
retry: getRetryPolicy(category),
|
||||||
|
userMessage: getUserMessage(code, apiError.message),
|
||||||
|
actionHint: getActionHint(code),
|
||||||
|
context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AppError from JavaScript Error
|
||||||
|
*/
|
||||||
|
export function createErrorFromException(
|
||||||
|
error: Error,
|
||||||
|
code: string = ErrorCodes.EXCEPTION,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
const category = classifyErrorCode(code);
|
||||||
|
const severity = determineSeverity(category);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
code,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
retry: getRetryPolicy(category),
|
||||||
|
userMessage: getUserMessage(code, error.message),
|
||||||
|
actionHint: getActionHint(code),
|
||||||
|
context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
originalError: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AppError from network error
|
||||||
|
*/
|
||||||
|
export function createNetworkError(
|
||||||
|
message: string = 'Network request failed',
|
||||||
|
isTimeout: boolean = false
|
||||||
|
): AppError {
|
||||||
|
const code = isTimeout ? ErrorCodes.NETWORK_TIMEOUT : ErrorCodes.NETWORK_ERROR;
|
||||||
|
const category: ErrorCategory = isTimeout ? 'timeout' : 'network';
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
severity: 'warning',
|
||||||
|
category,
|
||||||
|
retry: getRetryPolicy(category),
|
||||||
|
userMessage: getUserMessage(code),
|
||||||
|
actionHint: getActionHint(code),
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AppError for validation with field errors
|
||||||
|
*/
|
||||||
|
export function createValidationError(
|
||||||
|
message: string,
|
||||||
|
fieldErrors: FieldError[] = []
|
||||||
|
): AppError {
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
code: ErrorCodes.VALIDATION_ERROR,
|
||||||
|
severity: 'error',
|
||||||
|
category: 'validation',
|
||||||
|
retry: { isRetryable: false },
|
||||||
|
userMessage: getUserMessage(ErrorCodes.VALIDATION_ERROR, message),
|
||||||
|
fieldErrors,
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create AppError from any unknown error
|
||||||
|
*/
|
||||||
|
export function createErrorFromUnknown(
|
||||||
|
error: unknown,
|
||||||
|
defaultCode: string = ErrorCodes.UNKNOWN_ERROR,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
// Already an AppError
|
||||||
|
if (error && typeof error === 'object' && 'errorId' in error) {
|
||||||
|
return error as AppError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript Error
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return createErrorFromException(error, defaultCode, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApiError-like object
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
return createErrorFromApi(error as ApiError, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// String error
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return createErrorFromException(new Error(error), defaultCode, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown
|
||||||
|
return {
|
||||||
|
message: 'An unknown error occurred',
|
||||||
|
code: defaultCode,
|
||||||
|
severity: 'error',
|
||||||
|
category: 'unknown',
|
||||||
|
retry: { isRetryable: false },
|
||||||
|
userMessage: getUserMessage(defaultCode),
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
originalError: error,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple error with predefined code
|
||||||
|
*/
|
||||||
|
export function createError(
|
||||||
|
code: ErrorCode,
|
||||||
|
message?: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
const category = classifyErrorCode(code);
|
||||||
|
const severity = determineSeverity(category);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: message || getUserMessage(code),
|
||||||
|
code,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
retry: getRetryPolicy(category),
|
||||||
|
userMessage: getUserMessage(code, message),
|
||||||
|
actionHint: getActionHint(code),
|
||||||
|
context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
errorId: generateErrorId(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate retry delay with optional exponential backoff
|
||||||
|
*/
|
||||||
|
export function calculateRetryDelay(policy: RetryPolicy, attempt: number): number {
|
||||||
|
if (!policy.retryDelayMs) return 1000;
|
||||||
|
|
||||||
|
if (policy.useExponentialBackoff) {
|
||||||
|
// Exponential backoff: delay * 2^attempt with jitter
|
||||||
|
const baseDelay = policy.retryDelayMs * Math.pow(2, attempt);
|
||||||
|
const jitter = Math.random() * 0.3 * baseDelay; // 0-30% jitter
|
||||||
|
return Math.min(baseDelay + jitter, 30000); // Max 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy.retryDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error for debugging/analytics (placeholder for future integration)
|
||||||
|
*/
|
||||||
|
export function logError(error: AppError): void {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.error(`[${error.severity.toUpperCase()}] ${error.code}:`, error.message, {
|
||||||
|
category: error.category,
|
||||||
|
errorId: error.errorId,
|
||||||
|
context: error.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Integrate with error tracking service (e.g., Sentry, Bugsnag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export error codes for convenience
|
||||||
|
export { ErrorCodes };
|
||||||
268
types/errors.ts
Normal file
268
types/errors.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Comprehensive Error Types for WellNuo
|
||||||
|
*
|
||||||
|
* Provides extended error handling with severity levels, retry policies,
|
||||||
|
* contextual information, and user-friendly messaging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Error severity levels
|
||||||
|
export type ErrorSeverity =
|
||||||
|
| 'critical' // App cannot continue, requires immediate attention
|
||||||
|
| 'error' // Operation failed, but app can continue
|
||||||
|
| 'warning' // Something unexpected happened, but recovered
|
||||||
|
| 'info'; // Informational message about an issue
|
||||||
|
|
||||||
|
// Error categories for grouping and handling
|
||||||
|
export type ErrorCategory =
|
||||||
|
| 'network' // Network connectivity issues
|
||||||
|
| 'authentication' // Auth failures (401, 403, token expired)
|
||||||
|
| 'validation' // Input validation errors
|
||||||
|
| 'server' // Server-side errors (5xx)
|
||||||
|
| 'client' // Client-side errors (4xx except auth)
|
||||||
|
| 'timeout' // Request timeout
|
||||||
|
| 'permission' // Insufficient permissions (403)
|
||||||
|
| 'notFound' // Resource not found (404)
|
||||||
|
| 'conflict' // Resource conflict (409)
|
||||||
|
| 'rateLimit' // Too many requests (429)
|
||||||
|
| 'ble' // Bluetooth errors
|
||||||
|
| 'sensor' // Sensor-related errors
|
||||||
|
| 'subscription' // Subscription/payment errors
|
||||||
|
| 'unknown'; // Unclassified errors
|
||||||
|
|
||||||
|
// Retry policy for recoverable errors
|
||||||
|
export interface RetryPolicy {
|
||||||
|
isRetryable: boolean;
|
||||||
|
maxRetries?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
useExponentialBackoff?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-level validation error
|
||||||
|
export interface FieldError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended error with full context
|
||||||
|
export interface AppError {
|
||||||
|
// Core error info
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
severity: ErrorSeverity;
|
||||||
|
category: ErrorCategory;
|
||||||
|
|
||||||
|
// HTTP context (if from API)
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
|
||||||
|
// Retry info
|
||||||
|
retry: RetryPolicy;
|
||||||
|
|
||||||
|
// User-facing message (translated, friendly)
|
||||||
|
userMessage: string;
|
||||||
|
|
||||||
|
// Action hint for user
|
||||||
|
actionHint?: string;
|
||||||
|
|
||||||
|
// Additional context
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Field-level validation errors
|
||||||
|
fieldErrors?: FieldError[];
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
timestamp: Date;
|
||||||
|
|
||||||
|
// Unique error ID for tracking
|
||||||
|
errorId: string;
|
||||||
|
|
||||||
|
// Original error for debugging
|
||||||
|
originalError?: Error | unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error codes used throughout the app
|
||||||
|
export const ErrorCodes = {
|
||||||
|
// Network errors
|
||||||
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
|
NETWORK_TIMEOUT: 'NETWORK_TIMEOUT',
|
||||||
|
NETWORK_OFFLINE: 'NETWORK_OFFLINE',
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||||
|
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
||||||
|
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
||||||
|
SESSION_EXPIRED: 'SESSION_EXPIRED',
|
||||||
|
|
||||||
|
// Permission errors
|
||||||
|
FORBIDDEN: 'FORBIDDEN',
|
||||||
|
INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS',
|
||||||
|
|
||||||
|
// Resource errors
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
BENEFICIARY_NOT_FOUND: 'BENEFICIARY_NOT_FOUND',
|
||||||
|
DEPLOYMENT_NOT_FOUND: 'DEPLOYMENT_NOT_FOUND',
|
||||||
|
USER_NOT_FOUND: 'USER_NOT_FOUND',
|
||||||
|
|
||||||
|
// Conflict errors
|
||||||
|
CONFLICT: 'CONFLICT',
|
||||||
|
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
|
||||||
|
VERSION_MISMATCH: 'VERSION_MISMATCH',
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||||
|
INVALID_INPUT: 'INVALID_INPUT',
|
||||||
|
MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
|
||||||
|
INVALID_EMAIL: 'INVALID_EMAIL',
|
||||||
|
INVALID_PHONE: 'INVALID_PHONE',
|
||||||
|
INVALID_OTP: 'INVALID_OTP',
|
||||||
|
|
||||||
|
// Server errors
|
||||||
|
SERVER_ERROR: 'SERVER_ERROR',
|
||||||
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||||
|
MAINTENANCE: 'MAINTENANCE',
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
RATE_LIMITED: 'RATE_LIMITED',
|
||||||
|
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
|
||||||
|
|
||||||
|
// BLE errors
|
||||||
|
BLE_NOT_AVAILABLE: 'BLE_NOT_AVAILABLE',
|
||||||
|
BLE_NOT_ENABLED: 'BLE_NOT_ENABLED',
|
||||||
|
BLE_PERMISSION_DENIED: 'BLE_PERMISSION_DENIED',
|
||||||
|
BLE_CONNECTION_FAILED: 'BLE_CONNECTION_FAILED',
|
||||||
|
BLE_DEVICE_NOT_FOUND: 'BLE_DEVICE_NOT_FOUND',
|
||||||
|
BLE_TIMEOUT: 'BLE_TIMEOUT',
|
||||||
|
|
||||||
|
// Sensor errors
|
||||||
|
SENSOR_SETUP_FAILED: 'SENSOR_SETUP_FAILED',
|
||||||
|
SENSOR_OFFLINE: 'SENSOR_OFFLINE',
|
||||||
|
SENSOR_NOT_RESPONDING: 'SENSOR_NOT_RESPONDING',
|
||||||
|
SENSOR_WIFI_FAILED: 'SENSOR_WIFI_FAILED',
|
||||||
|
|
||||||
|
// Subscription errors
|
||||||
|
SUBSCRIPTION_REQUIRED: 'SUBSCRIPTION_REQUIRED',
|
||||||
|
SUBSCRIPTION_EXPIRED: 'SUBSCRIPTION_EXPIRED',
|
||||||
|
PAYMENT_FAILED: 'PAYMENT_FAILED',
|
||||||
|
PAYMENT_CANCELED: 'PAYMENT_CANCELED',
|
||||||
|
|
||||||
|
// General errors
|
||||||
|
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||||
|
API_ERROR: 'API_ERROR',
|
||||||
|
PARSE_ERROR: 'PARSE_ERROR',
|
||||||
|
EXCEPTION: 'EXCEPTION',
|
||||||
|
NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
|
||||||
|
NO_BENEFICIARY_SELECTED: 'NO_BENEFICIARY_SELECTED',
|
||||||
|
NO_DEPLOYMENT: 'NO_DEPLOYMENT',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
||||||
|
|
||||||
|
// Default retry policies per category
|
||||||
|
export const DefaultRetryPolicies: Record<ErrorCategory, RetryPolicy> = {
|
||||||
|
network: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelayMs: 1000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 2000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 3000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 1,
|
||||||
|
retryDelayMs: 30000, // Wait 30 seconds
|
||||||
|
useExponentialBackoff: false,
|
||||||
|
},
|
||||||
|
authentication: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
conflict: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
ble: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 1000,
|
||||||
|
useExponentialBackoff: false,
|
||||||
|
},
|
||||||
|
sensor: {
|
||||||
|
isRetryable: true,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelayMs: 2000,
|
||||||
|
useExponentialBackoff: true,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
isRetryable: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Error state for async operations
|
||||||
|
export interface ErrorState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: AppError | null;
|
||||||
|
errors: AppError[]; // For multiple errors
|
||||||
|
lastErrorAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial error state
|
||||||
|
export const initialErrorState: ErrorState = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errors: [],
|
||||||
|
lastErrorAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type guard to check if value is AppError
|
||||||
|
export function isAppError(error: unknown): error is AppError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
'severity' in error &&
|
||||||
|
'category' in error &&
|
||||||
|
'userMessage' in error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if error is retryable
|
||||||
|
export function isRetryableError(error: AppError): boolean {
|
||||||
|
return error.retry.isRetryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if error is critical
|
||||||
|
export function isCriticalError(error: AppError): boolean {
|
||||||
|
return error.severity === 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if error requires auth
|
||||||
|
export function isAuthError(error: AppError): boolean {
|
||||||
|
return error.category === 'authentication';
|
||||||
|
}
|
||||||
238
utils/errorMessages.ts
Normal file
238
utils/errorMessages.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Error Message Utilities
|
||||||
|
*
|
||||||
|
* Provides user-friendly error message translation and formatting.
|
||||||
|
* Includes helpers for common validation scenarios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ErrorCodes } from '@/types/errors';
|
||||||
|
|
||||||
|
// Generic error messages for common scenarios
|
||||||
|
export const ErrorMessages = {
|
||||||
|
// Network errors
|
||||||
|
networkOffline: 'Unable to connect. Please check your internet connection.',
|
||||||
|
networkTimeout: 'The request took too long. Please try again.',
|
||||||
|
networkError: 'Connection failed. Please check your network.',
|
||||||
|
|
||||||
|
// Auth errors
|
||||||
|
sessionExpired: 'Your session has expired. Please log in again.',
|
||||||
|
unauthorized: 'You need to log in to continue.',
|
||||||
|
forbidden: 'You don\'t have permission to do this.',
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
required: 'This field is required.',
|
||||||
|
invalidEmail: 'Please enter a valid email address.',
|
||||||
|
invalidPhone: 'Please enter a valid phone number.',
|
||||||
|
invalidOtp: 'Invalid code. Please try again.',
|
||||||
|
tooShort: (min: number) => `Must be at least ${min} characters.`,
|
||||||
|
tooLong: (max: number) => `Must be ${max} characters or less.`,
|
||||||
|
passwordMismatch: 'Passwords do not match.',
|
||||||
|
|
||||||
|
// Generic errors
|
||||||
|
genericError: 'Something went wrong. Please try again.',
|
||||||
|
tryAgainLater: 'Something went wrong. Please try again later.',
|
||||||
|
notFound: 'The item you\'re looking for could not be found.',
|
||||||
|
|
||||||
|
// Form errors
|
||||||
|
formInvalid: 'Please check the form for errors.',
|
||||||
|
fileTooBig: (maxMb: number) => `File is too large. Maximum size is ${maxMb}MB.`,
|
||||||
|
invalidFileType: 'This file type is not supported.',
|
||||||
|
|
||||||
|
// Subscription errors
|
||||||
|
subscriptionRequired: 'A subscription is required to use this feature.',
|
||||||
|
subscriptionExpired: 'Your subscription has expired.',
|
||||||
|
paymentFailed: 'Payment could not be processed. Please try again.',
|
||||||
|
|
||||||
|
// BLE errors
|
||||||
|
bluetoothDisabled: 'Please enable Bluetooth to continue.',
|
||||||
|
bluetoothPermissionDenied: 'Bluetooth permission is required.',
|
||||||
|
deviceNotFound: 'Device not found. Make sure it\'s powered on.',
|
||||||
|
|
||||||
|
// Sensor errors
|
||||||
|
sensorSetupFailed: 'Sensor setup failed. Please try again.',
|
||||||
|
sensorOffline: 'Sensor is offline.',
|
||||||
|
wifiConfigFailed: 'Failed to configure WiFi. Check your credentials.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Action hints for guiding users
|
||||||
|
export const ActionHints = {
|
||||||
|
checkConnection: 'Check your Wi-Fi or cellular connection',
|
||||||
|
enableBluetooth: 'Enable Bluetooth in Settings',
|
||||||
|
loginAgain: 'Tap here to log in again',
|
||||||
|
contactSupport: 'Contact support if the problem persists',
|
||||||
|
tryDifferentPayment: 'Try a different payment method',
|
||||||
|
refreshPage: 'Pull down to refresh',
|
||||||
|
checkCredentials: 'Double-check your email and password',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user-friendly message for an error code
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(code: string, fallback?: string): string {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
[ErrorCodes.NETWORK_ERROR]: ErrorMessages.networkError,
|
||||||
|
[ErrorCodes.NETWORK_TIMEOUT]: ErrorMessages.networkTimeout,
|
||||||
|
[ErrorCodes.NETWORK_OFFLINE]: ErrorMessages.networkOffline,
|
||||||
|
[ErrorCodes.UNAUTHORIZED]: ErrorMessages.sessionExpired,
|
||||||
|
[ErrorCodes.TOKEN_EXPIRED]: ErrorMessages.sessionExpired,
|
||||||
|
[ErrorCodes.SESSION_EXPIRED]: ErrorMessages.sessionExpired,
|
||||||
|
[ErrorCodes.FORBIDDEN]: ErrorMessages.forbidden,
|
||||||
|
[ErrorCodes.NOT_FOUND]: ErrorMessages.notFound,
|
||||||
|
[ErrorCodes.VALIDATION_ERROR]: ErrorMessages.formInvalid,
|
||||||
|
[ErrorCodes.INVALID_EMAIL]: ErrorMessages.invalidEmail,
|
||||||
|
[ErrorCodes.INVALID_PHONE]: ErrorMessages.invalidPhone,
|
||||||
|
[ErrorCodes.INVALID_OTP]: ErrorMessages.invalidOtp,
|
||||||
|
[ErrorCodes.SUBSCRIPTION_REQUIRED]: ErrorMessages.subscriptionRequired,
|
||||||
|
[ErrorCodes.SUBSCRIPTION_EXPIRED]: ErrorMessages.subscriptionExpired,
|
||||||
|
[ErrorCodes.PAYMENT_FAILED]: ErrorMessages.paymentFailed,
|
||||||
|
[ErrorCodes.BLE_NOT_ENABLED]: ErrorMessages.bluetoothDisabled,
|
||||||
|
[ErrorCodes.BLE_PERMISSION_DENIED]: ErrorMessages.bluetoothPermissionDenied,
|
||||||
|
[ErrorCodes.BLE_DEVICE_NOT_FOUND]: ErrorMessages.deviceNotFound,
|
||||||
|
[ErrorCodes.SENSOR_SETUP_FAILED]: ErrorMessages.sensorSetupFailed,
|
||||||
|
[ErrorCodes.SENSOR_OFFLINE]: ErrorMessages.sensorOffline,
|
||||||
|
[ErrorCodes.SENSOR_WIFI_FAILED]: ErrorMessages.wifiConfigFailed,
|
||||||
|
};
|
||||||
|
|
||||||
|
return messages[code] || fallback || ErrorMessages.genericError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an action hint for an error code
|
||||||
|
*/
|
||||||
|
export function getActionHint(code: string): string | undefined {
|
||||||
|
const hints: Record<string, string> = {
|
||||||
|
[ErrorCodes.NETWORK_ERROR]: ActionHints.checkConnection,
|
||||||
|
[ErrorCodes.NETWORK_OFFLINE]: ActionHints.checkConnection,
|
||||||
|
[ErrorCodes.UNAUTHORIZED]: ActionHints.loginAgain,
|
||||||
|
[ErrorCodes.TOKEN_EXPIRED]: ActionHints.loginAgain,
|
||||||
|
[ErrorCodes.BLE_NOT_ENABLED]: ActionHints.enableBluetooth,
|
||||||
|
[ErrorCodes.BLE_PERMISSION_DENIED]: ActionHints.enableBluetooth,
|
||||||
|
[ErrorCodes.PAYMENT_FAILED]: ActionHints.tryDifferentPayment,
|
||||||
|
};
|
||||||
|
|
||||||
|
return hints[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation helpers
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
export function validateEmail(email: string): string | null {
|
||||||
|
if (!email || email.trim() === '') {
|
||||||
|
return ErrorMessages.required;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email.trim())) {
|
||||||
|
return ErrorMessages.invalidEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate phone number format
|
||||||
|
*/
|
||||||
|
export function validatePhone(phone: string): string | null {
|
||||||
|
if (!phone || phone.trim() === '') {
|
||||||
|
return null; // Phone is usually optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common formatting characters
|
||||||
|
const cleaned = phone.replace(/[\s\-\(\)\.]/g, '');
|
||||||
|
|
||||||
|
// Check for valid phone format (with or without country code)
|
||||||
|
const phoneRegex = /^\+?[1-9]\d{9,14}$/;
|
||||||
|
if (!phoneRegex.test(cleaned)) {
|
||||||
|
return ErrorMessages.invalidPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required field
|
||||||
|
*/
|
||||||
|
export function validateRequired(value: string | undefined | null, fieldName?: string): string | null {
|
||||||
|
if (!value || value.trim() === '') {
|
||||||
|
return fieldName ? `${fieldName} is required.` : ErrorMessages.required;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate minimum length
|
||||||
|
*/
|
||||||
|
export function validateMinLength(value: string, minLength: number): string | null {
|
||||||
|
if (value.length < minLength) {
|
||||||
|
return ErrorMessages.tooShort(minLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate maximum length
|
||||||
|
*/
|
||||||
|
export function validateMaxLength(value: string, maxLength: number): string | null {
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
return ErrorMessages.tooLong(maxLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate OTP code format
|
||||||
|
*/
|
||||||
|
export function validateOtp(otp: string, length: number = 6): string | null {
|
||||||
|
if (!otp || otp.trim() === '') {
|
||||||
|
return ErrorMessages.required;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = otp.trim();
|
||||||
|
if (cleaned.length !== length || !/^\d+$/.test(cleaned)) {
|
||||||
|
return ErrorMessages.invalidOtp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for display (capitalize first letter, add period if missing)
|
||||||
|
*/
|
||||||
|
export function formatErrorMessage(message: string): string {
|
||||||
|
if (!message) return ErrorMessages.genericError;
|
||||||
|
|
||||||
|
let formatted = message.trim();
|
||||||
|
|
||||||
|
// Capitalize first letter
|
||||||
|
formatted = formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
||||||
|
|
||||||
|
// Add period if missing
|
||||||
|
if (!formatted.endsWith('.') && !formatted.endsWith('!') && !formatted.endsWith('?')) {
|
||||||
|
formatted += '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine multiple field errors into a single message
|
||||||
|
*/
|
||||||
|
export function combineFieldErrors(errors: { field: string; message: string }[]): string {
|
||||||
|
if (errors.length === 0) return '';
|
||||||
|
if (errors.length === 1) return errors[0].message;
|
||||||
|
|
||||||
|
return `Please fix ${errors.length} errors: ${errors.map((e) => e.message).join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plural form for error count
|
||||||
|
*/
|
||||||
|
export function getErrorCountText(count: number): string {
|
||||||
|
if (count === 0) return 'No errors';
|
||||||
|
if (count === 1) return '1 error';
|
||||||
|
return `${count} errors`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorMessages;
|
||||||
Loading…
x
Reference in New Issue
Block a user