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