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';