Hard paywall
This commit is contained in:
@@ -53,8 +53,18 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
|
||||
|
||||
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
|
||||
|
||||
function RootLayoutInner() {
|
||||
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
|
||||
function RootLayoutInner() {
|
||||
const {
|
||||
isDarkMode,
|
||||
colorPalette,
|
||||
signOut,
|
||||
session,
|
||||
billingSummary,
|
||||
isInitializing,
|
||||
isLoadingPlants,
|
||||
isLoadingBilling,
|
||||
syncRevenueCatState,
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const pathname = usePathname();
|
||||
const [installCheckDone, setInstallCheckDone] = useState(false);
|
||||
@@ -144,17 +154,25 @@ function RootLayoutInner() {
|
||||
})();
|
||||
}, [signOut]);
|
||||
|
||||
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
|
||||
const isAppReady = installCheckDone && !isInitializing && !isLoadingPlants;
|
||||
const hasActiveEntitlement = 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('scanner')
|
||||
|| pathname.includes('profile/billing');
|
||||
|
||||
let content = null;
|
||||
|
||||
if (isAppReady) {
|
||||
if (!session) {
|
||||
// Only redirect if we are not already on an auth-related page or the scanner
|
||||
const isAuthPage = pathname.includes('onboarding') || pathname.includes('auth/') || pathname.includes('scanner') || pathname.includes('profile/billing');
|
||||
if (!isAuthPage) {
|
||||
content = <Redirect href="/onboarding" />;
|
||||
} else {
|
||||
if (!session) {
|
||||
// Only redirect if we are not already on an auth-related page or the scanner
|
||||
if (!isAllowedWithoutSession) {
|
||||
content = <Redirect href="/onboarding" />;
|
||||
} else {
|
||||
content = (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -178,9 +196,11 @@ function RootLayoutInner() {
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
);
|
||||
}
|
||||
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
|
||||
content = <Redirect href="/profile/billing" />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<Stack
|
||||
@@ -246,11 +266,10 @@ export default function RootLayout() {
|
||||
initDatabase();
|
||||
|
||||
return (
|
||||
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
|
||||
host: 'https://us.i.posthog.com',
|
||||
enableSessionReplay: false,
|
||||
debug: __DEV__,
|
||||
}}>
|
||||
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
|
||||
host: 'https://us.i.posthog.com',
|
||||
enableSessionReplay: false,
|
||||
}}>
|
||||
<AppProvider>
|
||||
<CoachMarksProvider>
|
||||
<RootLayoutInner />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -16,17 +16,35 @@ import { router } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { AuthService } from '../../services/authService';
|
||||
import { AuthService } from '../../services/authService';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { usePostHog } from 'posthog-react-native';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isDarkMode, colorPalette, hydrateSession, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const posthog = usePostHog();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
AppleAuthentication.isAvailableAsync()
|
||||
.then((available) => {
|
||||
if (mounted) setAppleAvailable(available);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setAppleAvailable(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password) {
|
||||
@@ -36,9 +54,9 @@ export default function LoginScreen() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await AuthService.login(email, password);
|
||||
await hydrateSession(session);
|
||||
router.replace('/(tabs)');
|
||||
const session = await AuthService.login(email, password);
|
||||
await hydrateSession(session);
|
||||
router.replace('/profile/billing');
|
||||
} catch (e: any) {
|
||||
if (e.message === 'USER_NOT_FOUND') {
|
||||
setError(t.errUserNotFound);
|
||||
@@ -53,8 +71,53 @@ export default function LoginScreen() {
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
posthog.capture('apple_login_started', { surface: 'login' });
|
||||
try {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
|
||||
if (!credential.identityToken) {
|
||||
throw new Error('APPLE_AUTH_INVALID');
|
||||
}
|
||||
|
||||
const fullName = [
|
||||
credential.fullName?.givenName,
|
||||
credential.fullName?.familyName,
|
||||
].filter(Boolean).join(' ');
|
||||
const session = await AuthService.signInWithApple({
|
||||
identityToken: credential.identityToken,
|
||||
appleUser: credential.user,
|
||||
email: credential.email,
|
||||
name: fullName || undefined,
|
||||
});
|
||||
await hydrateSession(session);
|
||||
posthog.capture('apple_login_succeeded', { surface: 'login' });
|
||||
router.replace('/profile/billing');
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||
return;
|
||||
}
|
||||
posthog.capture('apple_login_failed', {
|
||||
surface: 'login',
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||
: t.errAuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
@@ -80,10 +143,30 @@ export default function LoginScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{/* Email */}
|
||||
<View style={styles.fieldGroup}>
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{appleAvailable ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={isDarkMode
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={12}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<View style={styles.dividerRowCompact}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Email */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
@@ -150,8 +233,8 @@ export default function LoginScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.dividerRow}>
|
||||
{/* Divider */}
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
@@ -201,7 +284,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
},
|
||||
card: {
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 24,
|
||||
@@ -210,7 +293,18 @@ const styles = StyleSheet.create({
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
},
|
||||
appleButton: {
|
||||
width: '100%',
|
||||
height: 50,
|
||||
marginBottom: 2,
|
||||
},
|
||||
dividerRowCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginVertical: 2,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -16,22 +16,40 @@ import { router } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { AuthService } from '../../services/authService';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AuthService } from '../../services/authService';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { usePostHog } from 'posthog-react-native';
|
||||
|
||||
export default function SignupScreen() {
|
||||
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const pendingPlant = getPendingPlant();
|
||||
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const posthog = usePostHog();
|
||||
const pendingPlant = getPendingPlant();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
AppleAuthentication.isAvailableAsync()
|
||||
.then((available) => {
|
||||
if (mounted) setAppleAvailable(available);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setAppleAvailable(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const validate = (): string | null => {
|
||||
if (!name.trim()) return t.errNameRequired;
|
||||
@@ -50,11 +68,11 @@ export default function SignupScreen() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await AuthService.signUp(email, name, password);
|
||||
await hydrateSession(session);
|
||||
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
router.replace('/onboarding/source');
|
||||
const session = await AuthService.signUp(email, name, password);
|
||||
await hydrateSession(session);
|
||||
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
router.replace('/profile/billing');
|
||||
} catch (e: any) {
|
||||
if (e.message === 'EMAIL_TAKEN') {
|
||||
setError(t.errEmailTaken);
|
||||
@@ -71,8 +89,54 @@ export default function SignupScreen() {
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppleSignIn = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
posthog.capture('apple_login_started', { surface: 'signup' });
|
||||
try {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
|
||||
if (!credential.identityToken) {
|
||||
throw new Error('APPLE_AUTH_INVALID');
|
||||
}
|
||||
|
||||
const fullName = [
|
||||
credential.fullName?.givenName,
|
||||
credential.fullName?.familyName,
|
||||
].filter(Boolean).join(' ');
|
||||
const session = await AuthService.signInWithApple({
|
||||
identityToken: credential.identityToken,
|
||||
appleUser: credential.user,
|
||||
email: credential.email,
|
||||
name: fullName || undefined,
|
||||
});
|
||||
await hydrateSession(session);
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
posthog.capture('apple_login_succeeded', { surface: 'signup' });
|
||||
router.replace('/profile/billing');
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||
return;
|
||||
}
|
||||
posthog.capture('apple_login_failed', {
|
||||
surface: 'signup',
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||
: t.errAuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
@@ -114,10 +178,30 @@ export default function SignupScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{/* Name */}
|
||||
<View style={styles.fieldGroup}>
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{appleAvailable ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={isDarkMode
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={12}
|
||||
style={styles.appleButton}
|
||||
onPress={handleAppleSignIn}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{appleAvailable ? (
|
||||
<View style={styles.dividerRowCompact}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
@@ -317,17 +401,36 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 24,
|
||||
gap: 14,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
fieldGroup: {
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
appleButton: {
|
||||
width: '100%',
|
||||
height: 50,
|
||||
marginBottom: 2,
|
||||
},
|
||||
dividerRowCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginVertical: 2,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
|
||||
@@ -19,9 +19,19 @@ import { Language } from '../../types';
|
||||
import { PurchaseProductId } from '../../services/backend/contracts';
|
||||
|
||||
type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
|
||||
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
|
||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
|
||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT: Record<TopupProductId, number> = {
|
||||
topup_small: 30,
|
||||
topup_medium: 100,
|
||||
topup_large: 250,
|
||||
};
|
||||
|
||||
const isTopupProductId = (productId: PurchaseProductId): productId is TopupProductId => (
|
||||
productId === 'topup_small' || productId === 'topup_medium' || productId === 'topup_large'
|
||||
);
|
||||
|
||||
const isMatchingPackage = (
|
||||
pkg: PurchasesPackage,
|
||||
@@ -79,7 +89,22 @@ const getBillingCopy = (language: Language) => {
|
||||
manageSubscription: 'Abo verwalten',
|
||||
subscriptionTitle: 'Abos',
|
||||
subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.',
|
||||
freePlanName: 'Free',
|
||||
paywallTitle: 'Vollstaendige Diagnose freischalten',
|
||||
paywallHint: 'Starte Pro fuer echte GPT-5.4 Scans, deinen 7-Tage-Rettungsplan und 100 Credits fuer AI-Scans und Follow-ups.',
|
||||
startTrial: '7 Tage kostenlos testen',
|
||||
monthlyCta: 'Monatlich starten',
|
||||
yearlyCta: 'Jaehrlich starten',
|
||||
yearlyTrialBadge: '7 TAGE GRATIS',
|
||||
monthlyBadge: 'FLEXIBEL',
|
||||
yearlySubline: 'Danach 39,99 EUR/Jahr. Jederzeit kuendbar.',
|
||||
monthlySubline: '4,99 EUR/Monat. Ohne Jahresbindung.',
|
||||
saveLabel: 'Bester Wert',
|
||||
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',
|
||||
perYear: '/ Jahr',
|
||||
perMonth: '/ Monat',
|
||||
freePlanName: 'Free',
|
||||
freePlanPrice: '0 EUR / Monat',
|
||||
proPlanName: 'Pro',
|
||||
proPlanPrice: '4,99 € / Monat',
|
||||
@@ -88,17 +113,20 @@ const getBillingCopy = (language: Language) => {
|
||||
proYearlyPlanPrice: '39,99 € / Jahr',
|
||||
proYearlyBadgeText: 'SPAREN',
|
||||
proBenefits: [
|
||||
'250 Credits jeden Monat',
|
||||
'100 Credits für AI-Scans und Follow-ups jeden Monat',
|
||||
'Pro-Scans mit GPT-5.4',
|
||||
'Unbegrenzte Historie & Galerie',
|
||||
'KI-Pflanzendoktor inklusive',
|
||||
'Priorisierter Support'
|
||||
],
|
||||
topupTitle: 'Credits Aufladen',
|
||||
topupSmall: '25 Credits – 1,99 €',
|
||||
topupMedium: '120 Credits – 6,99 €',
|
||||
topupLarge: '300 Credits – 12,99 €',
|
||||
topupBestValue: 'BESTES ANGEBOT',
|
||||
topupHint: 'Für aktive Pro-Nutzer, wenn die Monatscredits nicht reichen.',
|
||||
topupSmall: '30 Credits – 2,99 €',
|
||||
topupMedium: '100 Credits – 6,99 €',
|
||||
topupLarge: '250 Credits – 12,99 €',
|
||||
topupBestValue: 'BESTES ANGEBOT',
|
||||
topupRequiresProTitle: 'Pro erforderlich',
|
||||
topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.',
|
||||
cancelTitle: 'Schade, dass du gehst',
|
||||
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
|
||||
reasonTooExpensive: 'Es ist mir zu teuer',
|
||||
@@ -124,7 +152,22 @@ const getBillingCopy = (language: Language) => {
|
||||
manageSubscription: 'Administrar Suscripción',
|
||||
subscriptionTitle: 'Suscripciones',
|
||||
subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.',
|
||||
freePlanName: 'Gratis',
|
||||
paywallTitle: 'Desbloquear diagnostico completo',
|
||||
paywallHint: 'Inicia Pro para escaneos reales con GPT-5.4, tu plan de rescate de 7 dias y 100 creditos para escaneos IA y seguimientos.',
|
||||
startTrial: 'Probar 7 dias gratis',
|
||||
monthlyCta: 'Empezar mensual',
|
||||
yearlyCta: 'Empezar anual',
|
||||
yearlyTrialBadge: '7 DIAS GRATIS',
|
||||
monthlyBadge: 'FLEXIBLE',
|
||||
yearlySubline: 'Despues 39.99 EUR/ano. Cancela cuando quieras.',
|
||||
monthlySubline: '4.99 EUR/mes. Sin compromiso anual.',
|
||||
saveLabel: 'Mejor valor',
|
||||
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',
|
||||
perYear: '/ ano',
|
||||
perMonth: '/ mes',
|
||||
freePlanName: 'Gratis',
|
||||
freePlanPrice: '0 EUR / Mes',
|
||||
proPlanName: 'Pro',
|
||||
proPlanPrice: '4.99 EUR / Mes',
|
||||
@@ -133,17 +176,20 @@ const getBillingCopy = (language: Language) => {
|
||||
proYearlyPlanPrice: '39.99 EUR / Año',
|
||||
proYearlyBadgeText: 'AHORRAR',
|
||||
proBenefits: [
|
||||
'250 créditos cada mes',
|
||||
'100 créditos para escaneos IA y seguimientos cada mes',
|
||||
'Escaneos Pro con GPT-5.4',
|
||||
'Historial y galería ilimitados',
|
||||
'Doctor de plantas de IA incluido',
|
||||
'Soporte prioritario'
|
||||
],
|
||||
topupTitle: 'Recargar Créditos',
|
||||
topupSmall: '25 Créditos – 1,99 €',
|
||||
topupMedium: '120 Créditos – 6,99 €',
|
||||
topupLarge: '300 Créditos – 12,99 €',
|
||||
topupBestValue: 'MEJOR OFERTA',
|
||||
topupHint: 'Para usuarios Pro activos cuando los créditos mensuales no alcanzan.',
|
||||
topupSmall: '30 Créditos – 2,99 €',
|
||||
topupMedium: '100 Créditos – 6,99 €',
|
||||
topupLarge: '250 Créditos – 12,99 €',
|
||||
topupBestValue: 'MEJOR OFERTA',
|
||||
topupRequiresProTitle: 'Pro requerido',
|
||||
topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.',
|
||||
cancelTitle: 'Lamentamos verte ir',
|
||||
cancelQuestion: '¿Podemos saber por qué cancelas?',
|
||||
reasonTooExpensive: 'Es muy caro',
|
||||
@@ -169,7 +215,22 @@ const getBillingCopy = (language: Language) => {
|
||||
manageSubscription: 'Manage Subscription',
|
||||
subscriptionTitle: 'Subscriptions',
|
||||
subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.',
|
||||
freePlanName: 'Free',
|
||||
paywallTitle: 'Unlock the full diagnosis',
|
||||
paywallHint: 'Start Pro for real GPT-5.4 scans, your 7-day rescue plan, and 100 credits for AI scans and follow-ups.',
|
||||
startTrial: 'Start 7-day free trial',
|
||||
monthlyCta: 'Start monthly',
|
||||
yearlyCta: 'Start yearly',
|
||||
yearlyTrialBadge: '7 DAYS FREE',
|
||||
monthlyBadge: 'FLEXIBLE',
|
||||
yearlySubline: 'Then EUR 39.99/year. Cancel anytime.',
|
||||
monthlySubline: 'EUR 4.99/month. No annual commitment.',
|
||||
saveLabel: 'Best value',
|
||||
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',
|
||||
perYear: '/ year',
|
||||
perMonth: '/ month',
|
||||
freePlanName: 'Free',
|
||||
freePlanPrice: '0 EUR / Month',
|
||||
proPlanName: 'Pro',
|
||||
proPlanPrice: '4.99 EUR / Month',
|
||||
@@ -178,17 +239,20 @@ const getBillingCopy = (language: Language) => {
|
||||
proYearlyPlanPrice: '39.99 EUR / Year',
|
||||
proYearlyBadgeText: 'SAVE',
|
||||
proBenefits: [
|
||||
'250 credits every month',
|
||||
'100 credits for AI scans and follow-ups every month',
|
||||
'Pro scans with GPT-5.4',
|
||||
'Unlimited history & gallery',
|
||||
'AI Plant Doctor included',
|
||||
'Priority support'
|
||||
],
|
||||
topupTitle: 'Topup Credits',
|
||||
topupSmall: '25 Credits – €1.99',
|
||||
topupMedium: '120 Credits – €6.99',
|
||||
topupLarge: '300 Credits – €12.99',
|
||||
topupBestValue: 'BEST VALUE',
|
||||
topupHint: 'For active Pro users when monthly credits are not enough.',
|
||||
topupSmall: '30 Credits – €2.99',
|
||||
topupMedium: '100 Credits – €6.99',
|
||||
topupLarge: '250 Credits – €12.99',
|
||||
topupBestValue: 'BEST VALUE',
|
||||
topupRequiresProTitle: 'Pro required',
|
||||
topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.',
|
||||
cancelTitle: 'Sorry to see you go',
|
||||
cancelQuestion: 'May we ask why you are cancelling?',
|
||||
reasonTooExpensive: 'It is too expensive',
|
||||
@@ -225,8 +289,9 @@ export default function BillingScreen() {
|
||||
// Cancel Flow State
|
||||
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
|
||||
|
||||
const planId = billingSummary?.entitlement?.plan || 'free';
|
||||
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
|
||||
const planId = billingSummary?.entitlement?.plan || 'free';
|
||||
const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? '--');
|
||||
const showPaywallPlans = !session || planId !== 'pro';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -274,9 +339,15 @@ export default function BillingScreen() {
|
||||
};
|
||||
}, [isExpoGo]);
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('paywall_viewed', { plan_id: planId });
|
||||
}, [posthog, planId]);
|
||||
useEffect(() => {
|
||||
posthog.capture('paywall_viewed', { plan_id: planId });
|
||||
if (showPaywallPlans) {
|
||||
posthog.capture('hard_paywall_viewed', {
|
||||
plan_id: planId,
|
||||
authenticated: Boolean(session),
|
||||
});
|
||||
}
|
||||
}, [posthog, planId, session?.serverUserId, showPaywallPlans]);
|
||||
|
||||
const monthlyPackage = subscriptionPackages.monthly_pro;
|
||||
const yearlyPackage = subscriptionPackages.yearly_pro;
|
||||
@@ -285,22 +356,60 @@ export default function BillingScreen() {
|
||||
const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice;
|
||||
|
||||
const topupLabels = useMemo(() => ({
|
||||
topup_small: topupProducts.topup_small ? `25 Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
||||
topup_medium: topupProducts.topup_medium ? `120 Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
||||
topup_large: topupProducts.topup_large ? `300 Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
||||
topup_small: topupProducts.topup_small ? `${TOPUP_CREDITS_BY_PRODUCT.topup_small} Credits - ${topupProducts.topup_small.priceString}` : copy.topupSmall,
|
||||
topup_medium: topupProducts.topup_medium ? `${TOPUP_CREDITS_BY_PRODUCT.topup_medium} Credits - ${topupProducts.topup_medium.priceString}` : copy.topupMedium,
|
||||
topup_large: topupProducts.topup_large ? `${TOPUP_CREDITS_BY_PRODUCT.topup_large} Credits - ${topupProducts.topup_large.priceString}` : copy.topupLarge,
|
||||
}), [copy.topupLarge, copy.topupMedium, copy.topupSmall, topupProducts.topup_large, topupProducts.topup_medium, topupProducts.topup_small]);
|
||||
|
||||
const openAppleSubscriptions = async () => {
|
||||
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
||||
};
|
||||
|
||||
const handlePurchase = async (productId: PurchaseProductId) => {
|
||||
setIsUpdating(true);
|
||||
posthog.capture('purchase_initiated', { product_id: productId });
|
||||
const openAppleSubscriptions = async () => {
|
||||
await Linking.openURL('itms-apps://apps.apple.com/account/subscriptions');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (showPaywallPlans) {
|
||||
router.replace(session ? '/scanner' : '/onboarding');
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
};
|
||||
|
||||
const completeExpoGoSimulation = async (productId: PurchaseProductId) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await simulatePurchase(productId);
|
||||
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 });
|
||||
} else {
|
||||
posthog.capture('topup_purchased', { product_id: productId, simulated: true });
|
||||
}
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchase = async (productId: PurchaseProductId) => {
|
||||
if (isTopupProductId(productId) && planId !== 'pro') {
|
||||
Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [
|
||||
{ text: copy.manageSubscription, onPress: () => setSubModalVisible(true) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
posthog.capture('purchase_initiated', { product_id: productId });
|
||||
try {
|
||||
if (isExpoGo) {
|
||||
// ExpoGo has no native RevenueCat — use simulation for development only
|
||||
await simulatePurchase(productId);
|
||||
setIsUpdating(false);
|
||||
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||
Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [
|
||||
{ text: 'OK', style: 'cancel' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await completeExpoGoSimulation(productId);
|
||||
return;
|
||||
} else {
|
||||
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
|
||||
if (planId === 'pro') {
|
||||
@@ -323,8 +432,7 @@ export default function BillingScreen() {
|
||||
// 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');
|
||||
posthog.capture('subscription_started', { product_id: productId });
|
||||
} else {
|
||||
} else {
|
||||
const selectedProduct = topupProducts[productId];
|
||||
if (!selectedProduct) {
|
||||
throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.');
|
||||
@@ -332,18 +440,24 @@ export default function BillingScreen() {
|
||||
await Purchases.purchaseStoreProduct(selectedProduct);
|
||||
const customerInfo = await Purchases.getCustomerInfo();
|
||||
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
|
||||
posthog.capture('topup_purchased', { product_id: productId });
|
||||
}
|
||||
}
|
||||
setSubModalVisible(false);
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
setSubModalVisible(false);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
|
||||
|
||||
if (userCancelled) {
|
||||
posthog.capture('purchase_cancelled', { product_id: productId });
|
||||
return;
|
||||
}
|
||||
if (userCancelled) {
|
||||
posthog.capture('purchase_cancelled', { product_id: productId });
|
||||
posthog.capture('paywall_purchase_cancelled', { product_id: productId });
|
||||
return;
|
||||
}
|
||||
|
||||
// RevenueCat error code 7 = PRODUCT_ALREADY_PURCHASED — the Apple ID already
|
||||
// owns this subscription on a different GreenLens account. Silently dismiss;
|
||||
@@ -405,7 +519,7 @@ export default function BillingScreen() {
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
|
||||
@@ -417,7 +531,7 @@ export default function BillingScreen() {
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
||||
) : (
|
||||
<>
|
||||
{session && (
|
||||
{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 }]}>
|
||||
@@ -436,66 +550,73 @@ export default function BillingScreen() {
|
||||
<Text style={[styles.creditsValue, { color: colors.text }]}>{credits}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!session && (
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Subscription Plans</Text>
|
||||
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
|
||||
Choose a plan to unlock AI plant scans and care features.
|
||||
</Text>
|
||||
{showPaywallPlans && (
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||
<Text style={[styles.paywallTitle, { color: colors.text }]}>{copy.paywallTitle}</Text>
|
||||
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 16 }]}>
|
||||
{copy.paywallHint}
|
||||
</Text>
|
||||
|
||||
{/* Monthly */}
|
||||
<View style={[styles.guestPlanCard, { borderColor: colors.primary, backgroundColor: colors.primary + '10' }]}>
|
||||
<View style={styles.guestPlanHeader}>
|
||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
||||
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.proBadgeText}>MONTHLY</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{monthlyPrice}</Text>
|
||||
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewMonthly}</Text>
|
||||
<View style={{ gap: 4, marginTop: 8 }}>
|
||||
{copy.proBenefits.map((b, i) => (
|
||||
<View key={i} style={styles.benefitRow}>
|
||||
<Ionicons name="checkmark" size={14} color={colors.primary} />
|
||||
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={() => handlePurchase('monthly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
>
|
||||
<Text style={styles.manageBtnText}>Subscribe Monthly</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Yearly */}
|
||||
<View style={[styles.guestPlanCard, { borderColor: colors.border, marginTop: 12 }]}>
|
||||
<View style={styles.guestPlanHeader}>
|
||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
||||
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.proBadgeText}>YEARLY</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
|
||||
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.autoRenewYearly}</Text>
|
||||
<View style={{ gap: 4, marginTop: 8 }}>
|
||||
{copy.proBenefits.map((b, i) => (
|
||||
<View key={i} style={styles.benefitRow}>
|
||||
<Ionicons name="checkmark" size={14} color={colors.primary} />
|
||||
<Text style={[styles.benefitText, { color: colors.textSecondary }]}>{b}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.guestSubscribeBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={() => handlePurchase('yearly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
>
|
||||
<Text style={styles.manageBtnText}>Subscribe Yearly</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.paywallValueRows}>
|
||||
{copy.proBenefits.slice(0, 3).map((benefit, index) => (
|
||||
<View key={benefit} style={[styles.paywallValueRow, { backgroundColor: colors.surfaceMuted }]}>
|
||||
<Ionicons
|
||||
name={index === 0 ? 'scan-outline' : index === 1 ? 'medkit-outline' : 'calendar-outline'}
|
||||
size={17}
|
||||
color={colors.primary}
|
||||
/>
|
||||
<Text style={[styles.paywallValueText, { color: colors.text }]}>{benefit}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.paywallPlanCardPrimary, { borderColor: colors.primary, backgroundColor: colors.primary + '12' }]}
|
||||
onPress={() => handlePurchase('yearly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={styles.planTopRow}>
|
||||
<View>
|
||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>GreenLens Pro</Text>
|
||||
<Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.saveLabel}</Text>
|
||||
</View>
|
||||
<View style={[styles.proBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.proBadgeText}>{copy.yearlyTrialBadge}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={[styles.guestPlanPrice, { color: colors.text }]}>{yearlyPrice}</Text>
|
||||
<Text style={[styles.planTerm, { color: colors.textMuted }]}>{copy.perYear}</Text>
|
||||
</View>
|
||||
<Text style={[styles.guestPlanRenew, { color: colors.textMuted }]}>{copy.yearlySubline}</Text>
|
||||
<View style={[styles.trialCallout, { backgroundColor: colors.primarySoft }]}>
|
||||
<Ionicons name="sparkles-outline" size={16} color={colors.primary} />
|
||||
<Text style={[styles.trialCalloutText, { color: colors.text }]}>{copy.startTrial}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.paywallPlanCardSecondary, { borderColor: colors.border }]}
|
||||
onPress={() => handlePurchase('monthly_pro')}
|
||||
disabled={isUpdating || !storeReady}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={styles.planTopRow}>
|
||||
<View>
|
||||
<Text style={[styles.guestPlanName, { color: colors.text }]}>Monatlich</Text>
|
||||
<Text style={[styles.planSubline, { color: colors.textMuted }]}>{copy.monthlySubline}</Text>
|
||||
</View>
|
||||
<View style={[styles.secondaryBadge, { borderColor: colors.borderStrong }]}>
|
||||
<Text style={[styles.secondaryBadgeText, { color: colors.textSecondary }]}>{copy.monthlyBadge}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.priceRow}>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={[styles.legalLinksRow, { marginTop: 16 }]}>
|
||||
<TouchableOpacity onPress={() => Linking.openURL('https://greenlenspro.com/privacy')}>
|
||||
@@ -512,9 +633,11 @@ export default function BillingScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
|
||||
<View style={{ gap: 10, marginTop: 8 }}>
|
||||
{session && planId === 'pro' && !isExpoGo ? (
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.border }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{copy.topupTitle}</Text>
|
||||
<Text style={[styles.modalHint, { color: colors.text + '80', marginBottom: 8 }]}>{copy.topupHint}</Text>
|
||||
<View style={{ gap: 10, marginTop: 8 }}>
|
||||
{([
|
||||
{ id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small },
|
||||
{ id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue },
|
||||
@@ -560,10 +683,11 @@ export default function BillingScreen() {
|
||||
<Text style={[styles.legalLink, { color: colors.primary }]}>Terms of Use</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
|
||||
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.restoreBtn} onPress={handleRestore} disabled={isUpdating}>
|
||||
<Text style={[styles.legalLink, { color: colors.textMuted }]}>{copy.restorePurchases}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
@@ -779,11 +903,36 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
creditsValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
},
|
||||
topupBtn: {
|
||||
creditsValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
},
|
||||
paywallTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
lineHeight: 30,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paywallValueRows: {
|
||||
gap: 8,
|
||||
marginBottom: 14,
|
||||
},
|
||||
paywallValueRow: {
|
||||
minHeight: 42,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
},
|
||||
paywallValueText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
lineHeight: 18,
|
||||
},
|
||||
topupBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -940,29 +1089,93 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
guestPlanCard: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
guestPlanHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
guestPlanName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
guestPlanPrice: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
guestPlanRenew: {
|
||||
fontSize: 12,
|
||||
},
|
||||
guestPlanCard: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
paywallPlanCardPrimary: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
marginTop: 2,
|
||||
},
|
||||
paywallPlanCardSecondary: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
marginTop: 10,
|
||||
},
|
||||
guestPlanHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
planTopRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
guestPlanName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
planSubline: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 2,
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 6,
|
||||
},
|
||||
guestPlanPrice: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
planTerm: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
guestPlanRenew: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
trialCallout: {
|
||||
minHeight: 46,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
marginTop: 14,
|
||||
},
|
||||
trialCalloutText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
secondaryBadge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
secondaryBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
},
|
||||
monthlyCtaText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
marginTop: 8,
|
||||
},
|
||||
guestSubscribeBtn: {
|
||||
marginTop: 14,
|
||||
paddingVertical: 12,
|
||||
|
||||
471
app/scanner.tsx
471
app/scanner.tsx
@@ -6,18 +6,20 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as ImageManipulator from 'expo-image-manipulator';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { usePostHog } from 'posthog-react-native';
|
||||
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 { usePostHog } from 'posthog-react-native';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { useColors } from '../constants/Colors';
|
||||
import { PlantRecognitionService } from '../services/plantRecognitionService';
|
||||
import { IdentificationResult } from '../types';
|
||||
import { ResultCard } from '../components/ResultCard';
|
||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||
import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||
import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService } from '../services/authService';
|
||||
|
||||
const HEALTH_CHECK_CREDIT_COST = 2;
|
||||
|
||||
@@ -41,11 +43,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
|
||||
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health Check abgeschlossen',
|
||||
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
|
||||
signupLabel: 'Registrieren',
|
||||
};
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health Check abgeschlossen',
|
||||
healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.',
|
||||
signupLabel: 'Registrieren',
|
||||
demoTitle: 'Rettungsplan bereit',
|
||||
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
|
||||
appleCta: 'Mit Apple fortfahren',
|
||||
emailCta: 'Mit E-Mail fortfahren',
|
||||
unlockCta: 'Vollständige Diagnose freischalten',
|
||||
};
|
||||
}
|
||||
|
||||
if (language === 'es') {
|
||||
@@ -67,11 +74,16 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
|
||||
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
|
||||
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health-check completado',
|
||||
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
|
||||
signupLabel: 'Registrarse',
|
||||
};
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health-check completado',
|
||||
healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.',
|
||||
signupLabel: 'Registrarse',
|
||||
demoTitle: 'Plan de rescate listo',
|
||||
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
|
||||
appleCta: 'Continuar con Apple',
|
||||
emailCta: 'Continuar con email',
|
||||
unlockCta: 'Desbloquear diagnóstico completo',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -92,12 +104,17 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
|
||||
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
|
||||
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health Check Complete',
|
||||
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
|
||||
signupLabel: 'Sign Up',
|
||||
};
|
||||
};
|
||||
healthTitle: 'Health Check',
|
||||
healthDoneTitle: 'Health Check Complete',
|
||||
healthDoneMessage: 'The new photo was analyzed and added to gallery.',
|
||||
signupLabel: 'Sign Up',
|
||||
demoTitle: 'Rescue plan ready',
|
||||
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
|
||||
appleCta: 'Continue with Apple',
|
||||
emailCta: 'Continue with email',
|
||||
unlockCta: 'Unlock full diagnosis',
|
||||
};
|
||||
};
|
||||
|
||||
export default function ScannerScreen() {
|
||||
const params = useLocalSearchParams<{ mode?: string; plantId?: string }>();
|
||||
@@ -112,36 +129,53 @@ export default function ScannerScreen() {
|
||||
updatePlant,
|
||||
billingSummary,
|
||||
refreshBillingSummary,
|
||||
isLoadingBilling,
|
||||
session,
|
||||
setPendingPlant,
|
||||
guestScanCount,
|
||||
incrementGuestScanCount,
|
||||
} = useApp();
|
||||
isLoadingBilling,
|
||||
session,
|
||||
hydrateSession,
|
||||
setPendingPlant,
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const billingCopy = getBillingCopy(language);
|
||||
const isHealthMode = params.mode === 'health';
|
||||
const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId;
|
||||
const healthPlant = isHealthMode && healthPlantId
|
||||
? plants.find((item) => item.id === healthPlantId)
|
||||
: null;
|
||||
const availableCredits = session
|
||||
? (billingSummary?.credits.available ?? 0)
|
||||
: Math.max(0, 5 - guestScanCount);
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const healthPlant = isHealthMode && healthPlantId
|
||||
? plants.find((item) => item.id === healthPlantId)
|
||||
: null;
|
||||
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
|
||||
&& billingSummary?.entitlement?.status === 'active';
|
||||
const isDemoMode = !hasActiveEntitlement;
|
||||
const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0;
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isAuthLoading, setIsAuthLoading] = useState(false);
|
||||
const [appleAvailable, setAppleAvailable] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyzing) {
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
AppleAuthentication.isAvailableAsync()
|
||||
.then((available) => {
|
||||
if (mounted) setAppleAvailable(available);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setAppleAvailable(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyzing) {
|
||||
scanLineProgress.stopAnimation();
|
||||
scanLineProgress.setValue(0);
|
||||
scanPulse.stopAnimation();
|
||||
@@ -199,29 +233,25 @@ export default function ScannerScreen() {
|
||||
const analyzeImage = async (imageUri: string, galleryImageUri?: string) => {
|
||||
if (isAnalyzing) return;
|
||||
|
||||
if (availableCredits <= 0) {
|
||||
if (!session) {
|
||||
// Guest: show paywall directly — no registration required to purchase
|
||||
router.push('/profile/billing');
|
||||
return;
|
||||
}
|
||||
if (!isDemoMode && availableCredits <= 0) {
|
||||
Alert.alert(
|
||||
billingCopy.noCreditsTitle,
|
||||
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
|
||||
[
|
||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||
{
|
||||
text: billingCopy.managePlan,
|
||||
onPress: () => router.replace('/(tabs)/profile'),
|
||||
},
|
||||
],
|
||||
);
|
||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||
{
|
||||
text: billingCopy.managePlan,
|
||||
onPress: () => router.replace('/profile/billing'),
|
||||
},
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAnalysisProgress(0);
|
||||
setAnalysisResult(null);
|
||||
setIsAnalyzing(true);
|
||||
setAnalysisProgress(0);
|
||||
setAnalysisResult(null);
|
||||
setDemoResultVisible(false);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -234,10 +264,32 @@ export default function ScannerScreen() {
|
||||
});
|
||||
}, 150);
|
||||
|
||||
try {
|
||||
if (isHealthMode) {
|
||||
if (!healthPlant) {
|
||||
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
posthog.capture('demo_scan_started', {
|
||||
authenticated: Boolean(session),
|
||||
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||
setAnalysisProgress(100);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
setDemoResultVisible(true);
|
||||
posthog.capture('demo_scan_completed', {
|
||||
authenticated: Boolean(session),
|
||||
latency_ms: Date.now() - startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture('paid_scan_started', {
|
||||
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||
credits_available: availableCredits,
|
||||
});
|
||||
|
||||
if (isHealthMode) {
|
||||
if (!healthPlant) {
|
||||
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
|
||||
setSelectedImage(null);
|
||||
setIsAnalyzing(false);
|
||||
return;
|
||||
@@ -261,11 +313,7 @@ export default function ScannerScreen() {
|
||||
latency_ms: Date.now() - startTime,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
incrementGuestScanCount();
|
||||
}
|
||||
|
||||
const currentGallery = healthPlant.gallery || [];
|
||||
const currentGallery = healthPlant.gallery || [];
|
||||
const existingChecks = healthPlant.healthChecks || [];
|
||||
const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6);
|
||||
const updatedPlant = {
|
||||
@@ -285,16 +333,16 @@ export default function ScannerScreen() {
|
||||
latency_ms: Date.now() - startTime,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
incrementGuestScanCount();
|
||||
}
|
||||
|
||||
setAnalysisResult(result);
|
||||
}
|
||||
setAnalysisProgress(100);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setIsAnalyzing(false);
|
||||
setAnalysisResult(result);
|
||||
}
|
||||
setAnalysisProgress(100);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
posthog.capture('paid_scan_completed', {
|
||||
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||
latency_ms: Date.now() - startTime,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
setIsAnalyzing(false);
|
||||
if (isHealthMode && healthPlant) {
|
||||
Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [
|
||||
{ text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) },
|
||||
@@ -315,13 +363,13 @@ export default function ScannerScreen() {
|
||||
billingCopy.noCreditsTitle,
|
||||
isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage,
|
||||
[
|
||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||
{
|
||||
text: billingCopy.managePlan,
|
||||
onPress: () => router.replace('/(tabs)/profile'),
|
||||
},
|
||||
],
|
||||
);
|
||||
{ text: billingCopy.dismiss, style: 'cancel' },
|
||||
{
|
||||
text: billingCopy.managePlan,
|
||||
onPress: () => router.replace('/profile/billing'),
|
||||
},
|
||||
],
|
||||
);
|
||||
} else if (isTimeoutError(error)) {
|
||||
Alert.alert(
|
||||
billingCopy.timeoutTitle,
|
||||
@@ -360,20 +408,24 @@ export default function ScannerScreen() {
|
||||
}
|
||||
setSelectedImage(null);
|
||||
setIsAnalyzing(false);
|
||||
} finally {
|
||||
clearInterval(progressInterval);
|
||||
await refreshBillingSummary();
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
clearInterval(progressInterval);
|
||||
setIsAnalyzing(false);
|
||||
if (!isDemoMode) {
|
||||
await refreshBillingSummary();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isAnalyzing) return;
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
|
||||
if (photo) {
|
||||
const analysisUri = await resizeForAnalysis(photo.uri);
|
||||
setSelectedImage(analysisUri);
|
||||
analyzeImage(analysisUri, photo.uri);
|
||||
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
|
||||
if (photo) {
|
||||
const analysisUri = await resizeForAnalysis(photo.uri);
|
||||
setDemoResultVisible(false);
|
||||
setSelectedImage(analysisUri);
|
||||
analyzeImage(analysisUri, photo.uri);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -385,15 +437,16 @@ export default function ScannerScreen() {
|
||||
quality: 1,
|
||||
base64: false,
|
||||
});
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||
setSelectedImage(asset.uri);
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
const analysisUri = await resizeForAnalysis(asset.uri);
|
||||
setDemoResultVisible(false);
|
||||
setSelectedImage(asset.uri);
|
||||
analyzeImage(analysisUri, asset.uri);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async () => {
|
||||
if (analysisResult && selectedImage) {
|
||||
if (!session) {
|
||||
// Guest mode: store result and go to signup
|
||||
@@ -409,10 +462,72 @@ export default function ScannerScreen() {
|
||||
console.error('Saving identified plant failed', error);
|
||||
Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const routeToHardPaywall = () => {
|
||||
posthog.capture('auth_prompt_shown', {
|
||||
authenticated: Boolean(session),
|
||||
surface: 'demo_scan_result',
|
||||
});
|
||||
if (session) {
|
||||
router.replace('/profile/billing');
|
||||
return;
|
||||
}
|
||||
router.replace('/auth/signup');
|
||||
};
|
||||
|
||||
const handleDemoAppleSignIn = async () => {
|
||||
if (!appleAvailable) {
|
||||
routeToHardPaywall();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthLoading(true);
|
||||
posthog.capture('apple_login_started', { surface: 'scanner_demo' });
|
||||
try {
|
||||
const credential = await AppleAuthentication.signInAsync({
|
||||
requestedScopes: [
|
||||
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||
],
|
||||
});
|
||||
if (!credential.identityToken) {
|
||||
throw new Error('APPLE_AUTH_INVALID');
|
||||
}
|
||||
const fullName = [
|
||||
credential.fullName?.givenName,
|
||||
credential.fullName?.familyName,
|
||||
].filter(Boolean).join(' ');
|
||||
const nextSession = await AuthService.signInWithApple({
|
||||
identityToken: credential.identityToken,
|
||||
appleUser: credential.user,
|
||||
email: credential.email,
|
||||
name: fullName || undefined,
|
||||
});
|
||||
await hydrateSession(nextSession);
|
||||
posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' });
|
||||
router.replace('/profile/billing');
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ERR_REQUEST_CANCELED') {
|
||||
return;
|
||||
}
|
||||
posthog.capture('apple_login_failed', {
|
||||
surface: 'scanner_demo',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
Alert.alert(
|
||||
billingCopy.genericErrorTitle,
|
||||
error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE'
|
||||
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
|
||||
: billingCopy.genericErrorMessage,
|
||||
);
|
||||
} finally {
|
||||
setIsAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
@@ -470,14 +585,14 @@ export default function ScannerScreen() {
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.iconOnImage }]}>
|
||||
{isHealthMode ? billingCopy.healthTitle : t.scanner}
|
||||
</Text>
|
||||
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
|
||||
<Ionicons name="wallet-outline" size={12} color={colors.text} />
|
||||
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
|
||||
{billingCopy.creditsLabel}: {availableCredits}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Text>
|
||||
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
|
||||
<Ionicons name={isDemoMode ? 'sparkles-outline' : 'wallet-outline'} size={12} color={colors.text} />
|
||||
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
|
||||
{isDemoMode ? 'Demo' : `${billingCopy.creditsLabel}: ${availableCredits}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Camera */}
|
||||
<View style={styles.cameraContainer}>
|
||||
@@ -560,10 +675,65 @@ export default function ScannerScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<View
|
||||
)}
|
||||
|
||||
{demoResultVisible && !isAnalyzing ? (
|
||||
<View
|
||||
style={[
|
||||
styles.demoSheet,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
bottom: analysisBottomOffset,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.demoIconWrap, { backgroundColor: colors.primarySoft }]}>
|
||||
<Ionicons name="sparkles" size={22} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.demoTitle, { color: colors.text }]}>{billingCopy.demoTitle}</Text>
|
||||
<Text style={[styles.demoMessage, { color: colors.textSecondary }]}>{billingCopy.demoMessage}</Text>
|
||||
|
||||
{!session && appleAvailable ? (
|
||||
<AppleAuthentication.AppleAuthenticationButton
|
||||
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
|
||||
buttonStyle={isDarkMode
|
||||
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
||||
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
|
||||
cornerRadius={12}
|
||||
style={styles.demoAppleButton}
|
||||
onPress={handleDemoAppleSignIn}
|
||||
/>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.demoPrimaryBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={session ? routeToHardPaywall : handleDemoAppleSignIn}
|
||||
disabled={isAuthLoading}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.demoPrimaryText, { color: colors.onPrimary }]}>
|
||||
{isAuthLoading ? '...' : session ? billingCopy.unlockCta : billingCopy.appleCta}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!session ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.demoSecondaryBtn, { borderColor: colors.borderStrong }]}
|
||||
onPress={() => {
|
||||
posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' });
|
||||
router.replace('/auth/signup');
|
||||
}}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.demoSecondaryText, { color: colors.text }]}>{billingCopy.emailCta}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<View
|
||||
style={[
|
||||
styles.controls,
|
||||
{
|
||||
@@ -686,7 +856,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
shutterInner: { width: 64, height: 64, borderRadius: 32 },
|
||||
shutterBtnDisabled: { opacity: 0.6 },
|
||||
analysisSheet: {
|
||||
analysisSheet: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
@@ -699,9 +869,68 @@ const styles = StyleSheet.create({
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 14,
|
||||
elevation: 14,
|
||||
},
|
||||
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
||||
elevation: 14,
|
||||
},
|
||||
demoSheet: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
padding: 18,
|
||||
zIndex: 25,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.24,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
demoIconWrap: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
demoTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 6,
|
||||
},
|
||||
demoMessage: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 14,
|
||||
},
|
||||
demoAppleButton: {
|
||||
width: '100%',
|
||||
height: 50,
|
||||
marginBottom: 10,
|
||||
},
|
||||
demoPrimaryBtn: {
|
||||
height: 50,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
demoPrimaryText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
demoSecondaryBtn: {
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
demoSecondaryText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
||||
analysisBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user