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() {