@umituz/react-native-subscription 2.27.143 → 2.27.145
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/subscription/application/SubscriptionSyncProcessor.ts +24 -57
- package/src/domains/subscription/application/statusChangeHandlers.ts +54 -0
- package/src/domains/subscription/application/syncConstants.ts +11 -0
- package/src/domains/subscription/application/syncEventEmitter.ts +5 -0
- package/src/domains/subscription/application/syncIdGenerators.ts +19 -0
- package/src/domains/subscription/core/SubscriptionDisplayConfig.ts +0 -6
- package/src/domains/subscription/infrastructure/managers/SubscriptionManager.ts +24 -70
- package/src/domains/subscription/infrastructure/managers/initializationHandler.ts +14 -0
- package/src/domains/subscription/infrastructure/managers/managerConstants.ts +3 -0
- package/src/domains/subscription/infrastructure/managers/managerOperations.ts +38 -0
- package/src/domains/subscription/infrastructure/managers/packageHandlerFactory.ts +17 -0
- package/src/domains/subscription/infrastructure/managers/premiumStatusChecker.ts +17 -0
- package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.ts +9 -34
- package/src/domains/subscription/infrastructure/services/RevenueCatInitializer.types.ts +9 -0
- package/src/domains/subscription/infrastructure/services/RevenueCatService.ts +2 -134
- package/src/domains/subscription/infrastructure/services/RevenueCatService.types.ts +87 -0
- package/src/domains/subscription/infrastructure/services/initializerConstants.ts +9 -0
- package/src/domains/subscription/infrastructure/services/revenueCatServiceInstance.ts +17 -0
- package/src/domains/wallet/presentation/components/TransactionItem.styles.ts +30 -0
- package/src/domains/wallet/presentation/components/TransactionItem.tsx +16 -100
- package/src/domains/wallet/presentation/components/TransactionItem.types.ts +18 -0
- package/src/domains/wallet/presentation/components/TransactionList.constants.ts +1 -0
- package/src/domains/wallet/presentation/components/TransactionList.styles.ts +34 -0
- package/src/domains/wallet/presentation/components/TransactionList.tsx +15 -81
- package/src/domains/wallet/presentation/components/TransactionListStates.tsx +28 -0
- package/src/domains/wallet/presentation/components/transactionItemHelpers.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-subscription",
|
|
3
|
-
"version": "2.27.
|
|
3
|
+
"version": "2.27.145",
|
|
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,23 +1,19 @@
|
|
|
1
1
|
import type { CustomerInfo } from "react-native-purchases";
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
type PeriodType,
|
|
5
|
-
type PurchaseSource,
|
|
6
|
-
PURCHASE_SOURCE,
|
|
7
|
-
PURCHASE_TYPE
|
|
8
|
-
} from "../core/SubscriptionConstants";
|
|
2
|
+
import type { PeriodType, PurchaseSource } from "../core/SubscriptionConstants";
|
|
3
|
+
import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
9
4
|
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
10
5
|
import { extractRevenueCatData } from "./SubscriptionSyncUtils";
|
|
11
|
-
import {
|
|
6
|
+
import { emitCreditsUpdated } from "./syncEventEmitter";
|
|
7
|
+
import { generatePurchaseId, generateRenewalId } from "./syncIdGenerators";
|
|
8
|
+
import { handleExpiredSubscription, handleFreeUserInitialization, handlePremiumStatusSync } from "./statusChangeHandlers";
|
|
9
|
+
import { NO_SUBSCRIPTION_PRODUCT_ID } from "./syncConstants";
|
|
12
10
|
|
|
13
11
|
export class SubscriptionSyncProcessor {
|
|
14
12
|
constructor(private entitlementId: string) {}
|
|
15
13
|
|
|
16
14
|
async processPurchase(userId: string, productId: string, customerInfo: CustomerInfo, source?: PurchaseSource) {
|
|
17
15
|
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
18
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
19
|
-
? `purchase_${revenueCatData.originalTransactionId}`
|
|
20
|
-
: `purchase_${productId}_${Date.now()}`;
|
|
16
|
+
const purchaseId = generatePurchaseId(revenueCatData.originalTransactionId, productId);
|
|
21
17
|
|
|
22
18
|
await getCreditsRepository().initializeCredits(
|
|
23
19
|
userId,
|
|
@@ -28,15 +24,13 @@ export class SubscriptionSyncProcessor {
|
|
|
28
24
|
PURCHASE_TYPE.INITIAL
|
|
29
25
|
);
|
|
30
26
|
|
|
31
|
-
|
|
27
|
+
emitCreditsUpdated(userId);
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
async processRenewal(userId: string, productId: string, newExpirationDate: string, customerInfo: CustomerInfo) {
|
|
35
31
|
const revenueCatData = extractRevenueCatData(customerInfo, this.entitlementId);
|
|
36
32
|
revenueCatData.expirationDate = newExpirationDate || revenueCatData.expirationDate;
|
|
37
|
-
const purchaseId = revenueCatData.originalTransactionId
|
|
38
|
-
? `renewal_${revenueCatData.originalTransactionId}_${newExpirationDate}`
|
|
39
|
-
: `renewal_${productId}_${Date.now()}`;
|
|
33
|
+
const purchaseId = generateRenewalId(revenueCatData.originalTransactionId, productId, newExpirationDate);
|
|
40
34
|
|
|
41
35
|
await getCreditsRepository().initializeCredits(
|
|
42
36
|
userId,
|
|
@@ -47,19 +41,19 @@ export class SubscriptionSyncProcessor {
|
|
|
47
41
|
PURCHASE_TYPE.RENEWAL
|
|
48
42
|
);
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
emitCreditsUpdated(userId);
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
async processStatusChange(
|
|
54
|
-
userId: string,
|
|
55
|
-
isPremium: boolean,
|
|
48
|
+
userId: string,
|
|
49
|
+
isPremium: boolean,
|
|
56
50
|
productId?: string,
|
|
57
|
-
expiresAt?: string,
|
|
58
|
-
willRenew?: boolean,
|
|
51
|
+
expiresAt?: string,
|
|
52
|
+
willRenew?: boolean,
|
|
59
53
|
periodType?: PeriodType
|
|
60
54
|
) {
|
|
61
55
|
const repository = getCreditsRepository();
|
|
62
|
-
|
|
56
|
+
|
|
63
57
|
if (!isPremium && !productId) {
|
|
64
58
|
const currentCredits = await repository.getCredits(userId);
|
|
65
59
|
if (currentCredits.success && currentCredits.data?.isPremium) {
|
|
@@ -68,49 +62,22 @@ export class SubscriptionSyncProcessor {
|
|
|
68
62
|
}
|
|
69
63
|
|
|
70
64
|
if (!isPremium && productId) {
|
|
71
|
-
await
|
|
72
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
65
|
+
await handleExpiredSubscription(userId);
|
|
73
66
|
return;
|
|
74
67
|
}
|
|
75
68
|
|
|
76
69
|
if (!isPremium && !productId) {
|
|
77
|
-
|
|
78
|
-
await repository.initializeCredits(
|
|
79
|
-
userId,
|
|
80
|
-
stableSyncId,
|
|
81
|
-
'no_subscription',
|
|
82
|
-
PURCHASE_SOURCE.SETTINGS,
|
|
83
|
-
{
|
|
84
|
-
isPremium: false,
|
|
85
|
-
expirationDate: null,
|
|
86
|
-
willRenew: false,
|
|
87
|
-
periodType: null,
|
|
88
|
-
originalTransactionId: null
|
|
89
|
-
},
|
|
90
|
-
PURCHASE_TYPE.INITIAL
|
|
91
|
-
);
|
|
92
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
70
|
+
await handleFreeUserInitialization(userId);
|
|
93
71
|
return;
|
|
94
72
|
}
|
|
95
73
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const statusSyncId = `status_sync_${userId}_${isPremium ? 'premium' : 'free'}`;
|
|
105
|
-
await repository.initializeCredits(
|
|
106
|
-
userId,
|
|
107
|
-
statusSyncId,
|
|
108
|
-
productId ?? 'no_subscription',
|
|
109
|
-
PURCHASE_SOURCE.SETTINGS,
|
|
110
|
-
revenueCatData,
|
|
111
|
-
PURCHASE_TYPE.INITIAL
|
|
74
|
+
await handlePremiumStatusSync(
|
|
75
|
+
userId,
|
|
76
|
+
isPremium,
|
|
77
|
+
productId ?? NO_SUBSCRIPTION_PRODUCT_ID,
|
|
78
|
+
expiresAt ?? null,
|
|
79
|
+
willRenew ?? false,
|
|
80
|
+
periodType ?? null
|
|
112
81
|
);
|
|
113
|
-
|
|
114
|
-
subscriptionEventBus.emit(SUBSCRIPTION_EVENTS.CREDITS_UPDATED, userId);
|
|
115
82
|
}
|
|
116
83
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
2
|
+
import type { PeriodType } from "../core/SubscriptionConstants";
|
|
3
|
+
import { PURCHASE_SOURCE, PURCHASE_TYPE } from "../core/SubscriptionConstants";
|
|
4
|
+
import { getCreditsRepository } from "../../credits/infrastructure/CreditsRepositoryManager";
|
|
5
|
+
import { emitCreditsUpdated } from "./syncEventEmitter";
|
|
6
|
+
import { generateInitSyncId, generateStatusSyncId } from "./syncIdGenerators";
|
|
7
|
+
import { NO_SUBSCRIPTION_PRODUCT_ID, DEFAULT_FREE_USER_DATA } from "./syncConstants";
|
|
8
|
+
|
|
9
|
+
export const handleExpiredSubscription = async (userId: string): Promise<void> => {
|
|
10
|
+
await getCreditsRepository().syncExpiredStatus(userId);
|
|
11
|
+
emitCreditsUpdated(userId);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const handleFreeUserInitialization = async (userId: string): Promise<void> => {
|
|
15
|
+
const stableSyncId = generateInitSyncId(userId);
|
|
16
|
+
await getCreditsRepository().initializeCredits(
|
|
17
|
+
userId,
|
|
18
|
+
stableSyncId,
|
|
19
|
+
NO_SUBSCRIPTION_PRODUCT_ID,
|
|
20
|
+
PURCHASE_SOURCE.SETTINGS,
|
|
21
|
+
DEFAULT_FREE_USER_DATA,
|
|
22
|
+
PURCHASE_TYPE.INITIAL
|
|
23
|
+
);
|
|
24
|
+
emitCreditsUpdated(userId);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const handlePremiumStatusSync = async (
|
|
28
|
+
userId: string,
|
|
29
|
+
isPremium: boolean,
|
|
30
|
+
productId: string,
|
|
31
|
+
expiresAt: string | null,
|
|
32
|
+
willRenew: boolean,
|
|
33
|
+
periodType: PeriodType | null
|
|
34
|
+
): Promise<void> => {
|
|
35
|
+
const revenueCatData: RevenueCatData = {
|
|
36
|
+
expirationDate: expiresAt,
|
|
37
|
+
willRenew,
|
|
38
|
+
isPremium,
|
|
39
|
+
periodType,
|
|
40
|
+
originalTransactionId: null
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const statusSyncId = generateStatusSyncId(userId, isPremium);
|
|
44
|
+
await getCreditsRepository().initializeCredits(
|
|
45
|
+
userId,
|
|
46
|
+
statusSyncId,
|
|
47
|
+
productId,
|
|
48
|
+
PURCHASE_SOURCE.SETTINGS,
|
|
49
|
+
revenueCatData,
|
|
50
|
+
PURCHASE_TYPE.INITIAL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
emitCreditsUpdated(userId);
|
|
54
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RevenueCatData } from "../core/RevenueCatData";
|
|
2
|
+
|
|
3
|
+
export const NO_SUBSCRIPTION_PRODUCT_ID = 'no_subscription';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_FREE_USER_DATA: RevenueCatData = {
|
|
6
|
+
isPremium: false,
|
|
7
|
+
expirationDate: null,
|
|
8
|
+
willRenew: false,
|
|
9
|
+
periodType: null,
|
|
10
|
+
originalTransactionId: null,
|
|
11
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const generatePurchaseId = (originalTransactionId: string | null, productId: string): string => {
|
|
2
|
+
return originalTransactionId
|
|
3
|
+
? `purchase_${originalTransactionId}`
|
|
4
|
+
: `purchase_${productId}_${Date.now()}`;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const generateRenewalId = (originalTransactionId: string | null, productId: string, expirationDate: string): string => {
|
|
8
|
+
return originalTransactionId
|
|
9
|
+
? `renewal_${originalTransactionId}_${expirationDate}`
|
|
10
|
+
: `renewal_${productId}_${Date.now()}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const generateInitSyncId = (userId: string): string => {
|
|
14
|
+
return `init_sync_${userId}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const generateStatusSyncId = (userId: string, isPremium: boolean): string => {
|
|
18
|
+
return `status_sync_${userId}_${isPremium ? 'premium' : 'free'}`;
|
|
19
|
+
};
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
2
|
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
-
import {
|
|
4
|
-
import { PackageHandler } from "../handlers/PackageHandler";
|
|
3
|
+
import type { PackageHandler } from "../handlers/PackageHandler";
|
|
5
4
|
import { SubscriptionInternalState } from "./SubscriptionInternalState";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "./
|
|
12
|
-
|
|
13
|
-
import type {
|
|
14
|
-
SubscriptionManagerConfig,
|
|
15
|
-
PremiumStatus,
|
|
16
|
-
RestoreResultInfo
|
|
17
|
-
} from "./SubscriptionManager.types";
|
|
18
|
-
|
|
5
|
+
import { ensureConfigured, ensureServiceAvailable } from "./subscriptionManagerUtils";
|
|
6
|
+
import type { SubscriptionManagerConfig, PremiumStatus, RestoreResultInfo } from "./SubscriptionManager.types";
|
|
7
|
+
import { createPackageHandler } from "./packageHandlerFactory";
|
|
8
|
+
import { checkPremiumStatusFromService } from "./premiumStatusChecker";
|
|
9
|
+
import { getPackagesOperation, purchasePackageOperation, restoreOperation } from "./managerOperations";
|
|
10
|
+
import { performServiceInitialization } from "./initializationHandler";
|
|
19
11
|
|
|
20
12
|
class SubscriptionManagerImpl {
|
|
21
13
|
private managerConfig: SubscriptionManagerConfig | null = null;
|
|
@@ -29,17 +21,8 @@ class SubscriptionManagerImpl {
|
|
|
29
21
|
}
|
|
30
22
|
|
|
31
23
|
private ensurePackageHandlerInitialized(): void {
|
|
32
|
-
if (this.packageHandler)
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
ensureServiceAvailable(this.serviceInstance);
|
|
37
|
-
ensureConfigured(this.managerConfig);
|
|
38
|
-
|
|
39
|
-
this.packageHandler = new PackageHandler(
|
|
40
|
-
this.serviceInstance!,
|
|
41
|
-
this.managerConfig!.config.entitlementIdentifier
|
|
42
|
-
);
|
|
24
|
+
if (this.packageHandler) return;
|
|
25
|
+
this.packageHandler = createPackageHandler(this.serviceInstance, this.managerConfig);
|
|
43
26
|
}
|
|
44
27
|
|
|
45
28
|
async initialize(userId?: string): Promise<boolean> {
|
|
@@ -52,66 +35,41 @@ class SubscriptionManagerImpl {
|
|
|
52
35
|
return existingPromise;
|
|
53
36
|
}
|
|
54
37
|
|
|
55
|
-
const promise = (
|
|
56
|
-
await initializeRevenueCatService(this.managerConfig!.config);
|
|
57
|
-
this.serviceInstance = getRevenueCatService();
|
|
58
|
-
|
|
59
|
-
ensureServiceAvailable(this.serviceInstance);
|
|
60
|
-
this.ensurePackageHandlerInitialized();
|
|
61
|
-
|
|
62
|
-
const result = await this.serviceInstance!.initialize(actualUserId);
|
|
63
|
-
return result.success;
|
|
64
|
-
})();
|
|
65
|
-
|
|
38
|
+
const promise = this.performInitialization(actualUserId);
|
|
66
39
|
this.state.initCache.setPromise(promise, actualUserId);
|
|
67
40
|
return promise;
|
|
68
41
|
}
|
|
69
42
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return this.state.initCache.getCurrentUserId() === userId;
|
|
43
|
+
private async performInitialization(userId: string): Promise<boolean> {
|
|
44
|
+
const { service, success } = await performServiceInitialization(this.managerConfig!.config, userId);
|
|
45
|
+
this.serviceInstance = service;
|
|
46
|
+
this.ensurePackageHandlerInitialized();
|
|
47
|
+
return success;
|
|
76
48
|
}
|
|
77
49
|
|
|
50
|
+
isInitializedForUser = (userId: string): boolean =>
|
|
51
|
+
this.serviceInstance?.isInitialized() && this.state.initCache.getCurrentUserId() === userId;
|
|
52
|
+
|
|
78
53
|
async getPackages(): Promise<PurchasesPackage[]> {
|
|
79
|
-
ensureConfigured(this.managerConfig);
|
|
80
|
-
this.serviceInstance = getOrCreateService(this.serviceInstance);
|
|
81
54
|
this.ensurePackageHandlerInitialized();
|
|
82
|
-
|
|
83
|
-
return this.packageHandler!.fetchPackages();
|
|
55
|
+
return getPackagesOperation(this.managerConfig, this.serviceInstance, this.packageHandler!);
|
|
84
56
|
}
|
|
85
57
|
|
|
86
58
|
async purchasePackage(pkg: PurchasesPackage): Promise<boolean> {
|
|
87
|
-
ensureConfigured(this.managerConfig);
|
|
88
|
-
const userId = getCurrentUserIdOrThrow(this.state);
|
|
89
59
|
this.ensurePackageHandlerInitialized();
|
|
90
|
-
|
|
91
|
-
return this.packageHandler!.purchase(pkg, userId);
|
|
60
|
+
return purchasePackageOperation(pkg, this.managerConfig, this.state, this.packageHandler!);
|
|
92
61
|
}
|
|
93
62
|
|
|
94
63
|
async restore(): Promise<RestoreResultInfo> {
|
|
95
|
-
ensureConfigured(this.managerConfig);
|
|
96
|
-
const userId = getCurrentUserIdOrThrow(this.state);
|
|
97
64
|
this.ensurePackageHandlerInitialized();
|
|
98
|
-
|
|
99
|
-
return this.packageHandler!.restore(userId);
|
|
65
|
+
return restoreOperation(this.managerConfig, this.state, this.packageHandler!);
|
|
100
66
|
}
|
|
101
67
|
|
|
102
68
|
async checkPremiumStatus(): Promise<PremiumStatus> {
|
|
103
69
|
ensureConfigured(this.managerConfig);
|
|
104
|
-
getCurrentUserIdOrThrow(this.state);
|
|
105
70
|
ensureServiceAvailable(this.serviceInstance);
|
|
106
|
-
|
|
107
|
-
const customerInfo = await this.serviceInstance!.getCustomerInfo();
|
|
108
|
-
|
|
109
|
-
if (!customerInfo) {
|
|
110
|
-
throw new Error("Customer info not available");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
71
|
this.ensurePackageHandlerInitialized();
|
|
114
|
-
return this.packageHandler
|
|
72
|
+
return checkPremiumStatusFromService(this.serviceInstance!, this.packageHandler!);
|
|
115
73
|
}
|
|
116
74
|
|
|
117
75
|
async reset(): Promise<void> {
|
|
@@ -121,13 +79,9 @@ class SubscriptionManagerImpl {
|
|
|
121
79
|
this.packageHandler = null;
|
|
122
80
|
}
|
|
123
81
|
|
|
124
|
-
isConfigured(): boolean
|
|
125
|
-
return this.managerConfig !== null;
|
|
126
|
-
}
|
|
82
|
+
isConfigured = (): boolean => this.managerConfig !== null;
|
|
127
83
|
|
|
128
|
-
isInitialized(): boolean
|
|
129
|
-
return this.serviceInstance?.isInitialized() ?? false;
|
|
130
|
-
}
|
|
84
|
+
isInitialized = (): boolean => this.serviceInstance?.isInitialized() ?? false;
|
|
131
85
|
|
|
132
86
|
getEntitlementId(): string {
|
|
133
87
|
ensureConfigured(this.managerConfig);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
2
|
+
import { initializeRevenueCatService, getRevenueCatService } from "../services/RevenueCatService";
|
|
3
|
+
import { ensureServiceAvailable } from "./subscriptionManagerUtils";
|
|
4
|
+
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
5
|
+
|
|
6
|
+
export const performServiceInitialization = async (config: RevenueCatConfig, userId: string): Promise<{ service: IRevenueCatService; success: boolean }> => {
|
|
7
|
+
await initializeRevenueCatService(config);
|
|
8
|
+
const service = getRevenueCatService();
|
|
9
|
+
|
|
10
|
+
ensureServiceAvailable(service);
|
|
11
|
+
|
|
12
|
+
const result = await service!.initialize(userId);
|
|
13
|
+
return { service: service!, success: result.success };
|
|
14
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PurchasesPackage } from "react-native-purchases";
|
|
2
|
+
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
+
import type { PackageHandler } from "../handlers/PackageHandler";
|
|
4
|
+
import type { RestoreResultInfo } from "./SubscriptionManager.types";
|
|
5
|
+
import { SubscriptionInternalState } from "./SubscriptionInternalState";
|
|
6
|
+
import { ensureConfigured, getCurrentUserIdOrThrow, getOrCreateService } from "./subscriptionManagerUtils";
|
|
7
|
+
import type { SubscriptionManagerConfig } from "./SubscriptionManager.types";
|
|
8
|
+
|
|
9
|
+
export const getPackagesOperation = async (
|
|
10
|
+
managerConfig: SubscriptionManagerConfig | null,
|
|
11
|
+
serviceInstance: IRevenueCatService | null,
|
|
12
|
+
packageHandler: PackageHandler
|
|
13
|
+
): Promise<PurchasesPackage[]> => {
|
|
14
|
+
ensureConfigured(managerConfig);
|
|
15
|
+
getOrCreateService(serviceInstance);
|
|
16
|
+
return packageHandler.fetchPackages();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const purchasePackageOperation = async (
|
|
20
|
+
pkg: PurchasesPackage,
|
|
21
|
+
managerConfig: SubscriptionManagerConfig | null,
|
|
22
|
+
state: SubscriptionInternalState,
|
|
23
|
+
packageHandler: PackageHandler
|
|
24
|
+
): Promise<boolean> => {
|
|
25
|
+
ensureConfigured(managerConfig);
|
|
26
|
+
const userId = getCurrentUserIdOrThrow(state);
|
|
27
|
+
return packageHandler.purchase(pkg, userId);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const restoreOperation = async (
|
|
31
|
+
managerConfig: SubscriptionManagerConfig | null,
|
|
32
|
+
state: SubscriptionInternalState,
|
|
33
|
+
packageHandler: PackageHandler
|
|
34
|
+
): Promise<RestoreResultInfo> => {
|
|
35
|
+
ensureConfigured(managerConfig);
|
|
36
|
+
const userId = getCurrentUserIdOrThrow(state);
|
|
37
|
+
return packageHandler.restore(userId);
|
|
38
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
2
|
+
import { PackageHandler } from "../handlers/PackageHandler";
|
|
3
|
+
import { ensureServiceAvailable, ensureConfigured } from "./subscriptionManagerUtils";
|
|
4
|
+
import type { SubscriptionManagerConfig } from "./SubscriptionManager.types";
|
|
5
|
+
|
|
6
|
+
export const createPackageHandler = (
|
|
7
|
+
service: IRevenueCatService | null,
|
|
8
|
+
config: SubscriptionManagerConfig | null
|
|
9
|
+
): PackageHandler => {
|
|
10
|
+
ensureServiceAvailable(service);
|
|
11
|
+
ensureConfigured(config);
|
|
12
|
+
|
|
13
|
+
return new PackageHandler(
|
|
14
|
+
service!,
|
|
15
|
+
config!.config.entitlementIdentifier
|
|
16
|
+
);
|
|
17
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IRevenueCatService } from "../../../../shared/application/ports/IRevenueCatService";
|
|
2
|
+
import type { PackageHandler } from "../handlers/PackageHandler";
|
|
3
|
+
import type { PremiumStatus } from "./SubscriptionManager.types";
|
|
4
|
+
import { ERROR_MESSAGES } from "./managerConstants";
|
|
5
|
+
|
|
6
|
+
export const checkPremiumStatusFromService = async (
|
|
7
|
+
service: IRevenueCatService,
|
|
8
|
+
packageHandler: PackageHandler
|
|
9
|
+
): Promise<PremiumStatus> => {
|
|
10
|
+
const customerInfo = await service.getCustomerInfo();
|
|
11
|
+
|
|
12
|
+
if (!customerInfo) {
|
|
13
|
+
throw new Error(ERROR_MESSAGES.CUSTOMER_INFO_NOT_AVAILABLE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return packageHandler.checkPremiumStatusFromInfo(customerInfo);
|
|
17
|
+
};
|
|
@@ -1,40 +1,17 @@
|
|
|
1
1
|
import Purchases, { type CustomerInfo, type PurchasesOfferings } from "react-native-purchases";
|
|
2
2
|
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
3
|
-
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
4
3
|
import { resolveApiKey } from "../utils/ApiKeyResolver";
|
|
5
|
-
import {
|
|
4
|
+
import type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
5
|
+
import { FAILED_INITIALIZATION_RESULT, CONFIGURATION_RETRY_DELAY_MS } from "./initializerConstants";
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
config: RevenueCatConfig;
|
|
9
|
-
isInitialized: () => boolean;
|
|
10
|
-
getCurrentUserId: () => string | null;
|
|
11
|
-
setInitialized: (value: boolean) => void;
|
|
12
|
-
setCurrentUserId: (userId: string) => void;
|
|
13
|
-
}
|
|
7
|
+
export type { InitializerDeps } from "./RevenueCatInitializer.types";
|
|
14
8
|
|
|
15
|
-
// State management to prevent race conditions
|
|
16
9
|
const configurationState = {
|
|
17
10
|
isPurchasesConfigured: false,
|
|
18
|
-
isLogHandlerConfigured: false,
|
|
19
11
|
configurationInProgress: false,
|
|
20
12
|
configurationPromise: null as Promise<InitializeResult> | null,
|
|
21
13
|
};
|
|
22
14
|
|
|
23
|
-
|
|
24
|
-
// Simple lock mechanism to prevent concurrent configurations (implementation deferred)
|
|
25
|
-
|
|
26
|
-
function configureLogHandler(): void {
|
|
27
|
-
if (configurationState.isLogHandlerConfigured) return;
|
|
28
|
-
if (typeof Purchases.setLogHandler !== 'function') return;
|
|
29
|
-
try {
|
|
30
|
-
Purchases.setLogHandler((_logLevel, message) => {
|
|
31
|
-
if (REVENUE_CAT_IGNORED_LOG_MESSAGES.some(m => message.includes(m))) return;
|
|
32
|
-
});
|
|
33
|
-
configurationState.isLogHandlerConfigured = true;
|
|
34
|
-
} catch {
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
15
|
function buildSuccessResult(deps: InitializerDeps, customerInfo: CustomerInfo, offerings: PurchasesOfferings): InitializeResult {
|
|
39
16
|
const isPremium = !!customerInfo.entitlements.active[deps.config.entitlementIdentifier];
|
|
40
17
|
return { success: true, offering: offerings.current, isPremium };
|
|
@@ -53,7 +30,7 @@ export async function initializeSDK(
|
|
|
53
30
|
]);
|
|
54
31
|
return buildSuccessResult(deps, customerInfo, offerings);
|
|
55
32
|
} catch {
|
|
56
|
-
return
|
|
33
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
57
34
|
}
|
|
58
35
|
}
|
|
59
36
|
|
|
@@ -72,7 +49,7 @@ export async function initializeSDK(
|
|
|
72
49
|
const offerings = await Purchases.getOfferings();
|
|
73
50
|
return buildSuccessResult(deps, customerInfo, offerings);
|
|
74
51
|
} catch {
|
|
75
|
-
return
|
|
52
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
76
53
|
}
|
|
77
54
|
}
|
|
78
55
|
|
|
@@ -81,13 +58,13 @@ export async function initializeSDK(
|
|
|
81
58
|
await configurationState.configurationPromise;
|
|
82
59
|
return initializeSDK(deps, userId, apiKey);
|
|
83
60
|
}
|
|
84
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, CONFIGURATION_RETRY_DELAY_MS));
|
|
85
62
|
return initializeSDK(deps, userId, apiKey);
|
|
86
63
|
}
|
|
87
64
|
|
|
88
65
|
const key = apiKey || resolveApiKey(deps.config);
|
|
89
66
|
if (!key) {
|
|
90
|
-
return
|
|
67
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
91
68
|
}
|
|
92
69
|
|
|
93
70
|
let resolveInProgress: (value: InitializeResult) => void;
|
|
@@ -97,7 +74,6 @@ export async function initializeSDK(
|
|
|
97
74
|
|
|
98
75
|
configurationState.configurationInProgress = true;
|
|
99
76
|
try {
|
|
100
|
-
configureLogHandler();
|
|
101
77
|
await Purchases.configure({ apiKey: key, appUserID: userId });
|
|
102
78
|
configurationState.isPurchasesConfigured = true;
|
|
103
79
|
deps.setInitialized(true);
|
|
@@ -112,9 +88,8 @@ export async function initializeSDK(
|
|
|
112
88
|
resolveInProgress!(result);
|
|
113
89
|
return result;
|
|
114
90
|
} catch {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return errorResult;
|
|
91
|
+
resolveInProgress!(FAILED_INITIALIZATION_RESULT);
|
|
92
|
+
return FAILED_INITIALIZATION_RESULT;
|
|
118
93
|
} finally {
|
|
119
94
|
configurationState.configurationInProgress = false;
|
|
120
95
|
configurationState.configurationPromise = null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
2
|
+
|
|
3
|
+
export interface InitializerDeps {
|
|
4
|
+
config: RevenueCatConfig;
|
|
5
|
+
isInitialized: () => boolean;
|
|
6
|
+
getCurrentUserId: () => string | null;
|
|
7
|
+
setInitialized: (value: boolean) => void;
|
|
8
|
+
setCurrentUserId: (userId: string) => void;
|
|
9
|
+
}
|
|
@@ -1,134 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Main service class for RevenueCat operations
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import Purchases from "react-native-purchases";
|
|
7
|
-
import type {
|
|
8
|
-
PurchasesOffering,
|
|
9
|
-
PurchasesPackage,
|
|
10
|
-
CustomerInfo,
|
|
11
|
-
} from "react-native-purchases";
|
|
12
|
-
import type {
|
|
13
|
-
IRevenueCatService,
|
|
14
|
-
InitializeResult,
|
|
15
|
-
PurchaseResult,
|
|
16
|
-
RestoreResult,
|
|
17
|
-
} from "../../../../shared/application/ports/IRevenueCatService";
|
|
18
|
-
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
19
|
-
import { resolveApiKey } from "../utils/ApiKeyResolver";
|
|
20
|
-
import { initializeSDK } from "./RevenueCatInitializer";
|
|
21
|
-
import { fetchOfferings } from "./OfferingsFetcher";
|
|
22
|
-
import { handlePurchase } from "./PurchaseHandler";
|
|
23
|
-
import { handleRestore } from "./RestoreHandler";
|
|
24
|
-
import { CustomerInfoListenerManager } from "./CustomerInfoListenerManager";
|
|
25
|
-
import { ServiceStateManager } from "./ServiceStateManager";
|
|
26
|
-
|
|
27
|
-
export class RevenueCatService implements IRevenueCatService {
|
|
28
|
-
private stateManager: ServiceStateManager;
|
|
29
|
-
private listenerManager: CustomerInfoListenerManager;
|
|
30
|
-
|
|
31
|
-
constructor(config: RevenueCatConfig) {
|
|
32
|
-
this.stateManager = new ServiceStateManager(config);
|
|
33
|
-
this.listenerManager = new CustomerInfoListenerManager();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
getRevenueCatKey(): string | null {
|
|
37
|
-
return resolveApiKey(this.stateManager.getConfig());
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
isInitialized(): boolean {
|
|
41
|
-
return this.stateManager.isInitialized();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
getCurrentUserId(): string | null {
|
|
45
|
-
return this.stateManager.getCurrentUserId();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private getSDKParams() {
|
|
49
|
-
return {
|
|
50
|
-
config: this.stateManager.getConfig(),
|
|
51
|
-
isInitialized: () => this.isInitialized(),
|
|
52
|
-
getCurrentUserId: () => this.stateManager.getCurrentUserId(),
|
|
53
|
-
setInitialized: (value: boolean) => this.stateManager.setInitialized(value),
|
|
54
|
-
setCurrentUserId: (id: string | null) => this.stateManager.setCurrentUserId(id),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async initialize(userId: string, apiKey?: string): Promise<InitializeResult> {
|
|
59
|
-
if (this.isInitialized() && this.getCurrentUserId() === userId) {
|
|
60
|
-
const customerInfo = await Purchases.getCustomerInfo();
|
|
61
|
-
const isPremium = !!customerInfo.entitlements.active[this.stateManager.getConfig().entitlementIdentifier];
|
|
62
|
-
return {
|
|
63
|
-
success: true,
|
|
64
|
-
offering: await this.fetchOfferings(),
|
|
65
|
-
isPremium,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const result = await initializeSDK(this.getSDKParams(), userId, apiKey);
|
|
70
|
-
|
|
71
|
-
if (result.success) {
|
|
72
|
-
this.listenerManager.setUserId(userId);
|
|
73
|
-
this.listenerManager.setupListener(this.stateManager.getConfig());
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return result;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async fetchOfferings(): Promise<PurchasesOffering | null> {
|
|
80
|
-
return fetchOfferings(this.getSDKParams());
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async purchasePackage(
|
|
84
|
-
pkg: PurchasesPackage,
|
|
85
|
-
userId: string
|
|
86
|
-
): Promise<PurchaseResult> {
|
|
87
|
-
return handlePurchase(this.getSDKParams(), pkg, userId);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async restorePurchases(userId: string): Promise<RestoreResult> {
|
|
91
|
-
return handleRestore(this.getSDKParams(), userId);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async getCustomerInfo(): Promise<CustomerInfo | null> {
|
|
95
|
-
if (!this.isInitialized()) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
return Purchases.getCustomerInfo();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async reset(): Promise<void> {
|
|
102
|
-
if (!this.isInitialized()) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
this.listenerManager.destroy();
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await Purchases.logOut();
|
|
110
|
-
this.stateManager.setInitialized(false);
|
|
111
|
-
} catch {
|
|
112
|
-
// Silently fail during logout to allow cleanup to complete
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let revenueCatServiceInstance: RevenueCatService | null = null;
|
|
118
|
-
|
|
119
|
-
export function initializeRevenueCatService(
|
|
120
|
-
config: RevenueCatConfig
|
|
121
|
-
): RevenueCatService {
|
|
122
|
-
if (!revenueCatServiceInstance) {
|
|
123
|
-
revenueCatServiceInstance = new RevenueCatService(config);
|
|
124
|
-
}
|
|
125
|
-
return revenueCatServiceInstance;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function getRevenueCatService(): RevenueCatService | null {
|
|
129
|
-
return revenueCatServiceInstance;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function resetRevenueCatService(): void {
|
|
133
|
-
revenueCatServiceInstance = null;
|
|
134
|
-
}
|
|
1
|
+
export { RevenueCatService } from "./RevenueCatService.types";
|
|
2
|
+
export { initializeRevenueCatService, getRevenueCatService, resetRevenueCatService } from "./revenueCatServiceInstance";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import Purchases from "react-native-purchases";
|
|
2
|
+
import type { PurchasesOffering, PurchasesPackage, CustomerInfo } from "react-native-purchases";
|
|
3
|
+
import type { IRevenueCatService, InitializeResult, PurchaseResult, RestoreResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
4
|
+
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
5
|
+
import { resolveApiKey } from "../utils/ApiKeyResolver";
|
|
6
|
+
import { initializeSDK } from "./RevenueCatInitializer";
|
|
7
|
+
import { fetchOfferings } from "./OfferingsFetcher";
|
|
8
|
+
import { handlePurchase } from "./PurchaseHandler";
|
|
9
|
+
import { handleRestore } from "./RestoreHandler";
|
|
10
|
+
import { CustomerInfoListenerManager } from "./CustomerInfoListenerManager";
|
|
11
|
+
import { ServiceStateManager } from "./ServiceStateManager";
|
|
12
|
+
|
|
13
|
+
export class RevenueCatService implements IRevenueCatService {
|
|
14
|
+
private stateManager: ServiceStateManager;
|
|
15
|
+
private listenerManager: CustomerInfoListenerManager;
|
|
16
|
+
|
|
17
|
+
constructor(config: RevenueCatConfig) {
|
|
18
|
+
this.stateManager = new ServiceStateManager(config);
|
|
19
|
+
this.listenerManager = new CustomerInfoListenerManager();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getRevenueCatKey = (): string | null => resolveApiKey(this.stateManager.getConfig());
|
|
23
|
+
|
|
24
|
+
isInitialized = (): boolean => this.stateManager.isInitialized();
|
|
25
|
+
|
|
26
|
+
getCurrentUserId = (): string | null => this.stateManager.getCurrentUserId();
|
|
27
|
+
|
|
28
|
+
private getSDKParams() {
|
|
29
|
+
return {
|
|
30
|
+
config: this.stateManager.getConfig(),
|
|
31
|
+
isInitialized: () => this.isInitialized(),
|
|
32
|
+
getCurrentUserId: () => this.stateManager.getCurrentUserId(),
|
|
33
|
+
setInitialized: (value: boolean) => this.stateManager.setInitialized(value),
|
|
34
|
+
setCurrentUserId: (id: string | null) => this.stateManager.setCurrentUserId(id),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async initialize(userId: string, apiKey?: string): Promise<InitializeResult> {
|
|
39
|
+
if (this.isInitialized() && this.getCurrentUserId() === userId) {
|
|
40
|
+
const customerInfo = await Purchases.getCustomerInfo();
|
|
41
|
+
const isPremium = !!customerInfo.entitlements.active[this.stateManager.getConfig().entitlementIdentifier];
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
offering: await this.fetchOfferings(),
|
|
45
|
+
isPremium,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = await initializeSDK(this.getSDKParams(), userId, apiKey);
|
|
50
|
+
|
|
51
|
+
if (result.success) {
|
|
52
|
+
this.listenerManager.setUserId(userId);
|
|
53
|
+
this.listenerManager.setupListener(this.stateManager.getConfig());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async fetchOfferings(): Promise<PurchasesOffering | null> {
|
|
60
|
+
return fetchOfferings(this.getSDKParams());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async purchasePackage(pkg: PurchasesPackage, userId: string): Promise<PurchaseResult> {
|
|
64
|
+
return handlePurchase(this.getSDKParams(), pkg, userId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async restorePurchases(userId: string): Promise<RestoreResult> {
|
|
68
|
+
return handleRestore(this.getSDKParams(), userId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getCustomerInfo(): Promise<CustomerInfo | null> {
|
|
72
|
+
if (!this.isInitialized()) return null;
|
|
73
|
+
return Purchases.getCustomerInfo();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async reset(): Promise<void> {
|
|
77
|
+
if (!this.isInitialized()) return;
|
|
78
|
+
|
|
79
|
+
this.listenerManager.destroy();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await Purchases.logOut();
|
|
83
|
+
this.stateManager.setInitialized(false);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
|
|
2
|
+
|
|
3
|
+
export const FAILED_INITIALIZATION_RESULT: InitializeResult = {
|
|
4
|
+
success: false,
|
|
5
|
+
offering: null,
|
|
6
|
+
isPremium: false,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const CONFIGURATION_RETRY_DELAY_MS = 100;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RevenueCatService } from "./RevenueCatService.types";
|
|
2
|
+
import type { RevenueCatConfig } from "../../core/RevenueCatConfig";
|
|
3
|
+
|
|
4
|
+
let revenueCatServiceInstance: RevenueCatService | null = null;
|
|
5
|
+
|
|
6
|
+
export const initializeRevenueCatService = (config: RevenueCatConfig): RevenueCatService => {
|
|
7
|
+
if (!revenueCatServiceInstance) {
|
|
8
|
+
revenueCatServiceInstance = new RevenueCatService(config);
|
|
9
|
+
}
|
|
10
|
+
return revenueCatServiceInstance;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getRevenueCatService = (): RevenueCatService | null => revenueCatServiceInstance;
|
|
14
|
+
|
|
15
|
+
export const resetRevenueCatService = (): void => {
|
|
16
|
+
revenueCatServiceInstance = null;
|
|
17
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
|
|
3
|
+
export const transactionItemStyles = StyleSheet.create({
|
|
4
|
+
container: {
|
|
5
|
+
flexDirection: "row",
|
|
6
|
+
alignItems: "center",
|
|
7
|
+
padding: 12,
|
|
8
|
+
borderRadius: 12,
|
|
9
|
+
marginBottom: 8,
|
|
10
|
+
},
|
|
11
|
+
iconContainer: {
|
|
12
|
+
width: 40,
|
|
13
|
+
height: 40,
|
|
14
|
+
borderRadius: 20,
|
|
15
|
+
justifyContent: "center",
|
|
16
|
+
alignItems: "center",
|
|
17
|
+
marginRight: 12,
|
|
18
|
+
},
|
|
19
|
+
content: {
|
|
20
|
+
flex: 1,
|
|
21
|
+
gap: 2,
|
|
22
|
+
},
|
|
23
|
+
reason: {
|
|
24
|
+
fontWeight: "600",
|
|
25
|
+
},
|
|
26
|
+
change: {
|
|
27
|
+
fontWeight: "700",
|
|
28
|
+
marginLeft: 12,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -1,41 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transaction Item Component
|
|
3
|
-
*
|
|
4
|
-
* Displays a single credit transaction.
|
|
5
|
-
* Props-driven for full customization.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import React, { useMemo } from "react";
|
|
9
|
-
import { View
|
|
10
|
-
import {
|
|
11
|
-
useAppDesignTokens,
|
|
12
|
-
AtomicText,
|
|
13
|
-
AtomicIcon,
|
|
14
|
-
} from "@umituz/react-native-design-system";
|
|
15
|
-
import { timezoneService } from "@umituz/react-native-design-system";
|
|
16
|
-
import type { CreditLog } from "../../domain/types/transaction.types";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useAppDesignTokens, AtomicText, AtomicIcon } from "@umituz/react-native-design-system";
|
|
17
4
|
import { getTransactionIcon } from "../../utils/transactionIconMap";
|
|
5
|
+
import { transactionItemStyles } from "./TransactionItem.styles";
|
|
6
|
+
import type { TransactionItemProps } from "./TransactionItem.types";
|
|
7
|
+
import { defaultDateFormatter, getReasonLabel, getChangePrefix } from "./transactionItemHelpers";
|
|
18
8
|
|
|
19
|
-
export
|
|
20
|
-
purchase: string;
|
|
21
|
-
usage: string;
|
|
22
|
-
refund: string;
|
|
23
|
-
bonus: string;
|
|
24
|
-
subscription: string;
|
|
25
|
-
admin: string;
|
|
26
|
-
reward: string;
|
|
27
|
-
expired: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface TransactionItemProps {
|
|
31
|
-
transaction: CreditLog;
|
|
32
|
-
translations: TransactionItemTranslations;
|
|
33
|
-
dateFormatter?: (timestamp: number) => string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const defaultDateFormatter = (timestamp: number): string => {
|
|
37
|
-
return timezoneService.formatToDisplayDateTime(new Date(timestamp));
|
|
38
|
-
};
|
|
9
|
+
export type { TransactionItemTranslations, TransactionItemProps } from "./TransactionItem.types";
|
|
39
10
|
|
|
40
11
|
export const TransactionItem: React.FC<TransactionItemProps> = ({
|
|
41
12
|
transaction,
|
|
@@ -44,89 +15,34 @@ export const TransactionItem: React.FC<TransactionItemProps> = ({
|
|
|
44
15
|
}) => {
|
|
45
16
|
const tokens = useAppDesignTokens();
|
|
46
17
|
|
|
47
|
-
const reasonLabel = useMemo(() =>
|
|
48
|
-
return translations[transaction.reason] || transaction.reason;
|
|
49
|
-
}, [transaction.reason, translations]);
|
|
18
|
+
const reasonLabel = useMemo(() => getReasonLabel(transaction.reason, translations), [transaction.reason, translations]);
|
|
50
19
|
|
|
51
20
|
const isPositive = transaction.change > 0;
|
|
52
21
|
const changeColor = isPositive ? tokens.colors.success : tokens.colors.error;
|
|
53
|
-
const changePrefix =
|
|
22
|
+
const changePrefix = getChangePrefix(transaction.change);
|
|
54
23
|
const iconName = getTransactionIcon(transaction.reason);
|
|
55
24
|
|
|
56
25
|
return (
|
|
57
|
-
<View
|
|
58
|
-
style={[
|
|
59
|
-
styles.container,
|
|
60
|
-
{ backgroundColor: tokens.colors.surfaceSecondary },
|
|
61
|
-
]}
|
|
62
|
-
>
|
|
63
|
-
<View
|
|
64
|
-
style={[
|
|
65
|
-
styles.iconContainer,
|
|
66
|
-
{ backgroundColor: tokens.colors.surface },
|
|
67
|
-
]}
|
|
68
|
-
>
|
|
26
|
+
<View style={[transactionItemStyles.container, { backgroundColor: tokens.colors.surfaceSecondary }]}>
|
|
27
|
+
<View style={[transactionItemStyles.iconContainer, { backgroundColor: tokens.colors.surface }]}>
|
|
69
28
|
<AtomicIcon name={iconName} size="md" color="secondary" />
|
|
70
29
|
</View>
|
|
71
|
-
<View style={
|
|
72
|
-
<AtomicText
|
|
73
|
-
type="bodyMedium"
|
|
74
|
-
style={[styles.reason, { color: tokens.colors.textPrimary }]}
|
|
75
|
-
>
|
|
30
|
+
<View style={transactionItemStyles.content}>
|
|
31
|
+
<AtomicText type="bodyMedium" style={[transactionItemStyles.reason, { color: tokens.colors.textPrimary }]}>
|
|
76
32
|
{reasonLabel}
|
|
77
33
|
</AtomicText>
|
|
78
34
|
{transaction.description && (
|
|
79
|
-
<AtomicText
|
|
80
|
-
type="bodySmall"
|
|
81
|
-
style={{ color: tokens.colors.textSecondary }}
|
|
82
|
-
numberOfLines={1}
|
|
83
|
-
>
|
|
35
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }} numberOfLines={1}>
|
|
84
36
|
{transaction.description}
|
|
85
37
|
</AtomicText>
|
|
86
38
|
)}
|
|
87
|
-
<AtomicText
|
|
88
|
-
type="bodySmall"
|
|
89
|
-
style={{ color: tokens.colors.textSecondary }}
|
|
90
|
-
>
|
|
39
|
+
<AtomicText type="bodySmall" style={{ color: tokens.colors.textSecondary }}>
|
|
91
40
|
{dateFormatter(transaction.createdAt)}
|
|
92
41
|
</AtomicText>
|
|
93
42
|
</View>
|
|
94
|
-
<AtomicText
|
|
95
|
-
|
|
96
|
-
style={[styles.change, { color: changeColor }]}
|
|
97
|
-
>
|
|
98
|
-
{changePrefix}
|
|
99
|
-
{transaction.change}
|
|
43
|
+
<AtomicText type="titleMedium" style={[transactionItemStyles.change, { color: changeColor }]}>
|
|
44
|
+
{changePrefix}{transaction.change}
|
|
100
45
|
</AtomicText>
|
|
101
46
|
</View>
|
|
102
47
|
);
|
|
103
48
|
};
|
|
104
|
-
|
|
105
|
-
const styles = StyleSheet.create({
|
|
106
|
-
container: {
|
|
107
|
-
flexDirection: "row",
|
|
108
|
-
alignItems: "center",
|
|
109
|
-
padding: 12,
|
|
110
|
-
borderRadius: 12,
|
|
111
|
-
marginBottom: 8,
|
|
112
|
-
},
|
|
113
|
-
iconContainer: {
|
|
114
|
-
width: 40,
|
|
115
|
-
height: 40,
|
|
116
|
-
borderRadius: 20,
|
|
117
|
-
justifyContent: "center",
|
|
118
|
-
alignItems: "center",
|
|
119
|
-
marginRight: 12,
|
|
120
|
-
},
|
|
121
|
-
content: {
|
|
122
|
-
flex: 1,
|
|
123
|
-
gap: 2,
|
|
124
|
-
},
|
|
125
|
-
reason: {
|
|
126
|
-
fontWeight: "600",
|
|
127
|
-
},
|
|
128
|
-
change: {
|
|
129
|
-
fontWeight: "700",
|
|
130
|
-
marginLeft: 12,
|
|
131
|
-
},
|
|
132
|
-
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CreditLog } from "../../domain/types/transaction.types";
|
|
2
|
+
|
|
3
|
+
export interface TransactionItemTranslations {
|
|
4
|
+
purchase: string;
|
|
5
|
+
usage: string;
|
|
6
|
+
refund: string;
|
|
7
|
+
bonus: string;
|
|
8
|
+
subscription: string;
|
|
9
|
+
admin: string;
|
|
10
|
+
reward: string;
|
|
11
|
+
expired: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TransactionItemProps {
|
|
15
|
+
transaction: CreditLog;
|
|
16
|
+
translations: TransactionItemTranslations;
|
|
17
|
+
dateFormatter?: (timestamp: number) => string;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_TRANSACTION_LIST_MAX_HEIGHT = 400;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
|
+
|
|
3
|
+
export const transactionListStyles = StyleSheet.create({
|
|
4
|
+
container: {
|
|
5
|
+
marginTop: 24,
|
|
6
|
+
marginBottom: 24,
|
|
7
|
+
},
|
|
8
|
+
header: {
|
|
9
|
+
flexDirection: "row",
|
|
10
|
+
justifyContent: "space-between",
|
|
11
|
+
alignItems: "center",
|
|
12
|
+
marginHorizontal: 16,
|
|
13
|
+
marginBottom: 16,
|
|
14
|
+
},
|
|
15
|
+
title: {
|
|
16
|
+
fontSize: 20,
|
|
17
|
+
fontWeight: "700",
|
|
18
|
+
},
|
|
19
|
+
scrollView: {
|
|
20
|
+
paddingHorizontal: 16,
|
|
21
|
+
},
|
|
22
|
+
scrollContent: {
|
|
23
|
+
paddingBottom: 8,
|
|
24
|
+
},
|
|
25
|
+
stateContainer: {
|
|
26
|
+
padding: 40,
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
gap: 12,
|
|
29
|
+
},
|
|
30
|
+
stateText: {
|
|
31
|
+
fontSize: 14,
|
|
32
|
+
fontWeight: "500",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -1,23 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transaction List Component
|
|
3
|
-
*
|
|
4
|
-
* Displays a list of credit transactions.
|
|
5
|
-
* Props-driven for full customization.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import React from "react";
|
|
9
|
-
import { View,
|
|
10
|
-
import {
|
|
11
|
-
useAppDesignTokens,
|
|
12
|
-
AtomicText,
|
|
13
|
-
AtomicIcon,
|
|
14
|
-
AtomicSpinner,
|
|
15
|
-
} from "@umituz/react-native-design-system";
|
|
2
|
+
import { View, ScrollView } from "react-native";
|
|
3
|
+
import { useAppDesignTokens, AtomicText, AtomicIcon } from "@umituz/react-native-design-system";
|
|
16
4
|
import type { CreditLog } from "../../domain/types/transaction.types";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "./
|
|
5
|
+
import { TransactionItem, type TransactionItemTranslations } from "./TransactionItem";
|
|
6
|
+
import { transactionListStyles } from "./TransactionList.styles";
|
|
7
|
+
import { LoadingState, EmptyState } from "./TransactionListStates";
|
|
8
|
+
import { DEFAULT_TRANSACTION_LIST_MAX_HEIGHT } from "./TransactionList.constants";
|
|
21
9
|
|
|
22
10
|
export interface TransactionListTranslations extends TransactionItemTranslations {
|
|
23
11
|
title: string;
|
|
@@ -37,89 +25,35 @@ export const TransactionList: React.FC<TransactionListProps> = ({
|
|
|
37
25
|
transactions,
|
|
38
26
|
loading,
|
|
39
27
|
translations,
|
|
40
|
-
maxHeight =
|
|
28
|
+
maxHeight = DEFAULT_TRANSACTION_LIST_MAX_HEIGHT,
|
|
41
29
|
dateFormatter,
|
|
42
30
|
}) => {
|
|
43
31
|
const tokens = useAppDesignTokens();
|
|
44
32
|
|
|
45
33
|
return (
|
|
46
|
-
<View style={
|
|
47
|
-
<View style={
|
|
48
|
-
<AtomicText
|
|
49
|
-
type="titleLarge"
|
|
50
|
-
style={[styles.title, { color: tokens.colors.textPrimary }]}
|
|
51
|
-
>
|
|
34
|
+
<View style={transactionListStyles.container}>
|
|
35
|
+
<View style={transactionListStyles.header}>
|
|
36
|
+
<AtomicText type="titleLarge" style={[transactionListStyles.title, { color: tokens.colors.textPrimary }]}>
|
|
52
37
|
{translations.title}
|
|
53
38
|
</AtomicText>
|
|
54
39
|
<AtomicIcon name="time-outline" size="md" color="secondary" />
|
|
55
40
|
</View>
|
|
56
41
|
|
|
57
42
|
{loading ? (
|
|
58
|
-
<
|
|
59
|
-
size="lg"
|
|
60
|
-
color="primary"
|
|
61
|
-
text={translations.loading}
|
|
62
|
-
style={styles.stateContainer}
|
|
63
|
-
/>
|
|
43
|
+
<LoadingState message={translations.loading} />
|
|
64
44
|
) : transactions.length === 0 ? (
|
|
65
|
-
<
|
|
66
|
-
<AtomicIcon name="file-tray-outline" size="xl" color="secondary" />
|
|
67
|
-
<AtomicText
|
|
68
|
-
type="bodyMedium"
|
|
69
|
-
style={[styles.stateText, { color: tokens.colors.textSecondary }]}
|
|
70
|
-
>
|
|
71
|
-
{translations.empty}
|
|
72
|
-
</AtomicText>
|
|
73
|
-
</View>
|
|
45
|
+
<EmptyState message={translations.empty} />
|
|
74
46
|
) : (
|
|
75
47
|
<ScrollView
|
|
76
|
-
style={[
|
|
77
|
-
contentContainerStyle={
|
|
48
|
+
style={[transactionListStyles.scrollView, { maxHeight }]}
|
|
49
|
+
contentContainerStyle={transactionListStyles.scrollContent}
|
|
78
50
|
showsVerticalScrollIndicator={false}
|
|
79
51
|
>
|
|
80
52
|
{transactions.map((transaction) => (
|
|
81
|
-
<TransactionItem
|
|
82
|
-
key={transaction.id}
|
|
83
|
-
transaction={transaction}
|
|
84
|
-
translations={translations}
|
|
85
|
-
dateFormatter={dateFormatter}
|
|
86
|
-
/>
|
|
53
|
+
<TransactionItem key={transaction.id} transaction={transaction} translations={translations} dateFormatter={dateFormatter} />
|
|
87
54
|
))}
|
|
88
55
|
</ScrollView>
|
|
89
56
|
)}
|
|
90
57
|
</View>
|
|
91
58
|
);
|
|
92
59
|
};
|
|
93
|
-
|
|
94
|
-
const styles = StyleSheet.create({
|
|
95
|
-
container: {
|
|
96
|
-
marginTop: 24,
|
|
97
|
-
marginBottom: 24,
|
|
98
|
-
},
|
|
99
|
-
header: {
|
|
100
|
-
flexDirection: "row",
|
|
101
|
-
justifyContent: "space-between",
|
|
102
|
-
alignItems: "center",
|
|
103
|
-
marginHorizontal: 16,
|
|
104
|
-
marginBottom: 16,
|
|
105
|
-
},
|
|
106
|
-
title: {
|
|
107
|
-
fontSize: 20,
|
|
108
|
-
fontWeight: "700",
|
|
109
|
-
},
|
|
110
|
-
scrollView: {
|
|
111
|
-
paddingHorizontal: 16,
|
|
112
|
-
},
|
|
113
|
-
scrollContent: {
|
|
114
|
-
paddingBottom: 8,
|
|
115
|
-
},
|
|
116
|
-
stateContainer: {
|
|
117
|
-
padding: 40,
|
|
118
|
-
alignItems: "center",
|
|
119
|
-
gap: 12,
|
|
120
|
-
},
|
|
121
|
-
stateText: {
|
|
122
|
-
fontSize: 14,
|
|
123
|
-
fontWeight: "500",
|
|
124
|
-
},
|
|
125
|
-
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { useAppDesignTokens, AtomicText, AtomicIcon, AtomicSpinner } from "@umituz/react-native-design-system";
|
|
4
|
+
import { transactionListStyles } from "./TransactionList.styles";
|
|
5
|
+
|
|
6
|
+
interface LoadingStateProps {
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const LoadingState: React.FC<LoadingStateProps> = ({ message }) => (
|
|
11
|
+
<AtomicSpinner size="lg" color="primary" text={message} style={transactionListStyles.stateContainer} />
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
interface EmptyStateProps {
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const EmptyState: React.FC<EmptyStateProps> = ({ message }) => {
|
|
19
|
+
const tokens = useAppDesignTokens();
|
|
20
|
+
return (
|
|
21
|
+
<View style={transactionListStyles.stateContainer}>
|
|
22
|
+
<AtomicIcon name="file-tray-outline" size="xl" color="secondary" />
|
|
23
|
+
<AtomicText type="bodyMedium" style={[transactionListStyles.stateText, { color: tokens.colors.textSecondary }]}>
|
|
24
|
+
{message}
|
|
25
|
+
</AtomicText>
|
|
26
|
+
</View>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { timezoneService } from "@umituz/react-native-design-system";
|
|
2
|
+
import type { TransactionItemTranslations } from "./TransactionItem.types";
|
|
3
|
+
|
|
4
|
+
export const defaultDateFormatter = (timestamp: number): string => {
|
|
5
|
+
return timezoneService.formatToDisplayDateTime(new Date(timestamp));
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const getReasonLabel = (reason: string, translations: TransactionItemTranslations): string => {
|
|
9
|
+
return translations[reason as keyof TransactionItemTranslations] || reason;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const getChangePrefix = (change: number): string => {
|
|
13
|
+
return change > 0 ? "+" : "";
|
|
14
|
+
};
|