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>
)}

View File

@@ -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,

View File

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

View File

@@ -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 };

View File

@@ -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 ───────────────────────────────────────────────────────────