Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 256 additions & 0 deletions src/components/common/AsyncStateView.tsx
Original file line number Diff line number Diff line change
@@ -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:
*
* <AsyncStateView
* state={fetchState}
* onRetry={fetchSubscriptions}
* skeleton={<SubscriptionListSkeleton />}>
* <SubscriptionList />
* </AsyncStateView>
*/

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<ErrorCardProps> = ({
title,
message,
suggestions,
onRetry,
inline,
}) => {
const content = (
<View style={styles.errorCard}>
<Text style={styles.errorIcon} accessibilityElementsHidden>
⚠️
</Text>
<Text style={styles.errorTitle}>{title}</Text>
<Text style={styles.errorMessage}>{message}</Text>

{suggestions.length > 0 && (
<View style={styles.suggestionsBox}>
<Text style={styles.suggestionsLabel}>What you can try:</Text>
{suggestions.map((s, i) => (
<Text key={i} style={styles.suggestion}>
• {s}
</Text>
))}
</View>
)}

{onRetry && (
<TouchableOpacity
style={styles.retryBtn}
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Try again">
<Text style={styles.retryBtnText}>Try Again</Text>
</TouchableOpacity>
)}
</View>
);

if (inline) return content;

return (
<ScrollView
contentContainerStyle={styles.errorScrollContent}
showsVerticalScrollIndicator={false}>
{content}
</ScrollView>
);
};

// ─── Main component ───────────────────────────────────────────────────────────

export const AsyncStateView: React.FC<AsyncStateViewProps> = ({
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 (
<View style={styles.spinnerContainer} testID={testID ? `${testID}-loading` : undefined}>
<ActivityIndicator
size="large"
color={colors.primary}
accessibilityLabel="Loading"
/>
</View>
);

case 'error':
return (
<View
style={inline ? undefined : styles.errorContainer}
testID={testID ? `${testID}-error` : undefined}>
<ErrorCard
title={errorTitle}
message={state.errorMessage ?? 'An unexpected error occurred.'}
suggestions={state.recoverySuggestions}
onRetry={onRetry}
inline={inline}
/>
</View>
);

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;
44 changes: 27 additions & 17 deletions src/store/fraudStore.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -301,26 +303,34 @@ export const useFraudStore = create<FraudState>()(
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) => {
Expand Down
1 change: 1 addition & 0 deletions src/store/invoiceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 11 additions & 6 deletions src/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,20 +25,23 @@ export const useSettingsStore = create<SettingsState>()(
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 () => {
Expand Down
Loading
Loading