From d37b49f1f6f85bc16f3a5b7efd808920c9c52450 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 29 Apr 2026 21:16:16 +0200 Subject: [PATCH] Harte Paywall --- app/(tabs)/index.tsx | 113 +++++++++++++++++++++++++--------------- app/_layout.tsx | 7 ++- app/auth/login.tsx | 38 +++++++++----- app/auth/signup.tsx | 10 +++- app/profile/billing.tsx | 107 +++++++++++++++++++++++++++---------- app/scanner.tsx | 40 +++++++++----- context/AppContext.tsx | 56 ++++++++++++++++---- server/index.js | 8 ++- server/lib/auth.js | 23 ++++++-- services/authService.ts | 32 ++++++------ 10 files changed, 305 insertions(+), 129 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 07072cd..da926aa 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -230,8 +230,9 @@ export default function HomeScreen() { lexiconExplored: false, customizationDone: false, }); - const { registerLayout, startTour } = useCoachMarks(); + const { layouts, registerLayout, startTour } = useCoachMarks(); const fabRef = useRef(null); + const tourStartRequestedRef = useRef(false); const posthog = usePostHog(); useFocusEffect( @@ -250,49 +251,77 @@ export default function HomeScreen() { // Tour nach Registrierung starten useEffect(() => { - const checkTour = async () => { - const flag = await AsyncStorage.getItem('greenlens_show_tour'); - if (flag !== 'true') return; - await AsyncStorage.removeItem('greenlens_show_tour'); - - // 1 Sekunde warten, dann Tour starten - setTimeout(() => { - // Tab-Positionen approximieren (gleichmäßig verteilt) - const tabBarBottom = SCREEN_H - 85; - const tabW = SCREEN_W / 3; - registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 }); - registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 }); - - startTour([ - { - elementKey: 'fab', - title: t.tourFabTitle, - description: t.tourFabDesc, - tooltipSide: 'above', - }, - { - elementKey: 'tab_search', - title: t.tourSearchTitle, - description: t.tourSearchDesc, - tooltipSide: 'above', - }, - { - elementKey: 'tab_profile', - title: t.tourProfileTitle, - description: t.tourProfileDesc, - tooltipSide: 'above', - }, - { - elementKey: 'onboarding_checklist', - title: t.tourChecklistTitle, - description: t.tourChecklistDesc, - tooltipSide: 'below', - }, - ]); - }, 1000); + let cancelled = false; + let retryTimer: ReturnType | null = null; + + const tourSteps = [ + { + elementKey: 'fab', + title: t.tourFabTitle, + description: t.tourFabDesc, + tooltipSide: 'above' as const, + }, + { + elementKey: 'tab_search', + title: t.tourSearchTitle, + description: t.tourSearchDesc, + tooltipSide: 'above' as const, + }, + { + elementKey: 'tab_profile', + title: t.tourProfileTitle, + description: t.tourProfileDesc, + tooltipSide: 'above' as const, + }, + { + elementKey: 'onboarding_checklist', + title: t.tourChecklistTitle, + description: t.tourChecklistDesc, + tooltipSide: 'below' as const, + }, + ]; + + const registerTabLayouts = () => { + const tabBarBottom = SCREEN_H - 85; + const tabW = SCREEN_W / 3; + registerLayout('tab_search', { x: tabW, y: tabBarBottom + 8, width: tabW, height: 52 }); + registerLayout('tab_profile', { x: tabW * 2, y: tabBarBottom + 8, width: tabW, height: 52 }); }; + + const checkTour = async () => { + if (tourStartRequestedRef.current) return; + + const flag = await AsyncStorage.getItem('greenlens_show_tour'); + if (flag !== 'true') return; + tourStartRequestedRef.current = true; + + const startWhenReady = async (attempt = 0) => { + if (cancelled) return; + + registerTabLayouts(); + + if (!layouts.fab && attempt < 10) { + retryTimer = setTimeout(() => startWhenReady(attempt + 1), 250); + return; + } + + startTour(tourSteps); + await AsyncStorage.removeItem('greenlens_show_tour'); + tourStartRequestedRef.current = false; + }; + + retryTimer = setTimeout(() => startWhenReady(), 1000); + }; + checkTour(); - }, [registerLayout, startTour, t.tourChecklistDesc, t.tourChecklistTitle, t.tourFabDesc, t.tourFabTitle, t.tourProfileDesc, t.tourProfileTitle, t.tourSearchDesc, t.tourSearchTitle]); + + return () => { + cancelled = true; + if (retryTimer) { + clearTimeout(retryTimer); + } + }; + }, [layouts, registerLayout, startTour, t.tourChecklistDesc, t.tourChecklistTitle, t.tourFabDesc, t.tourFabTitle, t.tourProfileDesc, t.tourProfileTitle, t.tourSearchDesc, t.tourSearchTitle]); const copy = t; const greetingText = useMemo(() => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 82a84aa..f13c710 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -60,6 +60,7 @@ function RootLayoutInner() { signOut, session, billingSummary, + isActivatingEntitlement, isInitializing, isLoadingPlants, isLoadingBilling, @@ -155,13 +156,15 @@ function RootLayoutInner() { }, [signOut]); const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; - const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' - && billingSummary?.entitlement?.status === 'active'; + const hasActiveEntitlement = isActivatingEntitlement + || (billingSummary?.entitlement?.plan === 'pro' + && billingSummary?.entitlement?.status === 'active'); const isAllowedWithoutSession = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') || pathname.includes('profile/billing'); const isAllowedWithoutEntitlement = pathname.includes('auth/') + || pathname.includes('onboarding') || pathname.includes('scanner') || pathname.includes('profile/billing'); diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 9f5c9f5..4860496 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,26 +1,28 @@ import React, { useEffect, useState } from 'react'; -import { - View, - Text, - TextInput, +import { + View, + Text, + TextInput, TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform, ActivityIndicator, ScrollView, - Image, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; + Image, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; import { useApp } from '../../context/AppContext'; import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { AuthService } from '../../services/authService'; import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; import { usePostHog } from 'posthog-react-native'; - -export default function LoginScreen() { + +export default function LoginScreen() { const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = usePostHog(); @@ -31,8 +33,13 @@ export default function LoginScreen() { const [appleAvailable, setAppleAvailable] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const isExpoGo = Constants.appOwnership === 'expo'; useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } let mounted = true; AppleAuthentication.isAvailableAsync() .then((available) => { @@ -44,7 +51,7 @@ export default function LoginScreen() { return () => { mounted = false; }; - }, []); + }, [isExpoGo]); const handleLogin = async () => { if (!email.trim() || !password) { @@ -56,7 +63,7 @@ export default function LoginScreen() { try { const session = await AuthService.login(email, password); await hydrateSession(session); - router.replace('/profile/billing'); + router.replace('/(tabs)'); } catch (e: any) { if (e.message === 'USER_NOT_FOUND') { setError(t.errUserNotFound); @@ -101,8 +108,11 @@ export default function LoginScreen() { name: fullName || undefined, }); await hydrateSession(session); + if (session.isNewUser) { + await AsyncStorage.setItem('greenlens_show_tour', 'true'); + } posthog.capture('apple_login_succeeded', { surface: 'login' }); - router.replace('/profile/billing'); + router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); } catch (e: any) { if (e?.code === 'ERR_REQUEST_CANCELED') { return; diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx index 7468889..2a80249 100644 --- a/app/auth/signup.tsx +++ b/app/auth/signup.tsx @@ -19,6 +19,7 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { AuthService } from '../../services/authService'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; import { usePostHog } from 'posthog-react-native'; export default function SignupScreen() { @@ -36,8 +37,13 @@ export default function SignupScreen() { const [appleAvailable, setAppleAvailable] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const isExpoGo = Constants.appOwnership === 'expo'; useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } let mounted = true; AppleAuthentication.isAvailableAsync() .then((available) => { @@ -49,7 +55,7 @@ export default function SignupScreen() { return () => { mounted = false; }; - }, []); + }, [isExpoGo]); const validate = (): string | null => { if (!name.trim()) return t.errNameRequired; @@ -121,7 +127,7 @@ export default function SignupScreen() { await hydrateSession(session); await AsyncStorage.setItem('greenlens_show_tour', 'true'); posthog.capture('apple_login_succeeded', { surface: 'signup' }); - router.replace('/profile/billing'); + router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)'); } catch (e: any) { if (e?.code === 'ERR_REQUEST_CANCELED') { return; diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index f72da56..4b764b1 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { useFocusEffect } from '@react-navigation/native'; import Constants from 'expo-constants'; import Purchases, { PACKAGE_TYPE, @@ -102,6 +103,7 @@ const getBillingCopy = (language: Language) => { expoGoPurchaseTitle: 'Kauf nur im Dev Build', expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.', expoGoSimulate: 'Pro simulieren', + continueWithoutPro: 'Ohne Pro fortfahren', perYear: '/ Jahr', perMonth: '/ Monat', freePlanName: 'Free', @@ -165,6 +167,7 @@ const getBillingCopy = (language: Language) => { expoGoPurchaseTitle: 'Compra solo en Dev Build', expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.', expoGoSimulate: 'Simular Pro', + continueWithoutPro: 'Continuar sin Pro', perYear: '/ ano', perMonth: '/ mes', freePlanName: 'Gratis', @@ -228,6 +231,7 @@ const getBillingCopy = (language: Language) => { expoGoPurchaseTitle: 'Purchase requires a dev build', expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.', expoGoSimulate: 'Simulate Pro', + continueWithoutPro: 'Continue without Pro', perYear: '/ year', perMonth: '/ month', freePlanName: 'Free', @@ -290,7 +294,7 @@ export default function BillingScreen() { const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const planId = billingSummary?.entitlement?.plan || 'free'; - const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--'); + const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); const showPaywallPlans = !session || planId !== 'pro'; useEffect(() => { @@ -365,13 +369,31 @@ export default function BillingScreen() { await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); }; - const handleBack = () => { + const handleBack = useCallback(() => { if (showPaywallPlans) { - router.replace(session ? '/scanner' : '/onboarding'); + router.replace('/onboarding'); return; } - router.back(); - }; + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/(tabs)'); + }, [router, showPaywallPlans]); + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (!showPaywallPlans) { + return false; + } + router.replace('/onboarding'); + return true; + }); + + return () => subscription.remove(); + }, [router, showPaywallPlans]), + ); const completeExpoGoSimulation = async (productId: PurchaseProductId) => { setIsUpdating(true); @@ -380,6 +402,8 @@ export default function BillingScreen() { if (productId === 'monthly_pro' || productId === 'yearly_pro') { posthog.capture('subscription_started', { product_id: productId, simulated: true }); posthog.capture('trial_started', { product_id: productId, simulated: true }); + setSubModalVisible(false); + router.replace('/(tabs)'); } else { posthog.capture('topup_purchased', { product_id: productId, simulated: true }); } @@ -404,7 +428,8 @@ export default function BillingScreen() { setIsUpdating(false); if (productId === 'monthly_pro' || productId === 'yearly_pro') { Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ - { text: 'OK', style: 'cancel' }, + { text: copy.continueWithoutPro, style: 'cancel' }, + { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, ]); return; } @@ -428,10 +453,16 @@ export default function BillingScreen() { }); throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); } - await Purchases.purchasePackage(selectedPackage); - // Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) - const customerInfo = await Purchases.getCustomerInfo(); - await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); + const purchaseResult = await Purchases.purchasePackage(selectedPackage); + // Apply RevenueCat entitlement locally and let backend sync finish in the background. + const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo + ?? await Purchases.getCustomerInfo(); + void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); + posthog.capture('subscription_started', { product_id: productId }); + posthog.capture('trial_started', { product_id: productId }); + setSubModalVisible(false); + setTimeout(() => router.replace('/(tabs)'), 0); + return; } else { const selectedProduct = topupProducts[productId]; if (!selectedProduct) { @@ -442,12 +473,7 @@ export default function BillingScreen() { await syncRevenueCatState(customerInfo as any, 'topup_purchase'); } } - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - posthog.capture('subscription_started', { product_id: productId }); - posthog.capture('trial_started', { product_id: productId }); - } else { - posthog.capture('topup_purchased', { product_id: productId }); - } + posthog.capture('topup_purchased', { product_id: productId }); setSubModalVisible(false); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -532,9 +558,9 @@ export default function BillingScreen() { ) : ( <> {session && planId === 'pro' && ( - - {copy.planLabel} - + + {copy.planLabel} + {planId === 'pro' ? copy.planPro : copy.planFree} @@ -597,7 +623,11 @@ export default function BillingScreen() { handlePurchase('monthly_pro')} disabled={isUpdating || !storeReady} activeOpacity={0.9} @@ -615,7 +645,16 @@ export default function BillingScreen() { {monthlyPrice} {copy.perMonth} - {copy.monthlyCta} + + {!storeReady ? ( + + ) : ( + <> + {copy.monthlyCta} + + + )} + @@ -1106,6 +1145,9 @@ const styles = StyleSheet.create({ padding: 14, marginTop: 10, }, + disabledPlanCard: { + opacity: 0.72, + }, guestPlanHeader: { flexDirection: 'row', alignItems: 'center', @@ -1174,7 +1216,18 @@ const styles = StyleSheet.create({ monthlyCtaText: { fontSize: 13, fontWeight: '800', - marginTop: 8, + }, + monthlyCtaButton: { + minHeight: 42, + borderRadius: 12, + borderWidth: 1, + marginTop: 12, + paddingHorizontal: 14, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, }, guestSubscribeBtn: { marginTop: 14, diff --git a/app/scanner.tsx b/app/scanner.tsx index 31819e3..3f9d926 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -10,6 +10,7 @@ import * as ImagePicker from 'expo-image-picker'; import * as ImageManipulator from 'expo-image-manipulator'; import * as Haptics from 'expo-haptics'; import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; import { usePostHog } from 'posthog-react-native'; import { useApp } from '../context/AppContext'; import { useColors } from '../constants/Colors'; @@ -157,10 +158,15 @@ export default function ScannerScreen() { const [analysisResult, setAnalysisResult] = useState(null); const [demoResultVisible, setDemoResultVisible] = useState(false); const cameraRef = useRef(null); - const scanLineProgress = useRef(new Animated.Value(0)).current; - const scanPulse = useRef(new Animated.Value(0)).current; - + const scanLineProgress = useRef(new Animated.Value(0)).current; + const scanPulse = useRef(new Animated.Value(0)).current; + const isExpoGo = Constants.appOwnership === 'expo'; + useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } let mounted = true; AppleAuthentication.isAvailableAsync() .then((available) => { @@ -172,7 +178,7 @@ export default function ScannerScreen() { return () => { mounted = false; }; - }, []); + }, [isExpoGo]); useEffect(() => { if (!isAnalyzing) { @@ -447,7 +453,7 @@ export default function ScannerScreen() { }; const handleSave = async () => { - if (analysisResult && selectedImage) { + if (analysisResult && selectedImage) { if (!session) { // Guest mode: store result and go to signup setPendingPlant(analysisResult, selectedImage); @@ -455,10 +461,14 @@ export default function ScannerScreen() { return; } - try { - await savePlant(analysisResult, selectedImage); - router.back(); - } catch (error) { + try { + await savePlant(analysisResult, selectedImage); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/(tabs)'); + } + } catch (error) { console.error('Saving identified plant failed', error); Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); } @@ -507,7 +517,7 @@ export default function ScannerScreen() { }); await hydrateSession(nextSession); posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' }); - router.replace('/profile/billing'); + router.replace(nextSession.isNewUser ? '/profile/billing' : '/(tabs)'); } catch (error: any) { if (error?.code === 'ERR_REQUEST_CANCELED') { return; @@ -528,8 +538,12 @@ export default function ScannerScreen() { }; const handleClose = () => { - router.back(); - }; + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/onboarding'); + }; const controlsPaddingBottom = Math.max(20, insets.bottom + 10); const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; @@ -712,7 +726,7 @@ export default function ScannerScreen() { activeOpacity={0.85} > - {isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta} + {isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta} )} diff --git a/context/AppContext.tsx b/context/AppContext.tsx index 47b74ac..4180c06 100644 --- a/context/AppContext.tsx +++ b/context/AppContext.tsx @@ -32,8 +32,9 @@ interface AppState { colorPalette: ColorPalette; profileName: string; profileImageUri: string | null; - billingSummary: BillingSummary | null; - resolvedScheme: AppColorScheme; + billingSummary: BillingSummary | null; + isActivatingEntitlement: boolean; + resolvedScheme: AppColorScheme; isDarkMode: boolean; isInitializing: boolean; isLoadingPlants: boolean; @@ -152,8 +153,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children const [guestScanCount, setGuestScanCount] = useState(0); const [isInitializing, setIsInitializing] = useState(true); const [isLoadingPlants, setIsLoadingPlants] = useState(true); - const [billingSummary, setBillingSummary] = useState(null); - const [isLoadingBilling, setIsLoadingBilling] = useState(true); + const [billingSummary, setBillingSummary] = useState(null); + const [isLoadingBilling, setIsLoadingBilling] = useState(true); + const [isActivatingEntitlement, setIsActivatingEntitlement] = useState(false); const resolvedScheme: AppColorScheme = appearanceMode === 'system' @@ -389,20 +391,45 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children source: RevenueCatSyncSource = 'app_init', ) => { if (source === 'topup_purchase') { - return; + return false; } const activeEntitlements = customerInfo?.entitlements?.active || {}; const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID]; const proEntitlement = getValidProEntitlement(customerInfo); const isPro = Boolean(proEntitlement); + const now = new Date(); + const renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null; + const isTrial = (proEntitlement?.periodType || proEntitlement?.period_type || '').toUpperCase() === 'TRIAL'; + const monthlyAllowance = isTrial ? 30 : 100; setBillingSummary((prev) => { - if (!prev) return prev; if (!proEntitlement && rawProEntitlement) { return prev; } + if (!prev && isPro) { + return { + entitlement: { + plan: 'pro', + provider: 'revenuecat', + status: 'active', + renewsAt, + }, + credits: { + monthlyAllowance, + usedThisCycle: 0, + topupBalance: 0, + available: monthlyAllowance, + cycleStartedAt: now.toISOString(), + cycleEndsAt: renewsAt || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }, + availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'], + }; + } + + if (!prev) return prev; + return { ...prev, entitlement: { @@ -414,6 +441,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }, }; }); + + return isPro; }, []); const syncRevenueCatState = useCallback(async ( @@ -424,7 +453,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children source, customerInfo: summarizeRevenueCatCustomerInfo(customerInfo), }); - applyRevenueCatCustomerInfoLocally(customerInfo, source); + const didActivatePro = applyRevenueCatCustomerInfoLocally(customerInfo, source); + const isSubscriptionActivation = source === 'subscription_purchase' && didActivatePro; + if (isSubscriptionActivation) { + setIsActivatingEntitlement(true); + } try { const response = await backendApiClient.syncRevenueCatState({ customerInfo, source }); setBillingSummary(response.billing); @@ -432,6 +465,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children } catch (error) { console.error('Failed to sync RevenueCat state with backend', error); return null; + } finally { + if (isSubscriptionActivation) { + setIsActivatingEntitlement(false); + } } }, [applyRevenueCatCustomerInfoLocally]); @@ -537,8 +574,9 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children colorPalette, profileName, profileImageUri, - billingSummary, - resolvedScheme, + billingSummary, + isActivatingEntitlement, + resolvedScheme, isDarkMode, isInitializing, isLoadingPlants, diff --git a/server/index.js b/server/index.js index 4b167ce..472e6d5 100644 --- a/server/index.js +++ b/server/index.js @@ -1066,7 +1066,13 @@ app.post('/auth/apple', async (request, response) => { } const user = await authSignInWithApple(db, identityToken, { appleUser, email, name }); const token = issueToken(user.id, user.email, user.name); - response.status(200).json({ userId: user.id, email: user.email, name: user.name, token }); + response.status(200).json({ + userId: user.id, + email: user.email, + name: user.name, + token, + isNewUser: Boolean(user.isNewUser), + }); } catch (error) { const status = error.status || 500; response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); diff --git a/server/lib/auth.js b/server/lib/auth.js index 2977ba9..f725e88 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -12,6 +12,13 @@ const APPLE_AUDIENCE = ( || process.env.IOS_BUNDLE_ID || 'com.greenlens.app' ).trim(); +const APPLE_ALLOWED_AUDIENCES = [ + APPLE_AUDIENCE, + ...(process.env.APPLE_ALLOWED_AUDIENCES || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +]; let appleJwksCache = { keys: [], expiresAt: 0 }; // ─── Minimal JWT (HS256, no external deps) ───────────────────────────────── @@ -134,7 +141,7 @@ const verifyAppleIdentityToken = async (identityToken) => { const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }); const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url')); const nowSeconds = Math.floor(Date.now() / 1000); - const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean)); + const expectedAudiences = new Set(APPLE_ALLOWED_AUDIENCES.filter(Boolean)); if ( !validSignature @@ -143,6 +150,14 @@ const verifyAppleIdentityToken = async (identityToken) => { || !claims.sub || (claims.exp && nowSeconds > Number(claims.exp)) ) { + console.warn('Apple identityToken verification failed.', { + validSignature, + issuer: claims.iss, + audience: claims.aud, + expectedAudiences: Array.from(expectedAudiences), + hasSubject: Boolean(claims.sub), + expired: Boolean(claims.exp && nowSeconds > Number(claims.exp)), + }); const error = new Error('Apple identityToken could not be verified.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; @@ -241,7 +256,7 @@ const signInWithApple = async (db, identityToken, profile = {}) => { 'SELECT id, email, name FROM auth_users WHERE apple_subject = $1', [appleSubject], ); - if (existingByApple) return existingByApple; + if (existingByApple) return { ...existingByApple, isNewUser: false }; if (!normalizedEmail) { const err = new Error('Apple did not return an email for this account.'); @@ -262,7 +277,7 @@ const signInWithApple = async (db, identityToken, profile = {}) => { 'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4', [appleSubject, 'apple', nextName, existingByEmail.id], ); - return { ...existingByEmail, name: nextName }; + return { ...existingByEmail, name: nextName, isNewUser: false }; } const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; @@ -273,7 +288,7 @@ const signInWithApple = async (db, identityToken, profile = {}) => { VALUES ($1, $2, $3, NULL, $4, $5)`, [id, normalizedEmail, name, 'apple', appleSubject], ); - return { id, email: normalizedEmail, name }; + return { id, email: normalizedEmail, name, isNewUser: true }; }; module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken }; diff --git a/services/authService.ts b/services/authService.ts index 06cba90..cd147e9 100644 --- a/services/authService.ts +++ b/services/authService.ts @@ -4,14 +4,15 @@ import { getConfiguredBackendRootUrl } from '../utils/backendUrl'; const SESSION_KEY = 'greenlens_session_v3'; -export interface AuthSession { - userId: number; // local SQLite id (for plants/settings queries) - serverUserId: string; // server-side user id (in JWT) - email: string; - name: string; - token: string; // JWT from server - loggedInAt: string; -} +export interface AuthSession { + userId: number; // local SQLite id (for plants/settings queries) + serverUserId: string; // server-side user id (in JWT) + email: string; + name: string; + token: string; // JWT from server + loggedInAt: string; + isNewUser?: boolean; +} // ─── Internal helpers ────────────────────────────────────────────────────── @@ -19,7 +20,7 @@ const clearStoredSession = async (): Promise => { await SecureStore.deleteItemAsync(SESSION_KEY); }; -const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => { +const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string; isNewUser?: boolean }> => { const backendUrl = getConfiguredBackendRootUrl(); const hasBackendUrl = Boolean(backendUrl); const url = hasBackendUrl ? `${backendUrl}${path}` : path; @@ -49,17 +50,18 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e return data as any; }; -const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => { +const buildSession = (data: { userId: string; email: string; name: string; token: string; isNewUser?: boolean }): AuthSession => { const localUser = AuthDb.ensureLocalUser(data.email, data.name); return { userId: localUser.id, serverUserId: data.userId, email: data.email, - name: data.name, - token: data.token, - loggedInAt: new Date().toISOString(), - }; -}; + name: data.name, + token: data.token, + loggedInAt: new Date().toISOString(), + isNewUser: data.isNewUser, + }; +}; // ─── AuthService ───────────────────────────────────────────────────────────