@ridwan-retainer/paywall 0.1.0 → 0.1.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/README.md +1001 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,64 +1,1034 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @ridwan-retainer/paywall
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A complete, production-ready monetization solution for React Native apps using RevenueCat. Implements in-app purchases, subscriptions, paywalls, and entitlement management with a developer-friendly API.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@ridwan-retainer/paywall)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- Pre-built paywall UI components
|
|
9
|
-
- Subscription status tracking and management
|
|
10
|
-
- Purchase flow handling with proper error management
|
|
11
|
-
- Offering and package management
|
|
12
|
-
- TypeScript support with full type definitions
|
|
8
|
+
## 📋 Table of Contents
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
- [Features](#features)
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Quick Start](#quick-start)
|
|
13
|
+
- [Core Concepts](#core-concepts)
|
|
14
|
+
- [API Reference](#api-reference)
|
|
15
|
+
- [Initialization](#initialization)
|
|
16
|
+
- [Hooks](#hooks)
|
|
17
|
+
- [Components](#components)
|
|
18
|
+
- [Managers](#managers)
|
|
19
|
+
- [Complete Examples](#complete-examples)
|
|
20
|
+
- [Testing](#testing)
|
|
21
|
+
- [Best Practices](#best-practices)
|
|
22
|
+
- [Troubleshooting](#troubleshooting)
|
|
23
|
+
|
|
24
|
+
## ✨ Features
|
|
25
|
+
|
|
26
|
+
- **💰 RevenueCat Integration**: Full SDK integration with cross-platform support
|
|
27
|
+
- **🎨 Pre-built Components**: Ready-to-use paywall, purchase buttons, and subscription gates
|
|
28
|
+
- **🔐 Entitlement Management**: Automatic entitlement checking and caching
|
|
29
|
+
- **♻️ Restore Purchases**: Built-in restore functionality with error handling
|
|
30
|
+
- **📦 Offering Management**: Easy access to products and packages
|
|
31
|
+
- **🎯 Feature Gates**: Control feature access based on subscriptions
|
|
32
|
+
- **⚡ Performance**: Optimized caching and minimal re-renders
|
|
33
|
+
- **🪝 React Hooks**: Modern hooks-based API
|
|
34
|
+
- **📝 TypeScript**: Full type safety
|
|
35
|
+
- **✅ Well-tested**: Comprehensive test coverage
|
|
36
|
+
|
|
37
|
+
## 📦 Installation
|
|
15
38
|
|
|
16
39
|
```bash
|
|
17
|
-
npm install @
|
|
40
|
+
npm install @ridwan-retainer/paywall
|
|
18
41
|
```
|
|
19
42
|
|
|
20
43
|
### Peer Dependencies
|
|
21
44
|
|
|
22
|
-
|
|
45
|
+
```bash
|
|
46
|
+
npm install react-native-purchases \
|
|
47
|
+
react-native-purchases-ui \
|
|
48
|
+
@react-native-async-storage/async-storage \
|
|
49
|
+
react \
|
|
50
|
+
react-native
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Platform Setup
|
|
23
54
|
|
|
55
|
+
#### iOS Setup
|
|
56
|
+
|
|
57
|
+
1. **Install CocoaPods dependencies:**
|
|
24
58
|
```bash
|
|
25
|
-
|
|
59
|
+
cd ios && pod install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
2. **Configure App Store Connect:**
|
|
63
|
+
- Create your app in App Store Connect
|
|
64
|
+
- Set up in-app products and subscriptions
|
|
65
|
+
- Submit for review
|
|
66
|
+
|
|
67
|
+
3. **Add StoreKit configuration** (for testing in Xcode):
|
|
68
|
+
- File → New → File → StoreKit Configuration File
|
|
69
|
+
- Add your products for local testing
|
|
70
|
+
|
|
71
|
+
#### Android Setup
|
|
72
|
+
|
|
73
|
+
1. **Configure Google Play Console:**
|
|
74
|
+
- Create your app in Google Play Console
|
|
75
|
+
- Set up in-app products and subscriptions
|
|
76
|
+
- Add test users for testing
|
|
77
|
+
|
|
78
|
+
2. **Add billing permission** in `android/app/src/main/AndroidManifest.xml`:
|
|
79
|
+
```xml
|
|
80
|
+
<uses-permission android:name="com.android.vending.BILLING" />
|
|
26
81
|
```
|
|
27
82
|
|
|
28
|
-
|
|
83
|
+
### RevenueCat Setup
|
|
84
|
+
|
|
85
|
+
1. **Create RevenueCat account:** https://app.revenuecat.com/signup
|
|
86
|
+
2. **Create a project** and configure your app
|
|
87
|
+
3. **Link App Store/Google Play:**
|
|
88
|
+
- iOS: Add App Store Connect API Key
|
|
89
|
+
- Android: Add Google Play Service Account JSON
|
|
90
|
+
4. **Set up products:**
|
|
91
|
+
- Create offerings
|
|
92
|
+
- Add products to offerings
|
|
93
|
+
- Configure entitlements
|
|
94
|
+
5. **Get API keys:**
|
|
95
|
+
- Settings → API Keys
|
|
96
|
+
- Copy iOS and Android keys
|
|
97
|
+
|
|
98
|
+
## 🚀 Quick Start
|
|
29
99
|
|
|
30
|
-
1.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
100
|
+
### 1. Configure Environment Variables
|
|
101
|
+
|
|
102
|
+
Create a `.env` file:
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
EXPO_PUBLIC_REVENUECAT_IOS=your_ios_api_key
|
|
106
|
+
EXPO_PUBLIC_REVENUECAT_ANDROID=your_android_api_key
|
|
107
|
+
```
|
|
34
108
|
|
|
35
|
-
|
|
109
|
+
### 2. Initialize RevenueCat
|
|
36
110
|
|
|
37
111
|
```typescript
|
|
38
|
-
import {
|
|
112
|
+
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
|
|
113
|
+
import { useEffect } from 'react';
|
|
39
114
|
|
|
40
|
-
// Wrap your app with PaywallProvider
|
|
41
115
|
function App() {
|
|
116
|
+
const { isInitialized, error } = useRevenueCatInitialization({
|
|
117
|
+
appUserID: 'user_123', // Optional: your user ID
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (error) {
|
|
121
|
+
console.error('RevenueCat initialization failed:', error);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return isInitialized ? <YourApp /> : <LoadingScreen />;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 3. Display a Paywall
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { Paywall } from '@ridwan-retainer/paywall';
|
|
132
|
+
|
|
133
|
+
function PaywallScreen() {
|
|
134
|
+
return (
|
|
135
|
+
<Paywall
|
|
136
|
+
onPurchaseCompleted={({ customerInfo }) => {
|
|
137
|
+
console.log('Purchase successful!', customerInfo);
|
|
138
|
+
// Navigate away or show success message
|
|
139
|
+
}}
|
|
140
|
+
onDismiss={() => {
|
|
141
|
+
console.log('Paywall dismissed');
|
|
142
|
+
}}
|
|
143
|
+
onPurchaseError={({ error }) => {
|
|
144
|
+
console.error('Purchase failed:', error);
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4. Gate Features
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { SubscriptionGate } from '@ridwan-retainer/paywall';
|
|
155
|
+
|
|
156
|
+
function PremiumFeature() {
|
|
157
|
+
return (
|
|
158
|
+
<SubscriptionGate>
|
|
159
|
+
<Text>This is premium content!</Text>
|
|
160
|
+
<PremiumFeatureContent />
|
|
161
|
+
</SubscriptionGate>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 🧠 Core Concepts
|
|
167
|
+
|
|
168
|
+
### Offerings and Packages
|
|
169
|
+
|
|
170
|
+
**Offerings** are collections of products (packages) you want to sell. Create them in RevenueCat dashboard.
|
|
171
|
+
|
|
172
|
+
**Packages** are individual subscription or purchase options (e.g., Monthly, Annual).
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
import { useCurrentOffering } from '@ridwan-retainer/paywall';
|
|
176
|
+
|
|
177
|
+
function ProductList() {
|
|
178
|
+
const { offering, isLoading } = useCurrentOffering();
|
|
179
|
+
|
|
180
|
+
if (isLoading) return <Loading />;
|
|
181
|
+
|
|
42
182
|
return (
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
183
|
+
<View>
|
|
184
|
+
{offering?.availablePackages.map((pkg) => (
|
|
185
|
+
<Text key={pkg.identifier}>
|
|
186
|
+
{pkg.product.title} - {pkg.product.priceString}
|
|
187
|
+
</Text>
|
|
188
|
+
))}
|
|
189
|
+
</View>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Entitlements
|
|
195
|
+
|
|
196
|
+
**Entitlements** represent access to features (configured in RevenueCat dashboard).
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { useEntitlement } from '@ridwan-retainer/paywall';
|
|
200
|
+
|
|
201
|
+
function PremiumButton() {
|
|
202
|
+
const { hasEntitlement, isLoading } = useEntitlement('premium');
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Button disabled={!hasEntitlement}>
|
|
206
|
+
{hasEntitlement ? 'Premium Feature' : 'Upgrade to Access'}
|
|
207
|
+
</Button>
|
|
46
208
|
);
|
|
47
209
|
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Customer Info
|
|
213
|
+
|
|
214
|
+
**Customer Info** contains all subscription and purchase data for a user.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { useCustomerInfo } from '@ridwan-retainer/paywall';
|
|
218
|
+
|
|
219
|
+
function SubscriptionStatus() {
|
|
220
|
+
const { customerInfo, isLoading } = useCustomerInfo();
|
|
221
|
+
|
|
222
|
+
const activeSub = customerInfo?.activeSubscriptions[0];
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<Text>
|
|
226
|
+
Status: {activeSub ? 'Subscribed' : 'Free'}
|
|
227
|
+
</Text>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## 📚 API Reference
|
|
233
|
+
|
|
234
|
+
### Initialization
|
|
235
|
+
|
|
236
|
+
#### `useRevenueCatInitialization(config?: RevenueCatConfig)`
|
|
237
|
+
|
|
238
|
+
Initialize RevenueCat SDK. Call once at app startup.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
|
|
242
|
+
|
|
243
|
+
function App() {
|
|
244
|
+
const { isInitialized, error } = useRevenueCatInitialization({
|
|
245
|
+
appUserID: 'user_123', // Optional: Custom user ID
|
|
246
|
+
observerMode: false, // Optional: Observer mode (default: false)
|
|
247
|
+
useAmazon: false, // Optional: Use Amazon store (default: false)
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return isInitialized ? <Main /> : <Splash />;
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Returns:**
|
|
255
|
+
```typescript
|
|
256
|
+
{
|
|
257
|
+
isInitialized: boolean;
|
|
258
|
+
error: Error | null;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### `initializeRevenueCat(apiKey: string, config?: RevenueCatConfig): Promise<void>`
|
|
263
|
+
|
|
264
|
+
Programmatic initialization (alternative to hook).
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { initializeRevenueCat } from '@ridwan-retainer/paywall';
|
|
268
|
+
|
|
269
|
+
await initializeRevenueCat('rcb_abc123', {
|
|
270
|
+
appUserID: 'user_123',
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Hooks
|
|
275
|
+
|
|
276
|
+
#### `useOfferings()`
|
|
48
277
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
278
|
+
Get all available offerings.
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
import { useOfferings } from '@ridwan-retainer/paywall';
|
|
282
|
+
|
|
283
|
+
function OfferingsList() {
|
|
284
|
+
const { offerings, currentOffering, isLoading, error } = useOfferings();
|
|
285
|
+
|
|
286
|
+
if (isLoading) return <Loading />;
|
|
287
|
+
if (error) return <Error message={error.message} />;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<View>
|
|
291
|
+
{offerings?.all && Object.values(offerings.all).map((offering) => (
|
|
292
|
+
<OfferingCard key={offering.identifier} offering={offering} />
|
|
293
|
+
))}
|
|
294
|
+
</View>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Returns:**
|
|
300
|
+
```typescript
|
|
301
|
+
{
|
|
302
|
+
offerings: PurchasesOfferings | null;
|
|
303
|
+
currentOffering: PurchasesOffering | null;
|
|
304
|
+
isLoading: boolean;
|
|
305
|
+
error: Error | null;
|
|
306
|
+
refresh: () => Promise<void>;
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
#### `useCurrentOffering()`
|
|
311
|
+
|
|
312
|
+
Get the current (default) offering.
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import { useCurrentOffering } from '@ridwan-retainer/paywall';
|
|
316
|
+
|
|
317
|
+
function SubscriptionPlans() {
|
|
318
|
+
const { offering, isLoading } = useCurrentOffering();
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<View>
|
|
322
|
+
{offering?.availablePackages.map((pkg) => (
|
|
323
|
+
<PlanCard key={pkg.identifier} package={pkg} />
|
|
324
|
+
))}
|
|
325
|
+
</View>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**Returns:**
|
|
331
|
+
```typescript
|
|
332
|
+
{
|
|
333
|
+
offering: PurchasesOffering | null;
|
|
334
|
+
isLoading: boolean;
|
|
335
|
+
error: Error | null;
|
|
336
|
+
refresh: () => Promise<void>;
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### `useCustomerInfo()`
|
|
341
|
+
|
|
342
|
+
Get customer subscription information.
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
import { useCustomerInfo } from '@ridwan-retainer/paywall';
|
|
346
|
+
|
|
347
|
+
function AccountScreen() {
|
|
348
|
+
const { customerInfo, isLoading, refresh } = useCustomerInfo();
|
|
349
|
+
|
|
350
|
+
const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
|
|
351
|
+
const expirationDate = customerInfo?.entitlements.active['premium']?.expirationDate;
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<View>
|
|
355
|
+
<Text>Status: {isPremium ? 'Premium' : 'Free'}</Text>
|
|
356
|
+
{expirationDate && (
|
|
357
|
+
<Text>Expires: {new Date(expirationDate).toLocaleDateString()}</Text>
|
|
358
|
+
)}
|
|
359
|
+
<Button onPress={refresh}>Refresh</Button>
|
|
360
|
+
</View>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Returns:**
|
|
366
|
+
```typescript
|
|
367
|
+
{
|
|
368
|
+
customerInfo: CustomerInfo | null;
|
|
369
|
+
isLoading: boolean;
|
|
370
|
+
error: Error | null;
|
|
371
|
+
refresh: () => Promise<void>;
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
#### `useEntitlement(entitlementId: string)`
|
|
376
|
+
|
|
377
|
+
Check if user has a specific entitlement.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
import { useEntitlement } from '@ridwan-retainer/paywall';
|
|
381
|
+
|
|
382
|
+
function PremiumFeatureButton() {
|
|
383
|
+
const { hasEntitlement, isLoading, entitlementInfo } = useEntitlement('premium');
|
|
384
|
+
|
|
385
|
+
if (isLoading) return <Loading />;
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<Button disabled={!hasEntitlement}>
|
|
389
|
+
{hasEntitlement ? 'Access Premium' : 'Upgrade Required'}
|
|
390
|
+
</Button>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**Returns:**
|
|
396
|
+
```typescript
|
|
397
|
+
{
|
|
398
|
+
hasEntitlement: boolean;
|
|
399
|
+
entitlementInfo: PurchasesEntitlementInfo | null;
|
|
400
|
+
isLoading: boolean;
|
|
401
|
+
error: Error | null;
|
|
402
|
+
}
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
#### `usePurchase()`
|
|
406
|
+
|
|
407
|
+
Handle purchase flow.
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import { usePurchase } from '@ridwan-retainer/paywall';
|
|
411
|
+
|
|
412
|
+
function BuyButton({ package: pkg }) {
|
|
413
|
+
const { purchase, isPurchasing, error } = usePurchase();
|
|
414
|
+
|
|
415
|
+
const handlePurchase = async () => {
|
|
416
|
+
try {
|
|
417
|
+
const result = await purchase(pkg);
|
|
418
|
+
console.log('Purchase successful:', result);
|
|
419
|
+
// Show success message
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.error('Purchase failed:', err);
|
|
422
|
+
// Show error message
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<Button onPress={handlePurchase} disabled={isPurchasing}>
|
|
428
|
+
{isPurchasing ? 'Processing...' : `Buy ${pkg.product.priceString}`}
|
|
429
|
+
</Button>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**Returns:**
|
|
435
|
+
```typescript
|
|
436
|
+
{
|
|
437
|
+
purchase: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
|
|
438
|
+
isPurchasing: boolean;
|
|
439
|
+
error: PurchaseError | null;
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
#### `useRestorePurchases()`
|
|
444
|
+
|
|
445
|
+
Restore previous purchases.
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { useRestorePurchases } from '@ridwan-retainer/paywall';
|
|
449
|
+
|
|
450
|
+
function RestoreButton() {
|
|
451
|
+
const { restore, isRestoring, error } = useRestorePurchases();
|
|
452
|
+
|
|
453
|
+
const handleRestore = async () => {
|
|
454
|
+
try {
|
|
455
|
+
const result = await restore();
|
|
456
|
+
if (result.customerInfo.activeSubscriptions.length > 0) {
|
|
457
|
+
Alert.alert('Success', 'Purchases restored!');
|
|
458
|
+
} else {
|
|
459
|
+
Alert.alert('No Purchases', 'No purchases found to restore.');
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
Alert.alert('Error', 'Failed to restore purchases');
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<Button onPress={handleRestore} disabled={isRestoring}>
|
|
468
|
+
{isRestoring ? 'Restoring...' : 'Restore Purchases'}
|
|
469
|
+
</Button>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Returns:**
|
|
475
|
+
```typescript
|
|
476
|
+
{
|
|
477
|
+
restore: () => Promise<RestoreResult>;
|
|
478
|
+
isRestoring: boolean;
|
|
479
|
+
error: RestoreError | null;
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
#### `usePaywall()`
|
|
484
|
+
|
|
485
|
+
Access paywall controller for programmatic control.
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
import { usePaywall } from '@ridwan-retainer/paywall';
|
|
489
|
+
|
|
490
|
+
function CustomPaywall() {
|
|
491
|
+
const { showPaywall, dismissPaywall } = usePaywall();
|
|
492
|
+
|
|
493
|
+
const handleShowPaywall = () => {
|
|
494
|
+
showPaywall({
|
|
495
|
+
onPurchaseCompleted: (info) => {
|
|
496
|
+
console.log('Purchase completed:', info);
|
|
497
|
+
dismissPaywall();
|
|
498
|
+
},
|
|
499
|
+
onDismiss: () => {
|
|
500
|
+
console.log('Paywall dismissed');
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return <Button onPress={handleShowPaywall}>Upgrade</Button>;
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Components
|
|
510
|
+
|
|
511
|
+
#### `<Paywall />`
|
|
512
|
+
|
|
513
|
+
Full-screen RevenueCat paywall UI.
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
import { Paywall } from '@ridwan-retainer/paywall';
|
|
517
|
+
|
|
518
|
+
<Paywall
|
|
519
|
+
onPurchaseStarted={({ packageBeingPurchased }) => {
|
|
520
|
+
console.log('Starting purchase:', packageBeingPurchased);
|
|
521
|
+
}}
|
|
522
|
+
onPurchaseCompleted={({ customerInfo, storeTransaction }) => {
|
|
523
|
+
console.log('Purchase completed!');
|
|
524
|
+
navigation.goBack();
|
|
525
|
+
}}
|
|
526
|
+
onPurchaseError={({ error }) => {
|
|
527
|
+
Alert.alert('Purchase Failed', error.message);
|
|
528
|
+
}}
|
|
529
|
+
onPurchaseCancelled={() => {
|
|
530
|
+
console.log('User cancelled purchase');
|
|
531
|
+
}}
|
|
532
|
+
onRestoreCompleted={({ customerInfo }) => {
|
|
533
|
+
Alert.alert('Success', 'Purchases restored!');
|
|
534
|
+
}}
|
|
535
|
+
onRestoreError={({ error }) => {
|
|
536
|
+
Alert.alert('Error', 'Restore failed');
|
|
537
|
+
}}
|
|
538
|
+
onDismiss={() => {
|
|
539
|
+
navigation.goBack();
|
|
540
|
+
}}
|
|
541
|
+
/>
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
**Props:**
|
|
545
|
+
```typescript
|
|
546
|
+
{
|
|
547
|
+
onPurchaseStarted?: (data: { packageBeingPurchased: PurchasesPackage }) => void;
|
|
548
|
+
onPurchaseCompleted?: (data: { customerInfo: CustomerInfo; storeTransaction: any }) => void;
|
|
549
|
+
onPurchaseError?: (data: { error: PurchasesError }) => void;
|
|
550
|
+
onPurchaseCancelled?: () => void;
|
|
551
|
+
onRestoreCompleted?: (data: { customerInfo: CustomerInfo }) => void;
|
|
552
|
+
onRestoreError?: (data: { error: PurchasesError }) => void;
|
|
553
|
+
onDismiss?: () => void;
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
#### `<SubscriptionGate />`
|
|
558
|
+
|
|
559
|
+
Show content only to subscribers.
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
import { SubscriptionGate } from '@ridwan-retainer/paywall';
|
|
563
|
+
|
|
564
|
+
<SubscriptionGate>
|
|
565
|
+
<PremiumContent />
|
|
566
|
+
</SubscriptionGate>
|
|
567
|
+
|
|
568
|
+
// Automatically redirects non-subscribers to paywall
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
#### `<PaywallGate />`
|
|
572
|
+
|
|
573
|
+
Control feature access with modal paywall.
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
import { PaywallGate } from '@ridwan-retainer/paywall';
|
|
577
|
+
|
|
578
|
+
<PaywallGate
|
|
579
|
+
entitlementId="premium"
|
|
580
|
+
onAccessGranted={() => {
|
|
581
|
+
console.log('User now has access!');
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
<View>
|
|
585
|
+
<Text>Premium Feature</Text>
|
|
586
|
+
<Button title="Click to Access" />
|
|
587
|
+
</View>
|
|
588
|
+
</PaywallGate>
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
**Props:**
|
|
592
|
+
```typescript
|
|
593
|
+
{
|
|
594
|
+
entitlementId: string;
|
|
595
|
+
children: ReactNode;
|
|
596
|
+
onAccessGranted?: () => void;
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
#### `<PurchaseButton />`
|
|
601
|
+
|
|
602
|
+
Pre-styled purchase button.
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
import { PurchaseButton } from '@ridwan-retainer/paywall';
|
|
606
|
+
|
|
607
|
+
function SubscriptionPlan({ package: pkg }) {
|
|
608
|
+
return (
|
|
609
|
+
<PurchaseButton
|
|
610
|
+
package={pkg}
|
|
611
|
+
title={`Subscribe for ${pkg.product.priceString}`}
|
|
612
|
+
onSuccess={() => {
|
|
613
|
+
Alert.alert('Welcome!', 'You are now subscribed!');
|
|
614
|
+
}}
|
|
615
|
+
onError={(error) => {
|
|
616
|
+
Alert.alert('Error', error.message);
|
|
617
|
+
}}
|
|
618
|
+
style={{ backgroundColor: '#007AFF', padding: 16 }}
|
|
619
|
+
/>
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
**Props:**
|
|
625
|
+
```typescript
|
|
626
|
+
{
|
|
627
|
+
package: PurchasesPackage;
|
|
628
|
+
onSuccess?: () => void;
|
|
629
|
+
onError?: (error: Error) => void;
|
|
630
|
+
title?: string;
|
|
631
|
+
style?: ViewStyle;
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
#### `<RestoreButton />`
|
|
636
|
+
|
|
637
|
+
Pre-styled restore purchases button.
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
import { RestoreButton } from '@ridwan-retainer/paywall';
|
|
641
|
+
|
|
642
|
+
<RestoreButton
|
|
643
|
+
onSuccess={() => {
|
|
644
|
+
Alert.alert('Success', 'Purchases restored!');
|
|
645
|
+
}}
|
|
646
|
+
onError={(error) => {
|
|
647
|
+
Alert.alert('Error', error.message);
|
|
648
|
+
}}
|
|
649
|
+
title="Restore Purchases"
|
|
650
|
+
style={{ padding: 12 }}
|
|
651
|
+
/>
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**Props:**
|
|
655
|
+
```typescript
|
|
656
|
+
{
|
|
657
|
+
onSuccess?: () => void;
|
|
658
|
+
onError?: (error: Error) => void;
|
|
659
|
+
title?: string;
|
|
660
|
+
style?: ViewStyle;
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
#### `<PaywallFooter />`
|
|
665
|
+
|
|
666
|
+
Displays legal text required for App Store compliance.
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
import { PaywallFooter } from '@ridwan-retainer/paywall';
|
|
670
|
+
|
|
671
|
+
<PaywallFooter
|
|
672
|
+
termsUrl="https://yourapp.com/terms"
|
|
673
|
+
privacyUrl="https://yourapp.com/privacy"
|
|
674
|
+
/>
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### Managers
|
|
678
|
+
|
|
679
|
+
For advanced use cases, you can use managers directly:
|
|
680
|
+
|
|
681
|
+
#### `offeringsManager`
|
|
682
|
+
|
|
683
|
+
```typescript
|
|
684
|
+
import { offeringsManager } from '@ridwan-retainer/paywall';
|
|
685
|
+
|
|
686
|
+
// Fetch offerings
|
|
687
|
+
const offerings = await offeringsManager.fetchOfferings();
|
|
688
|
+
|
|
689
|
+
// Get cached offerings
|
|
690
|
+
const cached = offeringsManager.getCachedOfferings();
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
#### `customerInfoManager`
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
import { customerInfoManager } from '@ridwan-retainer/paywall';
|
|
697
|
+
|
|
698
|
+
// Get customer info
|
|
699
|
+
const info = await customerInfoManager.getCustomerInfo();
|
|
700
|
+
|
|
701
|
+
// Check entitlement
|
|
702
|
+
const hasAccess = await customerInfoManager.hasEntitlement('premium');
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
#### `purchaseManager`
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
import { purchaseManager } from '@ridwan-retainer/paywall';
|
|
709
|
+
|
|
710
|
+
// Make purchase
|
|
711
|
+
const result = await purchaseManager.purchasePackage(package);
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
#### `restoreManager`
|
|
715
|
+
|
|
716
|
+
```typescript
|
|
717
|
+
import { restoreManager } from '@ridwan-retainer/paywall';
|
|
718
|
+
|
|
719
|
+
// Restore purchases
|
|
720
|
+
const result = await restoreManager.restorePurchases();
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
## 💡 Complete Examples
|
|
724
|
+
|
|
725
|
+
### Example 1: Subscription Paywall Screen
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
import React from 'react';
|
|
729
|
+
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
|
730
|
+
import {
|
|
731
|
+
useCurrentOffering,
|
|
732
|
+
PurchaseButton,
|
|
733
|
+
RestoreButton,
|
|
734
|
+
PaywallFooter,
|
|
735
|
+
} from '@ridwan-retainer/paywall';
|
|
736
|
+
|
|
737
|
+
function SubscriptionScreen() {
|
|
738
|
+
const { offering, isLoading } = useCurrentOffering();
|
|
739
|
+
|
|
740
|
+
if (isLoading) {
|
|
741
|
+
return <LoadingSpinner />;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!offering) {
|
|
745
|
+
return <ErrorView message="No plans available" />;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<ScrollView style={styles.container}>
|
|
750
|
+
<Text style={styles.title}>Upgrade to Premium</Text>
|
|
751
|
+
<Text style={styles.subtitle}>
|
|
752
|
+
Unlock all features and support development
|
|
753
|
+
</Text>
|
|
754
|
+
|
|
755
|
+
<View style={styles.features}>
|
|
756
|
+
<FeatureRow icon="✓" text="Unlimited access" />
|
|
757
|
+
<FeatureRow icon="✓" text="Priority support" />
|
|
758
|
+
<FeatureRow icon="✓" text="Remove ads" />
|
|
759
|
+
<FeatureRow icon="✓" text="Cloud sync" />
|
|
760
|
+
</View>
|
|
761
|
+
|
|
762
|
+
{offering.availablePackages.map((pkg) => (
|
|
763
|
+
<View key={pkg.identifier} style={styles.plan}>
|
|
764
|
+
<Text style={styles.planTitle}>{pkg.product.title}</Text>
|
|
765
|
+
<Text style={styles.planPrice}>{pkg.product.priceString}</Text>
|
|
766
|
+
<PurchaseButton
|
|
767
|
+
package={pkg}
|
|
768
|
+
title="Subscribe"
|
|
769
|
+
style={styles.button}
|
|
770
|
+
onSuccess={() => {
|
|
771
|
+
navigation.goBack();
|
|
772
|
+
}}
|
|
773
|
+
/>
|
|
774
|
+
</View>
|
|
775
|
+
))}
|
|
776
|
+
|
|
777
|
+
<RestoreButton
|
|
778
|
+
title="Restore Purchases"
|
|
779
|
+
style={styles.restoreButton}
|
|
780
|
+
/>
|
|
781
|
+
|
|
782
|
+
<PaywallFooter
|
|
783
|
+
termsUrl="https://yourapp.com/terms"
|
|
784
|
+
privacyUrl="https://yourapp.com/privacy"
|
|
785
|
+
/>
|
|
786
|
+
</ScrollView>
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const styles = StyleSheet.create({
|
|
791
|
+
container: { flex: 1, padding: 20 },
|
|
792
|
+
title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
|
|
793
|
+
subtitle: { fontSize: 16, color: '#666', textAlign: 'center', marginTop: 8 },
|
|
794
|
+
features: { marginVertical: 24 },
|
|
795
|
+
plan: { marginVertical: 12, padding: 16, borderWidth: 1, borderRadius: 8 },
|
|
796
|
+
button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, marginTop: 12 },
|
|
797
|
+
restoreButton: { marginTop: 16, padding: 12 },
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### Example 2: Feature Gating
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
import { useEntitlement } from '@ridwan-retainer/paywall';
|
|
805
|
+
|
|
806
|
+
function AdvancedSettings() {
|
|
807
|
+
const { hasEntitlement, isLoading } = useEntitlement('premium');
|
|
808
|
+
|
|
809
|
+
if (isLoading) return <Loading />;
|
|
810
|
+
|
|
811
|
+
return (
|
|
812
|
+
<View>
|
|
813
|
+
<SettingItem title="Basic Setting" />
|
|
814
|
+
<SettingItem title="Another Setting" />
|
|
815
|
+
|
|
816
|
+
{hasEntitlement ? (
|
|
817
|
+
<>
|
|
818
|
+
<SettingItem title="Premium Setting 1" />
|
|
819
|
+
<SettingItem title="Premium Setting 2" />
|
|
820
|
+
<SettingItem title="Advanced Options" />
|
|
821
|
+
</>
|
|
822
|
+
) : (
|
|
823
|
+
<UpgradePrompt
|
|
824
|
+
message="Unlock advanced settings with Premium"
|
|
825
|
+
onPress={() => navigation.navigate('Subscription')}
|
|
826
|
+
/>
|
|
827
|
+
)}
|
|
828
|
+
</View>
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### Example 3: Usage-Based Limits
|
|
834
|
+
|
|
835
|
+
```typescript
|
|
836
|
+
import { useCustomerInfo } from '@ridwan-retainer/paywall';
|
|
837
|
+
import { useState, useEffect } from 'react';
|
|
838
|
+
|
|
839
|
+
function ExportFeature() {
|
|
840
|
+
const { customerInfo } = useCustomerInfo();
|
|
841
|
+
const [exportCount, setExportCount] = useState(0);
|
|
52
842
|
|
|
843
|
+
const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
|
|
844
|
+
const maxExports = isPremium ? Infinity : 3;
|
|
845
|
+
const canExport = exportCount < maxExports;
|
|
846
|
+
|
|
847
|
+
const handleExport = async () => {
|
|
848
|
+
if (!canExport && !isPremium) {
|
|
849
|
+
navigation.navigate('Subscription');
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
await performExport();
|
|
854
|
+
setExportCount(prev => prev + 1);
|
|
855
|
+
};
|
|
856
|
+
|
|
53
857
|
return (
|
|
54
858
|
<View>
|
|
55
|
-
{!
|
|
56
|
-
|
|
859
|
+
{!isPremium && (
|
|
860
|
+
<Text>{maxExports - exportCount} exports remaining</Text>
|
|
861
|
+
)}
|
|
862
|
+
<Button
|
|
863
|
+
title="Export"
|
|
864
|
+
onPress={handleExport}
|
|
865
|
+
disabled={!canExport && !isPremium}
|
|
866
|
+
/>
|
|
57
867
|
</View>
|
|
58
868
|
);
|
|
59
869
|
}
|
|
60
870
|
```
|
|
61
871
|
|
|
62
|
-
##
|
|
872
|
+
## 🧪 Testing
|
|
873
|
+
|
|
874
|
+
### Test Purchases
|
|
875
|
+
|
|
876
|
+
#### iOS
|
|
877
|
+
1. Use Sandbox test users (App Store Connect → Users and Access → Sandbox Testers)
|
|
878
|
+
2. Sign in with test account on device (Settings → App Store → Sandbox Account)
|
|
879
|
+
3. Make purchases (won't charge real money)
|
|
880
|
+
|
|
881
|
+
#### Android
|
|
882
|
+
1. Add test users in Google Play Console
|
|
883
|
+
2. Use license testing or internal testing track
|
|
884
|
+
3. Make purchases with test account
|
|
885
|
+
|
|
886
|
+
### Test in Development
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
import { initializeRevenueCat } from '@ridwan-retainer/paywall';
|
|
890
|
+
|
|
891
|
+
// Enable debug logging
|
|
892
|
+
await initializeRevenueCat(API_KEY, {
|
|
893
|
+
appUserID: `test_user_${Date.now()}`, // Unique test user
|
|
894
|
+
});
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Mock for Unit Tests
|
|
898
|
+
|
|
899
|
+
```typescript
|
|
900
|
+
jest.mock('@ridwan-retainer/paywall', () => ({
|
|
901
|
+
useCustomerInfo: () => ({
|
|
902
|
+
customerInfo: {
|
|
903
|
+
entitlements: {
|
|
904
|
+
active: {
|
|
905
|
+
premium: { isActive: true },
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
isLoading: false,
|
|
910
|
+
}),
|
|
911
|
+
}));
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
## ✅ Best Practices
|
|
915
|
+
|
|
916
|
+
### 1. Check Subscription Status Regularly
|
|
917
|
+
|
|
918
|
+
```typescript
|
|
919
|
+
// On app open or resume
|
|
920
|
+
useEffect(() => {
|
|
921
|
+
customerInfoManager.getCustomerInfo({ forceRefresh: true });
|
|
922
|
+
}, []);
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
### 2. Handle Errors Gracefully
|
|
926
|
+
|
|
927
|
+
```typescript
|
|
928
|
+
const { purchase, error } = usePurchase();
|
|
929
|
+
|
|
930
|
+
useEffect(() => {
|
|
931
|
+
if (error) {
|
|
932
|
+
if (error.userCancelled) {
|
|
933
|
+
// User cancelled, no need to show error
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
Alert.alert('Purchase Error', getUserFriendlyErrorMessage(error));
|
|
937
|
+
}
|
|
938
|
+
}, [error]);
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### 3. Provide Restore Option
|
|
942
|
+
|
|
943
|
+
Always show a "Restore Purchases" button for users who reinstalled the app.
|
|
944
|
+
|
|
945
|
+
### 4. Cache Appropriately
|
|
946
|
+
|
|
947
|
+
The library caches offerings and customer info automatically, but you can force refresh:
|
|
948
|
+
|
|
949
|
+
```typescript
|
|
950
|
+
const { refresh } = useCustomerInfo();
|
|
951
|
+
|
|
952
|
+
// Force refresh on pull-to-refresh
|
|
953
|
+
<ScrollView
|
|
954
|
+
refreshControl={
|
|
955
|
+
<RefreshControl refreshing={isLoading} onRefresh={refresh} />
|
|
956
|
+
}
|
|
957
|
+
>
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### 5. Test Both Platforms
|
|
961
|
+
|
|
962
|
+
Subscription behavior differs between iOS and Android. Test thoroughly on both.
|
|
963
|
+
|
|
964
|
+
### 6. Follow App Store Guidelines
|
|
965
|
+
|
|
966
|
+
- Show clear pricing
|
|
967
|
+
- Include terms and privacy links
|
|
968
|
+
- Provide restore functionality
|
|
969
|
+
- Handle subscription management
|
|
970
|
+
|
|
971
|
+
## 🐛 Troubleshooting
|
|
972
|
+
|
|
973
|
+
### Issue: "Unable to find offerings"
|
|
974
|
+
|
|
975
|
+
**Solution**: Check RevenueCat dashboard configuration:
|
|
976
|
+
1. Verify offerings are created
|
|
977
|
+
2. Check products are added to offerings
|
|
978
|
+
3. Ensure API keys are correct
|
|
979
|
+
|
|
980
|
+
### Issue: "Invalid product identifiers"
|
|
981
|
+
|
|
982
|
+
**Solution**: Product IDs must match exactly between:
|
|
983
|
+
- App Store Connect / Google Play Console
|
|
984
|
+
- RevenueCat dashboard
|
|
985
|
+
- Your code
|
|
986
|
+
|
|
987
|
+
### Issue: Purchases not working in production
|
|
988
|
+
|
|
989
|
+
**Solution**:
|
|
990
|
+
1. Use production API keys (not sandbox)
|
|
991
|
+
2. Verify app is live in stores
|
|
992
|
+
3. Check RevenueCat integration status
|
|
993
|
+
4. Test with real Apple/Google account
|
|
994
|
+
|
|
995
|
+
### Issue: "Could not find customer"
|
|
996
|
+
|
|
997
|
+
**Solution**: Initialize RevenueCat before making any calls:
|
|
998
|
+
|
|
999
|
+
```typescript
|
|
1000
|
+
const { isInitialized } = useRevenueCatInitialization();
|
|
1001
|
+
|
|
1002
|
+
if (!isInitialized) {
|
|
1003
|
+
return <Loading />;
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
### Issue: Subscription status not updating
|
|
1008
|
+
|
|
1009
|
+
**Solution**: Force refresh customer info:
|
|
1010
|
+
|
|
1011
|
+
```typescript
|
|
1012
|
+
const { refresh } = useCustomerInfo();
|
|
1013
|
+
await refresh();
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
## 🤝 Contributing
|
|
1017
|
+
|
|
1018
|
+
Contributions welcome! Please submit Pull Requests.
|
|
1019
|
+
|
|
1020
|
+
## 📄 License
|
|
1021
|
+
|
|
1022
|
+
MIT © [Ridwan Hamid](https://github.com/RidwanHamid501)
|
|
1023
|
+
|
|
1024
|
+
## 🔗 Links
|
|
1025
|
+
|
|
1026
|
+
- [npm Package](https://www.npmjs.com/package/@ridwan-retainer/paywall)
|
|
1027
|
+
- [GitHub Repository](https://github.com/RidwanHamid501/app-factory/tree/main/packages/paywall)
|
|
1028
|
+
- [RevenueCat Documentation](https://docs.revenuecat.com)
|
|
1029
|
+
- [RevenueCat React Native SDK](https://github.com/RevenueCat/react-native-purchases)
|
|
1030
|
+
|
|
1031
|
+
## 📞 Support
|
|
63
1032
|
|
|
64
|
-
|
|
1033
|
+
- [GitHub Issues](https://github.com/RidwanHamid501/app-factory/issues)
|
|
1034
|
+
- [RevenueCat Support](https://community.revenuecat.com)
|
package/package.json
CHANGED