Harte Paywall
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user