diff --git a/src/components/common/AsyncStateView.tsx b/src/components/common/AsyncStateView.tsx new file mode 100644 index 00000000..5a0a7e9f --- /dev/null +++ b/src/components/common/AsyncStateView.tsx @@ -0,0 +1,256 @@ +/** + * AsyncStateView — single component that handles all four loading states. + * + * Wrap any async-driven content with this component and it will + * automatically render the correct UI for each state: + * + * idle → renders nothing (or a custom idleFallback) + * loading → renders the skeleton prop (or a default spinner) + * error → renders an error card with message, suggestions, and retry button + * success → renders children + * + * Example: + * + * }> + * + * + */ + +import React, { ReactNode } from 'react'; +import { + View, + Text, + StyleSheet, + ActivityIndicator, + TouchableOpacity, + ScrollView, +} from 'react-native'; +import { LoadingState } from '../../types/loadingState'; +import { colors, spacing, typography, borderRadius } from '../../utils/constants'; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface AsyncStateViewProps { + /** The LoadingState object from a store or local state. */ + state: LoadingState; + /** Content to render when status === 'success'. */ + children: ReactNode; + /** + * Called when the user taps "Try Again". + * If omitted the retry button is not shown. + */ + onRetry?: () => void; + /** + * Skeleton UI shown while loading. + * Falls back to a centred ActivityIndicator when not provided. + */ + skeleton?: ReactNode; + /** + * Content shown when status === 'idle'. + * Renders nothing by default. + */ + idleFallback?: ReactNode; + /** + * Override the default error title. + * @default "Something went wrong" + */ + errorTitle?: string; + /** + * When true the error card is rendered inline (no ScrollView wrapper). + * Useful inside FlatList headers or small card areas. + * @default false + */ + inline?: boolean; + testID?: string; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +interface ErrorCardProps { + title: string; + message: string; + suggestions: string[]; + onRetry?: () => void; + inline?: boolean; +} + +const ErrorCard: React.FC = ({ + title, + message, + suggestions, + onRetry, + inline, +}) => { + const content = ( + + + ⚠️ + + {title} + {message} + + {suggestions.length > 0 && ( + + What you can try: + {suggestions.map((s, i) => ( + + • {s} + + ))} + + )} + + {onRetry && ( + + Try Again + + )} + + ); + + if (inline) return content; + + return ( + + {content} + + ); +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + +export const AsyncStateView: React.FC = ({ + state, + children, + onRetry, + skeleton, + idleFallback = null, + errorTitle = 'Something went wrong', + inline = false, + testID, +}) => { + switch (state.status) { + case 'idle': + return <>{idleFallback}; + + case 'loading': + if (skeleton) return <>{skeleton}; + return ( + + + + ); + + case 'error': + return ( + + + + ); + + case 'success': + default: + return <>{children}; + } +}; + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + spinnerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + errorContainer: { + flex: 1, + backgroundColor: colors.background, + }, + errorScrollContent: { + flexGrow: 1, + justifyContent: 'center', + padding: spacing.lg, + }, + errorCard: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.lg, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + errorIcon: { + fontSize: 40, + marginBottom: spacing.md, + }, + errorTitle: { + ...typography.h2, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.sm, + }, + errorMessage: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: spacing.md, + }, + suggestionsBox: { + width: '100%', + backgroundColor: colors.background, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.lg, + }, + suggestionsLabel: { + ...typography.body, + color: colors.text, + fontWeight: '600', + marginBottom: spacing.xs, + }, + suggestion: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.xs, + lineHeight: 20, + }, + retryBtn: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + minHeight: 48, + alignItems: 'center', + justifyContent: 'center', + }, + retryBtnText: { + ...typography.body, + color: colors.text, + fontWeight: '700', + }, +}); + +export default AsyncStateView; diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index a4f281a7..f9563247 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LoadingState, idle, loading, success, failure } from '../types/loadingState'; import { FraudAction, FraudAnalytics, @@ -244,6 +245,7 @@ interface FraudState { analytics: FraudAnalytics; loading: boolean; error: string | null; + loadingState: LoadingState; refreshFraudSignals: () => void; assessRisk: (subscriberId: string) => FraudRiskScore[]; flagSubscription: (subscriptionId: string) => void; @@ -301,26 +303,34 @@ export const useFraudStore = create()( reviewQueue: hydrateReviewQueue(reviewSeeds), analytics: computeAnalytics(subscriptionSeeds, reviewSeeds), loading: false, + loadingState: idle(), error: null, refreshFraudSignals: () => { - const { subscriptions, reviewQueue, merchants } = get(); - set({ - analytics: computeAnalytics(subscriptions, reviewQueue), - assessments: hydrateAssessments(subscriptions), - merchants: merchants.map((merchant) => ({ - ...merchant, - averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk, - blockedSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).blockedSubscriptions, - activeSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).totalSubscriptions, - status: - buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 60 - ? 'high-risk' - : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 35 - ? 'watch' - : 'healthy', - })), - }); + set({ loading: true, loadingState: loading() }); + try { + const { subscriptions, reviewQueue, merchants } = get(); + set({ + analytics: computeAnalytics(subscriptions, reviewQueue), + assessments: hydrateAssessments(subscriptions), + merchants: merchants.map((merchant) => ({ + ...merchant, + averageRisk: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk, + blockedSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).blockedSubscriptions, + activeSubscriptions: buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).totalSubscriptions, + status: + buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 60 + ? 'high-risk' + : buildMerchantReport(merchants, subscriptions, reviewQueue, merchant.id).averageRisk >= 35 + ? 'watch' + : 'healthy', + })), + loading: false, + loadingState: success(), + }); + } catch (e) { + set({ loading: false, loadingState: failure(e as Error) }); + } }, assessRisk: (subscriberId: string) => { diff --git a/src/store/invoiceStore.ts b/src/store/invoiceStore.ts index b4278ba6..90cb6c19 100644 --- a/src/store/invoiceStore.ts +++ b/src/store/invoiceStore.ts @@ -23,6 +23,7 @@ import { import { buildInvoice, calculateInvoiceTotals } from '../utils/invoice'; import { CACHE_CONSTANTS } from '../utils/constants/values'; import { errorHandler, AppError } from '../services/errorHandler'; +import { LoadingState, idle, loading, success, failure } from '../types/loadingState'; import { presentLocalNotification } from '../services/notificationService'; const STORAGE_KEY = 'subtrackr-invoices'; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 136a1381..ae5c11ca 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -2,13 +2,15 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { currencyService, ExchangeRates } from '../services/currencyService'; +import { LoadingState, idle, loading, success, failure } from '../types/loadingState'; interface SettingsState { preferredCurrency: string; notificationsEnabled: boolean; exchangeRates: ExchangeRates | null; isLoading: boolean; - + loadingState: LoadingState; + // Actions setPreferredCurrency: (currency: string) => void; setNotificationsEnabled: (enabled: boolean) => void; @@ -23,20 +25,23 @@ export const useSettingsStore = create()( notificationsEnabled: true, exchangeRates: null, isLoading: false, + loadingState: idle(), setPreferredCurrency: (currency) => { set({ preferredCurrency: currency }); - // Optionally update rates immediately if base changed, - // but here we keep USD as base for rates to simplify conversion void get().updateExchangeRates(); }, setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), updateExchangeRates: async () => { - set({ isLoading: true }); - const rates = await currencyService.fetchRates('USD'); - set({ exchangeRates: rates, isLoading: false }); + set({ isLoading: true, loadingState: loading() }); + try { + const rates = await currencyService.fetchRates('USD'); + set({ exchangeRates: rates, isLoading: false, loadingState: success() }); + } catch (e) { + set({ isLoading: false, loadingState: failure(e as Error, ['Check your internet connection', 'Try again later']) }); + } }, initializeSettings: async () => { diff --git a/src/store/supportStore.ts b/src/store/supportStore.ts index 706c4510..d0e4cf8a 100644 --- a/src/store/supportStore.ts +++ b/src/store/supportStore.ts @@ -11,10 +11,12 @@ import { TicketingIntegrationConfig, TicketStatus, } from '../types/support'; +import { LoadingState, idle, loading, success, failure } from '../types/loadingState'; interface SupportState { tickets: SupportTicket[]; integration: TicketingIntegrationConfig; + loadingState: LoadingState; createTicket: (event: SubscriptionSupportEvent) => SupportTicket; assignTicket: (ticketId: string, assignee: string) => void; updateTicketStatus: (ticketId: string, status: TicketStatus) => void; @@ -32,14 +34,21 @@ const mapTicket = ( export const useSupportStore = create((set, get) => ({ tickets: [], integration: { provider: 'internal', enabled: true, defaultAssignee: 'support-team' }, + loadingState: idle(), createTicket: (event) => { - const relatedTicketIds = get() - .tickets.filter((ticket) => ticket.subscriptionId === event.subscriptionId && ticket.status !== 'closed') - .map((ticket) => ticket.id); - const ticket = createTicketFromEvent(event, relatedTicketIds); - set((state) => ({ tickets: [...state.tickets, ticket] })); - return ticket; + set({ loadingState: loading() }); + try { + const relatedTicketIds = get() + .tickets.filter((ticket) => ticket.subscriptionId === event.subscriptionId && ticket.status !== 'closed') + .map((ticket) => ticket.id); + const ticket = createTicketFromEvent(event, relatedTicketIds); + set((state) => ({ tickets: [...state.tickets, ticket], loadingState: success() })); + return ticket; + } catch (e) { + set({ loadingState: failure(e as Error) }); + throw e; + } }, assignTicket: (ticketId, assignee) => diff --git a/src/types/loadingState.ts b/src/types/loadingState.ts new file mode 100644 index 00000000..b4e7e51a --- /dev/null +++ b/src/types/loadingState.ts @@ -0,0 +1,102 @@ +/** + * Standardized loading state for all async operations in SubTrackr. + * + * Every store and screen that performs async work should use this type + * instead of ad-hoc `isLoading: boolean` + `error: string | null` pairs. + * This ensures consistent UI treatment across the entire app. + * + * Usage in a Zustand store: + * + * import { LoadingState, idle, loading, success, failure } from '../types/loadingState'; + * + * interface MyState { + * fetchState: LoadingState; + * submitState: LoadingState; + * // ... + * } + * + * // In an action: + * set({ fetchState: loading() }); + * try { + * const data = await fetchData(); + * set({ fetchState: success() }); + * } catch (e) { + * set({ fetchState: failure(e as Error) }); + * } + * + * Usage in a component: + * + * import { AsyncStateView } from '../components/common/AsyncStateView'; + * + * }> + * + * + */ + +// ─── Status discriminant ────────────────────────────────────────────────────── + +export type LoadingStatus = 'idle' | 'loading' | 'success' | 'error'; + +// ─── Core type ──────────────────────────────────────────────────────────────── + +export interface LoadingState { + /** Current status of the async operation. */ + status: LoadingStatus; + /** + * Human-readable error message shown to the user. + * Only populated when status === 'error'. + */ + errorMessage: string | null; + /** + * Optional recovery suggestions shown below the error message. + * Mirrors the pattern used in AppError.recoverySuggestions. + */ + recoverySuggestions: string[]; +} + +// ─── Factory helpers ────────────────────────────────────────────────────────── + +/** Operation has not started yet. */ +export const idle = (): LoadingState => ({ + status: 'idle', + errorMessage: null, + recoverySuggestions: [], +}); + +/** Operation is in progress. */ +export const loading = (): LoadingState => ({ + status: 'loading', + errorMessage: null, + recoverySuggestions: [], +}); + +/** Operation completed successfully. */ +export const success = (): LoadingState => ({ + status: 'success', + errorMessage: null, + recoverySuggestions: [], +}); + +/** + * Operation failed. + * @param error The caught error or a plain message string. + * @param suggestions Optional recovery suggestions to show the user. + */ +export const failure = ( + error: Error | string, + suggestions: string[] = ['Try again', 'Restart the app if the problem persists'] +): LoadingState => ({ + status: 'error', + errorMessage: typeof error === 'string' ? error : error.message, + recoverySuggestions: suggestions, +}); + +// ─── Guard helpers ──────────────────────────────────────────────────────────── + +export const isIdle = (s: LoadingState): boolean => s.status === 'idle'; +export const isLoading = (s: LoadingState): boolean => s.status === 'loading'; +export const isSuccess = (s: LoadingState): boolean => s.status === 'success'; +export const isError = (s: LoadingState): boolean => s.status === 'error';