@umituz/react-native-subscription 2.27.145 → 2.27.147

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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/src/domains/subscription/presentation/components/details/PremiumDetailsCard.constants.ts +1 -0
  3. package/src/domains/subscription/presentation/components/details/PremiumDetailsCard.tsx +15 -75
  4. package/src/domains/subscription/presentation/components/details/PremiumDetailsCardActions.tsx +46 -0
  5. package/src/domains/subscription/presentation/components/details/PremiumDetailsCardHeader.tsx +32 -0
  6. package/src/domains/subscription/presentation/components/details/premiumDetailsHelpers.ts +5 -0
  7. package/src/domains/subscription/presentation/components/feedback/FeedbackOption.styles.ts +34 -0
  8. package/src/domains/subscription/presentation/components/feedback/FeedbackOption.tsx +12 -91
  9. package/src/domains/subscription/presentation/components/feedback/FeedbackOption.types.ts +9 -0
  10. package/src/domains/subscription/presentation/components/feedback/FeedbackRadioButton.tsx +28 -0
  11. package/src/domains/subscription/presentation/components/feedback/FeedbackTextInput.tsx +40 -0
  12. package/src/domains/subscription/presentation/components/feedback/feedbackOptionConstants.ts +3 -0
  13. package/src/domains/subscription/presentation/featureGateActions.ts +37 -0
  14. package/src/domains/subscription/presentation/featureGateHelpers.ts +31 -0
  15. package/src/domains/subscription/presentation/featureGateRefs.ts +27 -0
  16. package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.constants.ts +1 -0
  17. package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.styles.ts +41 -0
  18. package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.tsx +18 -108
  19. package/src/domains/subscription/presentation/screens/components/SubscriptionHeader.types.ts +20 -0
  20. package/src/domains/subscription/presentation/screens/components/SubscriptionHeaderContent.tsx +58 -0
  21. package/src/domains/subscription/presentation/useFeatureGate.ts +46 -94
  22. package/src/domains/subscription/presentation/useFeatureGate.types.ts +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.27.145",
3
+ "version": "2.27.147",
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",
@@ -0,0 +1 @@
1
+ export const DAYS_REMAINING_WARNING_THRESHOLD = 7;
@@ -1,17 +1,13 @@
1
- /**
2
- * Premium Details Card
3
- * Generic component for displaying subscription details
4
- * Accepts credits via props for app-specific customization
5
- */
6
-
7
1
  import React from "react";
8
- import { View, TouchableOpacity } from "react-native";
2
+ import { View } from "react-native";
9
3
  import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
10
- import { PremiumStatusBadge } from "./PremiumStatusBadge";
11
4
  import { DetailRow } from "./DetailRow";
12
5
  import { CreditRow } from "./CreditRow";
13
6
  import { styles } from "./PremiumDetailsCard.styles";
14
7
  import type { PremiumDetailsCardProps } from "./PremiumDetailsCardTypes";
8
+ import { PremiumDetailsCardHeader } from "./PremiumDetailsCardHeader";
9
+ import { PremiumDetailsCardActions } from "./PremiumDetailsCardActions";
10
+ import { shouldHighlightExpiration } from "./premiumDetailsHelpers";
15
11
 
16
12
  export type { CreditInfo, PremiumDetailsTranslations, PremiumDetailsCardProps } from "./PremiumDetailsCardTypes";
17
13
 
@@ -32,60 +28,26 @@ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
32
28
 
33
29
  return (
34
30
  <View style={[styles.card, { backgroundColor: tokens.colors.surface }]}>
35
- {(isPremium || showCredits) && (
36
- <View style={styles.header}>
37
- <View style={styles.headerTitleContainer}>
38
- <AtomicText type="titleLarge" style={{ color: tokens.colors.textPrimary }}>
39
- {translations.title}
40
- </AtomicText>
41
- </View>
42
- <PremiumStatusBadge
43
- status={statusType}
44
- activeLabel={translations.statusActive}
45
- expiredLabel={translations.statusExpired}
46
- noneLabel={translations.statusInactive}
47
- canceledLabel={translations.statusCanceled}
48
- />
49
- </View>
50
- )}
31
+ {(isPremium || showCredits) && <PremiumDetailsCardHeader statusType={statusType} translations={translations} />}
51
32
 
52
33
 
53
34
  {isPremium && (
54
35
  <View style={styles.detailsSection}>
55
36
  {isLifetime && translations.lifetimeLabel ? (
56
- <DetailRow
57
- label={translations.statusLabel}
58
- value={translations.lifetimeLabel}
59
- />
37
+ <DetailRow label={translations.statusLabel} value={translations.lifetimeLabel} />
60
38
  ) : (
61
39
  <>
62
40
  {expirationDate && (
63
- <DetailRow
64
- label={translations.expiresLabel}
65
- value={expirationDate}
66
- highlight={
67
- daysRemaining !== null &&
68
- daysRemaining !== undefined &&
69
- daysRemaining > 0 &&
70
- daysRemaining <= 7
71
- }
72
- />
73
- )}
74
- {purchaseDate && (
75
- <DetailRow
76
- label={translations.purchasedLabel}
77
- value={purchaseDate}
78
- />
41
+ <DetailRow label={translations.expiresLabel} value={expirationDate} highlight={shouldHighlightExpiration(daysRemaining)} />
79
42
  )}
43
+ {purchaseDate && <DetailRow label={translations.purchasedLabel} value={purchaseDate} />}
80
44
  </>
81
45
  )}
82
46
  </View>
83
47
  )}
84
48
 
85
49
  {showCredits && (
86
- <View
87
- style={[styles.creditsSection, { borderTopColor: tokens.colors.border }]}
88
- >
50
+ <View style={[styles.creditsSection, { borderTopColor: tokens.colors.border }]}>
89
51
  {translations.creditsTitle && (
90
52
  <AtomicText type="labelMedium" style={[styles.sectionTitle, { color: tokens.colors.textPrimary }]}>
91
53
  {translations.creditsTitle}
@@ -103,34 +65,12 @@ export const PremiumDetailsCard: React.FC<PremiumDetailsCardProps> = ({
103
65
  </View>
104
66
  )}
105
67
 
106
- <View style={styles.actionsSection}>
107
- {isPremium && onManageSubscription && translations.manageButton && (
108
- <TouchableOpacity
109
- style={[
110
- styles.secondaryButton,
111
- { backgroundColor: tokens.colors.surfaceSecondary },
112
- ]}
113
- onPress={onManageSubscription}
114
- >
115
- <AtomicText type="labelLarge" style={{ color: tokens.colors.textPrimary }}>
116
- {translations.manageButton}
117
- </AtomicText>
118
- </TouchableOpacity>
119
- )}
120
- {!isPremium && onUpgrade && translations.upgradeButton && (
121
- <TouchableOpacity
122
- style={[styles.premiumButton, { backgroundColor: tokens.colors.primary }]}
123
- onPress={onUpgrade}
124
- >
125
- <AtomicText
126
- type="titleMedium"
127
- style={{ color: tokens.colors.onPrimary, fontWeight: "700" }}
128
- >
129
- {translations.upgradeButton}
130
- </AtomicText>
131
- </TouchableOpacity>
132
- )}
133
- </View>
68
+ <PremiumDetailsCardActions
69
+ isPremium={isPremium}
70
+ onManageSubscription={onManageSubscription}
71
+ onUpgrade={onUpgrade}
72
+ translations={translations}
73
+ />
134
74
  </View>
135
75
  );
136
76
  };
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { View, TouchableOpacity } from "react-native";
3
+ import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
4
+ import { styles } from "./PremiumDetailsCard.styles";
5
+ import type { PremiumDetailsTranslations } from "./PremiumDetailsCardTypes";
6
+
7
+ interface PremiumDetailsCardActionsProps {
8
+ isPremium: boolean;
9
+ onManageSubscription?: () => void;
10
+ onUpgrade?: () => void;
11
+ translations: PremiumDetailsTranslations;
12
+ }
13
+
14
+ export const PremiumDetailsCardActions: React.FC<PremiumDetailsCardActionsProps> = ({
15
+ isPremium,
16
+ onManageSubscription,
17
+ onUpgrade,
18
+ translations,
19
+ }) => {
20
+ const tokens = useAppDesignTokens();
21
+
22
+ return (
23
+ <View style={styles.actionsSection}>
24
+ {isPremium && onManageSubscription && translations.manageButton && (
25
+ <TouchableOpacity
26
+ style={[styles.secondaryButton, { backgroundColor: tokens.colors.surfaceSecondary }]}
27
+ onPress={onManageSubscription}
28
+ >
29
+ <AtomicText type="labelLarge" style={{ color: tokens.colors.textPrimary }}>
30
+ {translations.manageButton}
31
+ </AtomicText>
32
+ </TouchableOpacity>
33
+ )}
34
+ {!isPremium && onUpgrade && translations.upgradeButton && (
35
+ <TouchableOpacity
36
+ style={[styles.premiumButton, { backgroundColor: tokens.colors.primary }]}
37
+ onPress={onUpgrade}
38
+ >
39
+ <AtomicText type="titleMedium" style={{ color: tokens.colors.onPrimary, fontWeight: "700" }}>
40
+ {translations.upgradeButton}
41
+ </AtomicText>
42
+ </TouchableOpacity>
43
+ )}
44
+ </View>
45
+ );
46
+ };
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
4
+ import { PremiumStatusBadge } from "./PremiumStatusBadge";
5
+ import { styles } from "./PremiumDetailsCard.styles";
6
+ import type { PremiumDetailsTranslations } from "./PremiumDetailsCardTypes";
7
+
8
+ interface PremiumDetailsCardHeaderProps {
9
+ statusType: "active" | "expired" | "none" | "canceled";
10
+ translations: PremiumDetailsTranslations;
11
+ }
12
+
13
+ export const PremiumDetailsCardHeader: React.FC<PremiumDetailsCardHeaderProps> = ({ statusType, translations }) => {
14
+ const tokens = useAppDesignTokens();
15
+
16
+ return (
17
+ <View style={styles.header}>
18
+ <View style={styles.headerTitleContainer}>
19
+ <AtomicText type="titleLarge" style={{ color: tokens.colors.textPrimary }}>
20
+ {translations.title}
21
+ </AtomicText>
22
+ </View>
23
+ <PremiumStatusBadge
24
+ status={statusType}
25
+ activeLabel={translations.statusActive}
26
+ expiredLabel={translations.statusExpired}
27
+ noneLabel={translations.statusInactive}
28
+ canceledLabel={translations.statusCanceled}
29
+ />
30
+ </View>
31
+ );
32
+ };
@@ -0,0 +1,5 @@
1
+ import { DAYS_REMAINING_WARNING_THRESHOLD } from "./PremiumDetailsCard.constants";
2
+
3
+ export const shouldHighlightExpiration = (daysRemaining: number | null | undefined): boolean => {
4
+ return daysRemaining !== null && daysRemaining !== undefined && daysRemaining > 0 && daysRemaining <= DAYS_REMAINING_WARNING_THRESHOLD;
5
+ };
@@ -0,0 +1,34 @@
1
+ import { StyleSheet } from "react-native";
2
+
3
+ export const feedbackOptionStyles = StyleSheet.create({
4
+ optionRow: {
5
+ flexDirection: "row",
6
+ alignItems: "center",
7
+ justifyContent: "space-between",
8
+ },
9
+ optionText: {
10
+ flex: 1,
11
+ marginRight: 12,
12
+ },
13
+ radioButton: {
14
+ width: 22,
15
+ height: 22,
16
+ borderRadius: 11,
17
+ borderWidth: 2,
18
+ justifyContent: "center",
19
+ alignItems: "center",
20
+ },
21
+ radioButtonInner: {
22
+ width: 12,
23
+ height: 12,
24
+ borderRadius: 6,
25
+ },
26
+ inputContainer: {
27
+ padding: 12,
28
+ },
29
+ textInput: {
30
+ fontSize: 15,
31
+ borderWidth: 1,
32
+ borderColor: "#ccc",
33
+ },
34
+ });
@@ -1,21 +1,13 @@
1
- /**
2
- * Feedback Option Component
3
- * Single feedback option with radio button and optional text input
4
- */
5
-
6
1
  import React from "react";
7
- import { View, TouchableOpacity, TextInput, StyleSheet } from "react-native";
2
+ import { View, TouchableOpacity } from "react-native";
8
3
  import { AtomicText, useAppDesignTokens } from "@umituz/react-native-design-system";
4
+ import type { FeedbackOptionProps } from "./FeedbackOption.types";
5
+ import { feedbackOptionStyles } from "./FeedbackOption.styles";
6
+ import { FEEDBACK_OPTION_OPACITY } from "./feedbackOptionConstants";
7
+ import { FeedbackRadioButton } from "./FeedbackRadioButton";
8
+ import { FeedbackTextInput } from "./FeedbackTextInput";
9
9
 
10
- interface FeedbackOptionProps {
11
- isSelected: boolean;
12
- text: string;
13
- showInput: boolean;
14
- placeholder: string;
15
- inputValue: string;
16
- onSelect: () => void;
17
- onChangeText: (text: string) => void;
18
- }
10
+ export type { FeedbackOptionProps } from "./FeedbackOption.types";
19
11
 
20
12
  export const FeedbackOption: React.FC<FeedbackOptionProps> = React.memo(({
21
13
  isSelected,
@@ -39,7 +31,7 @@ export const FeedbackOption: React.FC<FeedbackOptionProps> = React.memo(({
39
31
  <View style={containerStyle}>
40
32
  <TouchableOpacity
41
33
  style={[
42
- styles.optionRow,
34
+ feedbackOptionStyles.optionRow,
43
35
  {
44
36
  borderBottomWidth: showInput ? 1 : 0,
45
37
  borderBottomColor: tokens.colors.border,
@@ -48,92 +40,21 @@ export const FeedbackOption: React.FC<FeedbackOptionProps> = React.memo(({
48
40
  },
49
41
  ]}
50
42
  onPress={onSelect}
51
- activeOpacity={0.7}
43
+ activeOpacity={FEEDBACK_OPTION_OPACITY}
52
44
  >
53
45
  <AtomicText
54
46
  type="bodyMedium"
55
- style={[
56
- styles.optionText,
57
- isSelected && { color: tokens.colors.primary, fontWeight: "600" },
58
- ]}
47
+ style={[feedbackOptionStyles.optionText, isSelected && { color: tokens.colors.primary, fontWeight: "600" }]}
59
48
  >
60
49
  {text}
61
50
  </AtomicText>
62
51
 
63
- <View
64
- style={[
65
- styles.radioButton,
66
- {
67
- borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
68
- backgroundColor: isSelected ? tokens.colors.primary : "transparent",
69
- },
70
- ]}
71
- >
72
- {isSelected && (
73
- <View style={[styles.radioButtonInner, { backgroundColor: tokens.colors.primary }]} />
74
- )}
75
- </View>
52
+ <FeedbackRadioButton isSelected={isSelected} />
76
53
  </TouchableOpacity>
77
54
 
78
- {showInput && (
79
- <View style={styles.inputContainer}>
80
- <TextInput
81
- style={[
82
- styles.textInput,
83
- {
84
- backgroundColor: tokens.colors.surface,
85
- borderRadius: tokens.borderRadius.sm,
86
- padding: tokens.spacing.sm,
87
- color: tokens.colors.textPrimary,
88
- minHeight: 80,
89
- textAlignVertical: "top",
90
- },
91
- ]}
92
- placeholder={placeholder}
93
- placeholderTextColor={tokens.colors.textTertiary}
94
- multiline
95
- maxLength={200}
96
- value={inputValue}
97
- onChangeText={onChangeText}
98
- autoFocus
99
- />
100
- </View>
101
- )}
55
+ {showInput && <FeedbackTextInput placeholder={placeholder} value={inputValue} onChangeText={onChangeText} />}
102
56
  </View>
103
57
  );
104
58
  });
105
59
 
106
60
  FeedbackOption.displayName = "FeedbackOption";
107
-
108
- const styles = StyleSheet.create({
109
- optionRow: {
110
- flexDirection: "row",
111
- alignItems: "center",
112
- justifyContent: "space-between",
113
- },
114
- optionText: {
115
- flex: 1,
116
- marginRight: 12,
117
- },
118
- radioButton: {
119
- width: 22,
120
- height: 22,
121
- borderRadius: 11,
122
- borderWidth: 2,
123
- justifyContent: "center",
124
- alignItems: "center",
125
- },
126
- radioButtonInner: {
127
- width: 12,
128
- height: 12,
129
- borderRadius: 6,
130
- },
131
- inputContainer: {
132
- padding: 12,
133
- },
134
- textInput: {
135
- fontSize: 15,
136
- borderWidth: 1,
137
- borderColor: "#ccc",
138
- },
139
- });
@@ -0,0 +1,9 @@
1
+ export interface FeedbackOptionProps {
2
+ isSelected: boolean;
3
+ text: string;
4
+ showInput: boolean;
5
+ placeholder: string;
6
+ inputValue: string;
7
+ onSelect: () => void;
8
+ onChangeText: (text: string) => void;
9
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { useAppDesignTokens } from "@umituz/react-native-design-system";
4
+ import { feedbackOptionStyles } from "./FeedbackOption.styles";
5
+
6
+ interface FeedbackRadioButtonProps {
7
+ isSelected: boolean;
8
+ }
9
+
10
+ export const FeedbackRadioButton: React.FC<FeedbackRadioButtonProps> = ({ isSelected }) => {
11
+ const tokens = useAppDesignTokens();
12
+
13
+ return (
14
+ <View
15
+ style={[
16
+ feedbackOptionStyles.radioButton,
17
+ {
18
+ borderColor: isSelected ? tokens.colors.primary : tokens.colors.border,
19
+ backgroundColor: isSelected ? tokens.colors.primary : "transparent",
20
+ },
21
+ ]}
22
+ >
23
+ {isSelected && (
24
+ <View style={[feedbackOptionStyles.radioButtonInner, { backgroundColor: tokens.colors.primary }]} />
25
+ )}
26
+ </View>
27
+ );
28
+ };
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ import { View, TextInput } from "react-native";
3
+ import { useAppDesignTokens } from "@umituz/react-native-design-system";
4
+ import { feedbackOptionStyles } from "./FeedbackOption.styles";
5
+ import { FEEDBACK_INPUT_MAX_LENGTH, FEEDBACK_INPUT_MIN_HEIGHT } from "./feedbackOptionConstants";
6
+
7
+ interface FeedbackTextInputProps {
8
+ placeholder: string;
9
+ value: string;
10
+ onChangeText: (text: string) => void;
11
+ }
12
+
13
+ export const FeedbackTextInput: React.FC<FeedbackTextInputProps> = ({ placeholder, value, onChangeText }) => {
14
+ const tokens = useAppDesignTokens();
15
+
16
+ return (
17
+ <View style={feedbackOptionStyles.inputContainer}>
18
+ <TextInput
19
+ style={[
20
+ feedbackOptionStyles.textInput,
21
+ {
22
+ backgroundColor: tokens.colors.surface,
23
+ borderRadius: tokens.borderRadius.sm,
24
+ padding: tokens.spacing.sm,
25
+ color: tokens.colors.textPrimary,
26
+ minHeight: FEEDBACK_INPUT_MIN_HEIGHT,
27
+ textAlignVertical: "top",
28
+ },
29
+ ]}
30
+ placeholder={placeholder}
31
+ placeholderTextColor={tokens.colors.textTertiary}
32
+ multiline
33
+ maxLength={FEEDBACK_INPUT_MAX_LENGTH}
34
+ value={value}
35
+ onChangeText={onChangeText}
36
+ autoFocus
37
+ />
38
+ </View>
39
+ );
40
+ };
@@ -0,0 +1,3 @@
1
+ export const FEEDBACK_INPUT_MAX_LENGTH = 200;
2
+ export const FEEDBACK_INPUT_MIN_HEIGHT = 80;
3
+ export const FEEDBACK_OPTION_OPACITY = 0.7;
@@ -0,0 +1,37 @@
1
+ import type { MutableRefObject } from "react";
2
+
3
+ export const executeFeatureAction = (
4
+ action: () => void | Promise<void>,
5
+ isAuthenticated: boolean,
6
+ onShowAuthModal: (callback: () => void | Promise<void>) => void,
7
+ hasSubscriptionRef: MutableRefObject<boolean>,
8
+ creditBalanceRef: MutableRefObject<number>,
9
+ requiredCreditsRef: MutableRefObject<number>,
10
+ onShowPaywallRef: MutableRefObject<(requiredCredits?: number) => void>,
11
+ pendingActionRef: MutableRefObject<(() => void | Promise<void>) | null>,
12
+ isWaitingForAuthCreditsRef: MutableRefObject<boolean>,
13
+ isWaitingForPurchaseRef: MutableRefObject<boolean>
14
+ ): void => {
15
+ if (!isAuthenticated) {
16
+ const postAuthAction = () => {
17
+ pendingActionRef.current = action;
18
+ isWaitingForAuthCreditsRef.current = true;
19
+ };
20
+ onShowAuthModal(postAuthAction);
21
+ return;
22
+ }
23
+
24
+ if (hasSubscriptionRef.current) {
25
+ action();
26
+ return;
27
+ }
28
+
29
+ if (creditBalanceRef.current < requiredCreditsRef.current) {
30
+ pendingActionRef.current = action;
31
+ isWaitingForPurchaseRef.current = true;
32
+ onShowPaywallRef.current(requiredCreditsRef.current);
33
+ return;
34
+ }
35
+
36
+ action();
37
+ };
@@ -0,0 +1,31 @@
1
+ export const DEFAULT_REQUIRED_CREDITS = 1;
2
+
3
+ export const shouldExecuteAuthAction = (
4
+ isWaitingForAuthCredits: boolean,
5
+ isCreditsLoaded: boolean,
6
+ hasPendingAction: boolean,
7
+ hasSubscription: boolean,
8
+ creditBalance: number,
9
+ requiredCredits: number
10
+ ): boolean => {
11
+ if (!isWaitingForAuthCredits || !isCreditsLoaded || !hasPendingAction) {
12
+ return false;
13
+ }
14
+ return hasSubscription || creditBalance >= requiredCredits;
15
+ };
16
+
17
+ export const shouldExecutePurchaseAction = (
18
+ isWaitingForPurchase: boolean,
19
+ creditBalance: number,
20
+ prevBalance: number,
21
+ hasSubscription: boolean,
22
+ prevHasSubscription: boolean,
23
+ hasPendingAction: boolean
24
+ ): boolean => {
25
+ if (!isWaitingForPurchase || !hasPendingAction) {
26
+ return false;
27
+ }
28
+ const creditsIncreased = creditBalance > prevBalance;
29
+ const subscriptionAcquired = hasSubscription && !prevHasSubscription;
30
+ return creditsIncreased || subscriptionAcquired;
31
+ };
@@ -0,0 +1,27 @@
1
+ import { useRef, useEffect, type MutableRefObject } from "react";
2
+
3
+ export interface FeatureGateRefs {
4
+ creditBalanceRef: MutableRefObject<number>;
5
+ hasSubscriptionRef: MutableRefObject<boolean>;
6
+ onShowPaywallRef: MutableRefObject<(requiredCredits?: number) => void>;
7
+ requiredCreditsRef: MutableRefObject<number>;
8
+ }
9
+
10
+ export const useSyncedRefs = (
11
+ creditBalance: number,
12
+ hasSubscription: boolean,
13
+ onShowPaywall: (requiredCredits?: number) => void,
14
+ requiredCredits: number
15
+ ): FeatureGateRefs => {
16
+ const creditBalanceRef = useRef(creditBalance);
17
+ const hasSubscriptionRef = useRef(hasSubscription);
18
+ const onShowPaywallRef = useRef(onShowPaywall);
19
+ const requiredCreditsRef = useRef(requiredCredits);
20
+
21
+ useEffect(() => { creditBalanceRef.current = creditBalance; }, [creditBalance]);
22
+ useEffect(() => { hasSubscriptionRef.current = hasSubscription; }, [hasSubscription]);
23
+ useEffect(() => { onShowPaywallRef.current = onShowPaywall; }, [onShowPaywall]);
24
+ useEffect(() => { requiredCreditsRef.current = requiredCredits; }, [requiredCredits]);
25
+
26
+ return { creditBalanceRef, hasSubscriptionRef, onShowPaywallRef, requiredCreditsRef };
27
+ };
@@ -0,0 +1 @@
1
+ export const EXPIRING_SOON_THRESHOLD_DAYS = 7;
@@ -0,0 +1,41 @@
1
+ import { StyleSheet } from "react-native";
2
+ import type { AppDesignTokens } from "@umituz/react-native-design-system";
3
+
4
+ export const createSubscriptionHeaderStyles = (tokens: AppDesignTokens) =>
5
+ StyleSheet.create({
6
+ container: {
7
+ borderRadius: tokens.radius.lg,
8
+ padding: tokens.spacing.lg,
9
+ gap: tokens.spacing.lg,
10
+ backgroundColor: tokens.colors.surface,
11
+ },
12
+ header: {
13
+ flexDirection: "row",
14
+ justifyContent: "space-between",
15
+ alignItems: "center",
16
+ },
17
+ titleContainer: {
18
+ flex: 1,
19
+ marginRight: tokens.spacing.md,
20
+ },
21
+ title: {
22
+ fontWeight: "700",
23
+ },
24
+ details: {
25
+ gap: tokens.spacing.md,
26
+ paddingTop: tokens.spacing.md,
27
+ },
28
+ row: {
29
+ flexDirection: "row",
30
+ justifyContent: "space-between",
31
+ alignItems: "center",
32
+ gap: tokens.spacing.lg,
33
+ },
34
+ label: {
35
+ flex: 1,
36
+ },
37
+ value: {
38
+ fontWeight: "600",
39
+ textAlign: "right",
40
+ },
41
+ });
@@ -1,34 +1,13 @@
1
- /**
2
- * Subscription Header Component
3
- * Displays status badge and subscription details
4
- */
5
-
6
1
  import React, { useMemo } from "react";
7
- import { View, StyleSheet } from "react-native";
2
+ import { View } from "react-native";
8
3
  import { useAppDesignTokens, AtomicText } from "@umituz/react-native-design-system";
9
4
  import { PremiumStatusBadge } from "../../components/details/PremiumStatusBadge";
10
- import { DetailRow } from "../../components/details/DetailRow";
5
+ import type { SubscriptionHeaderProps } from "./SubscriptionHeader.types";
6
+ import { createSubscriptionHeaderStyles } from "./SubscriptionHeader.styles";
7
+ import { EXPIRING_SOON_THRESHOLD_DAYS } from "./SubscriptionHeader.constants";
8
+ import { SubscriptionHeaderContent } from "./SubscriptionHeaderContent";
11
9
 
12
- export interface SubscriptionHeaderProps {
13
- statusType: "active" | "expired" | "none" | "canceled";
14
- showExpirationDate: boolean;
15
- isLifetime: boolean;
16
- expirationDate?: string;
17
- purchaseDate?: string;
18
- daysRemaining?: number | null;
19
- hideTitle?: boolean;
20
- translations: {
21
- title: string;
22
- statusActive: string;
23
- statusExpired: string;
24
- statusFree: string;
25
- statusCanceled: string;
26
- statusLabel: string;
27
- lifetimeLabel: string;
28
- expiresLabel: string;
29
- purchasedLabel: string;
30
- };
31
- }
10
+ export type { SubscriptionHeaderProps } from "./SubscriptionHeader.types";
32
11
 
33
12
  export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
34
13
  statusType,
@@ -41,60 +20,15 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
41
20
  translations,
42
21
  }) => {
43
22
  const tokens = useAppDesignTokens();
44
- const showExpiring =
45
- daysRemaining !== null && daysRemaining !== undefined && daysRemaining <= 7;
46
-
47
- const styles = useMemo(
48
- () =>
49
- StyleSheet.create({
50
- container: {
51
- borderRadius: tokens.radius.lg,
52
- padding: tokens.spacing.lg,
53
- gap: tokens.spacing.lg,
54
- backgroundColor: tokens.colors.surface,
55
- },
56
- header: {
57
- flexDirection: "row",
58
- justifyContent: "space-between",
59
- alignItems: "center",
60
- },
61
- titleContainer: {
62
- flex: 1,
63
- marginRight: tokens.spacing.md,
64
- },
65
- title: {
66
- fontWeight: "700",
67
- },
68
- details: {
69
- gap: tokens.spacing.md,
70
- paddingTop: tokens.spacing.md,
71
- },
72
- row: {
73
- flexDirection: "row",
74
- justifyContent: "space-between",
75
- alignItems: "center",
76
- gap: tokens.spacing.lg,
77
- },
78
- label: {
79
- flex: 1,
80
- },
81
- value: {
82
- fontWeight: "600",
83
- textAlign: "right",
84
- },
85
- }),
86
- [tokens]
87
- );
23
+ const showExpiring = daysRemaining !== null && daysRemaining !== undefined && daysRemaining <= EXPIRING_SOON_THRESHOLD_DAYS;
24
+ const styles = useMemo(() => createSubscriptionHeaderStyles(tokens), [tokens]);
88
25
 
89
26
  return (
90
27
  <View style={styles.container}>
91
28
  <View style={styles.header}>
92
29
  {!hideTitle && (
93
30
  <View style={styles.titleContainer}>
94
- <AtomicText
95
- type="headlineSmall"
96
- style={[styles.title, { color: tokens.colors.textPrimary }]}
97
- >
31
+ <AtomicText type="headlineSmall" style={[styles.title, { color: tokens.colors.textPrimary }]}>
98
32
  {translations.title}
99
33
  </AtomicText>
100
34
  </View>
@@ -108,39 +42,15 @@ export const SubscriptionHeader: React.FC<SubscriptionHeaderProps> = ({
108
42
  />
109
43
  </View>
110
44
 
111
- <View style={styles.details}>
112
- {isLifetime ? (
113
- <DetailRow
114
- label={translations.statusLabel}
115
- value={translations.lifetimeLabel}
116
- style={styles.row}
117
- labelStyle={styles.label}
118
- valueStyle={styles.value}
119
- />
120
- ) : (
121
- <>
122
- {showExpirationDate && expirationDate && (
123
- <DetailRow
124
- label={translations.expiresLabel}
125
- value={expirationDate}
126
- highlight={showExpiring}
127
- style={styles.row}
128
- labelStyle={styles.label}
129
- valueStyle={styles.value}
130
- />
131
- )}
132
- {purchaseDate && (
133
- <DetailRow
134
- label={translations.purchasedLabel}
135
- value={purchaseDate}
136
- style={styles.row}
137
- labelStyle={styles.label}
138
- valueStyle={styles.value}
139
- />
140
- )}
141
- </>
142
- )}
143
- </View>
45
+ <SubscriptionHeaderContent
46
+ isLifetime={isLifetime}
47
+ showExpirationDate={showExpirationDate}
48
+ expirationDate={expirationDate}
49
+ purchaseDate={purchaseDate}
50
+ showExpiring={showExpiring}
51
+ translations={translations}
52
+ styles={styles}
53
+ />
144
54
  </View>
145
55
  );
146
56
  };
@@ -0,0 +1,20 @@
1
+ export interface SubscriptionHeaderProps {
2
+ statusType: "active" | "expired" | "none" | "canceled";
3
+ showExpirationDate: boolean;
4
+ isLifetime: boolean;
5
+ expirationDate?: string;
6
+ purchaseDate?: string;
7
+ daysRemaining?: number | null;
8
+ hideTitle?: boolean;
9
+ translations: {
10
+ title: string;
11
+ statusActive: string;
12
+ statusExpired: string;
13
+ statusFree: string;
14
+ statusCanceled: string;
15
+ statusLabel: string;
16
+ lifetimeLabel: string;
17
+ expiresLabel: string;
18
+ purchasedLabel: string;
19
+ };
20
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { DetailRow } from "../../components/details/DetailRow";
4
+ import type { SubscriptionHeaderProps } from "./SubscriptionHeader.types";
5
+
6
+ interface SubscriptionHeaderContentProps {
7
+ isLifetime: boolean;
8
+ showExpirationDate: boolean;
9
+ expirationDate?: string;
10
+ purchaseDate?: string;
11
+ showExpiring: boolean;
12
+ translations: SubscriptionHeaderProps["translations"];
13
+ styles: any;
14
+ }
15
+
16
+ export const SubscriptionHeaderContent: React.FC<SubscriptionHeaderContentProps> = ({
17
+ isLifetime,
18
+ showExpirationDate,
19
+ expirationDate,
20
+ purchaseDate,
21
+ showExpiring,
22
+ translations,
23
+ styles,
24
+ }) => (
25
+ <View style={styles.details}>
26
+ {isLifetime ? (
27
+ <DetailRow
28
+ label={translations.statusLabel}
29
+ value={translations.lifetimeLabel}
30
+ style={styles.row}
31
+ labelStyle={styles.label}
32
+ valueStyle={styles.value}
33
+ />
34
+ ) : (
35
+ <>
36
+ {showExpirationDate && expirationDate && (
37
+ <DetailRow
38
+ label={translations.expiresLabel}
39
+ value={expirationDate}
40
+ highlight={showExpiring}
41
+ style={styles.row}
42
+ labelStyle={styles.label}
43
+ valueStyle={styles.value}
44
+ />
45
+ )}
46
+ {purchaseDate && (
47
+ <DetailRow
48
+ label={translations.purchasedLabel}
49
+ value={purchaseDate}
50
+ style={styles.row}
51
+ labelStyle={styles.label}
52
+ valueStyle={styles.value}
53
+ />
54
+ )}
55
+ </>
56
+ )}
57
+ </View>
58
+ );
@@ -1,30 +1,10 @@
1
- /**
2
- * useFeatureGate Hook
3
- * Unified feature gate: Auth → Subscription → Credits
4
- * Uses ref pattern to avoid stale closure issues.
5
- * Event-driven approach - no polling, no waiting.
6
- */
7
-
8
1
  import { useCallback, useRef, useEffect } from "react";
2
+ import type { UseFeatureGateParams, UseFeatureGateResult } from "./useFeatureGate.types";
3
+ import { DEFAULT_REQUIRED_CREDITS, shouldExecuteAuthAction, shouldExecutePurchaseAction } from "./featureGateHelpers";
4
+ import { useSyncedRefs } from "./featureGateRefs";
5
+ import { executeFeatureAction } from "./featureGateActions";
9
6
 
10
- export interface UseFeatureGateParams {
11
- readonly isAuthenticated: boolean;
12
- readonly onShowAuthModal: (pendingCallback: () => void | Promise<void>) => void;
13
- readonly hasSubscription?: boolean;
14
- readonly creditBalance: number;
15
- readonly requiredCredits?: number;
16
- readonly onShowPaywall: (requiredCredits?: number) => void;
17
- readonly isCreditsLoaded?: boolean;
18
- }
19
-
20
- export interface UseFeatureGateResult {
21
- readonly requireFeature: (action: () => void | Promise<void>) => void;
22
- readonly isAuthenticated: boolean;
23
- readonly hasSubscription: boolean;
24
- readonly hasCredits: boolean;
25
- readonly creditBalance: number;
26
- readonly canAccess: boolean;
27
- }
7
+ export type { UseFeatureGateParams, UseFeatureGateResult } from "./useFeatureGate.types";
28
8
 
29
9
  export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResult {
30
10
  const {
@@ -32,7 +12,7 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
32
12
  onShowAuthModal,
33
13
  hasSubscription = false,
34
14
  creditBalance,
35
- requiredCredits = 1,
15
+ requiredCredits = DEFAULT_REQUIRED_CREDITS,
36
16
  onShowPaywall,
37
17
  isCreditsLoaded = true,
38
18
  } = params;
@@ -42,52 +22,41 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
42
22
  const isWaitingForPurchaseRef = useRef(false);
43
23
  const isWaitingForAuthCreditsRef = useRef(false);
44
24
 
45
- const creditBalanceRef = useRef(creditBalance);
46
- const hasSubscriptionRef = useRef(hasSubscription);
47
- const onShowPaywallRef = useRef(onShowPaywall);
48
- const requiredCreditsRef = useRef(requiredCredits);
49
-
50
- useEffect(() => {
51
- creditBalanceRef.current = creditBalance;
52
- }, [creditBalance]);
53
-
54
- useEffect(() => {
55
- hasSubscriptionRef.current = hasSubscription;
56
- }, [hasSubscription]);
57
-
58
- useEffect(() => {
59
- onShowPaywallRef.current = onShowPaywall;
60
- }, [onShowPaywall]);
61
-
62
- useEffect(() => {
63
- requiredCreditsRef.current = requiredCredits;
64
- }, [requiredCredits]);
25
+ const { creditBalanceRef, hasSubscriptionRef, onShowPaywallRef, requiredCreditsRef } = useSyncedRefs(creditBalance, hasSubscription, onShowPaywall, requiredCredits);
65
26
 
66
27
  useEffect(() => {
67
- if (!isWaitingForAuthCreditsRef.current || !isCreditsLoaded || !pendingActionRef.current) {
68
- return;
69
- }
70
-
71
- isWaitingForAuthCreditsRef.current = false;
72
-
73
- if (hasSubscription || creditBalance >= requiredCredits) {
74
- const action = pendingActionRef.current;
28
+ if (shouldExecuteAuthAction(
29
+ isWaitingForAuthCreditsRef.current,
30
+ isCreditsLoaded,
31
+ !!pendingActionRef.current,
32
+ hasSubscription,
33
+ creditBalance,
34
+ requiredCredits
35
+ )) {
36
+ isWaitingForAuthCreditsRef.current = false;
37
+ const action = pendingActionRef.current!;
75
38
  pendingActionRef.current = null;
76
39
  action();
77
40
  return;
78
41
  }
79
42
 
80
- isWaitingForPurchaseRef.current = true;
81
- onShowPaywall(requiredCredits);
43
+ if (isWaitingForAuthCreditsRef.current && isCreditsLoaded && pendingActionRef.current) {
44
+ isWaitingForAuthCreditsRef.current = false;
45
+ isWaitingForPurchaseRef.current = true;
46
+ onShowPaywall(requiredCredits);
47
+ }
82
48
  }, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, onShowPaywall]);
83
49
 
84
50
  useEffect(() => {
85
- const prevBalance = prevCreditBalanceRef.current ?? 0;
86
- const creditsIncreased = creditBalance > prevBalance;
87
- const subscriptionAcquired = hasSubscription && !hasSubscriptionRef.current;
88
-
89
- if (isWaitingForPurchaseRef.current && (creditsIncreased || subscriptionAcquired) && pendingActionRef.current) {
90
- const action = pendingActionRef.current;
51
+ if (shouldExecutePurchaseAction(
52
+ isWaitingForPurchaseRef.current,
53
+ creditBalance,
54
+ prevCreditBalanceRef.current ?? 0,
55
+ hasSubscription,
56
+ hasSubscriptionRef.current,
57
+ !!pendingActionRef.current
58
+ )) {
59
+ const action = pendingActionRef.current!;
91
60
  pendingActionRef.current = null;
92
61
  isWaitingForPurchaseRef.current = false;
93
62
  action();
@@ -99,45 +68,28 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
99
68
 
100
69
  const requireFeature = useCallback(
101
70
  (action: () => void | Promise<void>) => {
102
- if (!isAuthenticated) {
103
- const postAuthAction = () => {
104
- pendingActionRef.current = action;
105
- isWaitingForAuthCreditsRef.current = true;
106
- };
107
- onShowAuthModal(postAuthAction);
108
- return;
109
- }
110
-
111
- // Use ref values to avoid stale closure
112
- const currentHasSubscription = hasSubscriptionRef.current;
113
- const currentBalance = creditBalanceRef.current;
114
- const currentRequiredCredits = requiredCreditsRef.current;
115
-
116
- if (currentHasSubscription) {
117
- action();
118
- return;
119
- }
120
-
121
- if (currentBalance < currentRequiredCredits) {
122
- pendingActionRef.current = action;
123
- isWaitingForPurchaseRef.current = true;
124
- onShowPaywallRef.current(currentRequiredCredits);
125
- return;
126
- }
127
-
128
- action();
71
+ executeFeatureAction(
72
+ action,
73
+ isAuthenticated,
74
+ onShowAuthModal,
75
+ hasSubscriptionRef,
76
+ creditBalanceRef,
77
+ requiredCreditsRef,
78
+ onShowPaywallRef,
79
+ pendingActionRef,
80
+ isWaitingForAuthCreditsRef,
81
+ isWaitingForPurchaseRef
82
+ );
129
83
  },
130
- [isAuthenticated, onShowAuthModal]
84
+ [isAuthenticated, onShowAuthModal, hasSubscriptionRef, creditBalanceRef, requiredCreditsRef, onShowPaywallRef]
131
85
  );
132
86
 
133
- const hasCredits = creditBalance >= requiredCredits;
134
-
135
87
  return {
136
88
  requireFeature,
137
89
  isAuthenticated,
138
90
  hasSubscription,
139
- hasCredits,
91
+ hasCredits: creditBalance >= requiredCredits,
140
92
  creditBalance,
141
- canAccess: isAuthenticated && (hasSubscription || hasCredits),
93
+ canAccess: isAuthenticated && (hasSubscription || creditBalance >= requiredCredits),
142
94
  };
143
95
  }
@@ -0,0 +1,18 @@
1
+ export interface UseFeatureGateParams {
2
+ readonly isAuthenticated: boolean;
3
+ readonly onShowAuthModal: (pendingCallback: () => void | Promise<void>) => void;
4
+ readonly hasSubscription?: boolean;
5
+ readonly creditBalance: number;
6
+ readonly requiredCredits?: number;
7
+ readonly onShowPaywall: (requiredCredits?: number) => void;
8
+ readonly isCreditsLoaded?: boolean;
9
+ }
10
+
11
+ export interface UseFeatureGateResult {
12
+ readonly requireFeature: (action: () => void | Promise<void>) => void;
13
+ readonly isAuthenticated: boolean;
14
+ readonly hasSubscription: boolean;
15
+ readonly hasCredits: boolean;
16
+ readonly creditBalance: number;
17
+ readonly canAccess: boolean;
18
+ }