@umituz/react-native-subscription 2.14.56 → 2.14.58

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.14.56",
3
+ "version": "2.14.58",
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",
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * PaywallContainer Component
3
- * Package-driven paywall that encapsulates all logic
4
- * Main app only needs to add this ONE component
3
+ * Package-driven paywall with mode-based filtering
4
+ * Mode: credits | subscription | hybrid
5
5
  */
6
6
 
7
- import React, { useCallback, useEffect } from "react";
7
+ import React, { useCallback, useEffect, useMemo } from "react";
8
8
  import type { PurchasesPackage } from "react-native-purchases";
9
9
  import { usePaywallVisibility } from "../../../presentation/hooks/usePaywallVisibility";
10
10
  import { useSubscriptionPackages } from "../../../revenuecat/presentation/hooks/useSubscriptionPackages";
11
11
  import { usePurchasePackage } from "../../../revenuecat/presentation/hooks/usePurchasePackage";
12
12
  import { useRestorePurchase } from "../../../revenuecat/presentation/hooks/useRestorePurchase";
13
13
  import { SubscriptionManager } from "../../../revenuecat/infrastructure/managers/SubscriptionManager";
14
+ import { filterPackagesByMode } from "../../../utils/packageFilter";
14
15
  import { PaywallModal } from "./PaywallModal";
15
16
  import type { PaywallContainerProps } from "./PaywallContainer.types";
16
17
 
@@ -25,28 +26,36 @@ export const PaywallContainer: React.FC<PaywallContainerProps> = ({
25
26
  heroImage,
26
27
  bestValueIdentifier,
27
28
  creditsLabel,
29
+ creditAmounts,
30
+ packageFilterConfig,
28
31
  onPurchaseSuccess,
29
32
  onPurchaseError,
30
33
  }) => {
31
34
  const { showPaywall, closePaywall } = usePaywallVisibility();
32
- const { data: packages = [], isLoading, isFetching, status, error } = useSubscriptionPackages(userId ?? undefined);
35
+ const { data: allPackages = [], isLoading, isFetching, status, error } = useSubscriptionPackages(userId ?? undefined);
33
36
  const { mutateAsync: purchasePackage } = usePurchasePackage(userId ?? undefined);
34
37
  const { mutateAsync: restorePurchases } = useRestorePurchase(userId ?? undefined);
35
38
 
39
+ const filteredPackages = useMemo(() => {
40
+ return filterPackagesByMode(allPackages, mode, packageFilterConfig);
41
+ }, [allPackages, mode, packageFilterConfig]);
42
+
36
43
  useEffect(() => {
37
44
  if (__DEV__ && showPaywall) {
38
45
  console.log("[PaywallContainer] Paywall opened:", {
39
46
  userId,
47
+ mode,
40
48
  isConfigured: SubscriptionManager.isConfigured(),
41
49
  isInitialized: SubscriptionManager.isInitialized(),
42
- packagesCount: packages.length,
50
+ allPackagesCount: allPackages.length,
51
+ filteredPackagesCount: filteredPackages.length,
43
52
  isLoading,
44
53
  isFetching,
45
54
  status,
46
55
  error: error?.message ?? null,
47
56
  });
48
57
  }
49
- }, [showPaywall, userId, packages.length, isLoading, isFetching, status, error]);
58
+ }, [showPaywall, userId, mode, allPackages.length, filteredPackages.length, isLoading, isFetching, status, error]);
50
59
 
51
60
  const handlePurchase = useCallback(
52
61
  async (pkg: PurchasesPackage) => {
@@ -62,15 +71,15 @@ export const PaywallContainer: React.FC<PaywallContainerProps> = ({
62
71
  onPurchaseSuccess?.();
63
72
  closePaywall();
64
73
  }
65
- } catch (error) {
66
- const message = error instanceof Error ? error.message : String(error);
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
67
76
  if (__DEV__) {
68
77
  console.error("[PaywallContainer] Purchase failed:", message);
69
78
  }
70
79
  onPurchaseError?.(message);
71
80
  }
72
81
  },
73
- [purchasePackage, closePaywall, onPurchaseSuccess, onPurchaseError],
82
+ [purchasePackage, closePaywall, onPurchaseSuccess, onPurchaseError]
74
83
  );
75
84
 
76
85
  const handleRestore = useCallback(async () => {
@@ -86,8 +95,8 @@ export const PaywallContainer: React.FC<PaywallContainerProps> = ({
86
95
  onPurchaseSuccess?.();
87
96
  closePaywall();
88
97
  }
89
- } catch (error) {
90
- const message = error instanceof Error ? error.message : String(error);
98
+ } catch (err) {
99
+ const message = err instanceof Error ? err.message : String(err);
91
100
  if (__DEV__) {
92
101
  console.error("[PaywallContainer] Restore failed:", message);
93
102
  }
@@ -103,16 +112,16 @@ export const PaywallContainer: React.FC<PaywallContainerProps> = ({
103
112
  <PaywallModal
104
113
  visible={showPaywall}
105
114
  onClose={closePaywall}
106
- mode={mode}
107
115
  translations={translations}
108
- subscriptionPackages={packages}
116
+ packages={filteredPackages}
109
117
  isLoading={isLoading}
110
118
  legalUrls={legalUrls}
111
119
  features={features ? [...features] : undefined}
112
120
  heroImage={heroImage}
113
121
  bestValueIdentifier={bestValueIdentifier}
114
122
  creditsLabel={creditsLabel}
115
- onSubscriptionPurchase={handlePurchase}
123
+ creditAmounts={creditAmounts}
124
+ onPurchase={handlePurchase}
116
125
  onRestore={handleRestore}
117
126
  />
118
127
  );
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * PaywallContainer Types
3
- * Minimal props for package-driven paywall rendering
3
+ * Props for package-driven paywall with mode-based filtering
4
4
  */
5
5
 
6
- import type { PaywallMode, PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../entities";
7
6
  import type { ImageSourcePropType } from "react-native";
7
+ import type { PaywallMode, PaywallTranslations, PaywallLegalUrls, SubscriptionFeature } from "../entities";
8
+ import type { PackageFilterConfig } from "../../../utils/packageFilter";
8
9
 
9
10
  export interface PaywallContainerProps {
10
11
  /** User ID for subscription management */
@@ -23,6 +24,10 @@ export interface PaywallContainerProps {
23
24
  readonly bestValueIdentifier?: string;
24
25
  /** Credits label text */
25
26
  readonly creditsLabel?: string;
27
+ /** Credit amounts per package identifier */
28
+ readonly creditAmounts?: Record<string, number>;
29
+ /** Custom filter config for package categorization */
30
+ readonly packageFilterConfig?: PackageFilterConfig;
26
31
  /** Callback when purchase succeeds */
27
32
  readonly onPurchaseSuccess?: () => void;
28
33
  /** Callback when purchase fails */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Paywall Modal
3
- * Modern paywall with responsive design and theme support
3
+ * Renders packages passed from PaywallContainer
4
+ * Filtering is handled by PaywallContainer based on mode
4
5
  */
5
6
 
6
7
  import React, { useState, useCallback } from "react";
@@ -9,221 +10,191 @@ import { BaseModal, useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner }
9
10
  import { Image } from "expo-image";
10
11
  import type { PurchasesPackage } from "react-native-purchases";
11
12
  import { PlanCard } from "./PlanCard";
12
- import { CreditCard } from "./CreditCard";
13
- import type { PaywallMode, CreditsPackage, SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from '../entities';
13
+ import type { SubscriptionFeature, PaywallTranslations, PaywallLegalUrls } from "../entities";
14
14
  import { paywallModalStyles as styles } from "./PaywallModal.styles";
15
15
 
16
16
  export interface PaywallModalProps {
17
- visible: boolean;
18
- onClose: () => void;
19
- mode: PaywallMode;
20
- translations: PaywallTranslations;
21
- subscriptionPackages?: PurchasesPackage[];
22
- creditsPackages?: CreditsPackage[];
23
- features?: SubscriptionFeature[];
24
- isLoading?: boolean;
25
- legalUrls?: PaywallLegalUrls;
26
- bestValueIdentifier?: string;
27
- creditAmounts?: Record<string, number>;
28
- creditsLabel?: string;
29
- /** Hero image source (require or uri) */
30
- heroImage?: ImageSourcePropType;
31
- onSubscriptionPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
32
- onCreditsPurchase?: (packageId: string) => Promise<void | boolean>;
33
- onRestore?: () => Promise<void | boolean>;
17
+ visible: boolean;
18
+ onClose: () => void;
19
+ translations: PaywallTranslations;
20
+ packages?: PurchasesPackage[];
21
+ features?: SubscriptionFeature[];
22
+ isLoading?: boolean;
23
+ legalUrls?: PaywallLegalUrls;
24
+ bestValueIdentifier?: string;
25
+ creditAmounts?: Record<string, number>;
26
+ creditsLabel?: string;
27
+ heroImage?: ImageSourcePropType;
28
+ onPurchase?: (pkg: PurchasesPackage) => Promise<void | boolean>;
29
+ onRestore?: () => Promise<void | boolean>;
34
30
  }
35
31
 
36
32
  export const PaywallModal: React.FC<PaywallModalProps> = React.memo((props) => {
37
- const {
38
- visible,
39
- onClose,
40
- mode,
41
- translations,
42
- subscriptionPackages = [],
43
- creditsPackages = [],
44
- features = [],
45
- isLoading = false,
46
- legalUrls = {},
47
- bestValueIdentifier,
48
- creditAmounts,
49
- creditsLabel,
50
- heroImage,
51
- onSubscriptionPurchase,
52
- onCreditsPurchase,
53
- onRestore,
54
- } = props;
55
-
56
- const tokens = useAppDesignTokens();
57
- const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
58
- const [selectedCreditId, setSelectedCreditId] = useState<string | null>(null);
59
- const [isProcessing, setIsProcessing] = useState(false);
60
-
61
- const showCredits = mode === "credits";
62
- const showSubscription = mode === "subscription" || mode === "hybrid";
63
-
64
- const handlePurchase = useCallback(async () => {
65
- setIsProcessing(true);
66
- try {
67
- if (showSubscription && selectedPlanId && onSubscriptionPurchase) {
68
- const pkg = subscriptionPackages.find((p) => p.product.identifier === selectedPlanId);
69
- if (pkg) await onSubscriptionPurchase(pkg);
70
- } else if (showCredits && selectedCreditId && onCreditsPurchase) {
71
- await onCreditsPurchase(selectedCreditId);
72
- }
73
- } finally {
74
- setIsProcessing(false);
75
- }
76
- }, [showSubscription, showCredits, selectedPlanId, selectedCreditId, subscriptionPackages, onSubscriptionPurchase, onCreditsPurchase]);
77
-
78
- const handleRestore = useCallback(async () => {
79
- if (!onRestore || isProcessing) return;
80
- setIsProcessing(true);
81
- try {
82
- await onRestore();
83
- } finally {
84
- setIsProcessing(false);
85
- }
86
- }, [onRestore, isProcessing]);
87
-
88
- const handleLegalUrl = useCallback(async (url: string | undefined) => {
89
- if (!url) return;
90
- try {
91
- const supported = await Linking.canOpenURL(url);
92
- if (supported) {
93
- await Linking.openURL(url);
94
- }
95
- } catch {
96
- // Silent fail
97
- }
98
- }, []);
99
-
100
- const isPurchaseDisabled = showSubscription ? !selectedPlanId : !selectedCreditId;
101
-
102
- return (
103
- <BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
104
- <View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
105
- <TouchableOpacity
106
- onPress={onClose}
107
- style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
108
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
109
- >
110
- <AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
111
- </TouchableOpacity>
33
+ const {
34
+ visible,
35
+ onClose,
36
+ translations,
37
+ packages = [],
38
+ features = [],
39
+ isLoading = false,
40
+ legalUrls = {},
41
+ bestValueIdentifier,
42
+ creditAmounts,
43
+ creditsLabel,
44
+ heroImage,
45
+ onPurchase,
46
+ onRestore,
47
+ } = props;
48
+
49
+ const tokens = useAppDesignTokens();
50
+ const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
51
+ const [isProcessing, setIsProcessing] = useState(false);
52
+
53
+ const handlePurchase = useCallback(async () => {
54
+ if (!selectedPlanId || !onPurchase) return;
55
+ setIsProcessing(true);
56
+ try {
57
+ const pkg = packages.find((p) => p.product.identifier === selectedPlanId);
58
+ if (pkg) await onPurchase(pkg);
59
+ } finally {
60
+ setIsProcessing(false);
61
+ }
62
+ }, [selectedPlanId, packages, onPurchase]);
63
+
64
+ const handleRestore = useCallback(async () => {
65
+ if (!onRestore || isProcessing) return;
66
+ setIsProcessing(true);
67
+ try {
68
+ await onRestore();
69
+ } finally {
70
+ setIsProcessing(false);
71
+ }
72
+ }, [onRestore, isProcessing]);
73
+
74
+ const handleLegalUrl = useCallback(async (url: string | undefined) => {
75
+ if (!url) return;
76
+ try {
77
+ const supported = await Linking.canOpenURL(url);
78
+ if (supported) await Linking.openURL(url);
79
+ } catch {
80
+ // Silent fail
81
+ }
82
+ }, []);
83
+
84
+ const isPurchaseDisabled = !selectedPlanId;
85
+
86
+ return (
87
+ <BaseModal visible={visible} onClose={onClose} contentStyle={styles.modalContent}>
88
+ <View style={[styles.container, { backgroundColor: tokens.colors.surface }]}>
89
+ <TouchableOpacity
90
+ onPress={onClose}
91
+ style={[styles.closeBtn, { backgroundColor: tokens.colors.surfaceSecondary }]}
92
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
93
+ >
94
+ <AtomicIcon name="close-outline" size="md" customColor={tokens.colors.textPrimary} />
95
+ </TouchableOpacity>
96
+
97
+ <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
98
+ {heroImage && (
99
+ <View style={styles.heroContainer}>
100
+ <Image source={heroImage} style={styles.heroImage} contentFit="cover" transition={300} />
101
+ </View>
102
+ )}
103
+
104
+ <View style={styles.header}>
105
+ <AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>
106
+ {translations.title}
107
+ </AtomicText>
108
+ {translations.subtitle && (
109
+ <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
110
+ {translations.subtitle}
111
+ </AtomicText>
112
+ )}
113
+ </View>
114
+
115
+ {features.length > 0 && (
116
+ <View style={[styles.features, { backgroundColor: tokens.colors.surfaceSecondary }]}>
117
+ {features.map((feature, idx) => (
118
+ <View key={idx} style={styles.featureRow}>
119
+ <View style={[styles.featureIcon, { backgroundColor: tokens.colors.primaryLight }]}>
120
+ <AtomicIcon name={feature.icon} customSize={16} customColor={tokens.colors.primary} />
121
+ </View>
122
+ <AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
123
+ {feature.text}
124
+ </AtomicText>
125
+ </View>
126
+ ))}
127
+ </View>
128
+ )}
112
129
 
113
- <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
114
- {heroImage && (
115
- <View style={styles.heroContainer}>
116
- <Image
117
- source={heroImage}
118
- style={styles.heroImage}
119
- contentFit="cover"
120
- transition={300}
121
- />
122
- </View>
123
- )}
124
- <View style={styles.header}>
125
- <AtomicText type="headlineMedium" style={[styles.title, { color: tokens.colors.textPrimary }]}>
126
- {translations.title}
127
- </AtomicText>
128
- {translations.subtitle && (
129
- <AtomicText type="bodyMedium" style={[styles.subtitle, { color: tokens.colors.textSecondary }]}>
130
- {translations.subtitle}
131
- </AtomicText>
132
- )}
133
- </View>
134
-
135
- {features.length > 0 && (
136
- <View style={[styles.features, { backgroundColor: tokens.colors.surfaceSecondary }]}>
137
- {features.map((feature, idx) => (
138
- <View key={idx} style={styles.featureRow}>
139
- <View style={[styles.featureIcon, { backgroundColor: tokens.colors.primaryLight }]}>
140
- <AtomicIcon name={feature.icon} customSize={16} customColor={tokens.colors.primary} />
141
- </View>
142
- <AtomicText type="bodyMedium" style={[styles.featureText, { color: tokens.colors.textPrimary }]}>
143
- {feature.text}
144
- </AtomicText>
145
- </View>
146
- ))}
147
- </View>
148
- )}
149
-
150
- {isLoading ? (
151
- <View style={styles.loading}>
152
- <AtomicSpinner
153
- size="lg"
154
- color="primary"
155
- text={translations.loadingText}
156
- />
157
- </View>
158
- ) : (
159
- <View style={styles.plans}>
160
- {showSubscription &&
161
- subscriptionPackages.map((pkg) => (
162
- <PlanCard
163
- key={pkg.product.identifier}
164
- pkg={pkg}
165
- isSelected={selectedPlanId === pkg.product.identifier}
166
- onSelect={() => setSelectedPlanId(pkg.product.identifier)}
167
- badge={pkg.product.identifier === bestValueIdentifier ? translations.bestValueBadgeText : undefined}
168
- creditAmount={creditAmounts?.[pkg.product.identifier]}
169
- creditsLabel={creditsLabel}
170
- />
171
- ))}
172
- {showCredits &&
173
- creditsPackages.map((pkg) => (
174
- <CreditCard key={pkg.id} pkg={pkg} isSelected={selectedCreditId === pkg.id} onSelect={() => setSelectedCreditId(pkg.id)} />
175
- ))}
176
- </View>
177
- )}
178
-
179
- <TouchableOpacity
180
- onPress={handlePurchase}
181
- disabled={isPurchaseDisabled || isProcessing}
182
- style={[styles.cta, { backgroundColor: tokens.colors.primary }, (isPurchaseDisabled || isProcessing) && styles.ctaDisabled]}
183
- activeOpacity={0.8}
184
- >
185
- <AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
186
- {isProcessing
187
- ? translations.processingText
188
- : showSubscription
189
- ? (translations.subscribeButtonText || translations.purchaseButtonText)
190
- : translations.purchaseButtonText}
191
- </AtomicText>
192
- </TouchableOpacity>
193
-
194
- <View style={styles.footer}>
195
- {onRestore && (
196
- <TouchableOpacity
197
- onPress={handleRestore}
198
- disabled={isProcessing}
199
- style={[styles.restoreButton, isProcessing && styles.restoreButtonDisabled]}
200
- >
201
- <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
202
- {isProcessing ? translations.processingText : translations.restoreButtonText}
203
- </AtomicText>
204
- </TouchableOpacity>
205
- )}
206
- <View style={styles.legalRow}>
207
- {legalUrls.termsUrl && (
208
- <TouchableOpacity onPress={() => handleLegalUrl(legalUrls.termsUrl)}>
209
- <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
210
- {translations.termsOfServiceText}
211
- </AtomicText>
212
- </TouchableOpacity>
213
- )}
214
- {legalUrls.privacyUrl && (
215
- <TouchableOpacity onPress={() => handleLegalUrl(legalUrls.privacyUrl)}>
216
- <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
217
- {translations.privacyText}
218
- </AtomicText>
219
- </TouchableOpacity>
220
- )}
221
- </View>
222
- </View>
223
- </ScrollView>
130
+ {isLoading ? (
131
+ <View style={styles.loading}>
132
+ <AtomicSpinner size="lg" color="primary" text={translations.loadingText} />
133
+ </View>
134
+ ) : (
135
+ <View style={styles.plans}>
136
+ {packages.map((pkg) => (
137
+ <PlanCard
138
+ key={pkg.product.identifier}
139
+ pkg={pkg}
140
+ isSelected={selectedPlanId === pkg.product.identifier}
141
+ onSelect={() => setSelectedPlanId(pkg.product.identifier)}
142
+ badge={pkg.product.identifier === bestValueIdentifier ? translations.bestValueBadgeText : undefined}
143
+ creditAmount={creditAmounts?.[pkg.product.identifier]}
144
+ creditsLabel={creditsLabel}
145
+ />
146
+ ))}
147
+ </View>
148
+ )}
149
+
150
+ <TouchableOpacity
151
+ onPress={handlePurchase}
152
+ disabled={isPurchaseDisabled || isProcessing}
153
+ style={[
154
+ styles.cta,
155
+ { backgroundColor: tokens.colors.primary },
156
+ (isPurchaseDisabled || isProcessing) && styles.ctaDisabled,
157
+ ]}
158
+ activeOpacity={0.8}
159
+ >
160
+ <AtomicText type="titleLarge" style={[styles.ctaText, { color: tokens.colors.onPrimary }]}>
161
+ {isProcessing ? translations.processingText : translations.purchaseButtonText}
162
+ </AtomicText>
163
+ </TouchableOpacity>
164
+
165
+ <View style={styles.footer}>
166
+ {onRestore && (
167
+ <TouchableOpacity
168
+ onPress={handleRestore}
169
+ disabled={isProcessing}
170
+ style={[styles.restoreButton, isProcessing && styles.restoreButtonDisabled]}
171
+ >
172
+ <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
173
+ {isProcessing ? translations.processingText : translations.restoreButtonText}
174
+ </AtomicText>
175
+ </TouchableOpacity>
176
+ )}
177
+ <View style={styles.legalRow}>
178
+ {legalUrls.termsUrl && (
179
+ <TouchableOpacity onPress={() => handleLegalUrl(legalUrls.termsUrl)}>
180
+ <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
181
+ {translations.termsOfServiceText}
182
+ </AtomicText>
183
+ </TouchableOpacity>
184
+ )}
185
+ {legalUrls.privacyUrl && (
186
+ <TouchableOpacity onPress={() => handleLegalUrl(legalUrls.privacyUrl)}>
187
+ <AtomicText type="bodySmall" style={[styles.footerLink, { color: tokens.colors.textSecondary }]}>
188
+ {translations.privacyText}
189
+ </AtomicText>
190
+ </TouchableOpacity>
191
+ )}
224
192
  </View>
225
- </BaseModal>
226
- );
193
+ </View>
194
+ </ScrollView>
195
+ </View>
196
+ </BaseModal>
197
+ );
227
198
  });
228
199
 
229
200
  PaywallModal.displayName = "PaywallModal";
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  export {
34
34
  initializeSubscription,
35
35
  type SubscriptionInitConfig,
36
+ type CreditPackageConfig,
36
37
  } from "./infrastructure/services/SubscriptionInitializer";
37
38
 
38
39
  export { useSubscription } from "./presentation/hooks/useSubscription";
@@ -376,6 +377,14 @@ export {
376
377
  type SubscriptionPackageType,
377
378
  } from "./utils/packageTypeDetector";
378
379
 
380
+ export {
381
+ filterPackagesByMode,
382
+ separatePackages,
383
+ getPackageCategory,
384
+ type PackageCategory,
385
+ type PackageFilterConfig,
386
+ } from "./utils/packageFilter";
387
+
379
388
  export {
380
389
  getCreditAllocation,
381
390
  getImageCreditsForPackage,
@@ -4,8 +4,9 @@
4
4
  * Apps just call initializeSubscription with config
5
5
  */
6
6
 
7
+ import type { CustomerInfo } from "react-native-purchases";
7
8
  import type { CreditsConfig } from "../../domain/entities/Credits";
8
- import { configureCreditsRepository } from "../repositories/CreditsRepositoryProvider";
9
+ import { configureCreditsRepository, getCreditsRepository } from "../repositories/CreditsRepositoryProvider";
9
10
  import { SubscriptionManager } from "../../revenuecat/infrastructure/managers/SubscriptionManager";
10
11
  import { configureAuthProvider } from "../../presentation/hooks/useAuthAwarePurchase";
11
12
 
@@ -14,6 +15,13 @@ export interface FirebaseAuthLike {
14
15
  onAuthStateChanged: (callback: (user: { uid: string; isAnonymous: boolean } | null) => void) => () => void;
15
16
  }
16
17
 
18
+ export interface CreditPackageConfig {
19
+ /** Identifier pattern to match credit packages (e.g., "credit") */
20
+ identifierPattern?: string;
21
+ /** Map of productId to credit amounts */
22
+ amounts?: Record<string, number>;
23
+ }
24
+
17
25
  export interface SubscriptionInitConfig {
18
26
  apiKey: string;
19
27
  testStoreKey?: string;
@@ -24,6 +32,8 @@ export interface SubscriptionInitConfig {
24
32
  showAuthModal: () => void;
25
33
  onCreditsUpdated?: (userId: string) => void;
26
34
  onCreditRenewal?: (userId: string, productId: string, renewalId: string) => Promise<void>;
35
+ /** Credit package configuration for consumable purchases */
36
+ creditPackages?: CreditPackageConfig;
27
37
  timeoutMs?: number;
28
38
  authStateTimeoutMs?: number;
29
39
  }
@@ -59,6 +69,15 @@ const waitForAuthState = async (
59
69
  });
60
70
  };
61
71
 
72
+ /**
73
+ * Check if a product is a credit package
74
+ */
75
+ const isCreditPackage = (productId: string, pattern?: string): boolean => {
76
+ const patternToUse = pattern || "credit";
77
+ return productId.toLowerCase().includes(patternToUse.toLowerCase());
78
+ };
79
+
80
+
62
81
  export const initializeSubscription = async (
63
82
  config: SubscriptionInitConfig,
64
83
  ): Promise<void> => {
@@ -72,6 +91,7 @@ export const initializeSubscription = async (
72
91
  showAuthModal,
73
92
  onCreditsUpdated,
74
93
  onCreditRenewal,
94
+ creditPackages,
75
95
  timeoutMs = 10000,
76
96
  authStateTimeoutMs = 2000,
77
97
  } = config;
@@ -82,13 +102,62 @@ export const initializeSubscription = async (
82
102
 
83
103
  configureCreditsRepository(credits);
84
104
 
105
+ // Build consumable product identifiers from credit package pattern
106
+ const consumableIdentifiers: string[] = [];
107
+ if (creditPackages?.identifierPattern) {
108
+ consumableIdentifiers.push(creditPackages.identifierPattern);
109
+ } else {
110
+ consumableIdentifiers.push("credit");
111
+ }
112
+
113
+ // Create onPurchaseCompleted handler for credit packages
114
+ const handlePurchaseCompleted = async (
115
+ userId: string,
116
+ productId: string,
117
+ _customerInfo: CustomerInfo
118
+ ): Promise<void> => {
119
+ const isCredit = isCreditPackage(productId, creditPackages?.identifierPattern);
120
+
121
+ if (!isCredit) {
122
+ return;
123
+ }
124
+
125
+ try {
126
+ const repository = getCreditsRepository();
127
+
128
+ // Create a unique purchase ID to prevent duplicate credit additions
129
+ const purchaseId = `purchase_${productId}_${Date.now()}`;
130
+
131
+ await repository.initializeCredits(userId, purchaseId, productId);
132
+
133
+ if (__DEV__) {
134
+ console.log("[SubscriptionInitializer] Credits added for purchase:", {
135
+ userId,
136
+ productId,
137
+ purchaseId,
138
+ });
139
+ }
140
+
141
+ // Notify about credits update for cache invalidation
142
+ if (onCreditsUpdated) {
143
+ onCreditsUpdated(userId);
144
+ }
145
+ } catch (error) {
146
+ if (__DEV__) {
147
+ console.error("[SubscriptionInitializer] Failed to add credits:", error);
148
+ }
149
+ }
150
+ };
151
+
85
152
  SubscriptionManager.configure({
86
153
  config: {
87
154
  apiKey,
88
155
  testStoreKey,
89
156
  entitlementIdentifier: entitlementId,
157
+ consumableProductIdentifiers: consumableIdentifiers,
90
158
  onCreditRenewal,
91
159
  onCreditsUpdated,
160
+ onPurchaseCompleted: handlePurchaseCompleted,
92
161
  },
93
162
  apiKey,
94
163
  getAnonymousUserId,
@@ -8,8 +8,6 @@
8
8
  import { useQuery } from "@tanstack/react-query";
9
9
  import { useCallback, useMemo } from "react";
10
10
  import type { UserCredits, CreditType } from "../../domain/entities/Credits";
11
-
12
- declare const __DEV__: boolean;
13
11
  import {
14
12
  getCreditsRepository,
15
13
  getCreditsConfig,
@@ -8,8 +8,6 @@
8
8
  import { useQuery } from "@tanstack/react-query";
9
9
  import { SubscriptionManager } from "../../revenuecat/infrastructure/managers/SubscriptionManager";
10
10
 
11
- declare const __DEV__: boolean;
12
-
13
11
  export const subscriptionStatusQueryKeys = {
14
12
  all: ["subscriptionStatus"] as const,
15
13
  user: (userId: string) => ["subscriptionStatus", userId] as const,
@@ -54,6 +54,12 @@ export async function handlePurchase(
54
54
  const customerInfo = purchaseResult.customerInfo;
55
55
 
56
56
  if (isConsumable) {
57
+ await notifyPurchaseCompleted(
58
+ deps.config,
59
+ userId,
60
+ pkg.product.identifier,
61
+ customerInfo
62
+ );
57
63
  return {
58
64
  success: true,
59
65
  isPremium: false,
@@ -60,29 +60,25 @@ export class RevenueCatService implements IRevenueCatService {
60
60
  };
61
61
  }
62
62
 
63
- try {
64
- const result = await initializeSDK(
65
- {
66
- config: this.stateManager.getConfig(),
67
- isUsingTestStore: () => this.isUsingTestStore(),
68
- isInitialized: () => this.isInitialized(),
69
- getCurrentUserId: () => this.stateManager.getCurrentUserId(),
70
- setInitialized: (value) => this.stateManager.setInitialized(value),
71
- setCurrentUserId: (id) => this.stateManager.setCurrentUserId(id),
72
- },
73
- userId,
74
- apiKey
75
- );
76
-
77
- if (result.success) {
78
- this.listenerManager.setUserId(userId);
79
- this.listenerManager.setupListener(this.stateManager.getConfig());
80
- }
81
-
82
- return result;
83
- } catch (error) {
84
- throw error;
63
+ const result = await initializeSDK(
64
+ {
65
+ config: this.stateManager.getConfig(),
66
+ isUsingTestStore: () => this.isUsingTestStore(),
67
+ isInitialized: () => this.isInitialized(),
68
+ getCurrentUserId: () => this.stateManager.getCurrentUserId(),
69
+ setInitialized: (value) => this.stateManager.setInitialized(value),
70
+ setCurrentUserId: (id) => this.stateManager.setCurrentUserId(id),
71
+ },
72
+ userId,
73
+ apiKey
74
+ );
75
+
76
+ if (result.success) {
77
+ this.listenerManager.setUserId(userId);
78
+ this.listenerManager.setupListener(this.stateManager.getConfig());
85
79
  }
80
+
81
+ return result;
86
82
  }
87
83
 
88
84
  async fetchOfferings(): Promise<PurchasesOffering | null> {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Package Filter Utility
3
+ * Filters RevenueCat packages by type (credits vs subscription)
4
+ */
5
+
6
+ import type { PurchasesPackage } from "react-native-purchases";
7
+
8
+ declare const __DEV__: boolean;
9
+
10
+ export type PackageCategory = "credits" | "subscription";
11
+
12
+ export interface PackageFilterConfig {
13
+ creditIdentifierPattern?: RegExp;
14
+ subscriptionIdentifierPattern?: RegExp;
15
+ }
16
+
17
+ const DEFAULT_CONFIG: PackageFilterConfig = {
18
+ creditIdentifierPattern: /credit/i,
19
+ subscriptionIdentifierPattern: /(monthly|yearly|annual|weekly|premium|subscription)/i,
20
+ };
21
+
22
+ export function getPackageCategory(
23
+ pkg: PurchasesPackage,
24
+ config: PackageFilterConfig = DEFAULT_CONFIG
25
+ ): PackageCategory {
26
+ const identifier = pkg.identifier.toLowerCase();
27
+ const productIdentifier = pkg.product.identifier.toLowerCase();
28
+
29
+ const isCreditPackage =
30
+ config.creditIdentifierPattern?.test(identifier) ||
31
+ config.creditIdentifierPattern?.test(productIdentifier);
32
+
33
+ if (isCreditPackage) {
34
+ if (__DEV__) {
35
+ console.log("[PackageFilter] Credit package:", identifier);
36
+ }
37
+ return "credits";
38
+ }
39
+
40
+ if (__DEV__) {
41
+ console.log("[PackageFilter] Subscription package:", identifier);
42
+ }
43
+ return "subscription";
44
+ }
45
+
46
+ export function filterPackagesByMode(
47
+ packages: PurchasesPackage[],
48
+ mode: "credits" | "subscription" | "hybrid",
49
+ config: PackageFilterConfig = DEFAULT_CONFIG
50
+ ): PurchasesPackage[] {
51
+ if (mode === "hybrid") {
52
+ if (__DEV__) {
53
+ console.log("[PackageFilter] Hybrid mode - returning all packages:", packages.length);
54
+ }
55
+ return packages;
56
+ }
57
+
58
+ const filtered = packages.filter((pkg) => {
59
+ const category = getPackageCategory(pkg, config);
60
+ return category === mode;
61
+ });
62
+
63
+ if (__DEV__) {
64
+ console.log(`[PackageFilter] Mode: ${mode}, Filtered: ${filtered.length}/${packages.length}`);
65
+ }
66
+
67
+ return filtered;
68
+ }
69
+
70
+ export function separatePackages(
71
+ packages: PurchasesPackage[],
72
+ config: PackageFilterConfig = DEFAULT_CONFIG
73
+ ): { creditPackages: PurchasesPackage[]; subscriptionPackages: PurchasesPackage[] } {
74
+ const creditPackages: PurchasesPackage[] = [];
75
+ const subscriptionPackages: PurchasesPackage[] = [];
76
+
77
+ for (const pkg of packages) {
78
+ const category = getPackageCategory(pkg, config);
79
+ if (category === "credits") {
80
+ creditPackages.push(pkg);
81
+ } else {
82
+ subscriptionPackages.push(pkg);
83
+ }
84
+ }
85
+
86
+ if (__DEV__) {
87
+ console.log("[PackageFilter] Separated:", {
88
+ credits: creditPackages.length,
89
+ subscriptions: subscriptionPackages.length,
90
+ });
91
+ }
92
+
93
+ return { creditPackages, subscriptionPackages };
94
+ }