Harte Paywall

This commit is contained in:
2026-04-29 21:16:16 +02:00
committed by Timo Knuth
parent 0f933da3c9
commit d37b49f1f6
10 changed files with 305 additions and 129 deletions

View File

@@ -230,8 +230,9 @@ export default function HomeScreen() {
lexiconExplored: false, lexiconExplored: false,
customizationDone: false, customizationDone: false,
}); });
const { registerLayout, startTour } = useCoachMarks(); const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(null); const fabRef = useRef<View>(null);
const tourStartRequestedRef = useRef(false);
const posthog = usePostHog(); const posthog = usePostHog();
useFocusEffect( useFocusEffect(
@@ -250,49 +251,77 @@ export default function HomeScreen() {
// Tour nach Registrierung starten // Tour nach Registrierung starten
useEffect(() => { useEffect(() => {
const checkTour = async () => { let cancelled = false;
const flag = await AsyncStorage.getItem('greenlens_show_tour'); let retryTimer: ReturnType<typeof setTimeout> | null = null;
if (flag !== 'true') return;
await AsyncStorage.removeItem('greenlens_show_tour');
// 1 Sekunde warten, dann Tour starten const tourSteps = [
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', elementKey: 'fab',
title: t.tourFabTitle, title: t.tourFabTitle,
description: t.tourFabDesc, description: t.tourFabDesc,
tooltipSide: 'above', tooltipSide: 'above' as const,
}, },
{ {
elementKey: 'tab_search', elementKey: 'tab_search',
title: t.tourSearchTitle, title: t.tourSearchTitle,
description: t.tourSearchDesc, description: t.tourSearchDesc,
tooltipSide: 'above', tooltipSide: 'above' as const,
}, },
{ {
elementKey: 'tab_profile', elementKey: 'tab_profile',
title: t.tourProfileTitle, title: t.tourProfileTitle,
description: t.tourProfileDesc, description: t.tourProfileDesc,
tooltipSide: 'above', tooltipSide: 'above' as const,
}, },
{ {
elementKey: 'onboarding_checklist', elementKey: 'onboarding_checklist',
title: t.tourChecklistTitle, title: t.tourChecklistTitle,
description: t.tourChecklistDesc, description: t.tourChecklistDesc,
tooltipSide: 'below', tooltipSide: 'below' as const,
}, },
]); ];
}, 1000);
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(); 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 copy = t;
const greetingText = useMemo(() => { const greetingText = useMemo(() => {

View File

@@ -60,6 +60,7 @@ function RootLayoutInner() {
signOut, signOut,
session, session,
billingSummary, billingSummary,
isActivatingEntitlement,
isInitializing, isInitializing,
isLoadingPlants, isLoadingPlants,
isLoadingBilling, isLoadingBilling,
@@ -155,13 +156,15 @@ function RootLayoutInner() {
}, [signOut]); }, [signOut]);
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants; const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' const hasActiveEntitlement = isActivatingEntitlement
&& billingSummary?.entitlement?.status === 'active'; || (billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active');
const isAllowedWithoutSession = pathname.includes('onboarding') const isAllowedWithoutSession = pathname.includes('onboarding')
|| pathname.includes('auth/') || pathname.includes('auth/')
|| pathname.includes('scanner') || pathname.includes('scanner')
|| pathname.includes('profile/billing'); || pathname.includes('profile/billing');
const isAllowedWithoutEntitlement = pathname.includes('auth/') const isAllowedWithoutEntitlement = pathname.includes('auth/')
|| pathname.includes('onboarding')
|| pathname.includes('scanner') || pathname.includes('scanner')
|| pathname.includes('profile/billing'); || pathname.includes('profile/billing');

View File

@@ -11,6 +11,7 @@ import {
ScrollView, ScrollView,
Image, Image,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -18,6 +19,7 @@ import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
export default function LoginScreen() { export default function LoginScreen() {
@@ -31,8 +33,13 @@ export default function LoginScreen() {
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => { useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true; let mounted = true;
AppleAuthentication.isAvailableAsync() AppleAuthentication.isAvailableAsync()
.then((available) => { .then((available) => {
@@ -44,7 +51,7 @@ export default function LoginScreen() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, []); }, [isExpoGo]);
const handleLogin = async () => { const handleLogin = async () => {
if (!email.trim() || !password) { if (!email.trim() || !password) {
@@ -56,7 +63,7 @@ export default function LoginScreen() {
try { try {
const session = await AuthService.login(email, password); const session = await AuthService.login(email, password);
await hydrateSession(session); await hydrateSession(session);
router.replace('/profile/billing'); router.replace('/(tabs)');
} catch (e: any) { } catch (e: any) {
if (e.message === 'USER_NOT_FOUND') { if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); setError(t.errUserNotFound);
@@ -101,8 +108,11 @@ export default function LoginScreen() {
name: fullName || undefined, name: fullName || undefined,
}); });
await hydrateSession(session); await hydrateSession(session);
if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' }); posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace('/profile/billing'); router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)');
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;

View File

@@ -19,6 +19,7 @@ import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
export default function SignupScreen() { export default function SignupScreen() {
@@ -36,8 +37,13 @@ export default function SignupScreen() {
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => { useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true; let mounted = true;
AppleAuthentication.isAvailableAsync() AppleAuthentication.isAvailableAsync()
.then((available) => { .then((available) => {
@@ -49,7 +55,7 @@ export default function SignupScreen() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, []); }, [isExpoGo]);
const validate = (): string | null => { const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired; if (!name.trim()) return t.errNameRequired;
@@ -121,7 +127,7 @@ export default function SignupScreen() {
await hydrateSession(session); await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' }); posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace('/profile/billing'); router.replace(session.isNewUser ? '/profile/billing' : '/(tabs)');
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Purchases, { import Purchases, {
PACKAGE_TYPE, PACKAGE_TYPE,
@@ -102,6 +103,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Kauf nur im Dev Build', 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.', 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', expoGoSimulate: 'Pro simulieren',
continueWithoutPro: 'Ohne Pro fortfahren',
perYear: '/ Jahr', perYear: '/ Jahr',
perMonth: '/ Monat', perMonth: '/ Monat',
freePlanName: 'Free', freePlanName: 'Free',
@@ -165,6 +167,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Compra solo en Dev Build', 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.', 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', expoGoSimulate: 'Simular Pro',
continueWithoutPro: 'Continuar sin Pro',
perYear: '/ ano', perYear: '/ ano',
perMonth: '/ mes', perMonth: '/ mes',
freePlanName: 'Gratis', freePlanName: 'Gratis',
@@ -228,6 +231,7 @@ const getBillingCopy = (language: Language) => {
expoGoPurchaseTitle: 'Purchase requires a dev build', 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.', 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', expoGoSimulate: 'Simulate Pro',
continueWithoutPro: 'Continue without Pro',
perYear: '/ year', perYear: '/ year',
perMonth: '/ month', perMonth: '/ month',
freePlanName: 'Free', freePlanName: 'Free',
@@ -290,7 +294,7 @@ export default function BillingScreen() {
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
const planId = billingSummary?.entitlement?.plan || 'free'; 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'; const showPaywallPlans = !session || planId !== 'pro';
useEffect(() => { useEffect(() => {
@@ -365,13 +369,31 @@ export default function BillingScreen() {
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions'); await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
}; };
const handleBack = () => { const handleBack = useCallback(() => {
if (showPaywallPlans) { if (showPaywallPlans) {
router.replace(session ? '/scanner' : '/onboarding'); router.replace('/onboarding');
return; return;
} }
if (router.canGoBack()) {
router.back(); 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) => { const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
setIsUpdating(true); setIsUpdating(true);
@@ -380,6 +402,8 @@ export default function BillingScreen() {
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (productId === 'monthly_pro' || productId === 'yearly_pro') {
posthog.capture('subscription_started', { product_id: productId, simulated: true }); posthog.capture('subscription_started', { product_id: productId, simulated: true });
posthog.capture('trial_started', { product_id: productId, simulated: true }); posthog.capture('trial_started', { product_id: productId, simulated: true });
setSubModalVisible(false);
router.replace('/(tabs)');
} else { } else {
posthog.capture('topup_purchased', { product_id: productId, simulated: true }); posthog.capture('topup_purchased', { product_id: productId, simulated: true });
} }
@@ -404,7 +428,8 @@ export default function BillingScreen() {
setIsUpdating(false); setIsUpdating(false);
if (productId === 'monthly_pro' || productId === 'yearly_pro') { if (productId === 'monthly_pro' || productId === 'yearly_pro') {
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
{ text: 'OK', style: 'cancel' }, { text: copy.continueWithoutPro, style: 'cancel' },
{ text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) },
]); ]);
return; return;
} }
@@ -428,10 +453,16 @@ export default function BillingScreen() {
}); });
throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.');
} }
await Purchases.purchasePackage(selectedPackage); const purchaseResult = await Purchases.purchasePackage(selectedPackage);
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3) // Apply RevenueCat entitlement locally and let backend sync finish in the background.
const customerInfo = await Purchases.getCustomerInfo(); const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo
await syncRevenueCatState(customerInfo as any, 'subscription_purchase'); ?? 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 { } else {
const selectedProduct = topupProducts[productId]; const selectedProduct = topupProducts[productId];
if (!selectedProduct) { if (!selectedProduct) {
@@ -442,12 +473,7 @@ export default function BillingScreen() {
await syncRevenueCatState(customerInfo as any, 'topup_purchase'); 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); setSubModalVisible(false);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
@@ -597,7 +623,11 @@ export default function BillingScreen() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]} style={[
styles.paywallPlanCardSecondary,
{ borderColor: colors.border },
(!storeReady || isUpdating) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase('monthly_pro')} onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady} disabled={isUpdating || !storeReady}
activeOpacity={0.9} activeOpacity={0.9}
@@ -615,7 +645,16 @@ export default function BillingScreen() {
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text> <Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text> <Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text>
</View> </View>
<View style={[styles.monthlyCtaButton, { backgroundColor: colors.surfaceMuted, borderColor: colors.border }]}>
{!storeReady ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text> <Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
<Ionicons name="arrow-forward" size={14} color={colors.primary} />
</>
)}
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}> <View style={[styles.legalLinksRow, { marginTop: 16 }]}>
@@ -1106,6 +1145,9 @@ const styles = StyleSheet.create({
padding: 14, padding: 14,
marginTop: 10, marginTop: 10,
}, },
disabledPlanCard: {
opacity: 0.72,
},
guestPlanHeader: { guestPlanHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -1174,7 +1216,18 @@ const styles = StyleSheet.create({
monthlyCtaText: { monthlyCtaText: {
fontSize: 13, fontSize: 13,
fontWeight: '800', 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: { guestSubscribeBtn: {
marginTop: 14, marginTop: 14,

View File

@@ -10,6 +10,7 @@ import * as ImagePicker from 'expo-image-picker';
import * as ImageManipulator from 'expo-image-manipulator'; import * as ImageManipulator from 'expo-image-manipulator';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { usePostHog } from 'posthog-react-native'; import { usePostHog } from 'posthog-react-native';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useColors } from '../constants/Colors'; import { useColors } from '../constants/Colors';
@@ -159,8 +160,13 @@ export default function ScannerScreen() {
const cameraRef = useRef<CameraView>(null); const cameraRef = useRef<CameraView>(null);
const scanLineProgress = useRef(new Animated.Value(0)).current; const scanLineProgress = useRef(new Animated.Value(0)).current;
const scanPulse = useRef(new Animated.Value(0)).current; const scanPulse = useRef(new Animated.Value(0)).current;
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => { useEffect(() => {
if (isExpoGo) {
setAppleAvailable(false);
return;
}
let mounted = true; let mounted = true;
AppleAuthentication.isAvailableAsync() AppleAuthentication.isAvailableAsync()
.then((available) => { .then((available) => {
@@ -172,7 +178,7 @@ export default function ScannerScreen() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, []); }, [isExpoGo]);
useEffect(() => { useEffect(() => {
if (!isAnalyzing) { if (!isAnalyzing) {
@@ -457,7 +463,11 @@ export default function ScannerScreen() {
try { try {
await savePlant(analysisResult, selectedImage); await savePlant(analysisResult, selectedImage);
if (router.canGoBack()) {
router.back(); router.back();
} else {
router.replace('/(tabs)');
}
} catch (error) { } catch (error) {
console.error('Saving identified plant failed', error); console.error('Saving identified plant failed', error);
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
@@ -507,7 +517,7 @@ export default function ScannerScreen() {
}); });
await hydrateSession(nextSession); await hydrateSession(nextSession);
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' }); posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
router.replace('/profile/billing'); router.replace(nextSession.isNewUser ? '/profile/billing' : '/(tabs)');
} catch (error: any) { } catch (error: any) {
if (error?.code === 'ERR_REQUEST_CANCELED') { if (error?.code === 'ERR_REQUEST_CANCELED') {
return; return;
@@ -528,7 +538,11 @@ export default function ScannerScreen() {
}; };
const handleClose = () => { const handleClose = () => {
if (router.canGoBack()) {
router.back(); router.back();
return;
}
router.replace('/onboarding');
}; };
const controlsPaddingBottom = Math.max(20, insets.bottom + 10); const controlsPaddingBottom = Math.max(20, insets.bottom + 10);
@@ -712,7 +726,7 @@ export default function ScannerScreen() {
activeOpacity={0.85} activeOpacity={0.85}
> >
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}> <Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta} {isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -33,6 +33,7 @@ interface AppState {
profileName: string; profileName: string;
profileImageUri: string | null; profileImageUri: string | null;
billingSummary: BillingSummary | null; billingSummary: BillingSummary | null;
isActivatingEntitlement: boolean;
resolvedScheme: AppColorScheme; resolvedScheme: AppColorScheme;
isDarkMode: boolean; isDarkMode: boolean;
isInitializing: boolean; isInitializing: boolean;
@@ -154,6 +155,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoadingPlants, setIsLoadingPlants] = useState(true); const [isLoadingPlants, setIsLoadingPlants] = useState(true);
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null); const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(null);
const [isLoadingBilling, setIsLoadingBilling] = useState(true); const [isLoadingBilling, setIsLoadingBilling] = useState(true);
const [isActivatingEntitlement, setIsActivatingEntitlement] = useState(false);
const resolvedScheme: AppColorScheme = const resolvedScheme: AppColorScheme =
appearanceMode === 'system' appearanceMode === 'system'
@@ -389,20 +391,45 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
source: RevenueCatSyncSource = 'app_init', source: RevenueCatSyncSource = 'app_init',
) => { ) => {
if (source === 'topup_purchase') { if (source === 'topup_purchase') {
return; return false;
} }
const activeEntitlements = customerInfo?.entitlements?.active || {}; const activeEntitlements = customerInfo?.entitlements?.active || {};
const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID]; const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
const proEntitlement = getValidProEntitlement(customerInfo); const proEntitlement = getValidProEntitlement(customerInfo);
const isPro = Boolean(proEntitlement); 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) => { setBillingSummary((prev) => {
if (!prev) return prev;
if (!proEntitlement && rawProEntitlement) { if (!proEntitlement && rawProEntitlement) {
return prev; 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 { return {
...prev, ...prev,
entitlement: { entitlement: {
@@ -414,6 +441,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, },
}; };
}); });
return isPro;
}, []); }, []);
const syncRevenueCatState = useCallback(async ( const syncRevenueCatState = useCallback(async (
@@ -424,7 +453,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
source, source,
customerInfo: summarizeRevenueCatCustomerInfo(customerInfo), customerInfo: summarizeRevenueCatCustomerInfo(customerInfo),
}); });
applyRevenueCatCustomerInfoLocally(customerInfo, source); const didActivatePro = applyRevenueCatCustomerInfoLocally(customerInfo, source);
const isSubscriptionActivation = source === 'subscription_purchase' && didActivatePro;
if (isSubscriptionActivation) {
setIsActivatingEntitlement(true);
}
try { try {
const response = await backendApiClient.syncRevenueCatState({ customerInfo, source }); const response = await backendApiClient.syncRevenueCatState({ customerInfo, source });
setBillingSummary(response.billing); setBillingSummary(response.billing);
@@ -432,6 +465,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
} catch (error) { } catch (error) {
console.error('Failed to sync RevenueCat state with backend', error); console.error('Failed to sync RevenueCat state with backend', error);
return null; return null;
} finally {
if (isSubscriptionActivation) {
setIsActivatingEntitlement(false);
}
} }
}, [applyRevenueCatCustomerInfoLocally]); }, [applyRevenueCatCustomerInfoLocally]);
@@ -538,6 +575,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
profileName, profileName,
profileImageUri, profileImageUri,
billingSummary, billingSummary,
isActivatingEntitlement,
resolvedScheme, resolvedScheme,
isDarkMode, isDarkMode,
isInitializing, isInitializing,

View File

@@ -1066,7 +1066,13 @@ app.post('/auth/apple', async (request, response) => {
} }
const user = await authSignInWithApple(db, identityToken, { appleUser, email, name }); const user = await authSignInWithApple(db, identityToken, { appleUser, email, name });
const token = issueToken(user.id, user.email, user.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) { } catch (error) {
const status = error.status || 500; const status = error.status || 500;
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });

View File

@@ -12,6 +12,13 @@ const APPLE_AUDIENCE = (
|| process.env.IOS_BUNDLE_ID || process.env.IOS_BUNDLE_ID
|| 'com.greenlens.app' || 'com.greenlens.app'
).trim(); ).trim();
const APPLE_ALLOWED_AUDIENCES = [
APPLE_AUDIENCE,
...(process.env.APPLE_ALLOWED_AUDIENCES || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
];
let appleJwksCache = { keys: [], expiresAt: 0 }; let appleJwksCache = { keys: [], expiresAt: 0 };
// ─── Minimal JWT (HS256, no external deps) ───────────────────────────────── // ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
@@ -134,7 +141,7 @@ const verifyAppleIdentityToken = async (identityToken) => {
const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }); const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' });
const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url')); const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
const nowSeconds = Math.floor(Date.now() / 1000); 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 ( if (
!validSignature !validSignature
@@ -143,6 +150,14 @@ const verifyAppleIdentityToken = async (identityToken) => {
|| !claims.sub || !claims.sub
|| (claims.exp && nowSeconds > Number(claims.exp)) || (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.'); const error = new Error('Apple identityToken could not be verified.');
error.code = 'APPLE_AUTH_INVALID'; error.code = 'APPLE_AUTH_INVALID';
error.status = 401; error.status = 401;
@@ -241,7 +256,7 @@ const signInWithApple = async (db, identityToken, profile = {}) => {
'SELECT id, email, name FROM auth_users WHERE apple_subject = $1', 'SELECT id, email, name FROM auth_users WHERE apple_subject = $1',
[appleSubject], [appleSubject],
); );
if (existingByApple) return existingByApple; if (existingByApple) return { ...existingByApple, isNewUser: false };
if (!normalizedEmail) { if (!normalizedEmail) {
const err = new Error('Apple did not return an email for this account.'); 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', 'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4',
[appleSubject, 'apple', nextName, existingByEmail.id], [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)}`; 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)`, VALUES ($1, $2, $3, NULL, $4, $5)`,
[id, normalizedEmail, name, 'apple', appleSubject], [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 }; module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };

View File

@@ -11,6 +11,7 @@ export interface AuthSession {
name: string; name: string;
token: string; // JWT from server token: string; // JWT from server
loggedInAt: string; loggedInAt: string;
isNewUser?: boolean;
} }
// ─── Internal helpers ────────────────────────────────────────────────────── // ─── Internal helpers ──────────────────────────────────────────────────────
@@ -19,7 +20,7 @@ const clearStoredSession = async (): Promise<void> => {
await SecureStore.deleteItemAsync(SESSION_KEY); 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 backendUrl = getConfiguredBackendRootUrl();
const hasBackendUrl = Boolean(backendUrl); const hasBackendUrl = Boolean(backendUrl);
const url = hasBackendUrl ? `${backendUrl}${path}` : path; const url = hasBackendUrl ? `${backendUrl}${path}` : path;
@@ -49,7 +50,7 @@ const authPost = async (path: string, body: object): Promise<{ userId: string; e
return data as any; 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); const localUser = AuthDb.ensureLocalUser(data.email, data.name);
return { return {
userId: localUser.id, userId: localUser.id,
@@ -58,6 +59,7 @@ const buildSession = (data: { userId: string; email: string; name: string; token
name: data.name, name: data.name,
token: data.token, token: data.token,
loggedInAt: new Date().toISOString(), loggedInAt: new Date().toISOString(),
isNewUser: data.isNewUser,
}; };
}; };