@umituz/react-native-subscription 2.40.3 → 2.40.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domains/credits/application/CreditsInitializer.ts +1 -1
- package/src/domains/credits/infrastructure/CreditsRepository.ts +1 -1
- package/src/domains/paywall/components/PaywallFeatures.tsx +4 -3
- package/src/domains/paywall/components/PaywallFooter.tsx +2 -2
- package/src/domains/paywall/components/PaywallScreen.tsx +13 -13
- package/src/domains/subscription/presentation/components/details/CreditRow.tsx +6 -7
- package/src/domains/subscription/presentation/components/details/DetailRow.tsx +2 -2
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCard.tsx +4 -3
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCardActions.tsx +2 -2
- package/src/domains/subscription/presentation/components/details/PremiumDetailsCardHeader.tsx +2 -2
- package/src/domains/subscription/presentation/components/details/PremiumStatusBadge.tsx +2 -2
- package/src/domains/subscription/presentation/components/sections/SubscriptionSection.tsx +2 -2
- package/src/domains/subscription/presentation/screens/SubscriptionDetailScreen.tsx +2 -2
- package/src/domains/subscription/presentation/screens/components/CreditsList.tsx +4 -3
- package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.tsx +2 -2
- package/src/domains/subscription/presentation/screens/components/UpgradePrompt.tsx +4 -3
- package/src/domains/subscription/utils/progressCalculations.ts +55 -0
- package/src/domains/wallet/presentation/components/BalanceCard.tsx +2 -2
- package/src/domains/wallet/presentation/components/TransactionItem.tsx +2 -2
- package/src/domains/wallet/presentation/components/TransactionList.tsx +23 -12
- package/src/domains/wallet/presentation/components/TransactionListStates.tsx +4 -4
- package/src/domains/wallet/presentation/screens/WalletScreen.tsx +2 -2
- package/src/shared/utils/arrayUtils.ts +65 -0
- package/src/shared/utils/stringUtils.ts +64 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.40.
|
|
3
|
+
"version": "2.40.4",
|
|
4
4
|
"description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -2,7 +2,7 @@ import type { CreditsConfig } from "../core/Credits";
|
|
|
2
2
|
import { getAppVersion, validatePlatform } from "../../../utils/appUtils";
|
|
3
3
|
|
|
4
4
|
import type { InitializeCreditsMetadata, InitializationResult } from "../../subscription/application/SubscriptionInitializerTypes";
|
|
5
|
-
import { runTransaction,
|
|
5
|
+
import { runTransaction, type Transaction, type DocumentReference, type Firestore } from "@umituz/react-native-firebase";
|
|
6
6
|
import { getCreditDocumentOrDefault } from "./creditDocumentHelpers";
|
|
7
7
|
import { calculateNewCredits, buildCreditsData } from "./creditOperationUtils";
|
|
8
8
|
import { calculateCreditLimit } from "./CreditLimitCalculator";
|
|
@@ -93,7 +93,7 @@ export class CreditsRepository extends BaseRepository {
|
|
|
93
93
|
willRenew: boolean,
|
|
94
94
|
expirationDate: string | null,
|
|
95
95
|
periodType: string | null,
|
|
96
|
-
|
|
96
|
+
_storeTransactionId?: string | null,
|
|
97
97
|
): Promise<boolean> {
|
|
98
98
|
const db = requireFirestore();
|
|
99
99
|
const creditLimit = calculateCreditLimit(productId, this.config);
|
|
@@ -4,10 +4,11 @@ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms
|
|
|
4
4
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
5
|
import type { SubscriptionFeature } from "../entities/types";
|
|
6
6
|
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
7
|
+
import { isEmptyArray } from "../../../shared/utils/arrayUtils";
|
|
7
8
|
|
|
8
|
-
export const PaywallFeatures: React.FC<{ features: SubscriptionFeature[] }> = ({ features }) => {
|
|
9
|
+
export const PaywallFeatures: React.FC<{ features: SubscriptionFeature[] }> = React.memo(({ features }) => {
|
|
9
10
|
const tokens = useAppDesignTokens();
|
|
10
|
-
if (
|
|
11
|
+
if (isEmptyArray(features)) return null;
|
|
11
12
|
|
|
12
13
|
return (
|
|
13
14
|
<View style={[styles.featuresContainer, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
@@ -23,4 +24,4 @@ export const PaywallFeatures: React.FC<{ features: SubscriptionFeature[] }> = ({
|
|
|
23
24
|
))}
|
|
24
25
|
</View>
|
|
25
26
|
);
|
|
26
|
-
};
|
|
27
|
+
});
|
|
@@ -13,7 +13,7 @@ interface PaywallFooterProps {
|
|
|
13
13
|
onLegalClick: (url: string | undefined) => void;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const PaywallFooter: React.FC<PaywallFooterProps> = ({
|
|
16
|
+
export const PaywallFooter: React.FC<PaywallFooterProps> = React.memo(({
|
|
17
17
|
translations,
|
|
18
18
|
legalUrls,
|
|
19
19
|
isProcessing,
|
|
@@ -49,4 +49,4 @@ export const PaywallFooter: React.FC<PaywallFooterProps> = ({
|
|
|
49
49
|
</View>
|
|
50
50
|
</View>
|
|
51
51
|
);
|
|
52
|
-
};
|
|
52
|
+
});
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import React, { useCallback, useEffect, useMemo } from "react";
|
|
8
|
-
import {
|
|
9
|
-
View,
|
|
10
|
-
TouchableOpacity,
|
|
11
|
-
Linking,
|
|
12
|
-
FlatList,
|
|
13
|
-
ListRenderItem,
|
|
8
|
+
import {
|
|
9
|
+
View,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
Linking,
|
|
12
|
+
FlatList,
|
|
13
|
+
ListRenderItem,
|
|
14
14
|
StatusBar,
|
|
15
15
|
} from "react-native";
|
|
16
16
|
import { AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system/atoms";
|
|
@@ -20,13 +20,13 @@ import { Image } from "expo-image";
|
|
|
20
20
|
import { PlanCard } from "./PlanCard";
|
|
21
21
|
import { paywallScreenStyles as styles } from "./PaywallScreen.styles";
|
|
22
22
|
import { PaywallFooter } from "./PaywallFooter";
|
|
23
|
-
import { PurchaseLoadingOverlay } from "../../subscription/presentation/components/overlay/PurchaseLoadingOverlay";
|
|
24
23
|
import { usePaywallActions } from "../hooks/usePaywallActions";
|
|
25
24
|
import { PaywallScreenProps } from "./PaywallScreen.types";
|
|
26
|
-
import {
|
|
27
|
-
calculatePaywallItemLayout,
|
|
28
|
-
type PaywallListItem
|
|
25
|
+
import {
|
|
26
|
+
calculatePaywallItemLayout,
|
|
27
|
+
type PaywallListItem
|
|
29
28
|
} from "../utils/paywallLayoutUtils";
|
|
29
|
+
import { hasItems } from "../../../shared/utils/arrayUtils";
|
|
30
30
|
|
|
31
31
|
export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) => {
|
|
32
32
|
const {
|
|
@@ -78,7 +78,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
78
78
|
|
|
79
79
|
// Auto-select first package
|
|
80
80
|
useEffect(() => {
|
|
81
|
-
if (packages
|
|
81
|
+
if (hasItems(packages) && !selectedPlanId) {
|
|
82
82
|
setSelectedPlanId(packages[0].product.identifier);
|
|
83
83
|
}
|
|
84
84
|
}, [packages, selectedPlanId, setSelectedPlanId]);
|
|
@@ -100,7 +100,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
100
100
|
data.push({ type: 'HEADER' });
|
|
101
101
|
|
|
102
102
|
// 2. Features Section
|
|
103
|
-
if (features
|
|
103
|
+
if (hasItems(features)) {
|
|
104
104
|
data.push({ type: 'FEATURE_HEADER' });
|
|
105
105
|
features.forEach(feature => {
|
|
106
106
|
data.push({ type: 'FEATURE', feature });
|
|
@@ -108,7 +108,7 @@ export const PaywallScreen: React.FC<PaywallScreenProps> = React.memo((props) =>
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// 3. Plans Section
|
|
111
|
-
if (packages
|
|
111
|
+
if (hasItems(packages)) {
|
|
112
112
|
data.push({ type: 'PLAN_HEADER' });
|
|
113
113
|
packages.forEach(pkg => {
|
|
114
114
|
data.push({ type: 'PLAN', pkg });
|
|
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
|
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
4
4
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
|
+
import { calculatePercentage, getProgressColor } from "../../../utils/progressCalculations";
|
|
5
6
|
|
|
6
7
|
interface CreditRowProps {
|
|
7
8
|
label: string;
|
|
@@ -10,20 +11,18 @@ interface CreditRowProps {
|
|
|
10
11
|
remainingLabel?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export const CreditRow: React.FC<CreditRowProps> = ({
|
|
14
|
+
export const CreditRow: React.FC<CreditRowProps> = React.memo(({
|
|
14
15
|
label,
|
|
15
16
|
current,
|
|
16
17
|
total,
|
|
17
18
|
remainingLabel,
|
|
18
19
|
}) => {
|
|
19
20
|
const tokens = useAppDesignTokens();
|
|
20
|
-
const percentage =
|
|
21
|
-
|
|
21
|
+
const percentage = useMemo(() => calculatePercentage(current, total), [current, total]);
|
|
22
|
+
|
|
22
23
|
// Progress color based on percentage
|
|
23
24
|
const progressColor = useMemo(() => {
|
|
24
|
-
|
|
25
|
-
if (percentage <= 50) return tokens.colors.warning;
|
|
26
|
-
return tokens.colors.success;
|
|
25
|
+
return getProgressColor(percentage, tokens.colors);
|
|
27
26
|
}, [percentage, tokens.colors]);
|
|
28
27
|
|
|
29
28
|
return (
|
|
@@ -56,7 +55,7 @@ export const CreditRow: React.FC<CreditRowProps> = ({
|
|
|
56
55
|
)}
|
|
57
56
|
</View>
|
|
58
57
|
);
|
|
59
|
-
};
|
|
58
|
+
});
|
|
60
59
|
|
|
61
60
|
const styles = StyleSheet.create({
|
|
62
61
|
container: {
|
|
@@ -17,7 +17,7 @@ interface DetailRowProps {
|
|
|
17
17
|
valueStyle?: TextStyle;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export const DetailRow: React.FC<DetailRowProps> = ({
|
|
20
|
+
export const DetailRow: React.FC<DetailRowProps> = React.memo(({
|
|
21
21
|
label,
|
|
22
22
|
value,
|
|
23
23
|
highlight = false,
|
|
@@ -49,7 +49,7 @@ export const DetailRow: React.FC<DetailRowProps> = ({
|
|
|
49
49
|
</AtomicText>
|
|
50
50
|
</View>
|
|
51
51
|
);
|
|
52
|
-
};
|
|
52
|
+
});
|
|
53
53
|
|
|
54
54
|
const styles = StyleSheet.create({
|
|
55
55
|
container: {
|
|
@@ -9,10 +9,11 @@ import type { PremiumDetailsCardProps } from "./PremiumDetailsCardTypes";
|
|
|
9
9
|
import { PremiumDetailsCardHeader } from "./PremiumDetailsCardHeader";
|
|
10
10
|
import { PremiumDetailsCardActions } from "./PremiumDetailsCardActions";
|
|
11
11
|
import { shouldHighlightExpiration } from "../../../../subscription/utils/expirationHelpers";
|
|
12
|
+
import { hasItems } from "../../../../../shared/utils/arrayUtils";
|
|
12
13
|
|
|
13
14
|
export type { CreditInfo, PremiumDetailsTranslations, PremiumDetailsCardProps } from "./PremiumDetailsCardTypes";
|
|
14
15
|
|
|
15
|
-
export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
|
|
16
|
+
export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = React.memo(({
|
|
16
17
|
statusType,
|
|
17
18
|
isPremium,
|
|
18
19
|
expirationDate,
|
|
@@ -24,7 +25,7 @@ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
|
|
|
24
25
|
onUpgrade,
|
|
25
26
|
}) => {
|
|
26
27
|
const tokens = useAppDesignTokens();
|
|
27
|
-
const showCredits = isPremium && credits
|
|
28
|
+
const showCredits = isPremium && hasItems(credits);
|
|
28
29
|
|
|
29
30
|
return (
|
|
30
31
|
<View style={[styles.card, { backgroundColor: tokens.colors.surface }]}>
|
|
@@ -67,4 +68,4 @@ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
|
|
|
67
68
|
/>
|
|
68
69
|
</View>
|
|
69
70
|
);
|
|
70
|
-
};
|
|
71
|
+
});
|
package/src/domains/subscription/presentation/components/details/PremiumDetailsCardActions.tsx
CHANGED
|
@@ -12,7 +12,7 @@ interface PremiumDetailsCardActionsProps {
|
|
|
12
12
|
translations: PremiumDetailsTranslations;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const PremiumDetailsCardActions: React.FC<PremiumDetailsCardActionsProps> = ({
|
|
15
|
+
export const PremiumDetailsCardActions: React.FC<PremiumDetailsCardActionsProps> = React.memo(({
|
|
16
16
|
isPremium,
|
|
17
17
|
onManageSubscription,
|
|
18
18
|
onUpgrade,
|
|
@@ -44,4 +44,4 @@ export const PremiumDetailsCardActions: React.FC<PremiumDetailsCardActionsProps>
|
|
|
44
44
|
)}
|
|
45
45
|
</View>
|
|
46
46
|
);
|
|
47
|
-
};
|
|
47
|
+
});
|
package/src/domains/subscription/presentation/components/details/PremiumDetailsCardHeader.tsx
CHANGED
|
@@ -12,7 +12,7 @@ interface PremiumDetailsCardHeaderProps {
|
|
|
12
12
|
translations: PremiumDetailsTranslations;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const PremiumDetailsCardHeader: React.FC<PremiumDetailsCardHeaderProps> = ({ statusType, translations }) => {
|
|
15
|
+
export const PremiumDetailsCardHeader: React.FC<PremiumDetailsCardHeaderProps> = React.memo(({ statusType, translations }) => {
|
|
16
16
|
const tokens = useAppDesignTokens();
|
|
17
17
|
|
|
18
18
|
return (
|
|
@@ -31,4 +31,4 @@ export const PremiumDetailsCardHeader: React.FC<PremiumDetailsCardHeaderProps> =
|
|
|
31
31
|
/>
|
|
32
32
|
</View>
|
|
33
33
|
);
|
|
34
|
-
};
|
|
34
|
+
});
|
|
@@ -13,7 +13,7 @@ import type { SubscriptionStatusType } from "../../../core/SubscriptionConstants
|
|
|
13
13
|
|
|
14
14
|
export type { PremiumStatusBadgeProps };
|
|
15
15
|
|
|
16
|
-
export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
|
|
16
|
+
export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = React.memo(({
|
|
17
17
|
status,
|
|
18
18
|
activeLabel,
|
|
19
19
|
expiredLabel,
|
|
@@ -63,4 +63,4 @@ export const PremiumStatusBadge: React.FC<PremiumStatusBadgeProps> = ({
|
|
|
63
63
|
</AtomicText>
|
|
64
64
|
</View>
|
|
65
65
|
);
|
|
66
|
-
};
|
|
66
|
+
});
|
|
@@ -11,7 +11,7 @@ import type { SubscriptionSectionConfig, SubscriptionSectionProps } from "./Subs
|
|
|
11
11
|
|
|
12
12
|
export type { SubscriptionSectionConfig, SubscriptionSectionProps };
|
|
13
13
|
|
|
14
|
-
export const SubscriptionSection: React.FC<SubscriptionSectionProps> = ({
|
|
14
|
+
export const SubscriptionSection: React.FC<SubscriptionSectionProps> = React.memo(({
|
|
15
15
|
config,
|
|
16
16
|
containerStyle,
|
|
17
17
|
}) => {
|
|
@@ -42,4 +42,4 @@ export const SubscriptionSection: React.FC<SubscriptionSectionProps> = ({
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
return <View style={containerStyle}>{content}</View>;
|
|
45
|
-
};
|
|
45
|
+
});
|
|
@@ -15,7 +15,7 @@ import { SubscriptionDetailScreenProps } from "./SubscriptionDetailScreen.types"
|
|
|
15
15
|
|
|
16
16
|
const IS_DEV = typeof __DEV__ !== "undefined" && __DEV__;
|
|
17
17
|
|
|
18
|
-
export const SubscriptionDetailScreen: React.FC<SubscriptionDetailScreenProps> = ({ config }) => {
|
|
18
|
+
export const SubscriptionDetailScreen: React.FC<SubscriptionDetailScreenProps> = React.memo(({ config }) => {
|
|
19
19
|
const tokens = useAppDesignTokens();
|
|
20
20
|
const { showHeader, showCredits, showUpgradePrompt, showExpirationDate } = config.display;
|
|
21
21
|
|
|
@@ -91,7 +91,7 @@ export const SubscriptionDetailScreen: React.FC<SubscriptionDetailScreenProps> =
|
|
|
91
91
|
</View>
|
|
92
92
|
</ScreenLayout>
|
|
93
93
|
);
|
|
94
|
-
};
|
|
94
|
+
});
|
|
95
95
|
|
|
96
96
|
/* ─── DEV TEST PANEL ─── Only rendered in __DEV__ ─── */
|
|
97
97
|
|
|
@@ -8,6 +8,7 @@ import { View, StyleSheet } from "react-native";
|
|
|
8
8
|
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
10
|
import { CreditRow } from "../../components/details/CreditRow";
|
|
11
|
+
import { isEmptyArray } from "../../../../../shared/utils/arrayUtils";
|
|
11
12
|
|
|
12
13
|
interface CreditItem {
|
|
13
14
|
id: string;
|
|
@@ -23,7 +24,7 @@ interface CreditsListProps {
|
|
|
23
24
|
remainingLabel?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export const CreditsList: React.FC<CreditsListProps> = ({
|
|
27
|
+
export const CreditsList: React.FC<CreditsListProps> = React.memo(({
|
|
27
28
|
credits,
|
|
28
29
|
title,
|
|
29
30
|
description,
|
|
@@ -53,7 +54,7 @@ export const CreditsList: React.FC<CreditsListProps> = ({
|
|
|
53
54
|
[tokens]
|
|
54
55
|
);
|
|
55
56
|
|
|
56
|
-
if (!credits || credits
|
|
57
|
+
if (!credits || isEmptyArray(credits)) return null;
|
|
57
58
|
|
|
58
59
|
return (
|
|
59
60
|
<View style={styles.container}>
|
|
@@ -86,4 +87,4 @@ export const CreditsList: React.FC<CreditsListProps> = ({
|
|
|
86
87
|
</View>
|
|
87
88
|
</View>
|
|
88
89
|
);
|
|
89
|
-
};
|
|
90
|
+
});
|
|
@@ -9,7 +9,7 @@ import { SubscriptionHeaderContent } from "./SubscriptionHeaderContent";
|
|
|
9
9
|
|
|
10
10
|
const EXPIRING_SOON_THRESHOLD_DAYS = 7;
|
|
11
11
|
|
|
12
|
-
export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
12
|
+
export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = React.memo(({
|
|
13
13
|
statusType,
|
|
14
14
|
showExpirationDate,
|
|
15
15
|
expirationDate,
|
|
@@ -67,4 +67,4 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
|
|
|
67
67
|
/>
|
|
68
68
|
</View>
|
|
69
69
|
);
|
|
70
|
-
};
|
|
70
|
+
});
|
|
@@ -3,8 +3,9 @@ import { View, StyleSheet, TouchableOpacity } from "react-native";
|
|
|
3
3
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
4
4
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
5
|
import { UpgradePromptProps } from "./UpgradePrompt.types";
|
|
6
|
+
import { hasItems } from "../../../../../shared/utils/arrayUtils";
|
|
6
7
|
|
|
7
|
-
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({ title, subtitle, benefits, upgradeButtonLabel, onUpgrade }) => {
|
|
8
|
+
export const UpgradePrompt: React.FC<UpgradePromptProps> = React.memo(({ title, subtitle, benefits, upgradeButtonLabel, onUpgrade }) => {
|
|
8
9
|
const tokens = useAppDesignTokens();
|
|
9
10
|
const styles = useMemo(() => StyleSheet.create({
|
|
10
11
|
container: { gap: tokens.spacing.lg },
|
|
@@ -39,7 +40,7 @@ export const UpgradePrompt: React.FC<UpgradePromptProps> = ({ title, subtitle, b
|
|
|
39
40
|
<AtomicText type="headlineSmall" style={[styles.title, { color: tokens.colors.textPrimary }]}>{title}</AtomicText>
|
|
40
41
|
{subtitle && <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>{subtitle}</AtomicText>}
|
|
41
42
|
</View>
|
|
42
|
-
{benefits &&
|
|
43
|
+
{hasItems(benefits) && (
|
|
43
44
|
<View style={styles.benefitsCard}>
|
|
44
45
|
{benefits.map((benefit) => (
|
|
45
46
|
<View key={benefit.text} style={styles.benefitItem}>
|
|
@@ -58,4 +59,4 @@ export const UpgradePrompt: React.FC<UpgradePromptProps> = ({ title, subtitle, b
|
|
|
58
59
|
)}
|
|
59
60
|
</View>
|
|
60
61
|
);
|
|
61
|
-
};
|
|
62
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress calculation utilities
|
|
3
|
+
* All progress bar and percentage calculations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Calculate percentage for progress bars
|
|
8
|
+
* Returns 0 for invalid inputs (zero or negative total)
|
|
9
|
+
*/
|
|
10
|
+
export function calculatePercentage(current: number, total: number): number {
|
|
11
|
+
if (total <= 0) return 0;
|
|
12
|
+
return (current / total) * 100;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Determine progress color based on percentage thresholds
|
|
17
|
+
* @param percentage - Progress percentage (0-100)
|
|
18
|
+
* @param colors - Color object with error, warning, and success colors
|
|
19
|
+
* @returns Appropriate color based on percentage
|
|
20
|
+
*/
|
|
21
|
+
export interface ProgressColors {
|
|
22
|
+
error: string;
|
|
23
|
+
warning: string;
|
|
24
|
+
success: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getProgressColor(
|
|
28
|
+
percentage: number,
|
|
29
|
+
colors: ProgressColors
|
|
30
|
+
): string {
|
|
31
|
+
if (percentage <= 20) return colors.error;
|
|
32
|
+
if (percentage <= 50) return colors.warning;
|
|
33
|
+
return colors.success;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if percentage is in critical range (<= 20%)
|
|
38
|
+
*/
|
|
39
|
+
export function isCriticalPercentage(percentage: number): boolean {
|
|
40
|
+
return percentage <= 20;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if percentage is in warning range (21-50%)
|
|
45
|
+
*/
|
|
46
|
+
export function isWarningPercentage(percentage: number): boolean {
|
|
47
|
+
return percentage > 20 && percentage <= 50;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if percentage is in healthy range (> 50%)
|
|
52
|
+
*/
|
|
53
|
+
export function isHealthyPercentage(percentage: number): boolean {
|
|
54
|
+
return percentage > 50;
|
|
55
|
+
}
|
|
@@ -14,7 +14,7 @@ import type { BalanceCardTranslations, BalanceCardProps } from "./BalanceCard.ty
|
|
|
14
14
|
|
|
15
15
|
export type { BalanceCardTranslations };
|
|
16
16
|
|
|
17
|
-
export const BalanceCard: React.FC<BalanceCardProps> = ({
|
|
17
|
+
export const BalanceCard: React.FC<BalanceCardProps> = React.memo(({
|
|
18
18
|
balance,
|
|
19
19
|
translations,
|
|
20
20
|
iconName = "wallet",
|
|
@@ -58,7 +58,7 @@ export const BalanceCard: React.FC<BalanceCardProps> = ({
|
|
|
58
58
|
</View>
|
|
59
59
|
</View>
|
|
60
60
|
);
|
|
61
|
-
};
|
|
61
|
+
});
|
|
62
62
|
|
|
63
63
|
const styles = StyleSheet.create({
|
|
64
64
|
container: {
|
|
@@ -7,7 +7,7 @@ import { transactionItemStyles } from "./TransactionItem.styles";
|
|
|
7
7
|
import type { TransactionItemProps } from "./TransactionItem.types";
|
|
8
8
|
import { defaultDateFormatter, getReasonLabel, getChangePrefix } from "./transactionItemHelpers";
|
|
9
9
|
|
|
10
|
-
export const TransactionItem: React.FC<TransactionItemProps> = ({
|
|
10
|
+
export const TransactionItem: React.FC<TransactionItemProps> = React.memo(({
|
|
11
11
|
transaction,
|
|
12
12
|
translations,
|
|
13
13
|
dateFormatter = defaultDateFormatter,
|
|
@@ -44,4 +44,4 @@ export const TransactionItem: React.FC<TransactionItemProps> = ({
|
|
|
44
44
|
</AtomicText>
|
|
45
45
|
</View>
|
|
46
46
|
);
|
|
47
|
-
};
|
|
47
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { View,
|
|
1
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
|
+
import { View, FlatList } from "react-native";
|
|
3
3
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
4
4
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
5
|
import { TransactionItem } from "./TransactionItem";
|
|
@@ -7,10 +7,11 @@ import { transactionListStyles } from "./TransactionList.styles";
|
|
|
7
7
|
import { LoadingState, EmptyState } from "./TransactionListStates";
|
|
8
8
|
import { DEFAULT_TRANSACTION_LIST_MAX_HEIGHT } from "./TransactionList.constants";
|
|
9
9
|
import type { TransactionListTranslations, TransactionListProps } from "./TransactionList.types";
|
|
10
|
+
import { isEmptyArray } from "../../../../shared/utils/arrayUtils";
|
|
10
11
|
|
|
11
12
|
export type { TransactionListTranslations };
|
|
12
13
|
|
|
13
|
-
export const TransactionList: React.FC<TransactionListProps> = ({
|
|
14
|
+
export const TransactionList: React.FC<TransactionListProps> = React.memo(({
|
|
14
15
|
transactions,
|
|
15
16
|
loading,
|
|
16
17
|
translations,
|
|
@@ -19,6 +20,13 @@ export const TransactionList: React.FC<TransactionListProps> = ({
|
|
|
19
20
|
}) => {
|
|
20
21
|
const tokens = useAppDesignTokens();
|
|
21
22
|
|
|
23
|
+
const keyExtractor = useCallback((item: typeof transactions[0]) => item.id, []);
|
|
24
|
+
const renderItem = useCallback(({ item }: { item: typeof transactions[0] }) => (
|
|
25
|
+
<TransactionItem transaction={item} translations={translations} dateFormatter={dateFormatter} />
|
|
26
|
+
), [translations, dateFormatter]);
|
|
27
|
+
|
|
28
|
+
const listStyle = useMemo(() => [transactionListStyles.scrollView, { maxHeight }], [maxHeight]);
|
|
29
|
+
|
|
22
30
|
return (
|
|
23
31
|
<View style={transactionListStyles.container}>
|
|
24
32
|
<View style={transactionListStyles.header}>
|
|
@@ -30,19 +38,22 @@ export const TransactionList: React.FC<TransactionListProps> = ({
|
|
|
30
38
|
|
|
31
39
|
{loading ? (
|
|
32
40
|
<LoadingState message={translations.loading} />
|
|
33
|
-
) : transactions
|
|
41
|
+
) : isEmptyArray(transactions) ? (
|
|
34
42
|
<EmptyState message={translations.empty} />
|
|
35
43
|
) : (
|
|
36
|
-
<
|
|
37
|
-
|
|
44
|
+
<FlatList
|
|
45
|
+
data={transactions}
|
|
46
|
+
renderItem={renderItem}
|
|
47
|
+
keyExtractor={keyExtractor}
|
|
48
|
+
style={listStyle}
|
|
38
49
|
contentContainerStyle={transactionListStyles.scrollContent}
|
|
39
50
|
showsVerticalScrollIndicator={false}
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
removeClippedSubviews={true}
|
|
52
|
+
maxToRenderPerBatch={10}
|
|
53
|
+
windowSize={5}
|
|
54
|
+
initialNumToRender={10}
|
|
55
|
+
/>
|
|
45
56
|
)}
|
|
46
57
|
</View>
|
|
47
58
|
);
|
|
48
|
-
};
|
|
59
|
+
});
|
|
@@ -8,15 +8,15 @@ interface LoadingStateProps {
|
|
|
8
8
|
message: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export const LoadingState: React.FC<LoadingStateProps> = ({ message }) => (
|
|
11
|
+
export const LoadingState: React.FC<LoadingStateProps> = React.memo(({ message }) => (
|
|
12
12
|
<AtomicSpinner size="lg" color="primary" text={message} style={transactionListStyles.stateContainer} />
|
|
13
|
-
);
|
|
13
|
+
));
|
|
14
14
|
|
|
15
15
|
interface EmptyStateProps {
|
|
16
16
|
message: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export const EmptyState: React.FC<EmptyStateProps> = ({ message }) => {
|
|
19
|
+
export const EmptyState: React.FC<EmptyStateProps> = React.memo(({ message }) => {
|
|
20
20
|
const tokens = useAppDesignTokens();
|
|
21
21
|
return (
|
|
22
22
|
<View style={transactionListStyles.stateContainer}>
|
|
@@ -26,4 +26,4 @@ export const EmptyState: React.FC<EmptyStateProps> = ({ message }) => {
|
|
|
26
26
|
</AtomicText>
|
|
27
27
|
</View>
|
|
28
28
|
);
|
|
29
|
-
};
|
|
29
|
+
});
|
|
@@ -10,7 +10,7 @@ import { BalanceCard } from "../components/BalanceCard";
|
|
|
10
10
|
import { TransactionList } from "../components/TransactionList";
|
|
11
11
|
import { WalletScreenProps } from "./WalletScreen.types";
|
|
12
12
|
|
|
13
|
-
export const WalletScreen: React.FC<WalletScreenProps> = ({ translations, onBack, dateFormatter, footer }) => {
|
|
13
|
+
export const WalletScreen: React.FC<WalletScreenProps> = React.memo(({ translations, onBack, dateFormatter, footer }) => {
|
|
14
14
|
const tokens = useAppDesignTokens();
|
|
15
15
|
const navigation = useNavigation();
|
|
16
16
|
const config = getWalletConfig();
|
|
@@ -57,7 +57,7 @@ export const WalletScreen: React.FC<WalletScreenProps> = ({ translations, onBack
|
|
|
57
57
|
/>
|
|
58
58
|
</ScreenLayout>
|
|
59
59
|
);
|
|
60
|
-
};
|
|
60
|
+
});
|
|
61
61
|
|
|
62
62
|
const styles = StyleSheet.create({
|
|
63
63
|
content: { paddingBottom: 24 },
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Array validation and utility functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if array has items
|
|
7
|
+
*/
|
|
8
|
+
export function hasItems<T>(arr: readonly T[] | T[] | null | undefined): boolean {
|
|
9
|
+
return Array.isArray(arr) && arr.length > 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if array is empty
|
|
14
|
+
*/
|
|
15
|
+
export function isEmptyArray<T>(arr: readonly T[] | T[] | null | undefined): boolean {
|
|
16
|
+
return !Array.isArray(arr) || arr.length === 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get array length safely (returns 0 for null/undefined)
|
|
21
|
+
*/
|
|
22
|
+
export function getArrayLength<T>(arr: T[] | null | undefined): number {
|
|
23
|
+
return Array.isArray(arr) ? arr.length : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find item in array by predicate
|
|
28
|
+
*/
|
|
29
|
+
export function findItem<T>(
|
|
30
|
+
arr: T[] | null | undefined,
|
|
31
|
+
predicate: (item: T) => boolean
|
|
32
|
+
): T | undefined {
|
|
33
|
+
if (!Array.isArray(arr)) return undefined;
|
|
34
|
+
return arr.find(predicate);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Filter array by predicate
|
|
39
|
+
*/
|
|
40
|
+
export function filterItems<T>(
|
|
41
|
+
arr: T[] | null | undefined,
|
|
42
|
+
predicate: (item: T) => boolean
|
|
43
|
+
): T[] {
|
|
44
|
+
if (!Array.isArray(arr)) return [];
|
|
45
|
+
return arr.filter(predicate);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map array safely (returns empty array for null/undefined)
|
|
50
|
+
*/
|
|
51
|
+
export function mapItems<T, R>(
|
|
52
|
+
arr: T[] | null | undefined,
|
|
53
|
+
mapper: (item: T) => R
|
|
54
|
+
): R[] {
|
|
55
|
+
if (!Array.isArray(arr)) return [];
|
|
56
|
+
return arr.map(mapper);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if array contains item
|
|
61
|
+
*/
|
|
62
|
+
export function arrayContains<T>(arr: T[] | null | undefined, item: T): boolean {
|
|
63
|
+
if (!Array.isArray(arr)) return false;
|
|
64
|
+
return arr.includes(item);
|
|
65
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String validation and utility functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if string is not empty
|
|
7
|
+
*/
|
|
8
|
+
export function isNonEmptyString(str: string | null | undefined): boolean {
|
|
9
|
+
return typeof str === "string" && str.length > 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if string is empty
|
|
14
|
+
*/
|
|
15
|
+
export function isEmptyString(str: string | null | undefined): boolean {
|
|
16
|
+
return !isNonEmptyString(str);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Trim string and check if not empty
|
|
21
|
+
*/
|
|
22
|
+
export function isNonEmptyAfterTrim(str: string | null | undefined): boolean {
|
|
23
|
+
if (typeof str !== "string") return false;
|
|
24
|
+
return str.trim().length > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get trimmed string or empty string
|
|
29
|
+
*/
|
|
30
|
+
export function getTrimmedString(str: string | null | undefined): string {
|
|
31
|
+
if (typeof str !== "string") return "";
|
|
32
|
+
return str.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if string equals one of the values
|
|
37
|
+
*/
|
|
38
|
+
export function stringEqualsAny(
|
|
39
|
+
str: string | null | undefined,
|
|
40
|
+
values: string[]
|
|
41
|
+
): boolean {
|
|
42
|
+
if (typeof str !== "string") return false;
|
|
43
|
+
return values.includes(str);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if string contains substring
|
|
48
|
+
*/
|
|
49
|
+
export function stringContains(
|
|
50
|
+
str: string | null | undefined,
|
|
51
|
+
substring: string
|
|
52
|
+
): boolean {
|
|
53
|
+
if (typeof str !== "string") return false;
|
|
54
|
+
return str.includes(substring);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if string is neither 'undefined' nor 'null' string
|
|
59
|
+
*/
|
|
60
|
+
export function isValidString(str: string | null | undefined): boolean {
|
|
61
|
+
if (typeof str !== "string") return false;
|
|
62
|
+
const trimmed = str.trim();
|
|
63
|
+
return trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null";
|
|
64
|
+
}
|