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,
customizationDone: false,
});
const { registerLayout, startTour } = useCoachMarks();
const { layouts, registerLayout, startTour } = useCoachMarks();
const fabRef = useRef<View>(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<typeof setTimeout> | 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(() => {

View File

@@ -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');

View File

@@ -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<string | null>(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;

View File

@@ -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<string | null>(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;

View File

@@ -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' && (
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}>
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.planLabel}</Text>
<View style={[styles.row, { marginBottom: 16 }]}>
<Text style={[styles.value, { color: colors.text }]}>
{planId === 'pro' ? copy.planPro : copy.planFree}
</Text>
@@ -597,7 +623,11 @@ export default function BillingScreen() {
</TouchableOpacity>
<TouchableOpacity
style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]}
style={[
styles.paywallPlanCardSecondary,
{ borderColor: colors.border },
(!storeReady || isUpdating) && styles.disabledPlanCard,
]}
onPress={() => handlePurchase('monthly_pro')}
disabled={isUpdating || !storeReady}
activeOpacity={0.9}
@@ -615,7 +645,16 @@ export default function BillingScreen() {
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perMonth}</Text>
</View>
<Text style={[styles.monthlyCtaText, { color: colors.primary }]}>{copy.monthlyCta}</Text>
<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>
<Ionicons name="arrow-forward" size={14} color={colors.primary} />
</>
)}
</View>
</TouchableOpacity>
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
@@ -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,

View File

@@ -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<IdentificationResult | null>(null);
const [demoResultVisible, setDemoResultVisible] = useState(false);
const cameraRef = useRef<CameraView>(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}
>
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta}
</Text>
</TouchableOpacity>
)}