@umituz/react-native-subscription 2.44.1 → 2.45.1

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Status Change Sync Handler
3
+ * Handles premium status changes (expire, sync metadata, recovery)
4
+ */
5
+
6
+ import { getCreditsRepository } from "../../../credits/infrastructure/CreditsRepositoryManager";
7
+ import type { PremiumStatusChangedEvent } from "../../core/SubscriptionEvents";
8
+ import { UserIdResolver } from "./UserIdResolver";
9
+ import { CreditDocumentOperations } from "./CreditDocumentOperations";
10
+ import { PurchaseSyncHandler } from "./PurchaseSyncHandler";
11
+
12
+ export class StatusChangeSyncHandler {
13
+ constructor(
14
+ private userIdResolver: UserIdResolver,
15
+ private creditOps: CreditDocumentOperations,
16
+ private purchaseHandler: PurchaseSyncHandler
17
+ ) {}
18
+
19
+ async processStatusChange(event: PremiumStatusChangedEvent): Promise<void> {
20
+ // If purchase is in progress, only do recovery sync
21
+ if (this.purchaseHandler.isProcessing()) {
22
+ if (__DEV__) {
23
+ console.log("[StatusChangeSyncHandler] Purchase in progress - running recovery only");
24
+ }
25
+ if (event.isPremium && event.productId) {
26
+ const creditsUserId = await this.userIdResolver.resolveCreditsUserId(event.userId);
27
+ await this.creditOps.syncPremiumStatus(creditsUserId, event);
28
+ }
29
+ return;
30
+ }
31
+
32
+ const creditsUserId = await this.userIdResolver.resolveCreditsUserId(event.userId);
33
+
34
+ // Expired subscription
35
+ if (!event.isPremium && event.productId) {
36
+ await this.creditOps.expireSubscription(creditsUserId);
37
+ return;
38
+ }
39
+
40
+ // No product ID - check if credits doc exists and expire if needed
41
+ if (!event.isPremium && !event.productId) {
42
+ const hasDoc = await getCreditsRepository().creditsDocumentExists(creditsUserId);
43
+ if (hasDoc) {
44
+ await this.creditOps.expireSubscription(creditsUserId);
45
+ }
46
+ return;
47
+ }
48
+
49
+ // No product ID and is premium - nothing to do
50
+ if (!event.productId) {
51
+ return;
52
+ }
53
+
54
+ // Sync premium status (with recovery if needed)
55
+ await this.creditOps.syncPremiumStatus(creditsUserId, event);
56
+ }
57
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Sync Processor Logger
3
+ * Centralized logging for subscription sync operations
4
+ */
5
+
6
+ import { subscriptionEventBus, SUBSCRIPTION_EVENTS } from "../../../../shared/infrastructure/SubscriptionEventBus";
7
+ import type { PurchaseCompletedEvent, RenewalDetectedEvent, PremiumStatusChangedEvent } from "../../core/SubscriptionEvents";
8
+
9
+ export type SyncPhase = 'purchase' | 'renewal' | 'status_change';
10
+
11
+ export class SyncProcessorLogger {
12
+ emitSyncStatus(phase: SyncPhase, status: 'syncing' | 'success' | 'error', data: {
13
+ userId: string;
14
+ productId: string;
15
+ error?: string;
16
+ }) {
17
+ subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.SYNC_STATUS_CHANGED, {
18
+ status,
19
+ phase,
20
+ userId: data.userId,
21
+ productId: data.productId,
22
+ error: data.error,
23
+ });
24
+ }
25
+
26
+ logPurchaseStart(event: PurchaseCompletedEvent) {
27
+ if (__DEV__) {
28
+ console.log('[SubscriptionSyncProcessor] 🔵 PURCHASE START', {
29
+ userId: event.userId,
30
+ productId: event.productId,
31
+ source: event.source,
32
+ packageType: event.packageType,
33
+ timestamp: new Date().toISOString(),
34
+ });
35
+ }
36
+ }
37
+
38
+ logPurchaseSuccess(userId: string, productId: string) {
39
+ if (__DEV__) {
40
+ console.log('[SubscriptionSyncProcessor] 🟢 PURCHASE SUCCESS', {
41
+ userId,
42
+ productId,
43
+ timestamp: new Date().toISOString(),
44
+ });
45
+ }
46
+ }
47
+
48
+ logPurchaseError(userId: string, productId: string, error: string) {
49
+ console.error('[SubscriptionSyncProcessor] 🔴 PURCHASE FAILED', {
50
+ userId,
51
+ productId,
52
+ error,
53
+ timestamp: new Date().toISOString(),
54
+ });
55
+ }
56
+
57
+ logRenewalStart(event: RenewalDetectedEvent) {
58
+ if (__DEV__) {
59
+ console.log('[SubscriptionSyncProcessor] 🔵 RENEWAL START', {
60
+ userId: event.userId,
61
+ productId: event.productId,
62
+ newExpirationDate: event.newExpirationDate,
63
+ timestamp: new Date().toISOString(),
64
+ });
65
+ }
66
+ }
67
+
68
+ logRenewalSuccess(userId: string, productId: string) {
69
+ if (__DEV__) {
70
+ console.log('[SubscriptionSyncProcessor] 🟢 RENEWAL SUCCESS', {
71
+ userId,
72
+ productId,
73
+ timestamp: new Date().toISOString(),
74
+ });
75
+ }
76
+ }
77
+
78
+ logRenewalError(userId: string, productId: string, error: string) {
79
+ console.error('[SubscriptionSyncProcessor] 🔴 RENEWAL FAILED', {
80
+ userId,
81
+ productId,
82
+ error,
83
+ timestamp: new Date().toISOString(),
84
+ });
85
+ }
86
+
87
+ logStatusChangeStart(event: PremiumStatusChangedEvent) {
88
+ if (__DEV__) {
89
+ console.log('[SubscriptionSyncProcessor] 🔵 STATUS CHANGE START', {
90
+ userId: event.userId,
91
+ isPremium: event.isPremium,
92
+ productId: event.productId,
93
+ willRenew: event.willRenew,
94
+ expirationDate: event.expirationDate,
95
+ timestamp: new Date().toISOString(),
96
+ });
97
+ }
98
+ }
99
+
100
+ logStatusChangeSuccess(userId: string, isPremium: boolean, productId?: string) {
101
+ if (__DEV__) {
102
+ console.log('[SubscriptionSyncProcessor] 🟢 STATUS CHANGE SUCCESS', {
103
+ userId,
104
+ isPremium,
105
+ productId,
106
+ timestamp: new Date().toISOString(),
107
+ });
108
+ }
109
+ }
110
+
111
+ logStatusChangeError(userId: string, isPremium: boolean, productId: string | undefined, error: string) {
112
+ console.error('[SubscriptionSyncProcessor] 🔴 STATUS CHANGE FAILED', {
113
+ userId,
114
+ isPremium,
115
+ productId,
116
+ error,
117
+ timestamp: new Date().toISOString(),
118
+ });
119
+ }
120
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * User ID Resolver
3
+ * Handles resolution of RevenueCat user ID to credits user ID
4
+ */
5
+
6
+ export class UserIdResolver {
7
+ constructor(private getAnonymousUserId: () => Promise<string>) {}
8
+
9
+ async resolveCreditsUserId(revenueCatUserId: string | null | undefined): Promise<string> {
10
+ // Try revenueCatUserId first
11
+ const trimmed = revenueCatUserId?.trim();
12
+ if (this.isValidUserId(trimmed)) {
13
+ return trimmed;
14
+ }
15
+
16
+ // Fallback to anonymous user ID
17
+ console.warn("[UserIdResolver] revenueCatUserId is empty/null, falling back to anonymousUserId");
18
+ const anonymousId = await this.getAnonymousUserId();
19
+ const trimmedAnonymous = anonymousId?.trim();
20
+
21
+ if (!this.isValidUserId(trimmedAnonymous)) {
22
+ throw new Error("[UserIdResolver] Cannot resolve credits userId: both revenueCatUserId and anonymousUserId are empty");
23
+ }
24
+
25
+ return trimmedAnonymous;
26
+ }
27
+
28
+ private isValidUserId(userId: string | undefined): boolean {
29
+ return !!userId && userId.length > 0 && userId !== 'undefined' && userId !== 'null';
30
+ }
31
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Paywall Feedback Screen Parts
3
+ * Sub-components extracted for better maintainability
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, TouchableOpacity, ScrollView } from "react-native";
8
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
9
+ import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
10
+ import { FeedbackOption } from "./FeedbackOption";
11
+ import type { PaywallFeedbackTranslations } from "./PaywallFeedbackScreen.types";
12
+
13
+ // ============================================================================
14
+ // CLOSE BUTTON
15
+ // ============================================================================
16
+
17
+ interface FeedbackCloseButtonProps {
18
+ onPress: () => void;
19
+ topInset: number;
20
+ backgroundColor: string;
21
+ iconColor: string;
22
+ }
23
+
24
+ export const FeedbackCloseButton: React.FC<FeedbackCloseButtonProps> = ({
25
+ onPress,
26
+ topInset,
27
+ backgroundColor,
28
+ iconColor,
29
+ }) => (
30
+ <TouchableOpacity
31
+ onPress={onPress}
32
+ style={[{
33
+ position: 'absolute',
34
+ top: Math.max(topInset, 12),
35
+ right: 12,
36
+ width: 40,
37
+ height: 40,
38
+ borderRadius: 20,
39
+ zIndex: 1000,
40
+ justifyContent: 'center',
41
+ alignItems: 'center',
42
+ }, { backgroundColor }]}
43
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
44
+ >
45
+ <AtomicIcon name="close-outline" size="md" customColor={iconColor} />
46
+ </TouchableOpacity>
47
+ );
48
+
49
+ // ============================================================================
50
+ // HEADER
51
+ // ============================================================================
52
+
53
+ interface FeedbackHeaderProps {
54
+ title: string;
55
+ subtitle?: string;
56
+ titleColor: string;
57
+ subtitleColor: string;
58
+ style: any;
59
+ }
60
+
61
+ export const FeedbackHeader: React.FC<FeedbackHeaderProps> = ({
62
+ title,
63
+ subtitle,
64
+ titleColor,
65
+ subtitleColor,
66
+ style,
67
+ }) => (
68
+ <View style={style}>
69
+ <AtomicText
70
+ type="headlineMedium"
71
+ style={[{
72
+ marginBottom: 12,
73
+ }, { color: titleColor }]}
74
+ >
75
+ {title}
76
+ </AtomicText>
77
+ {subtitle && (
78
+ <AtomicText
79
+ type="bodyMedium"
80
+ style={[{
81
+ lineHeight: 24,
82
+ }, { color: subtitleColor }]}
83
+ >
84
+ {subtitle}
85
+ </AtomicText>
86
+ )}
87
+ </View>
88
+ );
89
+
90
+ // ============================================================================
91
+ // OPTIONS LIST
92
+ // ============================================================================
93
+
94
+ const FEEDBACK_OPTION_IDS = [
95
+ "too_expensive",
96
+ "no_need",
97
+ "trying_out",
98
+ "technical_issues",
99
+ "other",
100
+ ] as const;
101
+
102
+ interface FeedbackOptionsListProps {
103
+ translations: PaywallFeedbackTranslations;
104
+ selectedReason: string | null;
105
+ otherText: string;
106
+ onSelectReason: (reason: string) => void;
107
+ onSetOtherText: (text: string) => void;
108
+ }
109
+
110
+ export const FeedbackOptionsList: React.FC<FeedbackOptionsListProps> = ({
111
+ translations,
112
+ selectedReason,
113
+ otherText,
114
+ onSelectReason,
115
+ onSetOtherText,
116
+ }) => (
117
+ <View style={{ gap: 12 }}>
118
+ {FEEDBACK_OPTION_IDS.map((optionId) => {
119
+ const isSelected = selectedReason === optionId;
120
+ const isOther = optionId === "other";
121
+ const showInput = isSelected && isOther;
122
+
123
+ return (
124
+ <FeedbackOption
125
+ key={optionId}
126
+ isSelected={isSelected}
127
+ text={translations.reasons[optionId]}
128
+ showInput={showInput}
129
+ placeholder={translations.otherPlaceholder}
130
+ inputValue={otherText}
131
+ onSelect={() => onSelectReason(optionId)}
132
+ onChangeText={onSetOtherText}
133
+ />
134
+ );
135
+ })}
136
+ </View>
137
+ );
138
+
139
+ // ============================================================================
140
+ // SUBMIT BUTTON
141
+ // ============================================================================
142
+
143
+ interface FeedbackSubmitButtonProps {
144
+ title: string;
145
+ canSubmit: boolean;
146
+ backgroundColor: string;
147
+ textColor: string;
148
+ onPress: () => void;
149
+ bottomInset: number;
150
+ }
151
+
152
+ export const FeedbackSubmitButton: React.FC<FeedbackSubmitButtonProps> = ({
153
+ title,
154
+ canSubmit,
155
+ backgroundColor,
156
+ textColor,
157
+ onPress,
158
+ bottomInset,
159
+ }) => (
160
+ <View style={[{
161
+ position: 'absolute',
162
+ bottom: 0,
163
+ left: 0,
164
+ right: 0,
165
+ paddingHorizontal: 24,
166
+ paddingTop: 18,
167
+ paddingBottom: Math.max(bottomInset, 18),
168
+ borderTopWidth: 1,
169
+ borderTopColor: 'rgba(0,0,0,0.1)',
170
+ }, { backgroundColor: 'rgba(255,255,255,0.98)' }]}>
171
+ <TouchableOpacity
172
+ style={[{
173
+ borderRadius: 16,
174
+ paddingVertical: 18,
175
+ alignItems: 'center',
176
+ shadowColor: "#000",
177
+ shadowOffset: { width: 0, height: 2 },
178
+ shadowOpacity: 0.1,
179
+ shadowRadius: 4,
180
+ elevation: 3,
181
+ }, {
182
+ backgroundColor: canSubmit ? backgroundColor : 'rgba(0,0,0,0.1)',
183
+ opacity: canSubmit ? 1 : 0.6,
184
+ }]}
185
+ onPress={onPress}
186
+ disabled={!canSubmit}
187
+ activeOpacity={0.8}
188
+ >
189
+ <AtomicText
190
+ type="titleLarge"
191
+ style={[{
192
+ fontWeight: "700",
193
+ fontSize: 17,
194
+ letterSpacing: 0.3,
195
+ }, { color: canSubmit ? textColor : 'rgba(0,0,0,0.3)' }]}
196
+ >
197
+ {title}
198
+ </AtomicText>
199
+ </TouchableOpacity>
200
+ </View>
201
+ );