@umituz/react-native-subscription 2.43.1 → 2.43.3

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 (21) hide show
  1. package/package.json +1 -1
  2. package/src/domains/paywall/components/PaywallScreen.tsx +0 -1
  3. package/src/domains/paywall/hooks/usePaywallOrchestrator.ts +0 -4
  4. package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +2 -2
  5. package/src/domains/subscription/core/events/FlowEvents.ts +24 -0
  6. package/src/domains/subscription/core/events/SubscriptionEvents.ts +22 -0
  7. package/src/domains/subscription/infrastructure/hooks/usePurchasePackage.ts +2 -14
  8. package/src/domains/subscription/infrastructure/hooks/useRestorePurchase.ts +2 -14
  9. package/src/domains/subscription/presentation/featureGateActions.ts +27 -24
  10. package/src/domains/subscription/presentation/hooks/useFeatureGateState.ts +76 -0
  11. package/src/domains/subscription/presentation/useAuthAwarePurchase.ts +11 -17
  12. package/src/domains/subscription/presentation/useFeatureGate.ts +32 -48
  13. package/src/domains/subscription/presentation/usePremium.ts +1 -6
  14. package/src/domains/subscription/presentation/utils/authCheckUtils.ts +42 -0
  15. package/src/index.ts +15 -4
  16. package/src/shared/infrastructure/SubscriptionEventBus.ts +6 -18
  17. package/src/shared/infrastructure/react-query/queryConfig.ts +0 -3
  18. package/src/shared/infrastructure/react-query/utils/cacheInvalidation.ts +97 -0
  19. package/src/shared/infrastructure/react-query/utils/index.ts +6 -0
  20. package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.tsx +0 -60
  21. package/src/domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay.types.ts +0 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.43.1",
3
+ "version": "2.43.3",
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,6 @@
2
2
  * Paywall Screen Component
3
3
  *
4
4
  * Full-screen paywall with optimized FlatList for performance and modern design.
5
- * This is a "dumb" component that receives all data and actions via props.
6
5
  */
7
6
 
8
7
  import React, { useCallback, useEffect, useMemo } from "react";
@@ -22,9 +22,6 @@ export interface PaywallOrchestratorOptions {
22
22
  * High-level orchestrator for Paywall navigation.
23
23
  * Handles automatic triggers (post-onboarding) and manual triggers (showPaywall state).
24
24
  * Centralizes handlers for success, close, and feedback triggers.
25
- *
26
- * This orchestrator fetches all subscription data and passes it to PaywallScreen as props.
27
- * PaywallScreen is now a "dumb" component that doesn't call usePremium internally.
28
25
  */
29
26
  export function usePaywallOrchestrator({
30
27
  navigation,
@@ -86,7 +83,6 @@ export function usePaywallOrchestrator({
86
83
  packagesCount: packages.length
87
84
  });
88
85
 
89
- // Pass all data and actions as props - PaywallScreen is now a dumb component
90
86
  navigation.navigate("PaywallScreen", {
91
87
  // UI Props
92
88
  translations,
@@ -1,9 +1,9 @@
1
1
  import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
2
- import type { PremiumStatusChangedEvent, PurchaseCompletedEvent, RenewalDetectedEvent } from "../core/SubscriptionEvents";
2
+ import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
3
+ import type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "../core/SubscriptionEvents";
3
4
  import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
4
5
  import { extractRevenueCatData } from "./SubscriptionSyncUtils";
5
6
  import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
6
- import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../shared/infrastructure/SubscriptionEventBus";
7
7
 
8
8
  /**
9
9
  * Central processor for all subscription sync operations.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Application flow events
3
+ * Events emitted during high-level application flow transitions
4
+ */
5
+
6
+ export const FLOW_EVENTS = {
7
+ ONBOARDING_COMPLETED: "flow_onboarding_completed",
8
+ PAYWALL_SHOWN: "flow_paywall_shown",
9
+ PAYWALL_CLOSED: "flow_paywall_closed",
10
+ } as const;
11
+
12
+ export type FlowEventType = typeof FLOW_EVENTS[keyof typeof FLOW_EVENTS];
13
+
14
+ export interface OnboardingCompletedEvent {
15
+ timestamp: number;
16
+ }
17
+
18
+ export interface PaywallShownEvent {
19
+ timestamp: number;
20
+ }
21
+
22
+ export interface PaywallClosedEvent {
23
+ timestamp: number;
24
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Subscription-related events
3
+ * Events emitted during subscription lifecycle operations
4
+ */
5
+
6
+ export const SUBSCRIPTION_EVENTS = {
7
+ CREDITS_UPDATED: "credits_updated",
8
+ PURCHASE_COMPLETED: "purchase_completed",
9
+ RENEWAL_DETECTED: "renewal_detected",
10
+ PREMIUM_STATUS_CHANGED: "premium_status_changed",
11
+ SYNC_STATUS_CHANGED: "sync_status_changed",
12
+ } as const;
13
+
14
+ export type SubscriptionEventType = typeof SUBSCRIPTION_EVENTS[keyof typeof SUBSCRIPTION_EVENTS];
15
+
16
+ export interface SyncStatusChangedEvent {
17
+ status: 'syncing' | 'success' | 'error';
18
+ phase: 'purchase' | 'renewal';
19
+ userId?: string;
20
+ productId?: string;
21
+ error?: string;
22
+ }
@@ -6,10 +6,8 @@ import {
6
6
  selectUserId,
7
7
  } from "@umituz/react-native-auth";
8
8
  import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
9
- import { SUBSCRIPTION_QUERY_KEYS } from "./subscriptionQueryKeys";
10
- import { subscriptionStatusQueryKeys } from "../../presentation/useSubscriptionStatus";
11
- import { creditsQueryKeys } from "../../../credits/presentation/creditsQueryKeys";
12
9
  import { getErrorMessage } from "../../../revenuecat/core/errors/RevenueCatErrorHandler";
10
+ import { invalidateSubscriptionCaches } from "../../../../shared/infrastructure/react-query/utils";
13
11
 
14
12
  interface PurchaseMutationResult {
15
13
  success: boolean;
@@ -39,17 +37,7 @@ export const usePurchasePackage = () => {
39
37
  onSuccess: (result) => {
40
38
  if (result.success) {
41
39
  showSuccess("Purchase Successful", "Your subscription is now active!");
42
- queryClient.invalidateQueries({
43
- queryKey: SUBSCRIPTION_QUERY_KEYS.packages,
44
- });
45
- if (userId) {
46
- queryClient.invalidateQueries({
47
- queryKey: subscriptionStatusQueryKeys.user(userId),
48
- });
49
- queryClient.invalidateQueries({
50
- queryKey: creditsQueryKeys.user(userId),
51
- });
52
- }
40
+ invalidateSubscriptionCaches(queryClient, userId);
53
41
  } else {
54
42
  showError("Purchase Failed", "Unable to complete purchase. Please try again.");
55
43
  }
@@ -5,10 +5,8 @@ import {
5
5
  selectUserId,
6
6
  } from "@umituz/react-native-auth";
7
7
  import { SubscriptionManager } from "../../infrastructure/managers/SubscriptionManager";
8
- import { SUBSCRIPTION_QUERY_KEYS } from "./subscriptionQueryKeys";
9
- import { subscriptionStatusQueryKeys } from "../../presentation/useSubscriptionStatus";
10
- import { creditsQueryKeys } from "../../../credits/presentation/creditsQueryKeys";
11
8
  import { getErrorMessage } from "../../../revenuecat/core/errors/RevenueCatErrorHandler";
9
+ import { invalidateSubscriptionCaches } from "../../../../shared/infrastructure/react-query/utils";
12
10
 
13
11
  interface RestoreResult {
14
12
  success: boolean;
@@ -31,17 +29,7 @@ export const useRestorePurchase = () => {
31
29
  },
32
30
  onSuccess: (result) => {
33
31
  if (result.success) {
34
- queryClient.invalidateQueries({
35
- queryKey: SUBSCRIPTION_QUERY_KEYS.packages,
36
- });
37
- if (userId) {
38
- queryClient.invalidateQueries({
39
- queryKey: subscriptionStatusQueryKeys.user(userId),
40
- });
41
- queryClient.invalidateQueries({
42
- queryKey: creditsQueryKeys.user(userId),
43
- });
44
- }
32
+ invalidateSubscriptionCaches(queryClient, userId);
45
33
 
46
34
  if (result.productId) {
47
35
  showSuccess("Restore Successful", "Your subscription has been restored!");
@@ -1,50 +1,53 @@
1
- import type { MutableRefObject } from "react";
1
+ import type { FeatureGateState } from "./hooks/useFeatureGateState";
2
2
 
3
- export const executeFeatureAction = (
3
+ /**
4
+ * Executes a feature action with proper auth, subscription, and credit checks.
5
+ * Queues the action if waiting for auth completion or purchase flow.
6
+ *
7
+ * @param action - The action to execute when conditions are met
8
+ * @param isAuthenticated - Whether user is authenticated
9
+ * @param onShowAuthModal - Callback to show auth modal
10
+ * @param state - Feature gate state containing all refs and flags
11
+ * @returns true if action was executed immediately, false if queued/pending
12
+ */
13
+ export function executeFeatureAction(
4
14
  action: () => void | Promise<void>,
5
15
  isAuthenticated: boolean,
6
16
  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
- isCreditsLoadedRef: MutableRefObject<boolean>
15
- ): boolean => {
17
+ state: FeatureGateState
18
+ ): boolean {
16
19
 
17
20
  if (!isAuthenticated) {
18
21
  const postAuthAction = () => {
19
- if (hasSubscriptionRef.current || creditBalanceRef.current >= requiredCreditsRef.current) {
22
+ if (state.hasSubscriptionRef.current || state.creditBalanceRef.current >= state.requiredCreditsRef.current) {
20
23
  action();
21
24
  return;
22
25
  }
23
26
 
24
- if (isCreditsLoadedRef.current) {
25
- pendingActionRef.current = action;
26
- isWaitingForPurchaseRef.current = true;
27
- onShowPaywallRef.current(requiredCreditsRef.current);
27
+ if (state.isCreditsLoadedRef.current) {
28
+ state.pendingActionRef.current = action;
29
+ state.isWaitingForPurchaseRef.current = true;
30
+ state.onShowPaywallRef.current(state.requiredCreditsRef.current);
28
31
  return;
29
32
  }
30
- pendingActionRef.current = action;
31
- isWaitingForAuthCreditsRef.current = true;
33
+ state.pendingActionRef.current = action;
34
+ state.isWaitingForAuthCreditsRef.current = true;
32
35
  };
33
36
  onShowAuthModal(postAuthAction);
34
37
  return false;
35
38
  }
36
39
 
37
- if (hasSubscriptionRef.current) {
40
+ if (state.hasSubscriptionRef.current) {
38
41
  action();
39
42
  return true;
40
43
  }
41
44
 
42
- if (creditBalanceRef.current < requiredCreditsRef.current) {
43
- pendingActionRef.current = action;
44
- isWaitingForPurchaseRef.current = true;
45
- onShowPaywallRef.current(requiredCreditsRef.current);
45
+ if (state.creditBalanceRef.current < state.requiredCreditsRef.current) {
46
+ state.pendingActionRef.current = action;
47
+ state.isWaitingForPurchaseRef.current = true;
48
+ state.onShowPaywallRef.current(state.requiredCreditsRef.current);
46
49
  return false;
47
50
  }
48
51
  action();
49
52
  return true;
50
- };
53
+ }
@@ -0,0 +1,76 @@
1
+ import { useRef } from "react";
2
+ import type { UseFeatureGateParams } from "../useFeatureGate.types";
3
+
4
+ /**
5
+ * Internal state management for useFeatureGate hook.
6
+ * Encapsulates all refs and state to reduce parameter passing.
7
+ */
8
+ export interface FeatureGateState {
9
+ // Action queue
10
+ pendingActionRef: React.MutableRefObject<(() => void | Promise<void>) | null>;
11
+
12
+ // Previous values for change detection
13
+ prevCreditBalanceRef: React.MutableRefObject<number | undefined>;
14
+ prevHasSubscriptionRef: React.MutableRefObject<boolean>;
15
+
16
+ // Waiting flags for async operations
17
+ isWaitingForAuthCreditsRef: React.MutableRefObject<boolean>;
18
+ isWaitingForPurchaseRef: React.MutableRefObject<boolean>;
19
+
20
+ // Live refs (synced with current values)
21
+ creditBalanceRef: React.MutableRefObject<number>;
22
+ hasSubscriptionRef: React.MutableRefObject<boolean>;
23
+ onShowPaywallRef: React.MutableRefObject<(requiredCredits?: number) => void>;
24
+ requiredCreditsRef: React.MutableRefObject<number>;
25
+ isCreditsLoadedRef: React.MutableRefObject<boolean>;
26
+ }
27
+
28
+ /**
29
+ * Creates and initializes all refs for useFeatureGate.
30
+ * This encapsulates ref creation and initialization logic.
31
+ */
32
+ export function useFeatureGateRefs(params: UseFeatureGateParams): FeatureGateState {
33
+ const {
34
+ creditBalance,
35
+ hasSubscription = false,
36
+ onShowPaywall,
37
+ requiredCredits = 1,
38
+ isCreditsLoaded = true,
39
+ } = params;
40
+
41
+ const pendingActionRef = useRef<(() => void | Promise<void>) | null>(null);
42
+ const prevCreditBalanceRef = useRef(creditBalance);
43
+ const prevHasSubscriptionRef = useRef(hasSubscription);
44
+ const isWaitingForAuthCreditsRef = useRef(false);
45
+ const isWaitingForPurchaseRef = useRef(false);
46
+
47
+ return {
48
+ pendingActionRef,
49
+ prevCreditBalanceRef,
50
+ prevHasSubscriptionRef,
51
+ isWaitingForAuthCreditsRef,
52
+ isWaitingForPurchaseRef,
53
+ creditBalanceRef: useRef(creditBalance),
54
+ hasSubscriptionRef: useRef(hasSubscription),
55
+ onShowPaywallRef: useRef(onShowPaywall),
56
+ requiredCreditsRef: useRef(requiredCredits),
57
+ isCreditsLoadedRef: useRef(isCreditsLoaded),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Updates live refs when their source values change.
63
+ * Call this in a useEffect or when values update.
64
+ */
65
+ export function updateLiveRefs(
66
+ state: FeatureGateState,
67
+ params: Pick<UseFeatureGateParams, 'creditBalance' | 'hasSubscription' | 'onShowPaywall' | 'requiredCredits' | 'isCreditsLoaded'>
68
+ ): void {
69
+ const { creditBalance, hasSubscription, onShowPaywall, requiredCredits, isCreditsLoaded } = params;
70
+
71
+ state.creditBalanceRef.current = creditBalance;
72
+ state.hasSubscriptionRef.current = hasSubscription;
73
+ state.onShowPaywallRef.current = onShowPaywall;
74
+ state.requiredCreditsRef.current = requiredCredits;
75
+ state.isCreditsLoadedRef.current = isCreditsLoaded;
76
+ }
@@ -3,6 +3,7 @@ import type { PurchasesPackage } from "react-native-purchases";
3
3
  import { usePremium } from "./usePremium";
4
4
  import type { PurchaseSource } from "../core/SubscriptionConstants";
5
5
  import { authPurchaseStateManager } from "../infrastructure/utils/authPurchaseState";
6
+ import { requireAuthentication } from "./utils/authCheckUtils";
6
7
 
7
8
  export const configureAuthProvider = (provider: import("../infrastructure/utils/authPurchaseState").PurchaseAuthProvider): void => {
8
9
  authPurchaseStateManager.configure(provider);
@@ -26,6 +27,10 @@ interface UseAuthAwarePurchaseResult {
26
27
  executeSavedPurchase: () => Promise<boolean>;
27
28
  }
28
29
 
30
+ /**
31
+ * Hook for purchase operations that handle authentication.
32
+ * Automatically saves pending purchases and shows auth modal when needed.
33
+ */
29
34
  export const useAuthAwarePurchase = (
30
35
  params?: UseAuthAwarePurchaseParams
31
36
  ): UseAuthAwarePurchaseResult => {
@@ -50,11 +55,10 @@ export const useAuthAwarePurchase = (
50
55
  }
51
56
  }, [purchasePackage]);
52
57
 
58
+ // Auto-execute saved purchase when user authenticates
53
59
  useEffect(() => {
54
60
  const authProvider = authPurchaseStateManager.getProvider();
55
- if (!authProvider) return;
56
-
57
- const hasUser = authProvider.hasFirebaseUser();
61
+ const hasUser = authProvider && authProvider.hasFirebaseUser();
58
62
  const hasSavedPurchase = !!authPurchaseStateManager.getSavedPurchase();
59
63
 
60
64
  if (hasUser && hasSavedPurchase && !isExecutingSavedRef.current) {
@@ -69,18 +73,14 @@ export const useAuthAwarePurchase = (
69
73
  async (pkg: PurchasesPackage, source?: PurchaseSource): Promise<boolean> => {
70
74
  const authProvider = authPurchaseStateManager.getProvider();
71
75
 
72
- if (!authProvider) {
73
- return false;
74
- }
75
-
76
- if (!authProvider.hasFirebaseUser()) {
76
+ if (!requireAuthentication(authProvider)) {
77
+ // User not authenticated, purchase saved and auth modal shown
77
78
  authPurchaseStateManager.savePurchase(pkg, source || params?.source || "settings");
78
- authProvider.showAuthModal();
79
79
  return false;
80
80
  }
81
81
 
82
+ // User authenticated, proceed with purchase
82
83
  const result = await purchasePackage(pkg);
83
-
84
84
  return result;
85
85
  },
86
86
  [purchasePackage, params?.source]
@@ -89,17 +89,11 @@ export const useAuthAwarePurchase = (
89
89
  const handleRestore = useCallback(async (): Promise<boolean> => {
90
90
  const authProvider = authPurchaseStateManager.getProvider();
91
91
 
92
- if (!authProvider) {
93
- return false;
94
- }
95
-
96
- if (!authProvider.hasFirebaseUser()) {
97
- authProvider.showAuthModal();
92
+ if (!requireAuthentication(authProvider)) {
98
93
  return false;
99
94
  }
100
95
 
101
96
  const result = await restorePurchase();
102
-
103
97
  return result;
104
98
  }, [restorePurchase]);
105
99
 
@@ -1,8 +1,8 @@
1
- import { useCallback, useRef, useEffect } from "react";
1
+ import { useCallback, useEffect } from "react";
2
2
  import type { UseFeatureGateParams, UseFeatureGateResult } from "./useFeatureGate.types";
3
3
  import { DEFAULT_REQUIRED_CREDITS, canExecuteAuthAction, canExecutePurchaseAction } from "../application/featureGate/featureGateBusinessRules";
4
- import { useSyncedRefs } from "./featureGateRefs";
5
4
  import { executeFeatureAction } from "./featureGateActions";
5
+ import { useFeatureGateRefs, updateLiveRefs } from "./hooks/useFeatureGateState";
6
6
 
7
7
  export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResult {
8
8
  const {
@@ -11,73 +11,64 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
11
11
  hasSubscription = false,
12
12
  creditBalance,
13
13
  requiredCredits = DEFAULT_REQUIRED_CREDITS,
14
- onShowPaywall,
15
14
  isCreditsLoaded = true,
16
15
  } = params;
17
16
 
18
- const pendingActionRef = useRef<(() => void | Promise<void>) | null>(null);
19
- const prevCreditBalanceRef = useRef(creditBalance);
20
- // Separate ref to track previous subscription state for canExecutePurchaseAction.
21
- // NOTE: Must NOT use hasSubscriptionRef from useSyncedRefs here because useSyncedRefs
22
- // effects run BEFORE this effect (React runs effects in definition order), so
23
- // hasSubscriptionRef.current would already be the NEW value when we check it.
24
- const prevHasSubscriptionRef = useRef(hasSubscription);
25
- const isWaitingForPurchaseRef = useRef(false);
26
- const isWaitingForAuthCreditsRef = useRef(false);
17
+ const state = useFeatureGateRefs(params);
27
18
 
28
- const { creditBalanceRef, hasSubscriptionRef, onShowPaywallRef, requiredCreditsRef, isCreditsLoadedRef } = useSyncedRefs(creditBalance, hasSubscription, onShowPaywall, requiredCredits, isCreditsLoaded);
19
+ // Update live refs when params change
20
+ useEffect(() => {
21
+ updateLiveRefs(state, params);
22
+ });
29
23
 
24
+ // Handle post-auth credit loading and action execution
30
25
  useEffect(() => {
31
26
  const shouldExecute = canExecuteAuthAction(
32
- isWaitingForAuthCreditsRef.current,
27
+ state.isWaitingForAuthCreditsRef.current,
33
28
  isCreditsLoaded,
34
- !!pendingActionRef.current,
29
+ !!state.pendingActionRef.current,
35
30
  hasSubscription,
36
31
  creditBalance,
37
32
  requiredCredits
38
33
  );
39
34
 
40
35
  if (shouldExecute) {
41
- isWaitingForAuthCreditsRef.current = false;
42
- const action = pendingActionRef.current!;
43
- pendingActionRef.current = null;
36
+ state.isWaitingForAuthCreditsRef.current = false;
37
+ const action = state.pendingActionRef.current!;
38
+ state.pendingActionRef.current = null;
44
39
  action();
45
40
  return;
46
41
  }
47
42
 
48
- if (isWaitingForAuthCreditsRef.current && isCreditsLoaded && pendingActionRef.current) {
49
- isWaitingForAuthCreditsRef.current = false;
50
- isWaitingForPurchaseRef.current = true;
51
- // Use ref to avoid unstable callback dependency
52
- onShowPaywallRef.current(requiredCreditsRef.current);
43
+ if (state.isWaitingForAuthCreditsRef.current && isCreditsLoaded && state.pendingActionRef.current) {
44
+ state.isWaitingForAuthCreditsRef.current = false;
45
+ state.isWaitingForPurchaseRef.current = true;
46
+ state.onShowPaywallRef.current(state.requiredCreditsRef.current);
53
47
  }
54
- // Removed onShowPaywall from dependencies - using ref instead
55
- }, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, onShowPaywallRef, requiredCreditsRef]);
48
+ }, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, state]);
56
49
 
50
+ // Handle post-purchase action execution
57
51
  useEffect(() => {
58
- // Use prevHasSubscriptionRef (updated AFTER check) not hasSubscriptionRef from useSyncedRefs
59
- // (which is already updated to new value before this effect runs - race condition fix)
60
52
  const shouldExecute = canExecutePurchaseAction(
61
- isWaitingForPurchaseRef.current,
53
+ state.isWaitingForPurchaseRef.current,
62
54
  creditBalance,
63
- prevCreditBalanceRef.current ?? 0,
55
+ state.prevCreditBalanceRef.current ?? 0,
64
56
  hasSubscription,
65
- prevHasSubscriptionRef.current,
66
- !!pendingActionRef.current
57
+ state.prevHasSubscriptionRef.current,
58
+ !!state.pendingActionRef.current
67
59
  );
68
60
 
69
61
  if (shouldExecute) {
70
- const action = pendingActionRef.current!;
71
- pendingActionRef.current = null;
72
- isWaitingForPurchaseRef.current = false;
62
+ const action = state.pendingActionRef.current!;
63
+ state.pendingActionRef.current = null;
64
+ state.isWaitingForPurchaseRef.current = false;
73
65
  action();
74
66
  }
75
67
 
76
- // Update AFTER check so next render has correct "prev" values
77
- prevCreditBalanceRef.current = creditBalance;
78
- prevHasSubscriptionRef.current = hasSubscription;
79
-
80
- }, [creditBalance, hasSubscription]);
68
+ // Update prev refs AFTER check for next render
69
+ state.prevCreditBalanceRef.current = creditBalance;
70
+ state.prevHasSubscriptionRef.current = hasSubscription;
71
+ }, [creditBalance, hasSubscription, state]);
81
72
 
82
73
  const requireFeature = useCallback(
83
74
  (action: () => void | Promise<void>): boolean => {
@@ -85,17 +76,10 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
85
76
  action,
86
77
  isAuthenticated,
87
78
  onShowAuthModal,
88
- hasSubscriptionRef,
89
- creditBalanceRef,
90
- requiredCreditsRef,
91
- onShowPaywallRef,
92
- pendingActionRef,
93
- isWaitingForAuthCreditsRef,
94
- isWaitingForPurchaseRef,
95
- isCreditsLoadedRef
79
+ state
96
80
  );
97
81
  },
98
- [isAuthenticated, onShowAuthModal, hasSubscriptionRef, creditBalanceRef, requiredCreditsRef, onShowPaywallRef, isCreditsLoadedRef]
82
+ [isAuthenticated, onShowAuthModal, state]
99
83
  );
100
84
 
101
85
  return {
@@ -7,11 +7,7 @@ import { UsePremiumResult } from './usePremium.types';
7
7
  /**
8
8
  * Facade hook that combines status, packages, and actions.
9
9
  *
10
- * This provides backward compatibility with existing code while allowing
11
- * components to use more focused hooks (usePremiumStatus, usePremiumPackages, usePremiumActions)
12
- * for better performance and testability.
13
- *
14
- * For new components, consider using the focused hooks:
10
+ * Consider using the focused hooks for better performance:
15
11
  * - usePremiumStatus() - when you only need premium status
16
12
  * - usePremiumPackages() - when you only need package data
17
13
  * - usePremiumActions() - when you only need actions
@@ -28,7 +24,6 @@ export const usePremium = (): UsePremiumResult => {
28
24
  ...status,
29
25
  ...packages,
30
26
  ...actions,
31
- // Merge loading states for backward compatibility
32
27
  isLoading: status.isSyncing || packages.isLoading || actions.isLoading,
33
28
  }), [
34
29
  status,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Authentication check utilities for purchase flows.
3
+ * Extracted to reduce duplication in useAuthAwarePurchase.
4
+ */
5
+
6
+ export interface PurchaseAuthProvider {
7
+ hasFirebaseUser: () => boolean;
8
+ showAuthModal: () => void;
9
+ }
10
+
11
+ /**
12
+ * Checks if auth provider is available and user is authenticated.
13
+ *
14
+ * @param authProvider - Auth provider from authPurchaseStateManager
15
+ * @returns true if user is authenticated, false otherwise
16
+ */
17
+ export function isUserAuthenticated(authProvider: PurchaseAuthProvider | null): boolean {
18
+ if (!authProvider) {
19
+ return false;
20
+ }
21
+ return authProvider.hasFirebaseUser();
22
+ }
23
+
24
+ /**
25
+ * Ensures user is authenticated before proceeding with an action.
26
+ * If not authenticated, shows auth modal and returns false.
27
+ *
28
+ * @param authProvider - Auth provider from authPurchaseStateManager
29
+ * @returns true if authenticated, false if auth modal was shown
30
+ */
31
+ export function requireAuthentication(authProvider: PurchaseAuthProvider | null): boolean {
32
+ if (!authProvider) {
33
+ return false;
34
+ }
35
+
36
+ if (!authProvider.hasFirebaseUser()) {
37
+ authProvider.showAuthModal();
38
+ return false;
39
+ }
40
+
41
+ return true;
42
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,13 @@ export {
9
9
  PURCHASE_TYPE,
10
10
  ANONYMOUS_CACHE_KEY,
11
11
  } from "./domains/subscription/core/SubscriptionConstants";
12
+
13
+ // Domain Events
14
+ export { SUBSCRIPTION_EVENTS } from "./domains/subscription/core/events/SubscriptionEvents";
15
+ export type { SubscriptionEventType, SyncStatusChangedEvent } from "./domains/subscription/core/events/SubscriptionEvents";
16
+ export type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "./domains/subscription/core/SubscriptionEvents";
17
+ export { FLOW_EVENTS } from "./domains/subscription/core/events/FlowEvents";
18
+ export type { FlowEventType, OnboardingCompletedEvent, PaywallShownEvent, PaywallClosedEvent } from "./domains/subscription/core/events/FlowEvents";
12
19
  export type {
13
20
  UserTierType,
14
21
  SubscriptionStatusType,
@@ -44,6 +51,14 @@ export {
44
51
  } from "./shared/utils/Result";
45
52
  export type { Result, Success, Failure } from "./shared/utils/Result";
46
53
 
54
+ // Cache Invalidation Utilities
55
+ export {
56
+ invalidateSubscriptionCaches,
57
+ invalidateSubscriptionStatus,
58
+ invalidateCredits,
59
+ invalidateAllUserData,
60
+ } from "./shared/infrastructure/react-query/utils";
61
+
47
62
  // Infrastructure Layer (Services & Repositories)
48
63
  export { initializeSubscription } from "./domains/subscription/application/initializer/SubscriptionInitializer";
49
64
  export type { SubscriptionInitConfig, CreditPackageConfig } from "./domains/subscription/application/SubscriptionInitializerTypes";
@@ -176,10 +191,6 @@ export type { ManagedSubscriptionFlowProps } from "./domains/subscription/presen
176
191
  export { SubscriptionFlowStatus } from "./domains/subscription/presentation/useSubscriptionFlow";
177
192
  export { SubscriptionFlowProvider, useSubscriptionFlowStatus } from "./domains/subscription/presentation/providers/SubscriptionFlowProvider";
178
193
 
179
- // Purchase Loading Overlay
180
- export { PurchaseLoadingOverlay } from "./domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay";
181
- export type { PurchaseLoadingOverlayProps } from "./domains/subscription/presentation/components/overlay/PurchaseLoadingOverlay";
182
-
183
194
  // Init Module Factory
184
195
  export {
185
196
  createSubscriptionInitModule,
@@ -17,7 +17,7 @@ class SubscriptionEventBus {
17
17
  if (!this.listeners.has(event)) {
18
18
  this.listeners.set(event, new Set());
19
19
  }
20
-
20
+
21
21
  const eventSet = this.listeners.get(event)!;
22
22
  eventSet.add(callback as EventCallback);
23
23
 
@@ -36,8 +36,6 @@ class SubscriptionEventBus {
36
36
  const listeners = this.listeners.get(event);
37
37
  if (!listeners || listeners.size === 0) return;
38
38
 
39
- // Use microtask for async execution to not block main thread
40
- // but keep it fast.
41
39
  listeners.forEach(callback => {
42
40
  queueMicrotask(() => {
43
41
  try {
@@ -71,19 +69,9 @@ class SubscriptionEventBus {
71
69
 
72
70
  export const subscriptionEventBus = SubscriptionEventBus.getInstance();
73
71
 
74
- export const SUBSCRIPTION_EVENTS = {
75
- CREDITS_UPDATED: "credits_updated",
76
- PURCHASE_COMPLETED: "purchase_completed",
77
- RENEWAL_DETECTED: "renewal_detected",
78
- PREMIUM_STATUS_CHANGED: "premium_status_changed",
79
- SYNC_STATUS_CHANGED: "sync_status_changed",
80
- } as const;
81
-
82
- export const FLOW_EVENTS = {
83
- ONBOARDING_COMPLETED: "flow_onboarding_completed",
84
- PAYWALL_SHOWN: "flow_paywall_shown",
85
- PAYWALL_CLOSED: "flow_paywall_closed",
86
- } as const;
72
+ // Re-export event constants for external use
73
+ export { SUBSCRIPTION_EVENTS } from "../../domains/subscription/core/events/SubscriptionEvents";
74
+ export { FLOW_EVENTS } from "../../domains/subscription/core/events/FlowEvents";
75
+ export type { SubscriptionEventType } from "../../domains/subscription/core/events/SubscriptionEvents";
76
+ export type { FlowEventType } from "../../domains/subscription/core/events/FlowEvents";
87
77
 
88
- export type SubscriptionEventType = typeof SUBSCRIPTION_EVENTS[keyof typeof SUBSCRIPTION_EVENTS];
89
- export type FlowEventType = typeof FLOW_EVENTS[keyof typeof FLOW_EVENTS];
@@ -36,6 +36,3 @@ export const LONG_CACHE_CONFIG = {
36
36
  refetchOnWindowFocus: false,
37
37
  refetchOnReconnect: true,
38
38
  };
39
-
40
- /** @deprecated Use SHORT_CACHE_CONFIG instead */
41
- export const NO_CACHE_QUERY_CONFIG = SHORT_CACHE_CONFIG;
@@ -0,0 +1,97 @@
1
+ import type { QueryClient } from "@tanstack/react-query";
2
+ import { SUBSCRIPTION_QUERY_KEYS } from "../../../../domains/subscription/infrastructure/hooks/subscriptionQueryKeys";
3
+ import { subscriptionStatusQueryKeys } from "../../../../domains/subscription/presentation/useSubscriptionStatus";
4
+ import { creditsQueryKeys } from "../../../../domains/credits/presentation/creditsQueryKeys";
5
+
6
+ /**
7
+ * Centralized cache invalidation utilities for subscription-related queries.
8
+ * This ensures consistent cache invalidation across all mutations and removes code duplication.
9
+ */
10
+
11
+ /**
12
+ * Invalidate all subscription-related caches for a specific user.
13
+ * This includes:
14
+ * - Subscription packages
15
+ * - Subscription status
16
+ * - Credits
17
+ *
18
+ * @param queryClient - TanStack Query client instance
19
+ * @param userId - User ID to invalidate caches for
20
+ */
21
+ export function invalidateSubscriptionCaches(
22
+ queryClient: QueryClient,
23
+ userId: string | null | undefined
24
+ ): void {
25
+ if (!userId) {
26
+ return;
27
+ }
28
+
29
+ // Invalidate packages (global, not user-specific)
30
+ queryClient.invalidateQueries({
31
+ queryKey: SUBSCRIPTION_QUERY_KEYS.packages,
32
+ });
33
+
34
+ // Invalidate subscription status (user-specific)
35
+ queryClient.invalidateQueries({
36
+ queryKey: subscriptionStatusQueryKeys.user(userId),
37
+ });
38
+
39
+ // Invalidate credits (user-specific)
40
+ queryClient.invalidateQueries({
41
+ queryKey: creditsQueryKeys.user(userId),
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Invalidate only subscription status cache.
47
+ * Use this when only subscription status changes, not credits.
48
+ *
49
+ * @param queryClient - TanStack Query client instance
50
+ * @param userId - User ID to invalidate cache for
51
+ */
52
+ export function invalidateSubscriptionStatus(
53
+ queryClient: QueryClient,
54
+ userId: string | null | undefined
55
+ ): void {
56
+ if (!userId) {
57
+ return;
58
+ }
59
+
60
+ queryClient.invalidateQueries({
61
+ queryKey: subscriptionStatusQueryKeys.user(userId),
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Invalidate only credits cache.
67
+ * Use this when only credits change, not subscription status.
68
+ *
69
+ * @param queryClient - TanStack Query client instance
70
+ * @param userId - User ID to invalidate cache for
71
+ */
72
+ export function invalidateCredits(
73
+ queryClient: QueryClient,
74
+ userId: string | null | undefined
75
+ ): void {
76
+ if (!userId) {
77
+ return;
78
+ }
79
+
80
+ queryClient.invalidateQueries({
81
+ queryKey: creditsQueryKeys.user(userId),
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Invalidate all caches for a user (subscription + credits).
87
+ * Alias for invalidateSubscriptionCaches for better semantic clarity.
88
+ *
89
+ * @param queryClient - TanStack Query client instance
90
+ * @param userId - User ID to invalidate caches for
91
+ */
92
+ export function invalidateAllUserData(
93
+ queryClient: QueryClient,
94
+ userId: string | null | undefined
95
+ ): void {
96
+ invalidateSubscriptionCaches(queryClient, userId);
97
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ invalidateSubscriptionCaches,
3
+ invalidateSubscriptionStatus,
4
+ invalidateCredits,
5
+ invalidateAllUserData,
6
+ } from "./cacheInvalidation";
@@ -1,60 +0,0 @@
1
- /**
2
- * Purchase Loading Overlay
3
- * Full-screen overlay shown during purchase operations
4
- * Locks the UI and shows a spinner with optional message
5
- *
6
- * This is now a props-based component. Pass isLoading from parent component.
7
- */
8
-
9
- import React from "react";
10
- import { View, Modal, StyleSheet } from "react-native";
11
- import { AtomicSpinner, AtomicText } from "@umituz/react-native-design-system/atoms";
12
- import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
13
- import type { PurchaseLoadingOverlayProps } from "./PurchaseLoadingOverlay.types";
14
-
15
- export type { PurchaseLoadingOverlayProps };
16
-
17
- export const PurchaseLoadingOverlay: React.FC<PurchaseLoadingOverlayProps> = React.memo(
18
- ({ loadingText, isLoading }) => {
19
- const tokens = useAppDesignTokens();
20
-
21
- return (
22
- <Modal visible={isLoading} transparent animationType="none" statusBarTranslucent>
23
- <View style={[styles.container, { backgroundColor: "rgba(0, 0, 0, 0.7)" }]}>
24
- <View style={[styles.content, { backgroundColor: tokens.colors.surface }]}>
25
- <AtomicSpinner size="lg" color="primary" />
26
- {loadingText && (
27
- <AtomicText
28
- type="bodyLarge"
29
- style={[styles.text, { color: tokens.colors.textPrimary }]}
30
- >
31
- {loadingText}
32
- </AtomicText>
33
- )}
34
- </View>
35
- </View>
36
- </Modal>
37
- );
38
- }
39
- );
40
-
41
- PurchaseLoadingOverlay.displayName = "PurchaseLoadingOverlay";
42
-
43
- const styles = StyleSheet.create({
44
- container: {
45
- flex: 1,
46
- justifyContent: "center",
47
- alignItems: "center",
48
- },
49
- content: {
50
- paddingHorizontal: 32,
51
- paddingVertical: 24,
52
- borderRadius: 16,
53
- alignItems: "center",
54
- minWidth: 200,
55
- },
56
- text: {
57
- marginTop: 16,
58
- textAlign: "center",
59
- },
60
- });
@@ -1,6 +0,0 @@
1
- export interface PurchaseLoadingOverlayProps {
2
- /** Whether the overlay is visible */
3
- isLoading: boolean;
4
- /** Loading message to display */
5
- loadingText?: string;
6
- }