vibefast-cli 0.4.0 → 0.5.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/FEATURE-DEPENDENCY-SPEC.md +338 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +219 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/recipes.test.d.ts +2 -0
- package/dist/__tests__/recipes.test.d.ts.map +1 -0
- package/dist/__tests__/recipes.test.js +143 -0
- package/dist/__tests__/recipes.test.js.map +1 -0
- package/dist/commands/__tests__/init.test.d.ts +2 -0
- package/dist/commands/__tests__/init.test.d.ts.map +1 -0
- package/dist/commands/__tests__/init.test.js +95 -0
- package/dist/commands/__tests__/init.test.js.map +1 -0
- package/dist/commands/__tests__/platform.test.d.ts +2 -0
- package/dist/commands/__tests__/platform.test.d.ts.map +1 -0
- package/dist/commands/__tests__/platform.test.js +123 -0
- package/dist/commands/__tests__/platform.test.js.map +1 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +4 -5
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +12 -12
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/platform.d.ts +3 -0
- package/dist/commands/platform.d.ts.map +1 -0
- package/dist/commands/platform.js +245 -0
- package/dist/commands/platform.js.map +1 -0
- package/dist/core/journal.d.ts.map +1 -1
- package/dist/core/journal.js +36 -19
- package/dist/core/journal.js.map +1 -1
- package/dist/core/recipes.d.ts.map +1 -1
- package/dist/core/recipes.js +8 -39
- package/dist/core/recipes.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/recipes/ios-widget/recipe.json +78 -0
- package/recipes/ios-widget/targets/widget/AppIntent.swift +46 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@1x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-20x20@3x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@1x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-29x29@3x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@1x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-40x40@3x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-60x60@3x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@1x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-76x76@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/App-Icon-83.5x83.5@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
- package/recipes/ios-widget/targets/widget/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png +0 -0
- package/recipes/ios-widget/targets/widget/CalorieTrackerWidget.swift +424 -0
- package/recipes/ios-widget/targets/widget/HabitTrackerWidget.swift +305 -0
- package/recipes/ios-widget/targets/widget/Info.plist +11 -0
- package/recipes/ios-widget/targets/widget/WidgetLiveActivity.swift +75 -0
- package/recipes/ios-widget/targets/widget/expo-target.config.js +10 -0
- package/recipes/ios-widget/targets/widget/generated.entitlements +5 -0
- package/recipes/ios-widget/targets/widget/index.swift +18 -0
- package/recipes/ios-widget/targets/widget/widgets.swift +96 -0
- package/recipes/ios-widget@latest.zip +0 -0
- package/recipes/payments/apps/native/src/app/(root)/(protected)/paywall/index.tsx +74 -0
- package/recipes/payments/apps/native/src/app/(root)/(protected)/paywall/local.tsx +25 -0
- package/recipes/payments/apps/native/src/app/(root)/(protected)/paywall/remote.tsx +23 -0
- package/recipes/payments/apps/native/src/features/payments/README.md +200 -0
- package/recipes/payments/apps/native/src/features/payments/app/local-paywall.tsx +194 -0
- package/recipes/payments/apps/native/src/features/payments/app/remote-paywall.tsx +79 -0
- package/recipes/payments/apps/native/src/features/payments/components/payment-initializer.tsx +95 -0
- package/recipes/payments/apps/native/src/features/payments/components/paywall-error-state.tsx +60 -0
- package/recipes/payments/apps/native/src/features/payments/components/paywall-local-mode.tsx +116 -0
- package/recipes/payments/apps/native/src/features/payments/components/paywall-product-card.tsx +133 -0
- package/recipes/payments/apps/native/src/features/payments/components/paywall-remote-mode.tsx +146 -0
- package/recipes/payments/apps/native/src/features/payments/hooks/use-entitlement.ts +63 -0
- package/recipes/payments/apps/native/src/features/payments/index.ts +8 -0
- package/recipes/payments/apps/native/src/features/payments/services/revenuecat-adapter.ts +407 -0
- package/recipes/payments/recipe.json +58 -0
- package/recipes/payments@latest.zip +0 -0
- package/src/__tests__/integration.test.ts +249 -0
- package/src/__tests__/recipes.test.ts +168 -0
- package/src/commands/__tests__/init.test.ts +112 -0
- package/src/commands/__tests__/platform.test.ts +141 -0
- package/src/commands/add.ts +4 -5
- package/src/commands/init.ts +14 -15
- package/src/commands/platform.ts +309 -0
- package/src/core/journal.ts +42 -25
- package/src/core/recipes.ts +8 -40
- package/src/index.ts +2 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Payments Feature - RevenueCat Integration
|
|
2
|
+
|
|
3
|
+
This feature provides a complete RevenueCat integration for handling in-app purchases and subscriptions in your React Native Expo app.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ RevenueCat SDK initialization on app startup
|
|
8
|
+
- ✅ Automatic user authentication sync with RevenueCat
|
|
9
|
+
- ✅ Product offerings fetching
|
|
10
|
+
- ✅ Purchase handling (subscriptions and consumable products)
|
|
11
|
+
- ✅ Subscription status checking
|
|
12
|
+
- ✅ Purchase restoration
|
|
13
|
+
- ✅ Complete TypeScript support
|
|
14
|
+
- ✅ Comprehensive error handling
|
|
15
|
+
- ✅ Testing utilities
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
### 1. Environment Variables
|
|
20
|
+
|
|
21
|
+
Make sure your RevenueCat API keys are configured in your `.env.local` file:
|
|
22
|
+
|
|
23
|
+
```env
|
|
24
|
+
REVENUECAT_API_KEY_APPLE=your_apple_key_here
|
|
25
|
+
REVENUECAT_API_KEY_GOOGLE=your_google_key_here
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Automatic Initialization
|
|
29
|
+
|
|
30
|
+
The `PaymentInitializer` component is already added to your app's root layout and will:
|
|
31
|
+
|
|
32
|
+
- Initialize RevenueCat SDK on app startup
|
|
33
|
+
- Automatically set user ID when users log in
|
|
34
|
+
- Handle user logout from RevenueCat
|
|
35
|
+
|
|
36
|
+
No additional setup required!
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Checking User Entitlements
|
|
41
|
+
|
|
42
|
+
Use the `useEntitlement` hook to check if a user has access to premium features:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useEntitlement } from '@/features/payments';
|
|
46
|
+
|
|
47
|
+
export const PremiumFeature = () => {
|
|
48
|
+
const { isEntitled, isLoading, error } = useEntitlement('premium');
|
|
49
|
+
|
|
50
|
+
if (isLoading) {
|
|
51
|
+
return <Text>Checking subscription...</Text>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error) {
|
|
55
|
+
return <Text>Error: {error}</Text>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isEntitled) {
|
|
59
|
+
return <PaywallScreen />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return <PremiumContent />;
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Fetching Product Offerings
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { RevenueCatAdapter } from '@/features/payments';
|
|
70
|
+
|
|
71
|
+
const paymentService = new RevenueCatAdapter();
|
|
72
|
+
|
|
73
|
+
// Fetch available products
|
|
74
|
+
const offerings = await paymentService.fetchProductOfferings('remote');
|
|
75
|
+
console.log('Available products:', offerings);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Making Purchases
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { RevenueCatAdapter } from '@/features/payments';
|
|
82
|
+
|
|
83
|
+
const paymentService = new RevenueCatAdapter();
|
|
84
|
+
|
|
85
|
+
// Purchase a subscription
|
|
86
|
+
const result = await paymentService.purchaseProduct('premium_monthly');
|
|
87
|
+
|
|
88
|
+
if (result.success) {
|
|
89
|
+
console.log('Purchase successful!');
|
|
90
|
+
// Handle successful purchase
|
|
91
|
+
} else {
|
|
92
|
+
console.log('Purchase failed:', result.error);
|
|
93
|
+
// Handle error
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Handling Consumable Products (Credits)
|
|
98
|
+
|
|
99
|
+
For consumable products like credits, provide a callback to record the purchase:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
const result = await paymentService.purchaseProduct(
|
|
103
|
+
'credits_100',
|
|
104
|
+
undefined, // offering identifier (optional)
|
|
105
|
+
async ({ productId, quantity }) => {
|
|
106
|
+
// Record the credit purchase in your backend
|
|
107
|
+
await recordCreditPurchase(productId, quantity);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Restoring Purchases
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
const result = await paymentService.restorePurchases();
|
|
116
|
+
|
|
117
|
+
if (result.success) {
|
|
118
|
+
console.log('Restored entitlements:', result.restoredEntitlements);
|
|
119
|
+
} else {
|
|
120
|
+
console.log('Restore failed:', result.error);
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
### Components
|
|
127
|
+
|
|
128
|
+
- **`PaymentInitializer`** - Handles SDK initialization and user auth sync
|
|
129
|
+
- **`useEntitlement`** - Hook for checking subscription status
|
|
130
|
+
|
|
131
|
+
### Services
|
|
132
|
+
|
|
133
|
+
- **`RevenueCatAdapter`** - Main service implementing the PaymentService interface
|
|
134
|
+
- **`PaymentService`** - Core interface defined in `@/core/payments`
|
|
135
|
+
|
|
136
|
+
### Key Features
|
|
137
|
+
|
|
138
|
+
1. **Modular Design** - Uses adapter pattern for easy swapping of payment providers
|
|
139
|
+
2. **Type Safety** - Full TypeScript support with proper types
|
|
140
|
+
3. **Error Handling** - Graceful error handling with logging
|
|
141
|
+
4. **Testing** - Comprehensive test coverage
|
|
142
|
+
5. **Platform Support** - Works on both iOS and Android
|
|
143
|
+
|
|
144
|
+
## Error Handling
|
|
145
|
+
|
|
146
|
+
The integration includes comprehensive error handling:
|
|
147
|
+
|
|
148
|
+
- SDK initialization failures won't crash the app
|
|
149
|
+
- Purchase errors are returned as structured responses
|
|
150
|
+
- All errors are logged for debugging
|
|
151
|
+
- Network issues are handled gracefully
|
|
152
|
+
|
|
153
|
+
## Testing
|
|
154
|
+
|
|
155
|
+
Run the payment feature tests:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm test src/features/payments
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Configuration
|
|
162
|
+
|
|
163
|
+
### Product ID Patterns
|
|
164
|
+
|
|
165
|
+
For consumable products (credits), the adapter automatically detects quantities based on product ID patterns:
|
|
166
|
+
|
|
167
|
+
- `credits_100` → 100 credits
|
|
168
|
+
- `100_credits` → 100 credits
|
|
169
|
+
- `credit_50` → 50 credits
|
|
170
|
+
|
|
171
|
+
### Entitlement IDs
|
|
172
|
+
|
|
173
|
+
Make sure your entitlement identifiers in RevenueCat match what you use in the `useEntitlement` hook.
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
### Common Issues
|
|
178
|
+
|
|
179
|
+
1. **SDK Not Initialized Error**
|
|
180
|
+
- Ensure API keys are set in environment variables
|
|
181
|
+
- Check that `PaymentInitializer` is included in app root
|
|
182
|
+
|
|
183
|
+
2. **Purchase Failures**
|
|
184
|
+
- Verify product IDs match your RevenueCat configuration
|
|
185
|
+
- Check that offerings are properly configured in RevenueCat dashboard
|
|
186
|
+
|
|
187
|
+
3. **Entitlement Not Found**
|
|
188
|
+
- Ensure entitlement ID matches your RevenueCat configuration
|
|
189
|
+
- Check that user is properly logged in
|
|
190
|
+
|
|
191
|
+
### Debug Logging
|
|
192
|
+
|
|
193
|
+
In development mode, debug logs are automatically enabled. Check your console for detailed RevenueCat logs.
|
|
194
|
+
|
|
195
|
+
## Next Steps
|
|
196
|
+
|
|
197
|
+
1. Configure your products in the RevenueCat dashboard
|
|
198
|
+
2. Set up your entitlements and offerings
|
|
199
|
+
3. Test purchases in sandbox mode
|
|
200
|
+
4. Implement your paywall UI using the provided hooks and services
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Alert,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
11
|
+
import { paymentsApi } from '@/api-client/payments';
|
|
12
|
+
import { FocusAwareStatusBar } from '@/components/ui';
|
|
13
|
+
import type {
|
|
14
|
+
PaymentService,
|
|
15
|
+
ProductOffering,
|
|
16
|
+
} from '@/core/payments/payment-service';
|
|
17
|
+
import { translate } from '@/lib';
|
|
18
|
+
import { useThemeConfig } from '@/lib/use-theme-config';
|
|
19
|
+
|
|
20
|
+
import { PaywallLocalMode } from '../components/paywall-local-mode';
|
|
21
|
+
|
|
22
|
+
interface LocalPaywallProps {
|
|
23
|
+
paymentService: PaymentService;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function LocalPaywall({ paymentService }: LocalPaywallProps) {
|
|
27
|
+
const theme = useThemeConfig();
|
|
28
|
+
const [offerings, setOfferings] = useState<ProductOffering[]>([]);
|
|
29
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [isPurchasing, setIsPurchasing] = useState(false);
|
|
32
|
+
|
|
33
|
+
// Convex mutation for recording consumable purchases
|
|
34
|
+
const recordConsumablePurchase = paymentsApi.useRecordConsumablePurchase();
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const loadOfferings = async () => {
|
|
38
|
+
try {
|
|
39
|
+
setIsLoading(true);
|
|
40
|
+
setError(null);
|
|
41
|
+
const fetchedOfferings =
|
|
42
|
+
await paymentService.fetchProductOfferings('local');
|
|
43
|
+
setOfferings(fetchedOfferings);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
setError(translate('paywall.errors.load_offerings'));
|
|
46
|
+
console.error('Error fetching offerings:', e);
|
|
47
|
+
} finally {
|
|
48
|
+
setIsLoading(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
loadOfferings();
|
|
52
|
+
}, [paymentService]);
|
|
53
|
+
|
|
54
|
+
const handleLocalPurchase = async (productId: string) => {
|
|
55
|
+
try {
|
|
56
|
+
setIsPurchasing(true);
|
|
57
|
+
await paymentService.purchaseProduct(
|
|
58
|
+
productId,
|
|
59
|
+
undefined,
|
|
60
|
+
async (details) => {
|
|
61
|
+
try {
|
|
62
|
+
await recordConsumablePurchase({
|
|
63
|
+
productId: details.productId,
|
|
64
|
+
quantity: details.quantity,
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Failed to record consumable purchase:', error);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
const errorMessage = String(e);
|
|
73
|
+
if (
|
|
74
|
+
errorMessage.includes('userCancelled') ||
|
|
75
|
+
errorMessage.includes('User cancelled')
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
Alert.alert(
|
|
80
|
+
translate('paywall.errors.purchase_failed_title'),
|
|
81
|
+
translate('paywall.errors.purchase_failed_message'),
|
|
82
|
+
);
|
|
83
|
+
console.error('Purchase error:', e);
|
|
84
|
+
} finally {
|
|
85
|
+
setIsPurchasing(false);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleRestorePurchases = async () => {
|
|
90
|
+
try {
|
|
91
|
+
setIsPurchasing(true);
|
|
92
|
+
const result = await paymentService.restorePurchases();
|
|
93
|
+
|
|
94
|
+
if (result.success) {
|
|
95
|
+
const restoredCount = result.restoredEntitlements?.length ?? 0;
|
|
96
|
+
const message =
|
|
97
|
+
restoredCount > 0
|
|
98
|
+
? translate('paywall.restore.success_with_count', {
|
|
99
|
+
count: restoredCount,
|
|
100
|
+
})
|
|
101
|
+
: translate('paywall.restore.none');
|
|
102
|
+
Alert.alert(translate('paywall.restore.complete_title'), message);
|
|
103
|
+
} else {
|
|
104
|
+
Alert.alert(
|
|
105
|
+
translate('paywall.restore.failed_title'),
|
|
106
|
+
result.error ?? translate('paywall.restore.failed_message'),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
Alert.alert(
|
|
111
|
+
translate('paywall.restore.failed_title'),
|
|
112
|
+
translate('paywall.restore.error_message'),
|
|
113
|
+
);
|
|
114
|
+
console.error('Restore purchases error:', e);
|
|
115
|
+
} finally {
|
|
116
|
+
setIsPurchasing(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const retryLoadOfferings = async () => {
|
|
121
|
+
setError(null);
|
|
122
|
+
setIsLoading(true);
|
|
123
|
+
try {
|
|
124
|
+
const fetchedOfferings =
|
|
125
|
+
await paymentService.fetchProductOfferings('local');
|
|
126
|
+
setOfferings(fetchedOfferings);
|
|
127
|
+
} catch {
|
|
128
|
+
setError(translate('paywall.errors.load_offerings'));
|
|
129
|
+
} finally {
|
|
130
|
+
setIsLoading(false);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<FocusAwareStatusBar />
|
|
137
|
+
<View
|
|
138
|
+
className="flex-1"
|
|
139
|
+
style={{ backgroundColor: theme.colors.background }}
|
|
140
|
+
>
|
|
141
|
+
<ScrollView
|
|
142
|
+
className="flex-1"
|
|
143
|
+
showsVerticalScrollIndicator={false}
|
|
144
|
+
contentContainerStyle={{ paddingTop: 60, paddingBottom: 20 }}
|
|
145
|
+
>
|
|
146
|
+
<PaywallLocalMode
|
|
147
|
+
isLoading={isLoading}
|
|
148
|
+
error={error}
|
|
149
|
+
offerings={offerings}
|
|
150
|
+
onPurchase={handleLocalPurchase}
|
|
151
|
+
onRetry={retryLoadOfferings}
|
|
152
|
+
onRestorePurchases={handleRestorePurchases}
|
|
153
|
+
isPurchasing={isPurchasing}
|
|
154
|
+
primaryColor={theme.colors.primary}
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
{/* Restore Purchases Button */}
|
|
158
|
+
<View className="mx-4 mt-8">
|
|
159
|
+
<Pressable
|
|
160
|
+
onPress={handleRestorePurchases}
|
|
161
|
+
disabled={isPurchasing}
|
|
162
|
+
className="items-center py-3"
|
|
163
|
+
>
|
|
164
|
+
<Text
|
|
165
|
+
className="text-base"
|
|
166
|
+
style={{
|
|
167
|
+
color: theme.colors.mutedForeground,
|
|
168
|
+
opacity: isPurchasing ? 0.5 : 1,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{translate('paywall.restore_purchases')}
|
|
172
|
+
</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
|
|
175
|
+
{isPurchasing && (
|
|
176
|
+
<View className="mt-2 flex-row items-center justify-center">
|
|
177
|
+
<ActivityIndicator
|
|
178
|
+
size="small"
|
|
179
|
+
color={theme.colors.mutedForeground}
|
|
180
|
+
/>
|
|
181
|
+
<Text
|
|
182
|
+
className="ml-2"
|
|
183
|
+
style={{ color: theme.colors.mutedForeground }}
|
|
184
|
+
>
|
|
185
|
+
{translate('paywall.processing')}
|
|
186
|
+
</Text>
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
</View>
|
|
190
|
+
</ScrollView>
|
|
191
|
+
</View>
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Alert, View } from 'react-native';
|
|
3
|
+
import Purchases, {
|
|
4
|
+
type CustomerInfo,
|
|
5
|
+
type PurchasesOffering,
|
|
6
|
+
} from 'react-native-purchases';
|
|
7
|
+
import RevenueCatUI from 'react-native-purchases-ui';
|
|
8
|
+
|
|
9
|
+
import { FocusAwareStatusBar } from '@/components/ui';
|
|
10
|
+
import { translate } from '@/lib';
|
|
11
|
+
import { useThemeConfig } from '@/lib/use-theme-config';
|
|
12
|
+
|
|
13
|
+
export default function RemotePaywall() {
|
|
14
|
+
const theme = useThemeConfig();
|
|
15
|
+
const [offering, setOffering] = useState<PurchasesOffering | null>(null);
|
|
16
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const loadOfferings = async () => {
|
|
20
|
+
try {
|
|
21
|
+
setIsLoading(true);
|
|
22
|
+
// Get offerings directly from RevenueCat to get the correct type
|
|
23
|
+
const offerings = await Purchases.getOfferings();
|
|
24
|
+
setOffering(offerings.current);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.error('Error fetching offerings:', e);
|
|
27
|
+
Alert.alert(
|
|
28
|
+
translate('common.error'),
|
|
29
|
+
translate('paywall.errors.load_offerings'),
|
|
30
|
+
);
|
|
31
|
+
} finally {
|
|
32
|
+
setIsLoading(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
loadOfferings();
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const handleRestoreCompleted = ({
|
|
39
|
+
customerInfo,
|
|
40
|
+
}: {
|
|
41
|
+
customerInfo: CustomerInfo;
|
|
42
|
+
}) => {
|
|
43
|
+
console.log('Restore completed:', customerInfo);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (isLoading) {
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<FocusAwareStatusBar />
|
|
50
|
+
<View
|
|
51
|
+
className="flex-1 items-center justify-center"
|
|
52
|
+
style={{ backgroundColor: theme.colors.background }}
|
|
53
|
+
>
|
|
54
|
+
{/* Could add a loading spinner here */}
|
|
55
|
+
</View>
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<FocusAwareStatusBar />
|
|
63
|
+
<View
|
|
64
|
+
className="flex-1"
|
|
65
|
+
style={{ backgroundColor: theme.colors.background }}
|
|
66
|
+
>
|
|
67
|
+
<RevenueCatUI.Paywall
|
|
68
|
+
options={{
|
|
69
|
+
offering: offering || undefined,
|
|
70
|
+
}}
|
|
71
|
+
onRestoreCompleted={handleRestoreCompleted}
|
|
72
|
+
onDismiss={() => {
|
|
73
|
+
// Handle dismiss - could navigate back or show completion
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</View>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import * as Logger from '@/core/logging';
|
|
4
|
+
import { useAuthClientStore } from '@/lib/state';
|
|
5
|
+
|
|
6
|
+
import { RevenueCatAdapter } from '../services/revenuecat-adapter';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PaymentInitializer component that initializes RevenueCat SDK on app startup
|
|
10
|
+
* and manages user authentication state with RevenueCat.
|
|
11
|
+
* This component should be rendered at the root level of the app.
|
|
12
|
+
*/
|
|
13
|
+
export const PaymentInitializer = () => {
|
|
14
|
+
const { currentUser, isAuthenticated } = useAuthClientStore();
|
|
15
|
+
const previousAuthState = useRef<boolean | null>(null);
|
|
16
|
+
const previousUserId = useRef<string | null>(null);
|
|
17
|
+
|
|
18
|
+
// Initialize RevenueCat SDK on app startup
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const initializePayments = async () => {
|
|
21
|
+
try {
|
|
22
|
+
await RevenueCatAdapter.initialize();
|
|
23
|
+
Logger.info(
|
|
24
|
+
'[PaymentInitializer] RevenueCat SDK initialized successfully',
|
|
25
|
+
);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
Logger.error(
|
|
28
|
+
'[PaymentInitializer] Failed to initialize RevenueCat SDK: ' +
|
|
29
|
+
String(error),
|
|
30
|
+
);
|
|
31
|
+
// Don't throw error here to prevent app crash
|
|
32
|
+
// The adapter will handle individual method calls gracefully
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
initializePayments();
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
// Handle user authentication state changes (with Fast Refresh protection)
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handleAuthStateChange = async () => {
|
|
42
|
+
if (!RevenueCatAdapter.getIsInitialized()) {
|
|
43
|
+
return; // SDK not ready yet
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const currentUserId = currentUser?.id || null;
|
|
47
|
+
|
|
48
|
+
// Skip if this is the first run (initialization)
|
|
49
|
+
if (previousAuthState.current === null) {
|
|
50
|
+
previousAuthState.current = isAuthenticated;
|
|
51
|
+
previousUserId.current = currentUserId;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only react to actual auth state changes, not Fast Refresh temporary states
|
|
56
|
+
const authStateChanged = previousAuthState.current !== isAuthenticated;
|
|
57
|
+
const userIdChanged = previousUserId.current !== currentUserId;
|
|
58
|
+
|
|
59
|
+
if (!authStateChanged && !userIdChanged) {
|
|
60
|
+
return; // No actual change
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (isAuthenticated && currentUserId) {
|
|
65
|
+
// User logged in or switched users - set user ID in RevenueCat
|
|
66
|
+
await RevenueCatAdapter.setUserId(currentUserId);
|
|
67
|
+
Logger.info(
|
|
68
|
+
`[PaymentInitializer] User logged in to RevenueCat: ${currentUserId}`,
|
|
69
|
+
);
|
|
70
|
+
} else if (!isAuthenticated && previousAuthState.current === true) {
|
|
71
|
+
// TEMPORARILY DISABLED: Prevent RevenueCat logout during Fast Refresh
|
|
72
|
+
// This was causing auth session invalidation during development
|
|
73
|
+
// await RevenueCatAdapter.logOut();
|
|
74
|
+
Logger.info(
|
|
75
|
+
'[PaymentInitializer] Skipping RevenueCat logout to prevent Fast Refresh issues',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Update previous state
|
|
80
|
+
previousAuthState.current = isAuthenticated;
|
|
81
|
+
previousUserId.current = currentUserId;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
Logger.error(
|
|
84
|
+
'[PaymentInitializer] Failed to handle auth state change: ' +
|
|
85
|
+
String(error),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
handleAuthStateChange();
|
|
91
|
+
}, [isAuthenticated, currentUser?.id]);
|
|
92
|
+
|
|
93
|
+
// This component doesn't render anything
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { Text, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui';
|
|
5
|
+
import { translate } from '@/lib';
|
|
6
|
+
import { useThemeConfig } from '@/lib/use-theme-config';
|
|
7
|
+
|
|
8
|
+
type PaywallErrorStateProps = {
|
|
9
|
+
error: string;
|
|
10
|
+
onRetry: () => void;
|
|
11
|
+
onRestorePurchases: () => void;
|
|
12
|
+
isRestoringPurchases: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const PaywallErrorState: React.FC<PaywallErrorStateProps> = ({
|
|
16
|
+
error,
|
|
17
|
+
onRetry,
|
|
18
|
+
onRestorePurchases,
|
|
19
|
+
isRestoringPurchases,
|
|
20
|
+
}) => {
|
|
21
|
+
const theme = useThemeConfig();
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
{/* Error Message */}
|
|
26
|
+
<View className="mx-4 mb-6">
|
|
27
|
+
<View
|
|
28
|
+
className="rounded-2xl p-6"
|
|
29
|
+
style={{ backgroundColor: `${theme.colors.destructive}10` }}
|
|
30
|
+
>
|
|
31
|
+
<Text
|
|
32
|
+
className="mb-4 text-center font-semibold"
|
|
33
|
+
style={{ color: theme.colors.destructive }}
|
|
34
|
+
>
|
|
35
|
+
{error || translate('paywall.errors.loading_title')}
|
|
36
|
+
</Text>
|
|
37
|
+
<Button
|
|
38
|
+
label={translate('paywall.retry')}
|
|
39
|
+
onPress={onRetry}
|
|
40
|
+
variant="outline"
|
|
41
|
+
/>
|
|
42
|
+
</View>
|
|
43
|
+
</View>
|
|
44
|
+
|
|
45
|
+
{/* Restore Purchases */}
|
|
46
|
+
<View className="mx-4 mb-6">
|
|
47
|
+
<Button
|
|
48
|
+
label={
|
|
49
|
+
isRestoringPurchases
|
|
50
|
+
? translate('paywall.processing')
|
|
51
|
+
: translate('paywall.restore_purchases')
|
|
52
|
+
}
|
|
53
|
+
onPress={onRestorePurchases}
|
|
54
|
+
disabled={isRestoringPurchases}
|
|
55
|
+
variant="outline"
|
|
56
|
+
/>
|
|
57
|
+
</View>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
};
|