diff --git a/README.md b/README.md
index 9fc444a..545cdda 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,7 @@ To enable verbose logging, set the `DEBUG` (or `EXPO_PUBLIC_DEBUG` for mobile) v
* Everything: `EXPO_PUBLIC_DEBUG=*` (extremely noisy!)
* App: `EXPO_PUBLIC_DEBUG=app:*`
+* RevenueCat: `EXPO_PUBLIC_DEBUG=revenuecat:*`
## Deploying
diff --git a/REVENUECAT_SETUP.md b/REVENUECAT_SETUP.md
new file mode 100644
index 0000000..8b798a6
--- /dev/null
+++ b/REVENUECAT_SETUP.md
@@ -0,0 +1,214 @@
+# RevenueCat Integration Setup Guide
+
+## Overview
+
+RevenueCat SDK has been successfully integrated into your Plannting mobile app. This guide will help you complete the setup and configure your products.
+
+## Installation
+
+The packages have been added to `package.json`. You need to install them:
+
+```bash
+cd apps/mobile
+npm install
+# or
+npx expo install react-native-purchases react-native-purchases-ui
+```
+
+## Configuration
+
+### 1. API Key
+
+The API key is already configured in `apps/mobile/src/config/index.ts`:
+- Test API Key: `test_jNLGmFtxUPIzgIsTensvdythpeN`
+- For production, set `EXPO_PUBLIC_REVENUECAT_API_KEY` environment variable
+
+### 2. RevenueCat Dashboard Setup
+
+1. **Create Products in App Store Connect / Google Play Console:**
+ - Monthly subscription: `monthly`
+ - Yearly subscription: `yearly`
+ - Lifetime purchase: `lifetime`
+
+2. **Configure in RevenueCat Dashboard:**
+ - Go to your RevenueCat project
+ - Navigate to Products
+ - Add your products with the identifiers: `monthly`, `yearly`, `lifetime`
+ - Create an entitlement: `Plannting Pro`
+ - Attach all three products to the `Plannting Pro` entitlement
+
+3. **Create an Offering:**
+ - Go to Offerings in RevenueCat dashboard
+ - Create a new offering (e.g., "Default")
+ - Add your packages (monthly, yearly, lifetime)
+ - Set one as the default offering
+
+## Features Implemented
+
+### ✅ Core Functionality
+- RevenueCat SDK initialization with API key
+- User identification and login/logout
+- Customer info retrieval
+- Entitlement checking for "Plannting Pro"
+- Purchase flow with error handling
+- Restore purchases functionality
+
+### ✅ UI Components
+- **Paywall Component** (`src/components/Paywall.tsx`)
+ - Native RevenueCat Paywall UI support
+ - Custom paywall fallback
+ - Package selection (Monthly, Yearly, Lifetime)
+ - Purchase handling with loading states
+ - Restore purchases button
+
+- **Customer Center Component** (`src/components/CustomerCenter.tsx`)
+ - Native RevenueCat Customer Info View
+ - Subscription status display
+ - Subscription details
+ - Restore purchases functionality
+
+### ✅ Integration
+- RevenueCat Context Provider integrated into app layout
+- Settings screen updated with subscription management
+- Automatic initialization when user logs in
+- Automatic cleanup when user logs out
+
+## File Structure
+
+```
+apps/mobile/src/
+├── services/
+│ └── revenueCat/
+│ └── index.ts # RevenueCat service functions
+├── contexts/
+│ └── RevenueCatContext.tsx # RevenueCat context provider
+├── hooks/
+│ └── useRevenueCat.ts # Custom hooks for RevenueCat
+├── components/
+│ ├── Paywall.tsx # Paywall component
+│ └── CustomerCenter.tsx # Customer Center component
+└── config/
+ ├── index.ts # Configuration (includes API key)
+ └── types.ts # Type definitions
+```
+
+## Usage Examples
+
+### Check if User Has Pro
+```typescript
+import { useIsPro } from '../hooks/useRevenueCat'
+
+function MyComponent() {
+ const { isPro, isLoading } = useIsPro()
+
+ if (isLoading) return
+ if (isPro) return
+ return
+}
+```
+
+### Show Paywall
+```typescript
+import { Paywall } from '../components/Paywall'
+
+function MyComponent() {
+ const [showPaywall, setShowPaywall] = useState(false)
+
+ return (
+ <>
+
+ setShowPaywall(false)}
+ onPurchaseComplete={() => {
+ console.log('Purchase completed!')
+ }}
+ />
+ >
+ )
+}
+```
+
+### Access Customer Info
+```typescript
+import { useCustomerInfo } from '../hooks/useRevenueCat'
+
+function MyComponent() {
+ const { customerInfo, isLoading, refreshCustomerInfo } = useCustomerInfo()
+
+ // Access customer info
+ console.log(customerInfo?.entitlements.active)
+}
+```
+
+## Testing
+
+### Sandbox Testing
+1. Use test API key (already configured)
+2. Create sandbox test accounts in App Store Connect / Google Play Console
+3. Test purchases in development builds
+4. Verify entitlement status updates correctly
+
+### Production Checklist
+- [ ] Replace test API key with production key
+- [ ] Configure products in App Store Connect / Google Play Console
+- [ ] Set up products and entitlements in RevenueCat dashboard
+- [ ] Create and configure offering in RevenueCat
+- [ ] Test end-to-end purchase flow
+- [ ] Test restore purchases
+- [ ] Test subscription renewal
+- [ ] Test cancellation flow
+
+## Product Identifiers
+
+The app expects these product identifiers:
+- `monthly` - Monthly subscription
+- `yearly` - Yearly subscription
+- `lifetime` - Lifetime purchase
+
+## Entitlement Identifier
+
+- `Plannting Pro` - Main entitlement that grants access to premium features
+
+## Best Practices
+
+1. **Error Handling**: All purchase operations include comprehensive error handling
+2. **Loading States**: UI shows loading indicators during async operations
+3. **User Feedback**: Alerts inform users of success/failure
+4. **Automatic Refresh**: Customer info refreshes after purchases
+5. **User Identification**: RevenueCat automatically links purchases to user IDs
+
+## Troubleshooting
+
+### Common Issues
+
+1. **"No packages available"**
+ - Check that products are configured in RevenueCat dashboard
+ - Verify offering is set as default
+ - Ensure products are attached to entitlement
+
+2. **Purchases not restoring**
+ - Verify user is logged in with same account
+ - Check RevenueCat dashboard for purchase records
+ - Ensure restore is called after user login
+
+3. **Entitlement not active**
+ - Check product configuration in RevenueCat
+ - Verify subscription status in App Store/Play Store
+ - Check expiration dates in customer info
+
+## Next Steps
+
+1. Install the npm packages
+2. Configure products in RevenueCat dashboard
+3. Test the integration in development
+4. Set up production API key
+5. Deploy and test in production
+
+## Support
+
+For RevenueCat-specific issues, refer to:
+- [RevenueCat Documentation](https://www.revenuecat.com/docs)
+- [React Native SDK Docs](https://www.revenuecat.com/docs/getting-started/installation/reactnative)
+- [Paywalls Documentation](https://www.revenuecat.com/docs/tools/paywalls)
+- [Customer Center Documentation](https://www.revenuecat.com/docs/tools/customer-center)
diff --git a/apps/mobile/.env.dev b/apps/mobile/.env.dev
new file mode 100644
index 0000000..6c4e3b6
--- /dev/null
+++ b/apps/mobile/.env.dev
@@ -0,0 +1,12 @@
+# App name for display on device home screens
+APP_NAME=Plannting Dev
+
+# Debug logging
+# See readme for "Verbose debug logging"
+EXPO_PUBLIC_DEBUG=revenuecat:*
+
+# URL to API
+EXPO_PUBLIC_API_BASE_URL=https://api.plannting.completecodesolutions.com
+
+# RevenueCat
+EXPO_PUBLIC_REVENUECAT_API_KEY=test_jNLGmFtxUPIzgIsTensvdythpeN
diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example
index 6b12d94..ab5b41b 100644
--- a/apps/mobile/.env.example
+++ b/apps/mobile/.env.example
@@ -1,5 +1,9 @@
# Debug logging
+# See readme for "Verbose debug logging"
EXPO_PUBLIC_DEBUG=app:*
# URL to API
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
+
+# RevenueCat
+EXPO_PUBLIC_REVENUECAT_API_KEY=****
diff --git a/apps/mobile/.env.production b/apps/mobile/.env.production
deleted file mode 100644
index b85b048..0000000
--- a/apps/mobile/.env.production
+++ /dev/null
@@ -1,2 +0,0 @@
-# URL to API
-EXPO_PUBLIC_API_BASE_URL=https://api.plannting.completecodesolutions.com
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index 984e9b9..a1b3248 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -30,7 +30,7 @@ yarn-error.*
.DS_Store
*.pem
-!.env.production
+!.env.dev
# typescript
*.tsbuildinfo
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 4bdc3ce..e3200c2 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -1,11 +1,15 @@
import { version } from './package.json'
+import * as dotenv from 'dotenv'
+
+dotenv.config()
+
export default {
"expo": {
- "name": "Plannting",
- "slug": "plannting",
+ "name": process.env.APP_NAME || 'Plannting',
+ "slug": 'plannting',
"version": version.replace(/^([0-9]*\.[0-9]*\.[0-9]*).*/, '$1'),
- runtimeVersion: '5',
+ runtimeVersion: '6',
scheme: 'plannting',
"orientation": "portrait",
"icon": "./assets/icon.png",
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index a3987cf..94540c0 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -11,19 +11,34 @@
},
"developmentClient": false,
"autoIncrement": true,
- "env": { "EXPO_PUBLIC_API_BASE_URL": "https://api.plannting.completecodesolutions.com" }
+ "env": {
+ "EXPO_PUBLIC_API_BASE_URL": "https://api.plannting.completecodesolutions.com",
+ "EXPO_PUBLIC_REVENUECAT_API_KEY": "****"
+ }
+ },
+ "uat": {
+ "extends": "production",
+ "env": {
+ "EXPO_PUBLIC_API_BASE_URL": "https://api.plannting.completecodesolutions.com",
+ "EXPO_PUBLIC_REVENUECAT_API_KEY": "test_jNLGmFtxUPIzgIsTensvdythpeN"
+ }
},
- "on-demand": {
+ "dev": {
"extends": "production",
"developmentClient": true,
- "distribution": "internal",
- "env": { "EXPO_PUBLIC_API_BASE_URL": "https://api.plannting.completecodesolutions.com" }
+ "distribution": "internal"
}
},
"submit": {
"production": {
"ios": {
- "ascAppId": "abc123",
+ "ascAppId": "6756863931",
+ "appleTeamId": "X6H3QV6VCE"
+ }
+ },
+ "uat": {
+ "ios": {
+ "ascAppId": "6756863931",
"appleTeamId": "X6H3QV6VCE"
}
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index bcbf3e3..a05bdf5 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -10,8 +10,9 @@
"lint": "eslint src --ext .ts",
"build:development:add-device": "eas device:create",
"build:development:list-devices": "eas device:list",
- "build:development:build": "eas build --clear-cache --platform ios --profile on-demand --no-wait",
- "build:development:update": "ts-node ../../scripts/updateDevelopmentBuild.ts"
+ "build:development:build": "eas build --clear-cache --platform ios --profile dev --no-wait",
+ "build:development:update": "ts-node ../../scripts/updateDevelopmentBuild.ts",
+ "build:uat": "eas build --profile uat --platform ios --auto-submit --no-wait"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
@@ -35,6 +36,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-purchases": "^9.6.11",
+ "react-native-purchases-ui": "^9.6.11",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"superjson": "^1.13.3",
diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx
index ee2fd62..7666b91 100644
--- a/apps/mobile/src/app/(tabs)/settings.tsx
+++ b/apps/mobile/src/app/(tabs)/settings.tsx
@@ -5,12 +5,16 @@ import { Ionicons } from '@expo/vector-icons'
import * as Localization from 'expo-localization'
import * as ct from 'countries-and-timezones'
+import { CustomerCenter } from '../../components/CustomerCenter'
+import { Paywall } from '../../components/Paywall'
import { ScreenTitle } from '../../components/ScreenTitle'
import { ScreenWrapper } from '../../components/ScreenWrapper'
import { Version } from '../../components/Version'
import { useAuth } from '../../contexts/AuthContext'
+import { useRevenueCat } from '../../hooks/useRevenueCat'
+
import { trpc } from '../../lib/trpc'
import { palette } from '../../styles'
@@ -86,10 +90,13 @@ const TIMEZONES = getSupportedTimezones()
export function SettingsScreen() {
const queryClient = useQueryClient()
const { user, logout } = useAuth()
+ const { isPremium, isLoading: isRevenueCatLoading, refreshCustomerInfo } = useRevenueCat()
const [timezone, setTimezone] = useState('')
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(true)
const [showTimezoneModal, setShowTimezoneModal] = useState(false)
const [timezoneSearchQuery, setTimezoneSearchQuery] = useState('')
+ const [showPaywall, setShowPaywall] = useState(false)
+ const [showCustomerCenter, setShowCustomerCenter] = useState(false)
const searchInputRef = useRef(null)
// Get user settings
@@ -210,6 +217,55 @@ export function SettingsScreen() {
)}
+
+ Subscription
+
+
+
+ Plannting Premium
+
+ {isPremium
+ ? 'You have access to all premium features.'
+ : 'Unlock premium features with a subscription.'}
+
+
+
+ {isRevenueCatLoading ? (
+ Loading...
+ ) : isPremium ? (
+ <>
+
+
+ Active
+
+ >
+ ) : (
+ setShowPaywall(true)}
+ >
+ Upgrade
+
+ )}
+
+
+
+ {isPremium && (
+ setShowCustomerCenter(true)}
+ >
+
+ Manage Subscription
+
+ View subscription details and manage your account.
+
+
+ ›
+
+ )}
+
+
Notifications
@@ -217,7 +273,7 @@ export function SettingsScreen() {
Push Notifications
- Receive daily reminders at 8am when chores are due
+ Receive reminders when chores are due.
+
+ setShowPaywall(false)}
+ onPurchaseComplete={async () => {
+ setShowPaywall(false)
+
+ // Manually refreshing customer info should not be necessary, but context is failing to update automatically
+ await refreshCustomerInfo()
+ }}
+ />
+
+ {showCustomerCenter && (
+ setShowCustomerCenter(false)}
+ >
+ setShowCustomerCenter(false)}
+ />
+
+ )}
)
}
@@ -502,6 +583,31 @@ const localStyles = StyleSheet.create({
color: '#666',
textAlign: 'center',
},
+ subscriptionStatus: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ subscriptionStatusText: {
+ fontSize: 14,
+ color: '#666',
+ fontWeight: '500',
+ },
+ subscriptionStatusActive: {
+ color: palette.brandPrimary,
+ fontWeight: '600',
+ },
+ upgradeButton: {
+ backgroundColor: palette.brandPrimary,
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderRadius: 8,
+ },
+ upgradeButtonText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
})
export default SettingsScreen
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 9c6b468..141cafc 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -5,6 +5,7 @@ import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import { AuthProvider, useAuth } from '../contexts/AuthContext'
+import { RevenueCatProvider } from '../contexts/RevenueCatContext'
import { usePushNotifications } from '../hooks/usePushNotifications'
@@ -63,9 +64,11 @@ function App() {
return (
-
+
+
-
+
+
)
diff --git a/apps/mobile/src/components/CustomerCenter.tsx b/apps/mobile/src/components/CustomerCenter.tsx
new file mode 100644
index 0000000..7a5f96b
--- /dev/null
+++ b/apps/mobile/src/components/CustomerCenter.tsx
@@ -0,0 +1,294 @@
+import React, { useState } from 'react'
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ ScrollView,
+ Alert,
+ ActivityIndicator,
+} from 'react-native'
+import RevenueCatUI from 'react-native-purchases-ui'
+import { Ionicons } from '@expo/vector-icons'
+import { useRevenueCat, useCustomerInfo } from '../hooks/useRevenueCat'
+import { palette } from '../styles'
+import { ENTITLEMENT_IDENTIFIER } from '../services/revenueCat'
+
+interface CustomerCenterProps {
+ visible: boolean
+ onClose: () => void
+}
+
+/**
+ * Customer Center Component using RevenueCat's Customer Info View
+ */
+export function CustomerCenter({ visible, onClose }: CustomerCenterProps) {
+ const { isPremium, customerInfo, isLoading, restorePurchases } = useRevenueCat()
+ const [restoring, setRestoring] = useState(false)
+
+ const handleRestore = async () => {
+ try {
+ setRestoring(true)
+ await restorePurchases()
+ } catch (error) {
+ // Error handled in hook
+ } finally {
+ setRestoring(false)
+ }
+ }
+
+ if (!visible) return null
+
+ return (
+
+
+ Account & Subscriptions
+
+
+
+
+
+
+ {/* Status Card */}
+
+
+
+
+ {isPremium ? 'Plannting Premium Active' : 'Plannting Premium Not Active'}
+
+
+
+ {isPremium
+ ? 'You have access to all premium features.'
+ : 'Upgrade to unlock premium features.'}
+
+
+
+ {/* RevenueCat Customer Info View */}
+ {customerInfo && (
+
+ {
+ Alert.alert('Error', error.message || 'An error occurred')
+ }}
+ />
+
+ )}
+
+ {/* Manual Restore Button */}
+
+ {restoring ? (
+
+ ) : (
+ <>
+
+ Restore Purchases
+ >
+ )}
+
+
+ {/* Customer Info Details */}
+ {customerInfo && (
+
+ Subscription Details
+
+ {isPremium && customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER] && (
+
+ Status:
+ Active
+
+ )}
+
+ {customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER]?.willRenew !== undefined && (
+
+ Auto-Renew:
+
+ {customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER].willRenew
+ ? 'Enabled'
+ : 'Disabled'}
+
+
+ )}
+
+ {customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER]?.expirationDate && (
+
+ Expires:
+
+ {new Date(
+ customerInfo.entitlements.active[ENTITLEMENT_IDENTIFIER].expirationDate
+ ).toLocaleDateString()}
+
+
+ )}
+
+ {customerInfo.originalPurchaseDate && (
+
+ Original Purchase:
+
+ {new Date(customerInfo.originalPurchaseDate).toLocaleDateString()}
+
+
+ )}
+
+ {customerInfo.originalAppUserId && (
+
+ User ID:
+
+ {customerInfo.originalAppUserId}
+
+
+ )}
+
+ )}
+
+ {/* Help Text */}
+
+
+
+ Manage your subscription through the App Store or Google Play Store. For more
+ information, visit the Customer Center above.
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: palette.surface,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: palette.border,
+ backgroundColor: palette.surface,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: palette.textPrimary,
+ },
+ closeButton: {
+ padding: 4,
+ },
+ content: {
+ flex: 1,
+ },
+ contentContainer: {
+ padding: 16,
+ gap: 16,
+ },
+ statusCard: {
+ backgroundColor: palette.surfaceMuted,
+ borderRadius: 16,
+ padding: 20,
+ borderWidth: 1,
+ borderColor: palette.border,
+ },
+ statusHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginBottom: 8,
+ },
+ statusTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: palette.textPrimary,
+ },
+ statusText: {
+ fontSize: 14,
+ color: palette.textSecondary,
+ lineHeight: 20,
+ },
+ customerInfoContainer: {
+ borderRadius: 16,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: palette.border,
+ },
+ restoreButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ backgroundColor: palette.brandPrimary,
+ padding: 16,
+ borderRadius: 12,
+ },
+ restoreButtonDisabled: {
+ opacity: 0.6,
+ },
+ restoreButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ detailsCard: {
+ backgroundColor: palette.surfaceMuted,
+ borderRadius: 16,
+ padding: 20,
+ borderWidth: 1,
+ borderColor: palette.border,
+ },
+ detailsTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: palette.textPrimary,
+ marginBottom: 16,
+ },
+ detailRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: palette.border,
+ },
+ detailLabel: {
+ fontSize: 14,
+ color: palette.textSecondary,
+ fontWeight: '500',
+ },
+ detailValue: {
+ fontSize: 14,
+ color: palette.textPrimary,
+ fontWeight: '600',
+ },
+ detailValueActive: {
+ color: palette.brandPrimary,
+ },
+ detailValueSmall: {
+ fontSize: 12,
+ },
+ helpCard: {
+ flexDirection: 'row',
+ gap: 12,
+ backgroundColor: palette.surfaceMuted,
+ borderRadius: 12,
+ padding: 16,
+ borderWidth: 1,
+ borderColor: palette.border,
+ },
+ helpText: {
+ flex: 1,
+ fontSize: 12,
+ color: palette.textSecondary,
+ lineHeight: 18,
+ },
+})
diff --git a/apps/mobile/src/components/Paywall.tsx b/apps/mobile/src/components/Paywall.tsx
new file mode 100644
index 0000000..dec276c
--- /dev/null
+++ b/apps/mobile/src/components/Paywall.tsx
@@ -0,0 +1,377 @@
+import React, { useState, useEffect } from 'react'
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ ActivityIndicator,
+ ScrollView,
+ Alert,
+ Modal,
+} from 'react-native'
+import RevenueCatUI from 'react-native-purchases-ui'
+import { PurchasesPackage } from 'react-native-purchases'
+import { Ionicons } from '@expo/vector-icons'
+
+import { useRevenueCat } from '../hooks/useRevenueCat'
+
+import { PRODUCT_IDENTIFIERS, formatPrice, isLifetimeProduct } from '../services/revenueCat'
+
+import { palette } from '../styles'
+
+interface PaywallProps {
+ visible: boolean
+ onClose: () => void
+ onPurchaseComplete?: () => void
+}
+
+/**
+ * Custom Paywall Component with RevenueCat Paywall UI
+ */
+export function Paywall({ visible, onClose, onPurchaseComplete }: PaywallProps) {
+ const {
+ offering,
+ availablePackages,
+ isLoading,
+ isPremium,
+ purchasePackage,
+ restorePurchases,
+ refreshAll,
+ } = useRevenueCat()
+
+ const [purchasing, setPurchasing] = useState(null)
+ const [useNativePaywall, setUseNativePaywall] = useState(true)
+
+ useEffect(() => {
+ if (visible) {
+ refreshAll()
+ }
+ }, [visible, refreshAll])
+
+ useEffect(() => {
+ if (isPremium && visible) {
+ onPurchaseComplete?.()
+ onClose()
+ }
+ }, [isPremium, visible, onPurchaseComplete, onClose])
+
+ const handlePurchase = async (packageToPurchase: PurchasesPackage) => {
+ try {
+ setPurchasing(packageToPurchase.identifier)
+ await purchasePackage(packageToPurchase)
+ onPurchaseComplete?.()
+ onClose()
+ } catch (error) {
+ // Error is already handled in the hook
+ } finally {
+ setPurchasing(null)
+ }
+ }
+
+ // If using native paywall and offering is available
+ if (useNativePaywall && offering) {
+ return (
+
+
+ {
+ onPurchaseComplete?.()
+ onClose()
+ }}
+ onPurchaseError={({ error }) => {
+ Alert.alert('Error', error.message || 'An error occurred')
+ }}
+ onRestoreCompleted={({ customerInfo }) => {
+ Alert.alert('Success', 'Purchases restored successfully!')
+ }}
+ onRestoreError={({ error }) => {
+ Alert.alert('Error', error.message || 'An error occurred')
+ }}
+ />
+
+
+ )
+ }
+
+ // Custom paywall fallback
+ return (
+
+
+
+
+
+ Upgrade to Plannting Premium
+
+ Unlock all premium features and take your plant care to the next level
+
+
+
+ {isLoading ? (
+
+
+ Loading packages...
+
+ ) : availablePackages.length === 0 ? (
+
+ No packages available at this time.
+ {
+ setUseNativePaywall(true)
+ refreshAll()
+ }}
+ >
+ Try Native Paywall
+
+
+ ) : (
+
+ {availablePackages.map((pkg) => {
+ const isPurchasing = purchasing === pkg.identifier
+ const isLifetime = isLifetimeProduct(pkg.product)
+ const isRecommended = pkg.identifier === 'annual' || pkg.identifier.includes('yearly')
+
+ return (
+ handlePurchase(pkg)}
+ disabled={isPurchasing}
+ >
+ {isRecommended && (
+
+ BEST VALUE
+
+ )}
+
+
+ {pkg.product.title || pkg.identifier}
+
+ {isLifetime && (
+
+ LIFETIME
+
+ )}
+
+
+ {pkg.product.description || 'Premium features'}
+
+
+
+ {formatPrice(pkg.product)}
+
+ {isPurchasing ? (
+
+ ) : (
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {
+ try {
+ await restorePurchases()
+ } catch (error) {
+ // Error handled in hook
+ }
+ }}
+ >
+ Restore Purchases
+
+
+
+ Subscriptions will auto-renew unless cancelled. Cancel anytime in your account settings.
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ justifyContent: 'flex-end',
+ },
+ modalContent: {
+ backgroundColor: palette.surface,
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ maxHeight: '90%',
+ paddingTop: 16,
+ },
+ nativePaywallContainer: {
+ flex: 1,
+ backgroundColor: palette.surface,
+ },
+ closeButton: {
+ position: 'absolute',
+ top: 16,
+ right: 16,
+ zIndex: 1000,
+ padding: 8,
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ borderRadius: 20,
+ },
+ scrollContent: {
+ padding: 24,
+ paddingBottom: 40,
+ },
+ header: {
+ marginBottom: 24,
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ color: palette.textPrimary,
+ marginBottom: 8,
+ textAlign: 'center',
+ },
+ subtitle: {
+ fontSize: 16,
+ color: palette.textSecondary,
+ textAlign: 'center',
+ lineHeight: 22,
+ },
+ loadingContainer: {
+ padding: 40,
+ alignItems: 'center',
+ },
+ loadingText: {
+ marginTop: 16,
+ fontSize: 16,
+ color: palette.textSecondary,
+ },
+ emptyContainer: {
+ padding: 40,
+ alignItems: 'center',
+ },
+ emptyText: {
+ fontSize: 16,
+ color: palette.textSecondary,
+ marginBottom: 16,
+ textAlign: 'center',
+ },
+ retryButton: {
+ backgroundColor: palette.brandPrimary,
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ borderRadius: 12,
+ },
+ retryButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ packagesContainer: {
+ gap: 16,
+ marginBottom: 24,
+ },
+ packageCard: {
+ backgroundColor: palette.surfaceMuted,
+ borderRadius: 16,
+ padding: 20,
+ borderWidth: 2,
+ borderColor: palette.border,
+ position: 'relative',
+ },
+ recommendedPackage: {
+ borderColor: palette.brandPrimary,
+ borderWidth: 2,
+ backgroundColor: palette.surface,
+ },
+ recommendedBadge: {
+ position: 'absolute',
+ top: -10,
+ right: 20,
+ backgroundColor: palette.brandPrimary,
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 12,
+ },
+ recommendedText: {
+ color: '#fff',
+ fontSize: 12,
+ fontWeight: '700',
+ },
+ packageHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ packageTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: palette.textPrimary,
+ flex: 1,
+ },
+ lifetimeBadge: {
+ backgroundColor: palette.accent,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 8,
+ marginLeft: 8,
+ },
+ lifetimeText: {
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: '700',
+ },
+ packageDescription: {
+ fontSize: 14,
+ color: palette.textSecondary,
+ marginBottom: 16,
+ lineHeight: 20,
+ },
+ packageFooter: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ packagePrice: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: palette.brandPrimary,
+ },
+ restoreButton: {
+ padding: 16,
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ restoreButtonText: {
+ fontSize: 16,
+ color: palette.brandPrimary,
+ fontWeight: '600',
+ },
+ footerText: {
+ fontSize: 12,
+ color: palette.textSecondary,
+ textAlign: 'center',
+ lineHeight: 18,
+ },
+})
diff --git a/apps/mobile/src/config/index.ts b/apps/mobile/src/config/index.ts
index bca004f..9da594e 100644
--- a/apps/mobile/src/config/index.ts
+++ b/apps/mobile/src/config/index.ts
@@ -9,6 +9,9 @@ export const config: MobileConfig = {
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:3000',
},
buildNumber: Application.nativeBuildVersion ?? '0',
+ revenueCat: {
+ apiKey: process.env.EXPO_PUBLIC_REVENUECAT_API_KEY || '',
+ },
version: packageJson.version,
}
diff --git a/apps/mobile/src/config/types.ts b/apps/mobile/src/config/types.ts
index df3a047..d6be1b6 100644
--- a/apps/mobile/src/config/types.ts
+++ b/apps/mobile/src/config/types.ts
@@ -3,5 +3,8 @@ export type MobileConfig = {
baseUrl: string,
},
buildNumber: string,
+ revenueCat: {
+ apiKey: string,
+ },
version: string,
}
diff --git a/apps/mobile/src/contexts/RevenueCatContext.tsx b/apps/mobile/src/contexts/RevenueCatContext.tsx
new file mode 100644
index 0000000..b4a7eca
--- /dev/null
+++ b/apps/mobile/src/contexts/RevenueCatContext.tsx
@@ -0,0 +1,207 @@
+import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { AppState, AppStateStatus } from 'react-native'
+import { CustomerInfo, PurchasesOffering, PurchasesPackage } from 'react-native-purchases'
+import {
+ initializeRevenueCat,
+ getCustomerInfo,
+ getOfferings,
+ hasEntitlement,
+ ENTITLEMENT_IDENTIFIER,
+ restorePurchases as restorePurchasesService,
+ purchasePackage as purchasePackageService,
+ logIn as logInService,
+ logOut as logOutService,
+} from '../services/revenueCat'
+import { useAuth } from './AuthContext'
+
+interface RevenueCatContextType {
+ // State
+ customerInfo: CustomerInfo | null
+ offering: PurchasesOffering | null
+ isPremium: boolean
+ isLoading: boolean
+ error: Error | null
+
+ // Actions
+ refreshCustomerInfo: () => Promise
+ refreshOfferings: () => Promise
+ purchasePackage: (packageToPurchase: PurchasesPackage) => Promise
+ restorePurchases: () => Promise
+ refreshAll: () => Promise
+}
+
+const RevenueCatContext = createContext(undefined)
+
+export function RevenueCatProvider({ children }: { children: ReactNode }) {
+ const { user, isAuthenticated } = useAuth()
+ const [customerInfo, setCustomerInfo] = useState(null)
+ const [offering, setOffering] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [isInitialized, setIsInitialized] = useState(false)
+
+ // Initialize RevenueCat when user is authenticated
+ useEffect(() => {
+ if (isAuthenticated && user && !isInitialized) {
+ initialize()
+ }
+ }, [isAuthenticated, user, isInitialized])
+
+ const initialize = async () => {
+ try {
+ setIsLoading(true)
+ setError(null)
+
+ // Initialize RevenueCat with user ID
+ await initializeRevenueCat(user?._id)
+
+ setIsInitialized(true)
+
+ // Load initial data
+ await Promise.all([
+ refreshCustomerInfo(),
+ refreshOfferings(),
+ ])
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Failed to initialize RevenueCat')
+ setError(error)
+ console.error('RevenueCat initialization error:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const refreshCustomerInfo = useCallback(async () => {
+ try {
+ const info = await getCustomerInfo()
+ setCustomerInfo(info)
+ setError(null)
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Failed to fetch customer info')
+ setError(error)
+ console.error('Error refreshing customer info:', error)
+ }
+ }, [])
+
+ const refreshOfferings = useCallback(async () => {
+ try {
+ const currentOffering = await getOfferings()
+ setOffering(currentOffering)
+ setError(null)
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Failed to fetch offerings')
+ setError(error)
+ console.error('Error refreshing offerings:', error)
+ }
+ }, [])
+
+ const purchasePackage = useCallback(async (packageToPurchase: PurchasesPackage): Promise => {
+ try {
+ setError(null)
+ const info = await purchasePackageService(packageToPurchase)
+ setCustomerInfo(info)
+
+ return info
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Failed to purchase package')
+ setError(error)
+ throw error
+ }
+ }, [])
+
+ const restorePurchases = useCallback(async (): Promise => {
+ try {
+ setError(null)
+ const info = await restorePurchasesService()
+ setCustomerInfo(info)
+
+ return info
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error('Failed to restore purchases')
+ setError(error)
+ throw error
+ }
+ }, [])
+
+ const refreshAll = useCallback(async () => {
+ await Promise.all([
+ refreshCustomerInfo(),
+ refreshOfferings(),
+ ])
+ }, [refreshCustomerInfo, refreshOfferings])
+
+ // Update user ID when user changes
+ useEffect(() => {
+ if (isInitialized && user?._id) {
+ logInService(user._id).catch(console.error)
+ }
+ }, [isInitialized, user?._id])
+
+ // Log out when user logs out
+ useEffect(() => {
+ if (!isAuthenticated && isInitialized) {
+ logOutService().catch(console.error)
+ setCustomerInfo(null)
+ setOffering(null)
+ setIsInitialized(false)
+ }
+ }, [isAuthenticated, isInitialized])
+
+ // Refresh customer info when app becomes active
+ const appState = useRef(AppState.currentState)
+
+ useEffect(() => {
+ if (!isInitialized || !isAuthenticated) {
+ return
+ }
+
+ const handleAppStateChange = (nextAppState: AppStateStatus) => {
+ // Check if app is transitioning from background/inactive to active
+ if (nextAppState === 'active') {
+ // App has come to the foreground, refresh customer info
+ refreshCustomerInfo().catch((error) => {
+ console.error('Error refreshing customer info on app state change:', error)
+ })
+ }
+
+ appState.current = nextAppState
+ }
+
+ const subscription = AppState.addEventListener('change', handleAppStateChange)
+
+ return () => {
+ subscription.remove()
+ }
+ }, [isInitialized, isAuthenticated, refreshCustomerInfo])
+
+ const isPremium = useMemo(() => customerInfo ? hasEntitlement(customerInfo, ENTITLEMENT_IDENTIFIER) : false, [customerInfo])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useRevenueCat() {
+ const context = useContext(RevenueCatContext)
+
+ if (context === undefined) {
+ throw new Error('useRevenueCat must be used within a RevenueCatProvider')
+ }
+
+ return context
+}
diff --git a/apps/mobile/src/hooks/useRevenueCat.ts b/apps/mobile/src/hooks/useRevenueCat.ts
new file mode 100644
index 0000000..ec3c69f
--- /dev/null
+++ b/apps/mobile/src/hooks/useRevenueCat.ts
@@ -0,0 +1,150 @@
+import { useCallback } from 'react'
+import { Alert } from 'react-native'
+import { PurchasesPackage } from 'react-native-purchases'
+
+import { useRevenueCat as useRevenueCatContext } from '../contexts/RevenueCatContext'
+
+import {
+ getAvailablePackages,
+ getProductByIdentifier,
+ PRODUCT_IDENTIFIERS,
+ formatPrice,
+ isLifetimeProduct,
+ isSubscriptionProduct,
+} from '../services/revenueCat'
+
+/**
+ * Main hook for RevenueCat functionality
+ */
+export function useRevenueCat() {
+ const revenueCat = useRevenueCatContext()
+
+ /**
+ * Purchase a package with error handling
+ */
+ const purchasePackage = useCallback(
+ async (packageToPurchase: PurchasesPackage) => {
+ try {
+ const customerInfo = await revenueCat.purchasePackage(packageToPurchase)
+ Alert.alert('Success', 'Purchase completed successfully!')
+
+ return customerInfo
+ } catch (error: any) {
+ if (error.message === 'Purchase was cancelled') {
+ // User cancelled, don't show error
+ return null
+ }
+ Alert.alert('Purchase Failed', error.message || 'An error occurred during purchase')
+ throw error
+ }
+ },
+ [revenueCat]
+ )
+
+ /**
+ * Restore purchases with error handling
+ */
+ const restorePurchases = useCallback(async () => {
+ try {
+ const customerInfo = await revenueCat.restorePurchases()
+ if (revenueCat.isPremium) {
+ Alert.alert('Success', 'Purchases restored successfully!')
+ } else {
+ Alert.alert('No Purchases', 'No active purchases found to restore.')
+ }
+
+ return customerInfo
+ } catch (error: any) {
+ Alert.alert('Restore Failed', error.message || 'An error occurred while restoring purchases')
+ throw error
+ }
+ }, [revenueCat])
+
+ /**
+ * Get available packages from current offering
+ */
+ const availablePackages = getAvailablePackages(revenueCat.offering)
+
+ /**
+ * Get specific package by identifier
+ */
+ const getPackage = useCallback(
+ (identifier: string) => {
+ return getProductByIdentifier(availablePackages, identifier)
+ },
+ [availablePackages]
+ )
+
+ /**
+ * Get monthly package
+ */
+ const monthlyPackage = getPackage(PRODUCT_IDENTIFIERS.MONTHLY)
+
+ /**
+ * Get yearly package
+ */
+ const yearlyPackage = getPackage(PRODUCT_IDENTIFIERS.YEARLY)
+
+ /**
+ * Get lifetime package
+ */
+ const lifetimePackage = getPackage(PRODUCT_IDENTIFIERS.LIFETIME)
+
+ return {
+ ...revenueCat,
+ purchasePackage,
+ restorePurchases,
+ availablePackages,
+ getPackage,
+ monthlyPackage,
+ yearlyPackage,
+ lifetimePackage,
+ }
+}
+
+/**
+ * Hook to check if user has Premium entitlement
+ */
+export function useIsPremium() {
+ const { isPremium, isLoading } = useRevenueCat()
+
+ return { isPremium, isLoading }
+}
+
+/**
+ * Hook to get customer info
+ */
+export function useCustomerInfo() {
+ const { customerInfo, isLoading, refreshCustomerInfo } = useRevenueCat()
+
+ return { customerInfo, isLoading, refreshCustomerInfo }
+}
+
+/**
+ * Hook to get offerings and packages
+ */
+export function useOfferings() {
+ const { offering, isLoading, refreshOfferings } = useRevenueCat()
+ const packages = getAvailablePackages(offering)
+
+ return {
+ offering,
+ packages,
+ availablePackages: packages,
+ isLoading,
+ refreshOfferings,
+ }
+}
+
+/**
+ * Hook for package utilities
+ */
+export function usePackageUtils() {
+ return {
+ formatPrice,
+ isLifetimeProduct,
+ isSubscriptionProduct,
+ getProductByIdentifier: (packages: PurchasesPackage[], identifier: string) =>
+ getProductByIdentifier(packages, identifier),
+ }
+}
diff --git a/apps/mobile/src/services/revenueCat/index.ts b/apps/mobile/src/services/revenueCat/index.ts
new file mode 100644
index 0000000..b480cfb
--- /dev/null
+++ b/apps/mobile/src/services/revenueCat/index.ts
@@ -0,0 +1,159 @@
+import Purchases, {
+ CustomerInfo,
+ PurchasesOffering,
+ PurchasesPackage,
+ PurchasesStoreProduct,
+ LOG_LEVEL,
+} from 'react-native-purchases'
+import { debug } from 'debug'
+
+import { config } from '../../config'
+
+// Product identifiers
+export const PRODUCT_IDENTIFIERS = {
+ MONTHLY: 'monthly',
+ YEARLY: 'yearly',
+ LIFETIME: 'lifetime',
+} as const
+
+// Entitlement identifier
+export const ENTITLEMENT_IDENTIFIER = 'Plannting Premium'
+
+// Initialize RevenueCat SDK
+export async function initializeRevenueCat(userId?: string): Promise {
+ const isDebug = debug.enabled('revenuecat:*')
+
+ try {
+ // Set log level for debugging
+ Purchases.setLogLevel(isDebug ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR)
+
+ // Configure RevenueCat with API key
+ await Purchases.configure({
+ apiKey: config.revenueCat.apiKey,
+ })
+
+ // Set user ID if provided (for identifying users)
+ if (userId) {
+ await Purchases.logIn(userId)
+ }
+
+ // Enable debug logs in development
+ if (isDebug) {
+ console.log('RevenueCat initialized successfully')
+ }
+ } catch (error) {
+ console.error('Error initializing RevenueCat:', error)
+ throw error
+ }
+}
+
+// Get customer info
+export async function getCustomerInfo(): Promise {
+ try {
+ const customerInfo = await Purchases.getCustomerInfo()
+ return customerInfo
+ } catch (error) {
+ console.error('Error fetching customer info:', error)
+ throw error
+ }
+}
+
+// Check if user has entitlement
+export function hasEntitlement(customerInfo: CustomerInfo, entitlementId: string = ENTITLEMENT_IDENTIFIER): boolean {
+ return customerInfo.entitlements.active[entitlementId] !== undefined
+}
+
+// Get current offerings
+export async function getOfferings(): Promise {
+ try {
+ const offerings = await Purchases.getOfferings()
+ return offerings.current
+ } catch (error) {
+ console.error('Error fetching offerings:', error)
+ throw error
+ }
+}
+
+// Purchase a package
+export async function purchasePackage(packageToPurchase: PurchasesPackage): Promise {
+ try {
+ const { customerInfo } = await Purchases.purchasePackage(packageToPurchase)
+ return customerInfo
+ } catch (error: any) {
+ // Handle user cancellation
+ if (error.userCancelled) {
+ throw new Error('Purchase was cancelled')
+ }
+ // Handle other errors
+ console.error('Error purchasing package:', error)
+ throw error
+ }
+}
+
+// Restore purchases
+export async function restorePurchases(): Promise {
+ try {
+ const customerInfo = await Purchases.restorePurchases()
+ return customerInfo
+ } catch (error) {
+ console.error('Error restoring purchases:', error)
+ throw error
+ }
+}
+
+// Log out user
+export async function logOut(): Promise {
+ try {
+ const customerInfo = await Purchases.logOut()
+ return customerInfo
+ } catch (error) {
+ console.error('Error logging out:', error)
+ throw error
+ }
+}
+
+// Log in user
+export async function logIn(userId: string): Promise {
+ try {
+ const { customerInfo } = await Purchases.logIn(userId)
+ return customerInfo
+ } catch (error) {
+ console.error('Error logging in:', error)
+ throw error
+ }
+}
+
+// Get available packages from current offering
+export function getAvailablePackages(offering: PurchasesOffering | null): PurchasesPackage[] {
+ if (!offering) {
+ return []
+ }
+ return offering.availablePackages
+}
+
+// Format price for display
+export function formatPrice(product: PurchasesStoreProduct): string {
+ return product.priceString
+}
+
+// Get product by identifier
+export function getProductByIdentifier(
+ packages: PurchasesPackage[],
+ identifier: string
+): PurchasesPackage | undefined {
+ return packages.find(pkg => {
+ const productId = pkg.product.identifier
+ return productId === identifier || productId.includes(identifier)
+ })
+}
+
+// Check if product is lifetime
+export function isLifetimeProduct(product: PurchasesStoreProduct): boolean {
+ return product.identifier === PRODUCT_IDENTIFIERS.LIFETIME ||
+ product.identifier.includes('lifetime')
+}
+
+// Check if product is subscription
+export function isSubscriptionProduct(product: PurchasesStoreProduct): boolean {
+ return product.subscriptionPeriod !== null && product.subscriptionPeriod !== undefined
+}
diff --git a/package-lock.json b/package-lock.json
index f253e42..718cf18 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -98,6 +98,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
+ "react-native-purchases": "^9.6.11",
+ "react-native-purchases-ui": "^9.6.11",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
"superjson": "^1.13.3",
@@ -4283,6 +4285,27 @@
"nanoid": "^3.3.11"
}
},
+ "node_modules/@revenuecat/purchases-js": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.21.0.tgz",
+ "integrity": "sha512-PD63si5AgkZ8B5hau+o2hQV1XQwu9bcjw3kW6ouDBl6eHi4tWs9ek0JaLAfIz3DUSIe0Bu/7Ksa7gdnLMu/8MQ==",
+ "license": "MIT"
+ },
+ "node_modules/@revenuecat/purchases-js-hybrid-mappings": {
+ "version": "17.24.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-17.24.0.tgz",
+ "integrity": "sha512-YRVF6K9fI4NoZ+uvRz1z/7j37e4Wmw6cTMvXxyJWOLq7cQwxFkTP6DzID3jdd9vPMyrL+6VDU1vm4AbjO38o7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@revenuecat/purchases-js": "1.21.0"
+ }
+ },
+ "node_modules/@revenuecat/purchases-typescript-internal": {
+ "version": "17.24.0",
+ "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-17.24.0.tgz",
+ "integrity": "sha512-Yu/HHGsBceefHdaTyCyq3FxFqY5gL0YivvDOXTVZ70RAXSzf9fm1clQlXIcXpdcWpgN8dbX7bc5A51e63Zq5sg==",
+ "license": "MIT"
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"license": "MIT"
@@ -11761,6 +11784,50 @@
"react-native": "*"
}
},
+ "node_modules/react-native-purchases": {
+ "version": "9.6.11",
+ "resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-9.6.11.tgz",
+ "integrity": "sha512-ST0/V6ns5DrbfAQavx5fo1qVWQzV8TvKkHyDkQt8oMq8sf7AX3R71BvNNpu/MOCrGjHutFG2G8GHGdUAjht63w==",
+ "license": "MIT",
+ "workspaces": [
+ "examples/purchaseTesterTypescript",
+ "react-native-purchases-ui"
+ ],
+ "dependencies": {
+ "@revenuecat/purchases-js-hybrid-mappings": "17.24.0",
+ "@revenuecat/purchases-typescript-internal": "17.24.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.6.3",
+ "react-native": ">= 0.73.0",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-native-purchases-ui": {
+ "version": "9.6.11",
+ "resolved": "https://registry.npmjs.org/react-native-purchases-ui/-/react-native-purchases-ui-9.6.11.tgz",
+ "integrity": "sha512-VQPxXGqIpB4+7Z3rg+kRJyF9Pg6l0YnHdo3RMsyAlXOjip72WyBP9kLCitEQYlxUQoKHi+JmoHwEnG1s1uNaSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@revenuecat/purchases-typescript-internal": "17.24.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": ">= 0.73.0",
+ "react-native-purchases": "9.6.11",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native-safe-area-context": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
diff --git a/package.json b/package.json
index e85c691..00913df 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"build:mobile": "npm run build --workspace=apps/mobile",
"build:mobile:development-build": "npm run build:development:build --workspace=apps/mobile",
"build:mobile:update": "npm run build:development:update --workspace=apps/mobile",
+ "build:mobile:uat": "npm run build:uat --workspace=apps/mobile",
"start": "concurrently \"npm run start:api\" \"npm run start:mobile\"",
"start:api": "npm run start --workspace=apps/api",
"start:mobile": "npm run start --workspace=apps/mobile",
diff --git a/scripts/updateDevelopmentBuild.ts b/scripts/updateDevelopmentBuild.ts
index 9f46007..cde05c2 100644
--- a/scripts/updateDevelopmentBuild.ts
+++ b/scripts/updateDevelopmentBuild.ts
@@ -35,7 +35,7 @@ function backupEnvFile() {
execSync('mv .env .env.bak', { stdio: 'inherit' })
// Switch to production .env
- execSync('cp .env.production .env', { stdio: 'inherit' })
+ execSync('cp .env.dev .env', { stdio: 'inherit' })
}
function restoreEnvFile() {