Harte Paywall
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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<BillingSummary | null>(null);
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(true);
|
||||
const [billingSummary, setBillingSummary] = useState<BillingSummary | null>(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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<void> => {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user