@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.
- package/package.json +1 -1
- package/src/domains/paywall/hooks/usePaywallActions.ts +65 -61
- package/src/domains/paywall/hooks/usePaywallActions.utils.ts +39 -0
- package/src/domains/subscription/application/SubscriptionSyncProcessor.ts +84 -296
- package/src/domains/subscription/application/sync/CreditDocumentOperations.ts +64 -0
- package/src/domains/subscription/application/sync/PurchaseSyncHandler.ts +83 -0
- package/src/domains/subscription/application/sync/RenewalSyncHandler.ts +69 -0
- package/src/domains/subscription/application/sync/StatusChangeSyncHandler.ts +57 -0
- package/src/domains/subscription/application/sync/SyncProcessorLogger.ts +120 -0
- package/src/domains/subscription/application/sync/UserIdResolver.ts +31 -0
- package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackScreen.parts.tsx +201 -0
- package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackScreen.tsx +89 -185
|
@@ -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
|
+
}
|
package/src/domains/subscription/presentation/components/feedback/PaywallFeedbackScreen.parts.tsx
ADDED
|
@@ -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
|
+
);
|