diff --git a/__tests__/server/authAccountDeletion.test.js b/__tests__/server/authAccountDeletion.test.js index 1ac31c7..ef29071 100644 --- a/__tests__/server/authAccountDeletion.test.js +++ b/__tests__/server/authAccountDeletion.test.js @@ -1,40 +1,40 @@ -jest.mock('../../server/lib/postgres', () => ({ - get: jest.fn(), - run: jest.fn(), -})); - -const { get, run } = require('../../server/lib/postgres'); -const { deleteAccount, signUp } = require('../../server/lib/auth'); - -describe('server auth account deletion', () => { - beforeEach(() => { - jest.clearAllMocks(); - get.mockResolvedValue(null); - run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); - }); - - it('removes auth and billing rows so the same email can sign up again', async () => { - const email = 'same@example.com'; - - await signUp({}, email, 'First User', 'password-1'); - await deleteAccount({}, 'usr_deleted'); - await signUp({}, email, 'Second User', 'password-2'); - - const authDeletes = run.mock.calls.filter(([, sql]) => ( - typeof sql === 'string' && sql.includes('DELETE FROM auth_users') - )); - expect(authDeletes).toHaveLength(1); - - const billingAccountDeletes = run.mock.calls.filter(([, sql]) => ( - typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts') - )); - expect(billingAccountDeletes).toHaveLength(1); - - const signupChecks = get.mock.calls.filter(([, sql, params]) => ( - typeof sql === 'string' - && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') - && params?.[0] === email - )); - expect(signupChecks).toHaveLength(2); - }); -}); +jest.mock('../../server/lib/postgres', () => ({ + get: jest.fn(), + run: jest.fn(), +})); + +const { get, run } = require('../../server/lib/postgres'); +const { deleteAccount, signUp } = require('../../server/lib/auth'); + +describe('server auth account deletion', () => { + beforeEach(() => { + jest.clearAllMocks(); + get.mockResolvedValue(null); + run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); + }); + + it('removes auth and billing rows so the same email can sign up again', async () => { + const email = 'same@example.com'; + + await signUp({}, email, 'First User', 'password-1'); + await deleteAccount({}, 'usr_deleted'); + await signUp({}, email, 'Second User', 'password-2'); + + const authDeletes = run.mock.calls.filter(([, sql]) => ( + typeof sql === 'string' && sql.includes('DELETE FROM auth_users') + )); + expect(authDeletes).toHaveLength(1); + + const billingAccountDeletes = run.mock.calls.filter(([, sql]) => ( + typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts') + )); + expect(billingAccountDeletes).toHaveLength(1); + + const signupChecks = get.mock.calls.filter(([, sql, params]) => ( + typeof sql === 'string' + && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') + && params?.[0] === email + )); + expect(signupChecks).toHaveLength(2); + }); +}); diff --git a/__tests__/server/billingTimestampNormalization.test.js b/__tests__/server/billingTimestampNormalization.test.js index 9045ce9..e4d34f6 100644 --- a/__tests__/server/billingTimestampNormalization.test.js +++ b/__tests__/server/billingTimestampNormalization.test.js @@ -1,45 +1,45 @@ -jest.mock('../../server/lib/postgres', () => ({ - get: jest.fn(), - run: jest.fn(), -})); - -const { get, run } = require('../../server/lib/postgres'); -const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing'); - -describe('server billing timestamp normalization', () => { - beforeEach(() => { - jest.clearAllMocks(); - run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); - }); - - it('upserts ISO timestamps when postgres returns Date objects', async () => { - get.mockResolvedValueOnce({ - userId: 'usr_mnjcdwpo_ax9lf68b', - plan: 'free', - provider: 'revenuecat', - cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'), - cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'), - monthlyAllowance: 15, - usedThisCycle: 0, - topupBalance: 0, - renewsAt: null, - updatedAt: new Date('2026-04-02T12:00:00.000Z'), - }); - - await syncRevenueCatCustomerInfo( - {}, - 'usr_mnjcdwpo_ax9lf68b', - { entitlements: { active: {} }, nonSubscriptions: {} }, - { source: 'topup_purchase' }, - ); - - const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts')); - expect(upsertCall).toBeTruthy(); - - const params = upsertCall[2]; - expect(params[3]).toBe('2027-04-01T00:00:00.000Z'); - expect(params[4]).toBe('2027-05-01T00:00:00.000Z'); - expect(params[3]).not.toContain('Coordinated Universal Time'); - expect(params[4]).not.toContain('Coordinated Universal Time'); - }); -}); +jest.mock('../../server/lib/postgres', () => ({ + get: jest.fn(), + run: jest.fn(), +})); + +const { get, run } = require('../../server/lib/postgres'); +const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing'); + +describe('server billing timestamp normalization', () => { + beforeEach(() => { + jest.clearAllMocks(); + run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); + }); + + it('upserts ISO timestamps when postgres returns Date objects', async () => { + get.mockResolvedValueOnce({ + userId: 'usr_mnjcdwpo_ax9lf68b', + plan: 'free', + provider: 'revenuecat', + cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'), + cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'), + monthlyAllowance: 15, + usedThisCycle: 0, + topupBalance: 0, + renewsAt: null, + updatedAt: new Date('2026-04-02T12:00:00.000Z'), + }); + + await syncRevenueCatCustomerInfo( + {}, + 'usr_mnjcdwpo_ax9lf68b', + { entitlements: { active: {} }, nonSubscriptions: {} }, + { source: 'topup_purchase' }, + ); + + const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts')); + expect(upsertCall).toBeTruthy(); + + const params = upsertCall[2]; + expect(params[3]).toBe('2027-04-01T00:00:00.000Z'); + expect(params[4]).toBe('2027-05-01T00:00:00.000Z'); + expect(params[3]).not.toContain('Coordinated Universal Time'); + expect(params[4]).not.toContain('Coordinated Universal Time'); + }); +}); diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 96a152c..69533a7 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,385 +1,385 @@ -import React, { useEffect, useState } from 'react'; -import { - ActivityIndicator, - Alert, - ImageBackground, - KeyboardAvoidingView, - Platform, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import Constants from 'expo-constants'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { AuthService } from '../../services/authService'; -import { useSafeAnalytics } from '../../services/analytics'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; -import { Language } from '../../types'; - -const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); - -const getCopy = (language: Language) => { - if (language === 'de') { - return { - headline: 'Welcome back!', - subline: 'Melde dich an und mache mit deiner Pflanzenpflege weiter.', - loginCta: 'Anmelden', - forgot: 'Passwort vergessen?', - forgotTitle: 'Passwort zuruecksetzen', - forgotBody: 'Ein Reset-Link ist noch nicht in der App verfuegbar. Bitte nutze aktuell deine gespeicherten Login-Daten.', - newHere: 'Neu hier?', - create: 'Account erstellen', - emailLabel: 'E-Mail', - }; - } - if (language === 'es') { - return { - headline: 'Welcome back!', - subline: 'Inicia sesion para continuar con el cuidado de tus plantas.', - loginCta: 'Iniciar sesion', - forgot: 'Olvidaste tu contrasena?', - forgotTitle: 'Restablecer contrasena', - forgotBody: 'El enlace de restablecimiento aun no esta disponible en la app. Usa tus datos guardados por ahora.', - newHere: 'Nuevo aqui?', - create: 'Crear cuenta', - emailLabel: 'Email', - }; - } - return { - headline: 'Welcome back!', - subline: 'Log in to keep scanning, saving and caring for your plants.', - loginCta: 'Log in', - forgot: 'Forgot password?', - forgotTitle: 'Reset password', - forgotBody: 'Password reset is not available in the app yet. Please use your saved login details for now.', - newHere: 'New here?', - create: 'Create account', - emailLabel: 'Email', - }; -}; - -export default function LoginScreen() { - const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const copy = getCopy(language); - const posthog = useSafeAnalytics(); - const isExpoGo = Constants.appOwnership === 'expo'; - - 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(null); - - useEffect(() => { - if (isExpoGo) { - setAppleAvailable(false); - return; - } - let mounted = true; - AppleAuthentication.isAvailableAsync() - .then((available) => { - if (mounted) setAppleAvailable(available); - }) - .catch(() => { - if (mounted) setAppleAvailable(false); - }); - return () => { - mounted = false; - }; - }, [isExpoGo]); - - const finishAuth = async (session: Awaited>) => { - await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - if (session.isNewUser) { - await AsyncStorage.setItem('greenlens_show_tour', 'true'); - } - router.replace('/(tabs)'); - }; - - const handleLogin = async () => { - if (!email.trim() || !password) { - setError(t.errFillAllFields); - return; - } - setLoading(true); - setError(null); - try { - const session = await AuthService.login(email, password); - await finishAuth(session); - } catch (e: any) { - if (e.message === 'USER_NOT_FOUND') { - setError(t.errUserNotFound); - } else if (e.message === 'WRONG_PASSWORD') { - setError(t.errWrongPassword); - } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { - setError(t.errNetworkError); - } else { - setError(t.errLoginFailed); - } - } 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, - }); - posthog.capture('apple_login_succeeded', { surface: 'login' }); - await finishAuth(session); - } 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 ( - - - - - router.back()} activeOpacity={0.8}> - - - - {copy.headline} - {copy.subline} - - - - - {appleAvailable ? ( - - ) : null} - - {appleAvailable ? ( - - - {t.orDivider} - - - ) : null} - - - - {copy.emailLabel} - - - - - - - - {t.passwordLabel} - - - - setShowPassword((v) => !v)} style={styles.eyeBtn}> - - - - - - - Alert.alert(copy.forgotTitle, copy.forgotBody)} - activeOpacity={0.78} - > - {copy.forgot} - - - {error ? ( - - - {error} - - ) : null} - - - {loading ? ( - - ) : ( - {copy.loginCta} - )} - - - router.replace('/auth/signup')} activeOpacity={0.78}> - - {copy.newHere}{' '} - {copy.create} - - - - - - ); -} - -const styles = StyleSheet.create({ - flex: { flex: 1 }, - scroll: { flexGrow: 1 }, - hero: { - minHeight: 290, - justifyContent: 'flex-end', - paddingHorizontal: 24, - paddingTop: 56, - paddingBottom: 32, - }, - heroImage: { resizeMode: 'cover' }, - heroOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(5, 12, 7, 0.48)', - }, - backBtn: { - position: 'absolute', - top: 54, - left: 22, - width: 42, - height: 42, - borderRadius: 21, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.28)', - }, - heroCopy: { gap: 10 }, - heroTitle: { - color: '#ffffff', - fontSize: 36, - lineHeight: 41, - fontWeight: '900', - }, - heroSubline: { - color: 'rgba(255, 255, 255, 0.88)', - fontSize: 16, - lineHeight: 22, - fontWeight: '700', - }, - sheet: { - flex: 1, - marginTop: -24, - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - paddingHorizontal: 22, - paddingTop: 24, - paddingBottom: 34, - gap: 14, - }, - appleButton: { width: '100%', height: 56 }, - dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - dividerLine: { flex: 1, height: 1 }, - dividerText: { fontSize: 12, fontWeight: '800' }, - form: { gap: 12 }, - fieldGroup: { gap: 6 }, - label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, - inputRow: { - height: 54, - borderWidth: 1, - borderRadius: 14, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 14, - }, - inputIcon: { marginRight: 10 }, - input: { flex: 1, height: 54, fontSize: 15 }, - eyeBtn: { padding: 5, marginLeft: 6 }, - forgotBtn: { alignItems: 'flex-end', marginTop: -4 }, - forgotText: { fontSize: 14, fontWeight: '800' }, - errorBox: { - flexDirection: 'row', - alignItems: 'center', - gap: 7, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - }, - errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, - primaryBtn: { - height: 56, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - }, - primaryBtnText: { fontSize: 17, fontWeight: '900' }, - signupLink: { alignItems: 'center', paddingTop: 8 }, - signupLinkText: { fontSize: 15, fontWeight: '700' }, -}); +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ImageBackground, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { AuthService } from '../../services/authService'; +import { useSafeAnalytics } from '../../services/analytics'; +import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { Language } from '../../types'; + +const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); + +const getCopy = (language: Language) => { + if (language === 'de') { + return { + headline: 'Welcome back!', + subline: 'Melde dich an und mache mit deiner Pflanzenpflege weiter.', + loginCta: 'Anmelden', + forgot: 'Passwort vergessen?', + forgotTitle: 'Passwort zuruecksetzen', + forgotBody: 'Ein Reset-Link ist noch nicht in der App verfuegbar. Bitte nutze aktuell deine gespeicherten Login-Daten.', + newHere: 'Neu hier?', + create: 'Account erstellen', + emailLabel: 'E-Mail', + }; + } + if (language === 'es') { + return { + headline: 'Welcome back!', + subline: 'Inicia sesion para continuar con el cuidado de tus plantas.', + loginCta: 'Iniciar sesion', + forgot: 'Olvidaste tu contrasena?', + forgotTitle: 'Restablecer contrasena', + forgotBody: 'El enlace de restablecimiento aun no esta disponible en la app. Usa tus datos guardados por ahora.', + newHere: 'Nuevo aqui?', + create: 'Crear cuenta', + emailLabel: 'Email', + }; + } + return { + headline: 'Welcome back!', + subline: 'Log in to keep scanning, saving and caring for your plants.', + loginCta: 'Log in', + forgot: 'Forgot password?', + forgotTitle: 'Reset password', + forgotBody: 'Password reset is not available in the app yet. Please use your saved login details for now.', + newHere: 'New here?', + create: 'Create account', + emailLabel: 'Email', + }; +}; + +export default function LoginScreen() { + const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const copy = getCopy(language); + const posthog = useSafeAnalytics(); + const isExpoGo = Constants.appOwnership === 'expo'; + + 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(null); + + useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } + let mounted = true; + AppleAuthentication.isAvailableAsync() + .then((available) => { + if (mounted) setAppleAvailable(available); + }) + .catch(() => { + if (mounted) setAppleAvailable(false); + }); + return () => { + mounted = false; + }; + }, [isExpoGo]); + + const finishAuth = async (session: Awaited>) => { + await hydrateSession(session); + if (session?.userId) { + await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); + } + if (session.isNewUser) { + await AsyncStorage.setItem('greenlens_show_tour', 'true'); + } + router.replace('/(tabs)'); + }; + + const handleLogin = async () => { + if (!email.trim() || !password) { + setError(t.errFillAllFields); + return; + } + setLoading(true); + setError(null); + try { + const session = await AuthService.login(email, password); + await finishAuth(session); + } catch (e: any) { + if (e.message === 'USER_NOT_FOUND') { + setError(t.errUserNotFound); + } else if (e.message === 'WRONG_PASSWORD') { + setError(t.errWrongPassword); + } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { + setError(t.errNetworkError); + } else { + setError(t.errLoginFailed); + } + } 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, + }); + posthog.capture('apple_login_succeeded', { surface: 'login' }); + await finishAuth(session); + } 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 ( + + + + + router.back()} activeOpacity={0.8}> + + + + {copy.headline} + {copy.subline} + + + + + {appleAvailable ? ( + + ) : null} + + {appleAvailable ? ( + + + {t.orDivider} + + + ) : null} + + + + {copy.emailLabel} + + + + + + + + {t.passwordLabel} + + + + setShowPassword((v) => !v)} style={styles.eyeBtn}> + + + + + + + Alert.alert(copy.forgotTitle, copy.forgotBody)} + activeOpacity={0.78} + > + {copy.forgot} + + + {error ? ( + + + {error} + + ) : null} + + + {loading ? ( + + ) : ( + {copy.loginCta} + )} + + + router.replace('/auth/signup')} activeOpacity={0.78}> + + {copy.newHere}{' '} + {copy.create} + + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + scroll: { flexGrow: 1 }, + hero: { + minHeight: 290, + justifyContent: 'flex-end', + paddingHorizontal: 24, + paddingTop: 56, + paddingBottom: 32, + }, + heroImage: { resizeMode: 'cover' }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(5, 12, 7, 0.48)', + }, + backBtn: { + position: 'absolute', + top: 54, + left: 22, + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.28)', + }, + heroCopy: { gap: 10 }, + heroTitle: { + color: '#ffffff', + fontSize: 36, + lineHeight: 41, + fontWeight: '900', + }, + heroSubline: { + color: 'rgba(255, 255, 255, 0.88)', + fontSize: 16, + lineHeight: 22, + fontWeight: '700', + }, + sheet: { + flex: 1, + marginTop: -24, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 22, + paddingTop: 24, + paddingBottom: 34, + gap: 14, + }, + appleButton: { width: '100%', height: 56 }, + dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + dividerLine: { flex: 1, height: 1 }, + dividerText: { fontSize: 12, fontWeight: '800' }, + form: { gap: 12 }, + fieldGroup: { gap: 6 }, + label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, + inputRow: { + height: 54, + borderWidth: 1, + borderRadius: 14, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + }, + inputIcon: { marginRight: 10 }, + input: { flex: 1, height: 54, fontSize: 15 }, + eyeBtn: { padding: 5, marginLeft: 6 }, + forgotBtn: { alignItems: 'flex-end', marginTop: -4 }, + forgotText: { fontSize: 14, fontWeight: '800' }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + }, + errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, + primaryBtn: { + height: 56, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + primaryBtnText: { fontSize: 17, fontWeight: '900' }, + signupLink: { alignItems: 'center', paddingTop: 8 }, + signupLinkText: { fontSize: 15, fontWeight: '700' }, +}); diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx index bcfaad0..6e699b0 100644 --- a/app/auth/signup.tsx +++ b/app/auth/signup.tsx @@ -1,482 +1,482 @@ -import React, { useEffect, useState } from 'react'; -import { - ActivityIndicator, - ImageBackground, - KeyboardAvoidingView, - Platform, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import Constants from 'expo-constants'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { AuthService } from '../../services/authService'; -import { useSafeAnalytics } from '../../services/analytics'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; -import { Language } from '../../types'; - -const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); - -const getCopy = (language: Language) => { - if (language === 'de') { - return { - headline: "Let's finish your setup!", - subline: 'Erstelle einen Account, speichere deine Pflanzen und sichere dir 3 Gratis-Scans pro Monat.', - emailCta: 'Mit E-Mail fortfahren', - createCta: 'Account erstellen', - already: 'Schon einen Account?', - login: 'Anmelden', - legal: 'Mit dem Fortfahren akzeptierst du Datenschutz und Nutzungsbedingungen.', - nameLabel: 'Name', - emailLabel: 'E-Mail', - savePlantPrefix: 'Dein Scan wird nach der Registrierung gespeichert:', - }; - } - if (language === 'es') { - return { - headline: "Let's finish your setup!", - subline: 'Crea una cuenta para guardar tus plantas y recibir 3 escaneos gratis al mes.', - emailCta: 'Continuar con email', - createCta: 'Crear cuenta', - already: 'Ya tienes cuenta?', - login: 'Iniciar sesion', - legal: 'Al continuar aceptas la Politica de privacidad y los Terminos.', - nameLabel: 'Nombre', - emailLabel: 'Email', - savePlantPrefix: 'Guardaremos tu escaneo despues del registro:', - }; - } - return { - headline: "Let's finish your setup!", - subline: 'Create an account to save your plants and 3 free scans per month.', - emailCta: 'Continue with Email', - createCta: 'Create Account', - already: 'Already have an account?', - login: 'Log in', - legal: 'By continuing you agree to our Privacy Policy and Terms.', - nameLabel: 'Name', - emailLabel: 'Email', - savePlantPrefix: 'Your scan will be saved after signup:', - }; -}; - -export default function SignupScreen() { - const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const copy = getCopy(language); - const posthog = useSafeAnalytics(); - const pendingPlant = getPendingPlant(); - const isExpoGo = Constants.appOwnership === 'expo'; - - 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 [emailExpanded, setEmailExpanded] = useState(false); - const [appleAvailable, setAppleAvailable] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - posthog.capture('signup_screen_viewed', { context: 'onboarding' }); - }, [posthog]); - - useEffect(() => { - if (isExpoGo) { - setAppleAvailable(false); - return; - } - let mounted = true; - AppleAuthentication.isAvailableAsync() - .then((available) => { - if (mounted) setAppleAvailable(available); - }) - .catch(() => { - if (mounted) setAppleAvailable(false); - }); - return () => { - mounted = false; - }; - }, [isExpoGo]); - - const finishAuth = async (session: Awaited>) => { - await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - await AsyncStorage.setItem('greenlens_show_tour', 'true'); - router.replace('/(tabs)'); - }; - - const validate = (): string | null => { - if (!name.trim()) return t.errNameRequired; - if (!email.trim() || !email.includes('@')) return t.errEmailInvalid; - if (password.length < 6) return t.errPasswordShort; - if (password !== passwordConfirm) return t.errPasswordMismatch; - return null; - }; - - const handleSignup = async () => { - const validationError = validate(); - if (validationError) { - setError(validationError); - setEmailExpanded(true); - return; - } - setLoading(true); - setError(null); - try { - const session = await AuthService.signUp(email, name, password); - await finishAuth(session); - } catch (e: any) { - if (e.message === 'EMAIL_TAKEN') { - setError(t.errEmailTaken); - } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { - setError(t.errNetworkError); - } else if (e.message === 'SERVER_ERROR') { - setError(t.errServerError); - } else { - setError(t.errAuthError); - } - } 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, - }); - posthog.capture('apple_login_succeeded', { surface: 'signup' }); - await finishAuth(session); - } 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 ( - - - - - router.back()} activeOpacity={0.8}> - - - - {copy.headline} - {copy.subline} - - - - - {pendingPlant ? ( - - - - {copy.savePlantPrefix} {pendingPlant.result.name} - - - ) : null} - - {appleAvailable ? ( - - ) : null} - - {appleAvailable ? ( - - - {t.orDivider} - - - ) : null} - - {!emailExpanded ? ( - setEmailExpanded(true)} - activeOpacity={0.84} - > - - {copy.emailCta} - - ) : ( - - - {copy.nameLabel} - - - - - - - - {copy.emailLabel} - - - - - - - - {t.passwordLabel} - - - - setShowPassword((v) => !v)} style={styles.eyeBtn}> - - - - - - - {t.confirmPasswordLabel} - - - - setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> - - - - - - )} - - {error ? ( - - - {error} - - ) : null} - - {emailExpanded ? ( - - {loading ? ( - - ) : ( - {copy.createCta} - )} - - ) : null} - - router.replace('/auth/login')} activeOpacity={0.78}> - - {copy.already}{' '} - {copy.login} - - - - {copy.legal} - - - - ); -} - -const styles = StyleSheet.create({ - flex: { flex: 1 }, - scroll: { flexGrow: 1 }, - hero: { - minHeight: 330, - justifyContent: 'flex-end', - paddingHorizontal: 24, - paddingTop: 56, - paddingBottom: 34, - }, - heroImage: { resizeMode: 'cover' }, - heroOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(5, 12, 7, 0.46)', - }, - backBtn: { - position: 'absolute', - top: 54, - left: 22, - width: 42, - height: 42, - borderRadius: 21, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.28)', - }, - heroCopy: { gap: 10 }, - heroTitle: { - color: '#ffffff', - fontSize: 34, - lineHeight: 39, - fontWeight: '900', - }, - heroSubline: { - color: 'rgba(255, 255, 255, 0.88)', - fontSize: 16, - lineHeight: 22, - fontWeight: '700', - }, - sheet: { - flex: 1, - marginTop: -24, - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - paddingHorizontal: 22, - paddingTop: 24, - paddingBottom: 32, - gap: 14, - }, - pendingHint: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderRadius: 16, - borderWidth: 1, - paddingHorizontal: 14, - paddingVertical: 12, - }, - pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, - appleButton: { width: '100%', height: 56 }, - dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - dividerLine: { flex: 1, height: 1 }, - dividerText: { fontSize: 12, fontWeight: '800' }, - emailChoiceBtn: { - height: 56, - borderRadius: 14, - borderWidth: 1.5, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - }, - emailChoiceText: { fontSize: 16, fontWeight: '800' }, - form: { gap: 12 }, - fieldGroup: { gap: 6 }, - label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, - inputRow: { - height: 52, - borderWidth: 1, - borderRadius: 14, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 14, - }, - inputIcon: { marginRight: 10 }, - input: { flex: 1, height: 52, fontSize: 15 }, - eyeBtn: { padding: 5, marginLeft: 6 }, - errorBox: { - flexDirection: 'row', - alignItems: 'center', - gap: 7, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - }, - errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, - primaryBtn: { - height: 56, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - }, - primaryBtnText: { fontSize: 17, fontWeight: '900' }, - loginLink: { alignItems: 'center', paddingTop: 4, paddingBottom: 2 }, - loginLinkText: { fontSize: 15, fontWeight: '700' }, - legal: { textAlign: 'center', fontSize: 11.5, lineHeight: 16, paddingHorizontal: 12 }, -}); +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + ImageBackground, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { AuthService } from '../../services/authService'; +import { useSafeAnalytics } from '../../services/analytics'; +import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { Language } from '../../types'; + +const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); + +const getCopy = (language: Language) => { + if (language === 'de') { + return { + headline: "Let's finish your setup!", + subline: 'Erstelle einen Account, speichere deine Pflanzen und sichere dir 3 Gratis-Scans pro Monat.', + emailCta: 'Mit E-Mail fortfahren', + createCta: 'Account erstellen', + already: 'Schon einen Account?', + login: 'Anmelden', + legal: 'Mit dem Fortfahren akzeptierst du Datenschutz und Nutzungsbedingungen.', + nameLabel: 'Name', + emailLabel: 'E-Mail', + savePlantPrefix: 'Dein Scan wird nach der Registrierung gespeichert:', + }; + } + if (language === 'es') { + return { + headline: "Let's finish your setup!", + subline: 'Crea una cuenta para guardar tus plantas y recibir 3 escaneos gratis al mes.', + emailCta: 'Continuar con email', + createCta: 'Crear cuenta', + already: 'Ya tienes cuenta?', + login: 'Iniciar sesion', + legal: 'Al continuar aceptas la Politica de privacidad y los Terminos.', + nameLabel: 'Nombre', + emailLabel: 'Email', + savePlantPrefix: 'Guardaremos tu escaneo despues del registro:', + }; + } + return { + headline: "Let's finish your setup!", + subline: 'Create an account to save your plants and 3 free scans per month.', + emailCta: 'Continue with Email', + createCta: 'Create Account', + already: 'Already have an account?', + login: 'Log in', + legal: 'By continuing you agree to our Privacy Policy and Terms.', + nameLabel: 'Name', + emailLabel: 'Email', + savePlantPrefix: 'Your scan will be saved after signup:', + }; +}; + +export default function SignupScreen() { + const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const copy = getCopy(language); + const posthog = useSafeAnalytics(); + const pendingPlant = getPendingPlant(); + const isExpoGo = Constants.appOwnership === 'expo'; + + 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 [emailExpanded, setEmailExpanded] = useState(false); + const [appleAvailable, setAppleAvailable] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + posthog.capture('signup_screen_viewed', { context: 'onboarding' }); + }, [posthog]); + + useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } + let mounted = true; + AppleAuthentication.isAvailableAsync() + .then((available) => { + if (mounted) setAppleAvailable(available); + }) + .catch(() => { + if (mounted) setAppleAvailable(false); + }); + return () => { + mounted = false; + }; + }, [isExpoGo]); + + const finishAuth = async (session: Awaited>) => { + await hydrateSession(session); + if (session?.userId) { + await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); + } + await AsyncStorage.setItem('greenlens_show_tour', 'true'); + router.replace('/(tabs)'); + }; + + const validate = (): string | null => { + if (!name.trim()) return t.errNameRequired; + if (!email.trim() || !email.includes('@')) return t.errEmailInvalid; + if (password.length < 6) return t.errPasswordShort; + if (password !== passwordConfirm) return t.errPasswordMismatch; + return null; + }; + + const handleSignup = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + setEmailExpanded(true); + return; + } + setLoading(true); + setError(null); + try { + const session = await AuthService.signUp(email, name, password); + await finishAuth(session); + } catch (e: any) { + if (e.message === 'EMAIL_TAKEN') { + setError(t.errEmailTaken); + } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { + setError(t.errNetworkError); + } else if (e.message === 'SERVER_ERROR') { + setError(t.errServerError); + } else { + setError(t.errAuthError); + } + } 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, + }); + posthog.capture('apple_login_succeeded', { surface: 'signup' }); + await finishAuth(session); + } 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 ( + + + + + router.back()} activeOpacity={0.8}> + + + + {copy.headline} + {copy.subline} + + + + + {pendingPlant ? ( + + + + {copy.savePlantPrefix} {pendingPlant.result.name} + + + ) : null} + + {appleAvailable ? ( + + ) : null} + + {appleAvailable ? ( + + + {t.orDivider} + + + ) : null} + + {!emailExpanded ? ( + setEmailExpanded(true)} + activeOpacity={0.84} + > + + {copy.emailCta} + + ) : ( + + + {copy.nameLabel} + + + + + + + + {copy.emailLabel} + + + + + + + + {t.passwordLabel} + + + + setShowPassword((v) => !v)} style={styles.eyeBtn}> + + + + + + + {t.confirmPasswordLabel} + + + + setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> + + + + + + )} + + {error ? ( + + + {error} + + ) : null} + + {emailExpanded ? ( + + {loading ? ( + + ) : ( + {copy.createCta} + )} + + ) : null} + + router.replace('/auth/login')} activeOpacity={0.78}> + + {copy.already}{' '} + {copy.login} + + + + {copy.legal} + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + scroll: { flexGrow: 1 }, + hero: { + minHeight: 330, + justifyContent: 'flex-end', + paddingHorizontal: 24, + paddingTop: 56, + paddingBottom: 34, + }, + heroImage: { resizeMode: 'cover' }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(5, 12, 7, 0.46)', + }, + backBtn: { + position: 'absolute', + top: 54, + left: 22, + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.28)', + }, + heroCopy: { gap: 10 }, + heroTitle: { + color: '#ffffff', + fontSize: 34, + lineHeight: 39, + fontWeight: '900', + }, + heroSubline: { + color: 'rgba(255, 255, 255, 0.88)', + fontSize: 16, + lineHeight: 22, + fontWeight: '700', + }, + sheet: { + flex: 1, + marginTop: -24, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 22, + paddingTop: 24, + paddingBottom: 32, + gap: 14, + }, + pendingHint: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderRadius: 16, + borderWidth: 1, + paddingHorizontal: 14, + paddingVertical: 12, + }, + pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, + appleButton: { width: '100%', height: 56 }, + dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + dividerLine: { flex: 1, height: 1 }, + dividerText: { fontSize: 12, fontWeight: '800' }, + emailChoiceBtn: { + height: 56, + borderRadius: 14, + borderWidth: 1.5, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + }, + emailChoiceText: { fontSize: 16, fontWeight: '800' }, + form: { gap: 12 }, + fieldGroup: { gap: 6 }, + label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, + inputRow: { + height: 52, + borderWidth: 1, + borderRadius: 14, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + }, + inputIcon: { marginRight: 10 }, + input: { flex: 1, height: 52, fontSize: 15 }, + eyeBtn: { padding: 5, marginLeft: 6 }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 10, + }, + errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, + primaryBtn: { + height: 56, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + primaryBtnText: { fontSize: 17, fontWeight: '900' }, + loginLink: { alignItems: 'center', paddingTop: 4, paddingBottom: 2 }, + loginLinkText: { fontSize: 15, fontWeight: '700' }, + legal: { textAlign: 'center', fontSize: 11.5, lineHeight: 16, paddingHorizontal: 12 }, +}); diff --git a/app/onboarding.tsx b/app/onboarding.tsx index d867f28..45eb19c 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,332 +1,332 @@ -import React, { useEffect } from 'react'; -import { - Image, - ImageBackground, - SafeAreaView, - StyleSheet, - Text, - TouchableOpacity, - View, - useWindowDimensions, -} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useApp } from '../context/AppContext'; -import { useSafeAnalytics } from '../services/analytics'; -import { Language } from '../types'; - -const getWelcomeCopy = (language: Language) => { - if (language === 'de') { - return { - headline: 'Willkommen bei GreenLens!', - subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.', - testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."', - testimonialAuthor: 'Anna M.', - cta: "Los geht's", - login: 'Anmelden', - demoScan: 'Oder direkt eine Pflanze scannen', - legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.', - rating: '4,8', - }; - } - if (language === 'es') { - return { - headline: '¡Bienvenido a GreenLens!', - subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.', - testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."', - testimonialAuthor: 'Anna M.', - cta: 'Empezar', - login: 'Iniciar sesión', - demoScan: 'O escanea una planta ahora', - legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.', - rating: '4.8', - }; - } - return { - headline: 'Welcome to GreenLens!', - subline: 'Identify, understand and care for your plants — effortlessly.', - testimonial: '"Finally my plants stay alive! Highly recommend."', - testimonialAuthor: 'Anna M.', - cta: "Let's Go", - login: 'Log in', - demoScan: 'Or scan a plant right now', - legal: 'By continuing you agree to our Privacy Policy and Terms.', - rating: '4.8', - }; -}; - -export default function OnboardingScreen() { - const { language } = useApp(); - const { height } = useWindowDimensions(); - const compact = height < 700; - const posthog = useSafeAnalytics(); - const copy = getWelcomeCopy(language); - - useEffect(() => { - posthog.capture('onboarding_welcome_viewed'); - }, [posthog]); - - return ( - - - - - - - - - GreenLens - - - - - {copy.rating} - - - - - {copy.testimonial} - - {copy.testimonialAuthor} - - {[0, 1, 2, 3, 4].map((i) => ( - - ))} - - - - - - - - - - {copy.headline} - {copy.subline} - - - - { - posthog.capture('onboarding_started'); - router.push('/onboarding/slides'); - }} - activeOpacity={0.86} - > - {copy.cta} - - - router.push('/auth/login')} style={styles.loginLink}> - {copy.login} - - - router.push('/scanner')} style={styles.demoLink}> - - {copy.demoScan} - - - {copy.legal} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#0a110b', - }, - hero: { - width: '100%', - }, - heroImageContent: { - backgroundColor: '#0a110b', - }, - heroShadeTop: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - height: 120, - backgroundColor: 'rgba(10,17,11,0.4)', - }, - heroSafe: { - flex: 1, - justifyContent: 'space-between', - paddingHorizontal: 20, - paddingTop: 8, - paddingBottom: 24, - }, - heroTopRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - brandRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - }, - logo: { - width: 36, - height: 36, - borderRadius: 10, - backgroundColor: '#fff', - }, - brandName: { - color: '#ffffff', - fontSize: 24, - fontWeight: '900', - }, - brandAccent: { - color: '#a6d66f', - }, - ratingPill: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - backgroundColor: 'rgba(255,255,255,0.18)', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.3)', - borderRadius: 999, - paddingHorizontal: 12, - paddingVertical: 6, - }, - ratingText: { - color: '#ffffff', - fontSize: 12, - fontWeight: '700', - }, - testimonialCard: { - backgroundColor: 'rgba(255,255,255,0.97)', - borderRadius: 16, - padding: 16, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.18, - shadowRadius: 14, - elevation: 4, - }, - testimonialCardCompact: { - padding: 12, - }, - testimonialText: { - color: '#191d16', - fontSize: 14.5, - lineHeight: 20, - fontStyle: 'italic', - marginBottom: 10, - }, - testimonialMeta: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - testimonialAuthor: { - color: '#42493c', - fontSize: 13, - fontWeight: '700', - }, - starsRow: { - flexDirection: 'row', - gap: 1, - }, - sheet: { - flex: 1, - backgroundColor: '#fbfaf3', - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - marginTop: -20, - }, - sheetHandle: { - alignSelf: 'center', - width: 44, - height: 5, - borderRadius: 3, - backgroundColor: 'rgba(16,28,18,0.15)', - marginTop: 12, - marginBottom: 8, - }, - sheetContent: { - flex: 1, - paddingHorizontal: 24, - paddingTop: 8, - paddingBottom: 12, - }, - headline: { - color: '#101c12', - fontSize: 28, - lineHeight: 34, - fontWeight: '900', - textAlign: 'center', - marginBottom: 10, - }, - headlineCompact: { - fontSize: 24, - lineHeight: 29, - }, - subline: { - color: '#5f625d', - fontSize: 16, - lineHeight: 22, - fontWeight: '500', - textAlign: 'center', - }, - spacer: { - flex: 1, - minHeight: 12, - }, - cta: { - height: 60, - borderRadius: 16, - backgroundColor: '#437824', - alignItems: 'center', - justifyContent: 'center', - marginBottom: 6, - }, - ctaText: { - color: '#f8f7ef', - fontSize: 18, - fontWeight: '800', - }, - loginLink: { - alignItems: 'center', - paddingVertical: 10, - }, - loginText: { - color: '#437824', - fontSize: 15, - fontWeight: '800', - }, - demoLink: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 7, - paddingVertical: 6, - marginBottom: 8, - }, - demoText: { - color: '#4b7c31', - fontSize: 13.5, - fontWeight: '700', - }, - legal: { - color: '#6b6d68', - fontSize: 11, - lineHeight: 14, - fontWeight: '500', - textAlign: 'center', - }, -}); +import React, { useEffect } from 'react'; +import { + Image, + ImageBackground, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useApp } from '../context/AppContext'; +import { useSafeAnalytics } from '../services/analytics'; +import { Language } from '../types'; + +const getWelcomeCopy = (language: Language) => { + if (language === 'de') { + return { + headline: 'Willkommen bei GreenLens!', + subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.', + testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."', + testimonialAuthor: 'Anna M.', + cta: "Los geht's", + login: 'Anmelden', + demoScan: 'Oder direkt eine Pflanze scannen', + legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.', + rating: '4,8', + }; + } + if (language === 'es') { + return { + headline: '¡Bienvenido a GreenLens!', + subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.', + testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."', + testimonialAuthor: 'Anna M.', + cta: 'Empezar', + login: 'Iniciar sesión', + demoScan: 'O escanea una planta ahora', + legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.', + rating: '4.8', + }; + } + return { + headline: 'Welcome to GreenLens!', + subline: 'Identify, understand and care for your plants — effortlessly.', + testimonial: '"Finally my plants stay alive! Highly recommend."', + testimonialAuthor: 'Anna M.', + cta: "Let's Go", + login: 'Log in', + demoScan: 'Or scan a plant right now', + legal: 'By continuing you agree to our Privacy Policy and Terms.', + rating: '4.8', + }; +}; + +export default function OnboardingScreen() { + const { language } = useApp(); + const { height } = useWindowDimensions(); + const compact = height < 700; + const posthog = useSafeAnalytics(); + const copy = getWelcomeCopy(language); + + useEffect(() => { + posthog.capture('onboarding_welcome_viewed'); + }, [posthog]); + + return ( + + + + + + + + + GreenLens + + + + + {copy.rating} + + + + + {copy.testimonial} + + {copy.testimonialAuthor} + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + + + + + + + + + {copy.headline} + {copy.subline} + + + + { + posthog.capture('onboarding_started'); + router.push('/onboarding/slides'); + }} + activeOpacity={0.86} + > + {copy.cta} + + + router.push('/auth/login')} style={styles.loginLink}> + {copy.login} + + + router.push('/scanner')} style={styles.demoLink}> + + {copy.demoScan} + + + {copy.legal} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a110b', + }, + hero: { + width: '100%', + }, + heroImageContent: { + backgroundColor: '#0a110b', + }, + heroShadeTop: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + height: 120, + backgroundColor: 'rgba(10,17,11,0.4)', + }, + heroSafe: { + flex: 1, + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: 24, + }, + heroTopRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + brandRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + logo: { + width: 36, + height: 36, + borderRadius: 10, + backgroundColor: '#fff', + }, + brandName: { + color: '#ffffff', + fontSize: 24, + fontWeight: '900', + }, + brandAccent: { + color: '#a6d66f', + }, + ratingPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + backgroundColor: 'rgba(255,255,255,0.18)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 6, + }, + ratingText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '700', + }, + testimonialCard: { + backgroundColor: 'rgba(255,255,255,0.97)', + borderRadius: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.18, + shadowRadius: 14, + elevation: 4, + }, + testimonialCardCompact: { + padding: 12, + }, + testimonialText: { + color: '#191d16', + fontSize: 14.5, + lineHeight: 20, + fontStyle: 'italic', + marginBottom: 10, + }, + testimonialMeta: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + testimonialAuthor: { + color: '#42493c', + fontSize: 13, + fontWeight: '700', + }, + starsRow: { + flexDirection: 'row', + gap: 1, + }, + sheet: { + flex: 1, + backgroundColor: '#fbfaf3', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + marginTop: -20, + }, + sheetHandle: { + alignSelf: 'center', + width: 44, + height: 5, + borderRadius: 3, + backgroundColor: 'rgba(16,28,18,0.15)', + marginTop: 12, + marginBottom: 8, + }, + sheetContent: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 8, + paddingBottom: 12, + }, + headline: { + color: '#101c12', + fontSize: 28, + lineHeight: 34, + fontWeight: '900', + textAlign: 'center', + marginBottom: 10, + }, + headlineCompact: { + fontSize: 24, + lineHeight: 29, + }, + subline: { + color: '#5f625d', + fontSize: 16, + lineHeight: 22, + fontWeight: '500', + textAlign: 'center', + }, + spacer: { + flex: 1, + minHeight: 12, + }, + cta: { + height: 60, + borderRadius: 16, + backgroundColor: '#437824', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 6, + }, + ctaText: { + color: '#f8f7ef', + fontSize: 18, + fontWeight: '800', + }, + loginLink: { + alignItems: 'center', + paddingVertical: 10, + }, + loginText: { + color: '#437824', + fontSize: 15, + fontWeight: '800', + }, + demoLink: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + paddingVertical: 6, + marginBottom: 8, + }, + demoText: { + color: '#4b7c31', + fontSize: 13.5, + fontWeight: '700', + }, + legal: { + color: '#6b6d68', + fontSize: 11, + lineHeight: 14, + fontWeight: '500', + textAlign: 'center', + }, +}); diff --git a/app/onboarding/experience.tsx b/app/onboarding/experience.tsx index 3aaef29..9fb11dc 100644 --- a/app/onboarding/experience.tsx +++ b/app/onboarding/experience.tsx @@ -1,67 +1,67 @@ -import React, { useState } from 'react'; -import { useRouter } from 'expo-router'; -import { useSafeAnalytics } from '../../services/analytics'; -import { useColors } from '../../constants/Colors'; -import { useApp } from '../../context/AppContext'; -import { OnboardingProgressService } from '../../services/onboardingProgressService'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; -import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; - -const EXPERIENCE_OPTIONS = [ - { id: 'beginner', emoji: '🌱' }, - { id: 'intermediate', emoji: '☀️' }, - { id: 'advanced', emoji: '🧪' }, -]; - -export default function OnboardingExperienceScreen() { - const router = useRouter(); - const posthog = useSafeAnalytics(); - const { session, isDarkMode, colorPalette, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const [selectedLevel, setSelectedLevel] = useState(null); - - const levelLabels: Record = { - beginner: t.experienceOptionBeginner, - intermediate: t.experienceOptionIntermediate, - advanced: t.experienceOptionAdvanced, - }; - - const options: QuestionOption[] = EXPERIENCE_OPTIONS.map((option) => ({ - id: option.id, - emoji: option.emoji, - label: levelLabels[option.id], - })); - - const finish = (level: string | null) => { - if (session?.userId && level) { - OnboardingProgressService.setExperienceLevel(session.userId, level); - } - if (level) { - void PreAuthOnboardingService.setAnswer('experienceLevel', level); - } - - posthog.capture('onboarding_experience_completed', { - experience_level: level ?? 'skipped', - }); - router.replace('/onboarding/health-check'); - }; - - return ( - finish(selectedLevel)} - onBack={() => router.back()} - continueLabel={t.experienceOnboardingContinue} - skipLabel={t.experienceOnboardingSkip} - onSkip={() => finish(null)} - /> - ); -} +import React, { useState } from 'react'; +import { useRouter } from 'expo-router'; +import { useSafeAnalytics } from '../../services/analytics'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; +import { OnboardingProgressService } from '../../services/onboardingProgressService'; +import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; + +const EXPERIENCE_OPTIONS = [ + { id: 'beginner', emoji: '🌱' }, + { id: 'intermediate', emoji: '☀️' }, + { id: 'advanced', emoji: '🧪' }, +]; + +export default function OnboardingExperienceScreen() { + const router = useRouter(); + const posthog = useSafeAnalytics(); + const { session, isDarkMode, colorPalette, t } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const [selectedLevel, setSelectedLevel] = useState(null); + + const levelLabels: Record = { + beginner: t.experienceOptionBeginner, + intermediate: t.experienceOptionIntermediate, + advanced: t.experienceOptionAdvanced, + }; + + const options: QuestionOption[] = EXPERIENCE_OPTIONS.map((option) => ({ + id: option.id, + emoji: option.emoji, + label: levelLabels[option.id], + })); + + const finish = (level: string | null) => { + if (session?.userId && level) { + OnboardingProgressService.setExperienceLevel(session.userId, level); + } + if (level) { + void PreAuthOnboardingService.setAnswer('experienceLevel', level); + } + + posthog.capture('onboarding_experience_completed', { + experience_level: level ?? 'skipped', + }); + router.replace('/onboarding/health-check'); + }; + + return ( + finish(selectedLevel)} + onBack={() => router.back()} + continueLabel={t.experienceOnboardingContinue} + skipLabel={t.experienceOnboardingSkip} + onSkip={() => finish(null)} + /> + ); +} diff --git a/app/onboarding/goal.tsx b/app/onboarding/goal.tsx index 93ce68a..b8f6c53 100644 --- a/app/onboarding/goal.tsx +++ b/app/onboarding/goal.tsx @@ -1,69 +1,69 @@ -import React, { useState } from 'react'; -import { useRouter } from 'expo-router'; -import { useSafeAnalytics } from '../../services/analytics'; -import { useColors } from '../../constants/Colors'; -import { useApp } from '../../context/AppContext'; -import { OnboardingProgressService } from '../../services/onboardingProgressService'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; -import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; - -const GOAL_OPTIONS = [ - { id: 'identify', emoji: '🔍' }, - { id: 'care', emoji: '💧' }, - { id: 'collection', emoji: '🗂️' }, - { id: 'learn', emoji: '📚' }, -]; - -export default function OnboardingGoalScreen() { - const router = useRouter(); - const posthog = useSafeAnalytics(); - const { session, isDarkMode, colorPalette, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const [selectedGoal, setSelectedGoal] = useState(null); - - const goalLabels: Record = { - identify: t.goalOptionIdentify, - care: t.goalOptionCare, - collection: t.goalOptionCollection, - learn: t.goalOptionLearn, - }; - - const options: QuestionOption[] = GOAL_OPTIONS.map((option) => ({ - id: option.id, - emoji: option.emoji, - label: goalLabels[option.id], - })); - - const finish = (goal: string | null) => { - if (session?.userId && goal) { - OnboardingProgressService.setPrimaryGoal(session.userId, goal); - } - if (goal) { - void PreAuthOnboardingService.setAnswer('primaryGoal', goal); - } - - posthog.capture('onboarding_goal_completed', { - goal: goal ?? 'skipped', - }); - router.replace('/onboarding/experience'); - }; - - return ( - finish(selectedGoal)} - onBack={() => router.back()} - continueLabel={t.goalOnboardingContinue} - skipLabel={t.goalOnboardingSkip} - onSkip={() => finish(null)} - /> - ); -} +import React, { useState } from 'react'; +import { useRouter } from 'expo-router'; +import { useSafeAnalytics } from '../../services/analytics'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; +import { OnboardingProgressService } from '../../services/onboardingProgressService'; +import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; + +const GOAL_OPTIONS = [ + { id: 'identify', emoji: '🔍' }, + { id: 'care', emoji: '💧' }, + { id: 'collection', emoji: '🗂️' }, + { id: 'learn', emoji: '📚' }, +]; + +export default function OnboardingGoalScreen() { + const router = useRouter(); + const posthog = useSafeAnalytics(); + const { session, isDarkMode, colorPalette, t } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const [selectedGoal, setSelectedGoal] = useState(null); + + const goalLabels: Record = { + identify: t.goalOptionIdentify, + care: t.goalOptionCare, + collection: t.goalOptionCollection, + learn: t.goalOptionLearn, + }; + + const options: QuestionOption[] = GOAL_OPTIONS.map((option) => ({ + id: option.id, + emoji: option.emoji, + label: goalLabels[option.id], + })); + + const finish = (goal: string | null) => { + if (session?.userId && goal) { + OnboardingProgressService.setPrimaryGoal(session.userId, goal); + } + if (goal) { + void PreAuthOnboardingService.setAnswer('primaryGoal', goal); + } + + posthog.capture('onboarding_goal_completed', { + goal: goal ?? 'skipped', + }); + router.replace('/onboarding/experience'); + }; + + return ( + finish(selectedGoal)} + onBack={() => router.back()} + continueLabel={t.goalOnboardingContinue} + skipLabel={t.goalOnboardingSkip} + onSkip={() => finish(null)} + /> + ); +} diff --git a/app/onboarding/health-check.tsx b/app/onboarding/health-check.tsx index ed4b687..1c067e6 100644 --- a/app/onboarding/health-check.tsx +++ b/app/onboarding/health-check.tsx @@ -1,205 +1,205 @@ -import React from 'react'; -import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; -import { useSafeAnalytics } from '../../services/analytics'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { useColors } from '../../constants/Colors'; -import { useApp } from '../../context/AppContext'; - -const ONBOARDING_BACKGROUND = { - light: '#fbfaf3', - dark: '#0a110b', -}; - -const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => { - if (language === 'de') { - return { - title: 'Wo ist der Health-Scan?', - subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.', - buttonPreview: 'Health-Scan starten', - cta: 'Weiter', - skip: 'Spaeter', - flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'], - outputTitle: 'Was du danach bekommst', - outputs: [ - 'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.', - 'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.', - 'Wahrscheinlichste Ursachen mit Confidence-Werten.', - 'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.', - ], - guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.', - }; - } - - if (language === 'es') { - return { - title: 'Donde esta el health-scan?', - subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.', - buttonPreview: 'Iniciar health-scan', - cta: 'Continuar', - skip: 'Mas tarde', - flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'], - outputTitle: 'Que recibes despues', - outputs: [ - 'Puntaje de salud con estado: estable, observar o critico.', - 'Analisis detallado con senales visibles e incertidumbre.', - 'Causas probables con valores de confianza.', - 'Acciones inmediatas y plan concreto de 7 dias.', - ], - guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.', - }; - } - - return { - title: 'Where is the health scan?', - subtitle: 'It lives on every saved plant, directly below the plant description.', - buttonPreview: 'Start health scan', - cta: 'Continue', - skip: 'Later', - flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'], - outputTitle: 'What you get after', - outputs: [ - 'Health score with stable, watch, or critical status.', - 'Detailed analysis with visible signals and uncertainty.', - 'Most likely causes with confidence values.', - 'Immediate actions plus a concrete 7-day care plan.', - ], - guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.', - }; -}; - -export default function HealthCheckOnboardingScreen() { - const router = useRouter(); - const posthog = useSafeAnalytics(); - const { isDarkMode, colorPalette, language, billingSummary } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; - const copy = getHealthOnboardingCopy(language); - - const finish = (skipped = false) => { - posthog.capture('onboarding_health_check_explained', { - skipped, - plan: billingSummary?.entitlement?.plan ?? 'free', - }); - router.replace('/onboarding/personalizing'); - }; - - return ( - - {isDarkMode ? : null} - - - router.back()} style={[styles.backBtn, { backgroundColor: colors.surface }]}> - - - - - - - - - {copy.title} - {copy.subtitle} - - - - - - - - - {copy.flow.map((item, index) => ( - - - - {index + 1} - - - {item} - - ))} - - - - {copy.outputTitle} - {copy.outputs.map((item) => ( - - - {item} - - ))} - - - - - {copy.guidanceNote} - - - - - finish(true)} - > - {copy.skip} - - finish(false)}> - {copy.cta} - - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, - safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, - topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, - backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, - progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, - progressFill: { height: 6, borderRadius: 3 }, - header: { gap: 9, marginTop: 8, marginBottom: 18 }, - title: { fontSize: 30, lineHeight: 34, fontWeight: '900' }, - subtitle: { fontSize: 14, lineHeight: 20 }, - content: { gap: 14, paddingBottom: 12 }, - illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' }, - illustrationImage: { borderRadius: 28 }, - illustrationOverlay: { ...StyleSheet.absoluteFillObject }, - phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 }, - phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 }, - phoneTitle: { fontSize: 13, fontWeight: '800' }, - phoneRows: { gap: 8 }, - phoneRowLong: { height: 8, borderRadius: 999 }, - phoneRowShort: { width: '66%', height: 8, borderRadius: 999 }, - healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 }, - healthButtonText: { fontSize: 10, fontWeight: '800' }, - scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 }, - scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' }, - scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' }, - scanLine: { height: 8, borderRadius: 999 }, - scanLineShort: { width: '68%', height: 8, borderRadius: 999 }, - flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 }, - flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, - flowIndexText: { fontSize: 12, fontWeight: '900' }, - flowText: { flex: 1, fontSize: 14, fontWeight: '700' }, - outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 }, - outputTitle: { fontSize: 15, fontWeight: '800' }, - outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 }, - outputText: { flex: 1, fontSize: 13, lineHeight: 18 }, - guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, - guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' }, - footer: { flexDirection: 'row', gap: 12, marginTop: 12 }, - secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, - secondaryBtnText: { fontSize: 15, fontWeight: '600' }, - primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, - primaryBtnText: { fontSize: 15, fontWeight: '700' }, -}); +import React from 'react'; +import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAnalytics } from '../../services/analytics'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; + +const ONBOARDING_BACKGROUND = { + light: '#fbfaf3', + dark: '#0a110b', +}; + +const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + title: 'Wo ist der Health-Scan?', + subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.', + buttonPreview: 'Health-Scan starten', + cta: 'Weiter', + skip: 'Spaeter', + flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'], + outputTitle: 'Was du danach bekommst', + outputs: [ + 'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.', + 'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.', + 'Wahrscheinlichste Ursachen mit Confidence-Werten.', + 'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.', + ], + guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.', + }; + } + + if (language === 'es') { + return { + title: 'Donde esta el health-scan?', + subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.', + buttonPreview: 'Iniciar health-scan', + cta: 'Continuar', + skip: 'Mas tarde', + flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'], + outputTitle: 'Que recibes despues', + outputs: [ + 'Puntaje de salud con estado: estable, observar o critico.', + 'Analisis detallado con senales visibles e incertidumbre.', + 'Causas probables con valores de confianza.', + 'Acciones inmediatas y plan concreto de 7 dias.', + ], + guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.', + }; + } + + return { + title: 'Where is the health scan?', + subtitle: 'It lives on every saved plant, directly below the plant description.', + buttonPreview: 'Start health scan', + cta: 'Continue', + skip: 'Later', + flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'], + outputTitle: 'What you get after', + outputs: [ + 'Health score with stable, watch, or critical status.', + 'Detailed analysis with visible signals and uncertainty.', + 'Most likely causes with confidence values.', + 'Immediate actions plus a concrete 7-day care plan.', + ], + guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.', + }; +}; + +export default function HealthCheckOnboardingScreen() { + const router = useRouter(); + const posthog = useSafeAnalytics(); + const { isDarkMode, colorPalette, language, billingSummary } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; + const copy = getHealthOnboardingCopy(language); + + const finish = (skipped = false) => { + posthog.capture('onboarding_health_check_explained', { + skipped, + plan: billingSummary?.entitlement?.plan ?? 'free', + }); + router.replace('/onboarding/personalizing'); + }; + + return ( + + {isDarkMode ? : null} + + + router.back()} style={[styles.backBtn, { backgroundColor: colors.surface }]}> + + + + + + + + + {copy.title} + {copy.subtitle} + + + + + + + + + {copy.flow.map((item, index) => ( + + + + {index + 1} + + + {item} + + ))} + + + + {copy.outputTitle} + {copy.outputs.map((item) => ( + + + {item} + + ))} + + + + + {copy.guidanceNote} + + + + + finish(true)} + > + {copy.skip} + + finish(false)}> + {copy.cta} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, + topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, + backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, + progressFill: { height: 6, borderRadius: 3 }, + header: { gap: 9, marginTop: 8, marginBottom: 18 }, + title: { fontSize: 30, lineHeight: 34, fontWeight: '900' }, + subtitle: { fontSize: 14, lineHeight: 20 }, + content: { gap: 14, paddingBottom: 12 }, + illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' }, + illustrationImage: { borderRadius: 28 }, + illustrationOverlay: { ...StyleSheet.absoluteFillObject }, + phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 }, + phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 }, + phoneTitle: { fontSize: 13, fontWeight: '800' }, + phoneRows: { gap: 8 }, + phoneRowLong: { height: 8, borderRadius: 999 }, + phoneRowShort: { width: '66%', height: 8, borderRadius: 999 }, + healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 }, + healthButtonText: { fontSize: 10, fontWeight: '800' }, + scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 }, + scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' }, + scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' }, + scanLine: { height: 8, borderRadius: 999 }, + scanLineShort: { width: '68%', height: 8, borderRadius: 999 }, + flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 }, + flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, + flowIndexText: { fontSize: 12, fontWeight: '900' }, + flowText: { flex: 1, fontSize: 14, fontWeight: '700' }, + outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 }, + outputTitle: { fontSize: 15, fontWeight: '800' }, + outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 }, + outputText: { flex: 1, fontSize: 13, lineHeight: 18 }, + guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, + guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' }, + footer: { flexDirection: 'row', gap: 12, marginTop: 12 }, + secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, + secondaryBtnText: { fontSize: 15, fontWeight: '600' }, + primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, + primaryBtnText: { fontSize: 15, fontWeight: '700' }, +}); diff --git a/app/onboarding/personalizing.tsx b/app/onboarding/personalizing.tsx index 8915540..c66d816 100644 --- a/app/onboarding/personalizing.tsx +++ b/app/onboarding/personalizing.tsx @@ -1,165 +1,165 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import Svg, { Circle } from 'react-native-svg'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { useSafeAnalytics } from '../../services/analytics'; -import { Language } from '../../types'; - -const getCopy = (language: Language) => { - if (language === 'de') { - return { - status: 'Dein Pflegeplan wird personalisiert…', - steps: ['Antworten werden analysiert', 'Pflegeplan wird erstellt', 'Scan-Credits werden vorbereitet', 'Plan wird finalisiert'], - testimonial: '„GreenLens hat meine Geigenfeige gerettet. Die täglichen Routinen sind unglaublich präzise."', - author: 'Elena R.', - rating: '4,8 APP-STORE-BEWERTUNG', - }; - } - if (language === 'es') { - return { - status: 'Personalizando tu plan de cuidados…', - steps: ['Analizando tus respuestas', 'Creando tu plan de cuidados', 'Preparando tus créditos de escaneo', 'Finalizando tu plan'], - testimonial: '"GreenLens salvó mi ficus lyrata. Las rutinas diarias son increíblemente precisas."', - author: 'Elena R.', - rating: '4.8 VALORACIÓN EN APP STORE', - }; - } - return { - status: 'Personalizing your care plan…', - steps: ['Analyzing your answers', 'Building your care plan', 'Preparing your scan credits', 'Finalizing your plan'], - testimonial: '"GreenLens completely saved my Fiddle Leaf Fig. The daily routines feel incredibly precise."', - author: 'Elena R.', - rating: '4.8 APP STORE RATING', - }; -}; - -const STEP_THRESHOLDS = [25, 50, 75, 95]; -const RING_SIZE = 150; -const RING_STROKE_WIDTH = 7; -const RING_RADIUS = (RING_SIZE - RING_STROKE_WIDTH) / 2; -const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; - -const AnimatedCircle = Animated.createAnimatedComponent(Circle); - -export default function OnboardingPersonalizingScreen() { - const { language, isDarkMode, colorPalette } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const posthog = useSafeAnalytics(); - const copy = getCopy(language); - const progress = useRef(new Animated.Value(0)).current; - const [percent, setPercent] = useState(0); - const navigated = useRef(false); - - const strokeDashoffset = progress.interpolate({ - inputRange: [0, 100], - outputRange: [RING_CIRCUMFERENCE, 0], - }); - - useEffect(() => { - posthog.capture('onboarding_personalizing_viewed'); - const listener = progress.addListener(({ value }) => setPercent(Math.round(value))); - Animated.timing(progress, { - toValue: 100, - duration: 6000, - easing: Easing.inOut(Easing.cubic), - useNativeDriver: false, - }).start(({ finished }) => { - if (finished && !navigated.current) { - navigated.current = true; - setTimeout(() => { - posthog.capture('paywall_opened', { source: 'onboarding' }); - router.replace('/profile/billing?view=paywall&context=onboarding'); - }, 450); - } - }); - return () => progress.removeListener(listener); - }, [progress, posthog]); - - return ( - - {percent}% - - - - - - - - - - {copy.status} - - - {copy.steps.map((label, index) => { - const done = percent >= STEP_THRESHOLDS[index]; - return ( - - - {label} - - ); - })} - - - - {copy.author} - - {[0, 1, 2, 3, 4].map((i) => )} - - - {copy.testimonial} - - - - {copy.rating} - - - ); -} - -const styles = StyleSheet.create({ - safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 }, - percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 }, - ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 }, - ringImage: { width: 112, height: 112, borderRadius: 56 }, - statusPill: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, paddingHorizontal: 18, paddingVertical: 11, marginBottom: 26 }, - statusText: { fontSize: 14.5, fontWeight: '800' }, - checklist: { alignSelf: 'stretch', gap: 15, marginBottom: 26, paddingHorizontal: 8 }, - checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - checkLabel: { fontSize: 16.5, fontWeight: '700' }, - testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 }, - testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, - testimonialAuthor: { fontSize: 14.5, fontWeight: '800' }, - starsRow: { flexDirection: 'row', gap: 2 }, - testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' }, - ratingBadge: { flexDirection: 'row', alignItems: 'center', gap: 7, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 9 }, - ratingText: { fontSize: 12.5, fontWeight: '900', letterSpacing: 0.6 }, -}); +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import Svg, { Circle } from 'react-native-svg'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { useSafeAnalytics } from '../../services/analytics'; +import { Language } from '../../types'; + +const getCopy = (language: Language) => { + if (language === 'de') { + return { + status: 'Dein Pflegeplan wird personalisiert…', + steps: ['Antworten werden analysiert', 'Pflegeplan wird erstellt', 'Scan-Credits werden vorbereitet', 'Plan wird finalisiert'], + testimonial: '„GreenLens hat meine Geigenfeige gerettet. Die täglichen Routinen sind unglaublich präzise."', + author: 'Elena R.', + rating: '4,8 APP-STORE-BEWERTUNG', + }; + } + if (language === 'es') { + return { + status: 'Personalizando tu plan de cuidados…', + steps: ['Analizando tus respuestas', 'Creando tu plan de cuidados', 'Preparando tus créditos de escaneo', 'Finalizando tu plan'], + testimonial: '"GreenLens salvó mi ficus lyrata. Las rutinas diarias son increíblemente precisas."', + author: 'Elena R.', + rating: '4.8 VALORACIÓN EN APP STORE', + }; + } + return { + status: 'Personalizing your care plan…', + steps: ['Analyzing your answers', 'Building your care plan', 'Preparing your scan credits', 'Finalizing your plan'], + testimonial: '"GreenLens completely saved my Fiddle Leaf Fig. The daily routines feel incredibly precise."', + author: 'Elena R.', + rating: '4.8 APP STORE RATING', + }; +}; + +const STEP_THRESHOLDS = [25, 50, 75, 95]; +const RING_SIZE = 150; +const RING_STROKE_WIDTH = 7; +const RING_RADIUS = (RING_SIZE - RING_STROKE_WIDTH) / 2; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +export default function OnboardingPersonalizingScreen() { + const { language, isDarkMode, colorPalette } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const posthog = useSafeAnalytics(); + const copy = getCopy(language); + const progress = useRef(new Animated.Value(0)).current; + const [percent, setPercent] = useState(0); + const navigated = useRef(false); + + const strokeDashoffset = progress.interpolate({ + inputRange: [0, 100], + outputRange: [RING_CIRCUMFERENCE, 0], + }); + + useEffect(() => { + posthog.capture('onboarding_personalizing_viewed'); + const listener = progress.addListener(({ value }) => setPercent(Math.round(value))); + Animated.timing(progress, { + toValue: 100, + duration: 6000, + easing: Easing.inOut(Easing.cubic), + useNativeDriver: false, + }).start(({ finished }) => { + if (finished && !navigated.current) { + navigated.current = true; + setTimeout(() => { + posthog.capture('paywall_opened', { source: 'onboarding' }); + router.replace('/profile/billing?view=paywall&context=onboarding'); + }, 450); + } + }); + return () => progress.removeListener(listener); + }, [progress, posthog]); + + return ( + + {percent}% + + + + + + + + + + {copy.status} + + + {copy.steps.map((label, index) => { + const done = percent >= STEP_THRESHOLDS[index]; + return ( + + + {label} + + ); + })} + + + + {copy.author} + + {[0, 1, 2, 3, 4].map((i) => )} + + + {copy.testimonial} + + + + {copy.rating} + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 }, + percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 }, + ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 }, + ringImage: { width: 112, height: 112, borderRadius: 56 }, + statusPill: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, paddingHorizontal: 18, paddingVertical: 11, marginBottom: 26 }, + statusText: { fontSize: 14.5, fontWeight: '800' }, + checklist: { alignSelf: 'stretch', gap: 15, marginBottom: 26, paddingHorizontal: 8 }, + checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + checkLabel: { fontSize: 16.5, fontWeight: '700' }, + testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 }, + testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, + testimonialAuthor: { fontSize: 14.5, fontWeight: '800' }, + starsRow: { flexDirection: 'row', gap: 2 }, + testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' }, + ratingBadge: { flexDirection: 'row', alignItems: 'center', gap: 7, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 9 }, + ratingText: { fontSize: 12.5, fontWeight: '900', letterSpacing: 0.6 }, +}); diff --git a/app/onboarding/slides.tsx b/app/onboarding/slides.tsx index 4add317..d0aeb9e 100644 --- a/app/onboarding/slides.tsx +++ b/app/onboarding/slides.tsx @@ -1,495 +1,495 @@ -import React, { useEffect, useState } from 'react'; -import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; -import { useSafeAnalytics } from '../../services/analytics'; -import { useColors } from '../../constants/Colors'; -import { useApp } from '../../context/AppContext'; -import { Language } from '../../types'; - -type ColorsType = ReturnType; - -const getSlidesCopy = (language: Language) => { - if (language === 'de') { - return { - slides: [ - { - title: 'Scanne jede Pflanze', - body: 'Richte die Kamera auf eine Pflanze und GreenLens erkennt sie in Sekunden.', - }, - { - title: 'Health Check & Pflegeplan', - body: 'GreenLens erkennt Probleme früh und erstellt deinen Rettungsplan.', - }, - { - title: 'Nie mehr Gießen vergessen', - body: 'Smarte Erinnerungen und deine Pflanzen-Bibliothek halten alles im Blick.', - }, - ], - resultChip: 'Monstera · 98%', - healthCheckLabel: 'Health Check', - overwateringDetected: 'Überwässerung erkannt', - rescuePlanReady: '7-Tage-Rettungsplan bereit', - waterReminder: 'Monstera gießen — heute', - fertilizeReminder: 'Basilikum düngen — in 3 Tagen', - continueLabel: 'Weiter', - }; - } - if (language === 'es') { - return { - slides: [ - { - title: 'Escanea cualquier planta', - body: 'Apunta la cámara a una planta y GreenLens la identifica en segundos.', - }, - { - title: 'Chequeo de salud y plan de cuidados', - body: 'GreenLens detecta problemas a tiempo y crea tu plan de rescate.', - }, - { - title: 'No olvides regar nunca más', - body: 'Recordatorios inteligentes y tu biblioteca de plantas lo mantienen todo al día.', - }, - ], - resultChip: 'Monstera · 98%', - healthCheckLabel: 'Chequeo de salud', - overwateringDetected: 'Exceso de riego detectado', - rescuePlanReady: 'Plan de rescate de 7 días listo', - waterReminder: 'Regar Monstera — hoy', - fertilizeReminder: 'Abonar albahaca — en 3 días', - continueLabel: 'Continuar', - }; - } - return { - slides: [ - { - title: 'Scan Any Plant', - body: 'Point your camera at a plant and GreenLens identifies it in seconds.', - }, - { - title: 'Health Check & Care Plan', - body: 'GreenLens spots problems early and builds a rescue plan for you.', - }, - { - title: 'Never Forget Watering', - body: 'Smart reminders and your personal plant library keep everything on track.', - }, - ], - resultChip: 'Monstera · 98%', - healthCheckLabel: 'Health Check', - overwateringDetected: 'Overwatering detected', - rescuePlanReady: '7-day rescue plan ready', - waterReminder: 'Water Monstera — today', - fertilizeReminder: 'Fertilize Basil — in 3 days', - continueLabel: 'Continue', - }; -}; - -function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors: ColorsType }) { - return ( - <> - - - - - - - - - {resultChip} - - - ); -} - -function HealthCardOverlay({ - label, - overwateringDetected, - rescuePlanReady, -}: { - label: string; - overwateringDetected: string; - rescuePlanReady: string; -}) { - return ( - - - - - - {label} - - - - {overwateringDetected} - - - - {rescuePlanReady} - - - ); -} - -function ReminderChipsOverlay({ waterReminder, fertilizeReminder }: { waterReminder: string; fertilizeReminder: string }) { - const [waterLabel, waterMeta] = splitReminder(waterReminder); - const [fertilizeLabel, fertilizeMeta] = splitReminder(fertilizeReminder); - return ( - <> - - - - - - {waterLabel} - {waterMeta} - - - - - - - - {fertilizeLabel} - {fertilizeMeta} - - - - ); -} - -// Splits "Water Monstera — today" into ["Water Monstera", "today"] for a two-line chip. -function splitReminder(text: string): [string, string] { - const parts = text.split('—').map((part) => part.trim()); - if (parts.length === 2) return [parts[0], parts[1]]; - return [text, '']; -} - -export default function OnboardingSlidesScreen() { - const router = useRouter(); - const { language, isDarkMode, colorPalette } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const posthog = useSafeAnalytics(); - const [page, setPage] = useState(0); - const copy = getSlidesCopy(language); - const slide = copy.slides[page]; - - useEffect(() => { - posthog.capture('onboarding_slide_viewed', { index: page }); - }, [page, posthog]); - - const next = () => { - if (page < copy.slides.length - 1) { - setPage(page + 1); - } else { - router.replace('/onboarding/source'); - } - }; - - const back = () => { - if (page > 0) { - setPage(page - 1); - } else { - router.back(); - } - }; - - return ( - - - - - - - - - {page === 0 && } - {page === 1 && ( - - )} - {page === 2 && ( - - )} - - - {slide.title} - {slide.body} - - {copy.slides.map((_, index) => ( - - ))} - - - {copy.continueLabel} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - imageArea: { - height: '58%', - position: 'relative', - overflow: 'hidden', - }, - image: { - width: '100%', - height: '100%', - position: 'absolute', - }, - imageSafeArea: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - }, - backBtn: { - marginLeft: 16, - marginTop: 8, - width: 38, - height: 38, - borderRadius: 19, - backgroundColor: 'rgba(255,255,255,0.85)', - alignItems: 'center', - justifyContent: 'center', - }, - // Scan frame overlay (slide 1) - scanFrameWrap: { - position: 'absolute', - top: '22%', - left: '20%', - right: '20%', - bottom: '26%', - }, - cornerTL: { - position: 'absolute', - top: 0, - left: 0, - width: 30, - height: 30, - borderTopWidth: 4, - borderLeftWidth: 4, - borderTopLeftRadius: 8, - }, - cornerTR: { - position: 'absolute', - top: 0, - right: 0, - width: 30, - height: 30, - borderTopWidth: 4, - borderRightWidth: 4, - borderTopRightRadius: 8, - }, - cornerBL: { - position: 'absolute', - bottom: 0, - left: 0, - width: 30, - height: 30, - borderBottomWidth: 4, - borderLeftWidth: 4, - borderBottomLeftRadius: 8, - }, - cornerBR: { - position: 'absolute', - bottom: 0, - right: 0, - width: 30, - height: 30, - borderBottomWidth: 4, - borderRightWidth: 4, - borderBottomRightRadius: 8, - }, - resultChip: { - position: 'absolute', - bottom: '10%', - left: 20, - right: 20, - flexDirection: 'row', - alignItems: 'center', - gap: 8, - backgroundColor: 'rgba(255,255,255,0.94)', - borderRadius: 999, - paddingHorizontal: 16, - paddingVertical: 12, - }, - resultChipText: { - fontSize: 15, - fontWeight: '700', - color: '#1f2520', - }, - // Health card overlay (slide 2) - healthCard: { - position: 'absolute', - bottom: 16, - left: 16, - right: 16, - backgroundColor: 'rgba(255,255,255,0.96)', - borderRadius: 18, - padding: 14, - gap: 8, - }, - healthCardHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - healthCardIcon: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: '#fdeaea', - alignItems: 'center', - justifyContent: 'center', - }, - healthCardTitle: { - fontSize: 16, - fontWeight: '800', - color: '#1f2520', - }, - healthRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - borderRadius: 10, - paddingHorizontal: 10, - paddingVertical: 8, - }, - healthRowWarning: { - backgroundColor: '#fdeaea', - }, - healthRowWarningText: { - fontSize: 13.5, - fontWeight: '700', - color: '#C62828', - }, - healthRowSuccess: { - backgroundColor: '#e8f3e3', - }, - healthRowSuccessText: { - fontSize: 13.5, - fontWeight: '700', - color: '#2e7d32', - }, - // Reminder chips overlay (slide 3) - reminderChip: { - position: 'absolute', - flexDirection: 'row', - alignItems: 'center', - gap: 10, - backgroundColor: 'rgba(255,255,255,0.96)', - borderRadius: 999, - paddingVertical: 8, - paddingRight: 18, - paddingLeft: 8, - }, - reminderChipTop: { - top: '24%', - right: 20, - }, - reminderChipBottom: { - top: '42%', - left: 20, - }, - reminderIcon: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - }, - reminderLabel: { - fontSize: 14, - fontWeight: '800', - color: '#1f2520', - }, - reminderMeta: { - fontSize: 12, - fontWeight: '600', - color: '#5a8a3d', - }, - // Bottom sheet - sheet: { - flex: 1, - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - marginTop: -24, - paddingHorizontal: 24, - paddingTop: 32, - alignItems: 'center', - }, - title: { - fontSize: 30, - fontWeight: '900', - textAlign: 'center', - marginBottom: 10, - }, - body: { - fontSize: 15.5, - lineHeight: 22, - textAlign: 'center', - maxWidth: 320, - marginBottom: 20, - }, - dots: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 'auto', - }, - dot: { - width: 8, - height: 8, - borderRadius: 4, - }, - dotActive: { - width: 26, - height: 8, - borderRadius: 4, - }, - cta: { - alignSelf: 'stretch', - height: 58, - borderRadius: 14, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 24, - }, - ctaText: { - fontSize: 17, - fontWeight: '800', - }, -}); +import React, { useEffect, useState } from 'react'; +import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAnalytics } from '../../services/analytics'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; +import { Language } from '../../types'; + +type ColorsType = ReturnType; + +const getSlidesCopy = (language: Language) => { + if (language === 'de') { + return { + slides: [ + { + title: 'Scanne jede Pflanze', + body: 'Richte die Kamera auf eine Pflanze und GreenLens erkennt sie in Sekunden.', + }, + { + title: 'Health Check & Pflegeplan', + body: 'GreenLens erkennt Probleme früh und erstellt deinen Rettungsplan.', + }, + { + title: 'Nie mehr Gießen vergessen', + body: 'Smarte Erinnerungen und deine Pflanzen-Bibliothek halten alles im Blick.', + }, + ], + resultChip: 'Monstera · 98%', + healthCheckLabel: 'Health Check', + overwateringDetected: 'Überwässerung erkannt', + rescuePlanReady: '7-Tage-Rettungsplan bereit', + waterReminder: 'Monstera gießen — heute', + fertilizeReminder: 'Basilikum düngen — in 3 Tagen', + continueLabel: 'Weiter', + }; + } + if (language === 'es') { + return { + slides: [ + { + title: 'Escanea cualquier planta', + body: 'Apunta la cámara a una planta y GreenLens la identifica en segundos.', + }, + { + title: 'Chequeo de salud y plan de cuidados', + body: 'GreenLens detecta problemas a tiempo y crea tu plan de rescate.', + }, + { + title: 'No olvides regar nunca más', + body: 'Recordatorios inteligentes y tu biblioteca de plantas lo mantienen todo al día.', + }, + ], + resultChip: 'Monstera · 98%', + healthCheckLabel: 'Chequeo de salud', + overwateringDetected: 'Exceso de riego detectado', + rescuePlanReady: 'Plan de rescate de 7 días listo', + waterReminder: 'Regar Monstera — hoy', + fertilizeReminder: 'Abonar albahaca — en 3 días', + continueLabel: 'Continuar', + }; + } + return { + slides: [ + { + title: 'Scan Any Plant', + body: 'Point your camera at a plant and GreenLens identifies it in seconds.', + }, + { + title: 'Health Check & Care Plan', + body: 'GreenLens spots problems early and builds a rescue plan for you.', + }, + { + title: 'Never Forget Watering', + body: 'Smart reminders and your personal plant library keep everything on track.', + }, + ], + resultChip: 'Monstera · 98%', + healthCheckLabel: 'Health Check', + overwateringDetected: 'Overwatering detected', + rescuePlanReady: '7-day rescue plan ready', + waterReminder: 'Water Monstera — today', + fertilizeReminder: 'Fertilize Basil — in 3 days', + continueLabel: 'Continue', + }; +}; + +function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors: ColorsType }) { + return ( + <> + + + + + + + + + {resultChip} + + + ); +} + +function HealthCardOverlay({ + label, + overwateringDetected, + rescuePlanReady, +}: { + label: string; + overwateringDetected: string; + rescuePlanReady: string; +}) { + return ( + + + + + + {label} + + + + {overwateringDetected} + + + + {rescuePlanReady} + + + ); +} + +function ReminderChipsOverlay({ waterReminder, fertilizeReminder }: { waterReminder: string; fertilizeReminder: string }) { + const [waterLabel, waterMeta] = splitReminder(waterReminder); + const [fertilizeLabel, fertilizeMeta] = splitReminder(fertilizeReminder); + return ( + <> + + + + + + {waterLabel} + {waterMeta} + + + + + + + + {fertilizeLabel} + {fertilizeMeta} + + + + ); +} + +// Splits "Water Monstera — today" into ["Water Monstera", "today"] for a two-line chip. +function splitReminder(text: string): [string, string] { + const parts = text.split('—').map((part) => part.trim()); + if (parts.length === 2) return [parts[0], parts[1]]; + return [text, '']; +} + +export default function OnboardingSlidesScreen() { + const router = useRouter(); + const { language, isDarkMode, colorPalette } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const posthog = useSafeAnalytics(); + const [page, setPage] = useState(0); + const copy = getSlidesCopy(language); + const slide = copy.slides[page]; + + useEffect(() => { + posthog.capture('onboarding_slide_viewed', { index: page }); + }, [page, posthog]); + + const next = () => { + if (page < copy.slides.length - 1) { + setPage(page + 1); + } else { + router.replace('/onboarding/source'); + } + }; + + const back = () => { + if (page > 0) { + setPage(page - 1); + } else { + router.back(); + } + }; + + return ( + + + + + + + + + {page === 0 && } + {page === 1 && ( + + )} + {page === 2 && ( + + )} + + + {slide.title} + {slide.body} + + {copy.slides.map((_, index) => ( + + ))} + + + {copy.continueLabel} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + imageArea: { + height: '58%', + position: 'relative', + overflow: 'hidden', + }, + image: { + width: '100%', + height: '100%', + position: 'absolute', + }, + imageSafeArea: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, + backBtn: { + marginLeft: 16, + marginTop: 8, + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: 'rgba(255,255,255,0.85)', + alignItems: 'center', + justifyContent: 'center', + }, + // Scan frame overlay (slide 1) + scanFrameWrap: { + position: 'absolute', + top: '22%', + left: '20%', + right: '20%', + bottom: '26%', + }, + cornerTL: { + position: 'absolute', + top: 0, + left: 0, + width: 30, + height: 30, + borderTopWidth: 4, + borderLeftWidth: 4, + borderTopLeftRadius: 8, + }, + cornerTR: { + position: 'absolute', + top: 0, + right: 0, + width: 30, + height: 30, + borderTopWidth: 4, + borderRightWidth: 4, + borderTopRightRadius: 8, + }, + cornerBL: { + position: 'absolute', + bottom: 0, + left: 0, + width: 30, + height: 30, + borderBottomWidth: 4, + borderLeftWidth: 4, + borderBottomLeftRadius: 8, + }, + cornerBR: { + position: 'absolute', + bottom: 0, + right: 0, + width: 30, + height: 30, + borderBottomWidth: 4, + borderRightWidth: 4, + borderBottomRightRadius: 8, + }, + resultChip: { + position: 'absolute', + bottom: '10%', + left: 20, + right: 20, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: 'rgba(255,255,255,0.94)', + borderRadius: 999, + paddingHorizontal: 16, + paddingVertical: 12, + }, + resultChipText: { + fontSize: 15, + fontWeight: '700', + color: '#1f2520', + }, + // Health card overlay (slide 2) + healthCard: { + position: 'absolute', + bottom: 16, + left: 16, + right: 16, + backgroundColor: 'rgba(255,255,255,0.96)', + borderRadius: 18, + padding: 14, + gap: 8, + }, + healthCardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + healthCardIcon: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: '#fdeaea', + alignItems: 'center', + justifyContent: 'center', + }, + healthCardTitle: { + fontSize: 16, + fontWeight: '800', + color: '#1f2520', + }, + healthRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + }, + healthRowWarning: { + backgroundColor: '#fdeaea', + }, + healthRowWarningText: { + fontSize: 13.5, + fontWeight: '700', + color: '#C62828', + }, + healthRowSuccess: { + backgroundColor: '#e8f3e3', + }, + healthRowSuccessText: { + fontSize: 13.5, + fontWeight: '700', + color: '#2e7d32', + }, + // Reminder chips overlay (slide 3) + reminderChip: { + position: 'absolute', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: 'rgba(255,255,255,0.96)', + borderRadius: 999, + paddingVertical: 8, + paddingRight: 18, + paddingLeft: 8, + }, + reminderChipTop: { + top: '24%', + right: 20, + }, + reminderChipBottom: { + top: '42%', + left: 20, + }, + reminderIcon: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + reminderLabel: { + fontSize: 14, + fontWeight: '800', + color: '#1f2520', + }, + reminderMeta: { + fontSize: 12, + fontWeight: '600', + color: '#5a8a3d', + }, + // Bottom sheet + sheet: { + flex: 1, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + marginTop: -24, + paddingHorizontal: 24, + paddingTop: 32, + alignItems: 'center', + }, + title: { + fontSize: 30, + fontWeight: '900', + textAlign: 'center', + marginBottom: 10, + }, + body: { + fontSize: 15.5, + lineHeight: 22, + textAlign: 'center', + maxWidth: 320, + marginBottom: 20, + }, + dots: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 'auto', + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, + dotActive: { + width: 26, + height: 8, + borderRadius: 4, + }, + cta: { + alignSelf: 'stretch', + height: 58, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 24, + }, + ctaText: { + fontSize: 17, + fontWeight: '800', + }, +}); diff --git a/app/onboarding/source.tsx b/app/onboarding/source.tsx index 58efd9c..0634e1a 100644 --- a/app/onboarding/source.tsx +++ b/app/onboarding/source.tsx @@ -1,74 +1,74 @@ -import React, { useState } from 'react'; -import { useRouter } from 'expo-router'; -import { useSafeAnalytics } from '../../services/analytics'; -import { useColors } from '../../constants/Colors'; -import { useApp } from '../../context/AppContext'; -import { OnboardingProgressService } from '../../services/onboardingProgressService'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; -import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; - -const SOURCE_OPTIONS = [ - { id: 'app_store', emoji: '🏬', signal: 'organic_store' }, - { id: 'instagram', emoji: '📸', signal: 'social_visual' }, - { id: 'tiktok', emoji: '🎵', signal: 'social_video' }, - { id: 'friend', emoji: '👥', signal: 'referral' }, - { id: 'search', emoji: '🔎', signal: 'high_intent_search' }, - { id: 'other', emoji: '✨', signal: 'unclassified' }, -]; - -export default function OnboardingSourceScreen() { - const router = useRouter(); - const posthog = useSafeAnalytics(); - const { session, isDarkMode, colorPalette, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const [selectedSource, setSelectedSource] = useState(null); - - const sourceLabels: Record = { - app_store: t.sourceOptionAppStore, - instagram: t.sourceOptionInstagram, - tiktok: t.sourceOptionTikTok, - friend: t.sourceOptionFriend, - search: t.sourceOptionSearch, - other: t.sourceOptionOther, - }; - - const options: QuestionOption[] = SOURCE_OPTIONS.map((option) => ({ - id: option.id, - emoji: option.emoji, - label: sourceLabels[option.id], - })); - - const finish = (source: string | null) => { - if (session?.userId && source) { - OnboardingProgressService.setAcquisitionSource(session.userId, source); - } - if (source) { - void PreAuthOnboardingService.setAnswer('acquisitionSource', source); - } - - posthog.capture('onboarding_source_completed', { - source: source ?? 'skipped', - revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped', - }); - router.replace('/onboarding/goal'); - }; - - return ( - finish(selectedSource)} - onBack={() => router.back()} - continueLabel={t.sourceOnboardingContinue} - skipLabel={t.sourceOnboardingSkip} - onSkip={() => finish(null)} - /> - ); -} +import React, { useState } from 'react'; +import { useRouter } from 'expo-router'; +import { useSafeAnalytics } from '../../services/analytics'; +import { useColors } from '../../constants/Colors'; +import { useApp } from '../../context/AppContext'; +import { OnboardingProgressService } from '../../services/onboardingProgressService'; +import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; + +const SOURCE_OPTIONS = [ + { id: 'app_store', emoji: '🏬', signal: 'organic_store' }, + { id: 'instagram', emoji: '📸', signal: 'social_visual' }, + { id: 'tiktok', emoji: '🎵', signal: 'social_video' }, + { id: 'friend', emoji: '👥', signal: 'referral' }, + { id: 'search', emoji: '🔎', signal: 'high_intent_search' }, + { id: 'other', emoji: '✨', signal: 'unclassified' }, +]; + +export default function OnboardingSourceScreen() { + const router = useRouter(); + const posthog = useSafeAnalytics(); + const { session, isDarkMode, colorPalette, t } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const [selectedSource, setSelectedSource] = useState(null); + + const sourceLabels: Record = { + app_store: t.sourceOptionAppStore, + instagram: t.sourceOptionInstagram, + tiktok: t.sourceOptionTikTok, + friend: t.sourceOptionFriend, + search: t.sourceOptionSearch, + other: t.sourceOptionOther, + }; + + const options: QuestionOption[] = SOURCE_OPTIONS.map((option) => ({ + id: option.id, + emoji: option.emoji, + label: sourceLabels[option.id], + })); + + const finish = (source: string | null) => { + if (session?.userId && source) { + OnboardingProgressService.setAcquisitionSource(session.userId, source); + } + if (source) { + void PreAuthOnboardingService.setAnswer('acquisitionSource', source); + } + + posthog.capture('onboarding_source_completed', { + source: source ?? 'skipped', + revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped', + }); + router.replace('/onboarding/goal'); + }; + + return ( + finish(selectedSource)} + onBack={() => router.back()} + continueLabel={t.sourceOnboardingContinue} + skipLabel={t.sourceOnboardingSkip} + onSkip={() => finish(null)} + /> + ); +} diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index bdc99e6..b05cf19 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -1,1311 +1,1311 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useRouter, useLocalSearchParams } from 'expo-router'; -import { useFocusEffect } from '@react-navigation/native'; -import Constants from 'expo-constants'; -import Purchases, { - LOG_LEVEL, - PACKAGE_TYPE, - PRODUCT_CATEGORY, - PurchasesOffering, - PurchasesPackage, - PurchasesStoreProduct, -} from 'react-native-purchases'; -import { useApp } from '../../context/AppContext'; -import { useSafeAnalytics } from '../../services/analytics'; -import { useColors } from '../../constants/Colors'; -import { ThemeBackdrop } from '../../components/ThemeBackdrop'; -import { Language } from '../../types'; -import { PurchaseProductId } from '../../services/backend/contracts'; - -type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; -type TopupProductId = Extract; -type SubscriptionPackages = Partial>; -type TopupProducts = Partial>; -type PaywallPlanId = 'weekly' | 'yearly'; - -const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png'); - -const TOPUP_CREDITS_BY_PRODUCT: Record = { - 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, - productId: SubscriptionProductId, - expectedPackageType: PACKAGE_TYPE, -) => { - return ( - pkg.product.identifier === productId - || pkg.identifier === productId - || pkg.packageType === expectedPackageType - ); -}; - -const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { - if (!offering) { - return {}; - } - - const availablePackages = [ - offering.monthly, - offering.annual, - ...offering.availablePackages, - ].filter((value): value is PurchasesPackage => Boolean(value)); - - return { - monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), - yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), - }; -}; - -const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { - if (!offering) { - return { identifier: null, packages: [] as Array> }; - } - - return { - identifier: offering.identifier, - packages: offering.availablePackages.map((pkg) => ({ - identifier: pkg.identifier, - packageType: pkg.packageType, - productIdentifier: pkg.product.identifier, - priceString: pkg.product.priceString, - })), - }; -}; - -let revenueCatConfigured = false; - -const ensureRevenueCatConfigured = () => { - if (revenueCatConfigured || Constants.appOwnership === 'expo') { - return; - } - - Purchases.setLogLevel(LOG_LEVEL.WARN); - const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR'; - const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder'; - if (Platform.OS === 'ios') { - Purchases.configure({ apiKey: iosApiKey }); - } else if (Platform.OS === 'android') { - Purchases.configure({ apiKey: androidApiKey }); - } - revenueCatConfigured = true; -}; - -const getBillingCopy = (language: Language) => { - if (language === 'de') { - return { - title: 'Abo und Credits', - planLabel: 'Aktueller Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Verfügbare Credits', - manageSubscription: 'Abo verwalten', - subscriptionTitle: 'Abos', - subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', - startTrial: '7 Tage kostenlos testen', - expoGoPurchaseTitle: 'Kauf nur im Dev Build', - expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.', - expoGoSimulate: 'Pro simulieren', - continueWithoutPro: 'Ohne Pro fortfahren', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Monat', - proPlanName: 'Pro', - proPlanPrice: '4,99 € / Monat', - proBadgeText: 'EMPFOHLEN', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39,99 € / Jahr', - proYearlyBadgeText: 'SPAREN', - proBenefits: [ - '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', - 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', - cancelTitle: 'Schade, dass du gehst', - cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', - reasonTooExpensive: 'Es ist mir zu teuer', - reasonNotUsing: 'Ich nutze die App zu selten', - reasonOther: 'Ein anderer Grund', - offerTitle: 'Ein Geschenk für dich!', - offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', - offerAccept: 'Rabatt sichern', - offerDecline: 'Nein, Kündigung fortsetzen', - confirmCancelBtn: 'Jetzt kündigen', - restorePurchases: 'Käufe wiederherstellen', - autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', - manageInSettings: 'In iOS-Einstellungen verwalten', - paywallEyebrow: 'GreenLens Pro', - paywallHeadline: 'Unbegrenzter Zugriff', - paywallSub: 'Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.', - planCardTitle: 'GreenLens Pro', - planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat', - planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`, - planCardPriceMonthly: (price: string) => `${price}/Monat`, - trialToggleLabel: 'Gratis-Test aktiviert', - dueTodayTrial: 'Fällig heute — 7 Tage gratis', - dueTodayAmount: '0,00 €', - dueLater: (date: string) => `Fällig am ${date}`, - ctaTrial: 'Gratis testen', - ctaMonthly: 'Jetzt starten', - cancelAnytime: 'Jederzeit kündbar', - }; - } else if (language === 'es') { - return { - title: 'Suscripción y Créditos', - planLabel: 'Plan Actual', - planFree: 'Gratis', - planPro: 'Pro', - creditsAvailableLabel: 'Créditos Disponibles', - manageSubscription: 'Administrar Suscripción', - subscriptionTitle: 'Suscripciones', - subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', - startTrial: 'Probar 7 dias gratis', - expoGoPurchaseTitle: 'Compra solo en Dev Build', - expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.', - expoGoSimulate: 'Simular Pro', - continueWithoutPro: 'Continuar sin Pro', - freePlanName: 'Gratis', - freePlanPrice: '0 EUR / Mes', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Mes', - proBadgeText: 'RECOMENDADO', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Año', - proYearlyBadgeText: 'AHORRAR', - proBenefits: [ - '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', - 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', - cancelTitle: 'Lamentamos verte ir', - cancelQuestion: '¿Podemos saber por qué cancelas?', - reasonTooExpensive: 'Es muy caro', - reasonNotUsing: 'No lo uso suficiente', - reasonOther: 'Otra razón', - offerTitle: '¡Un regalo para ti!', - offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', - offerAccept: 'Aceptar descuento', - offerDecline: 'No, continuar cancelando', - confirmCancelBtn: 'Cancelar ahora', - restorePurchases: 'Restaurar Compras', - autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', - manageInSettings: 'Administrar en Ajustes de iOS', - paywallEyebrow: 'GreenLens Pro', - paywallHeadline: 'Acceso ilimitado', - paywallSub: 'Escaneos ilimitados, chequeos de salud y tu plan de cuidados personal.', - planCardTitle: 'GreenLens Pro', - planCardBody: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes', - planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`, - planCardPriceMonthly: (price: string) => `${price}/mes`, - trialToggleLabel: 'Prueba gratis activada', - dueTodayTrial: 'Hoy — 7 días gratis', - dueTodayAmount: '0,00 €', - dueLater: (date: string) => `El ${date}`, - ctaTrial: 'Probar gratis', - ctaMonthly: 'Empezar ahora', - cancelAnytime: 'Cancela cuando quieras', - }; - } - return { - title: 'Billing & Credits', - planLabel: 'Current Plan', - planFree: 'Free', - planPro: 'Pro', - creditsAvailableLabel: 'Available Credits', - manageSubscription: 'Manage Subscription', - subscriptionTitle: 'Subscriptions', - subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', - startTrial: 'Start 7-day free trial', - expoGoPurchaseTitle: 'Purchase requires a dev build', - expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.', - expoGoSimulate: 'Simulate Pro', - continueWithoutPro: 'Continue without Pro', - freePlanName: 'Free', - freePlanPrice: '0 EUR / Month', - proPlanName: 'Pro', - proPlanPrice: '4.99 EUR / Month', - proBadgeText: 'RECOMMENDED', - proYearlyPlanName: 'Pro', - proYearlyPlanPrice: '39.99 EUR / Year', - proYearlyBadgeText: 'SAVE', - proBenefits: [ - '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', - 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', - cancelTitle: 'Sorry to see you go', - cancelQuestion: 'May we ask why you are cancelling?', - reasonTooExpensive: 'It is too expensive', - reasonNotUsing: 'I don\'t use it enough', - reasonOther: 'Other reason', - offerTitle: 'A gift for you!', - offerText: 'Stay with us and get your next month for just €2.49 (50% off).', - offerAccept: 'Claim discount', - offerDecline: 'No, continue cancelling', - confirmCancelBtn: 'Cancel now', - restorePurchases: 'Restore Purchases', - autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', - autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', - manageInSettings: 'Manage in iOS Settings', - paywallEyebrow: 'GreenLens Pro', - paywallHeadline: 'Get Unlimited Access', - paywallSub: 'Unlimited scans, health checks and your personal care plan.', - planCardTitle: 'GreenLens Pro', - planCardBody: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month', - planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`, - planCardPriceMonthly: (price: string) => `${price}/month`, - trialToggleLabel: 'Free Trial Enabled', - dueTodayTrial: 'Due today — 7 days free', - dueTodayAmount: '€0.00', - dueLater: (date: string) => `Due ${date}`, - ctaTrial: 'Try Free', - ctaMonthly: 'Start Now', - cancelAnytime: 'Cancel Anytime', - }; -}; - - - -export default function BillingScreen() { - const router = useRouter(); - const params = useLocalSearchParams<{ view?: string; context?: string }>(); - const paywallRequested = params.view === 'paywall'; - const onboardingContext = params.context === 'onboarding'; - const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const posthog = useSafeAnalytics(); - const copy = getBillingCopy(language); - const isExpoGo = Constants.appOwnership === 'expo'; - - const [subModalVisible, setSubModalVisible] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [storeReady, setStoreReady] = useState(isExpoGo); - const [storeError, setStoreError] = useState(null); - const [subscriptionPackages, setSubscriptionPackages] = useState({}); - const [topupProducts, setTopupProducts] = useState({}); - const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('yearly'); - - // Cancel Flow State - const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); - - const planId = billingSummary?.entitlement?.plan || 'free'; - const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); - const showPaywallPlans = (!session || paywallRequested) && (!isLoadingBilling || !session) && planId !== 'pro'; - - useEffect(() => { - let cancelled = false; - - const loadStoreProducts = async () => { - if (isExpoGo) { - setStoreReady(true); - return; - } - - try { - ensureRevenueCatConfigured(); - const [offerings, topups] = await Promise.all([ - Purchases.getOfferings(), - Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), - ]); - - if (cancelled) return; - - const currentOffering = offerings.current; - const resolvedPackages = resolveSubscriptionPackages(currentOffering); - if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { - console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); - } - - setSubscriptionPackages(resolvedPackages); - - setTopupProducts({ - topup_small: topups.find((product) => product.identifier === 'topup_small'), - topup_medium: topups.find((product) => product.identifier === 'topup_medium'), - topup_large: topups.find((product) => product.identifier === 'topup_large'), - }); - setStoreError(null); - } catch (error) { - console.warn('Failed to load RevenueCat products', error); - if (!cancelled) { - setStoreError('Purchases are temporarily unavailable. Please try again later.'); - } - } finally { - if (!cancelled) { - setStoreReady(true); - } - } - }; - - loadStoreProducts(); - - return () => { - cancelled = true; - }; - }, [isExpoGo]); - - const trialEnabled = selectedPaywallPlan === 'yearly'; - - useEffect(() => { - try { - posthog.capture('paywall_viewed', { - plan_id: planId, - context: onboardingContext ? 'onboarding' : 'in_app', - trial_enabled: trialEnabled, - }); - } catch {} - if (showPaywallPlans) { - try { - posthog.capture('hard_paywall_viewed', { - plan_id: planId, - authenticated: Boolean(session), - }); - } catch {} - } - }, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled]); - - const monthlyPackage = subscriptionPackages.monthly_pro; - const yearlyPackage = subscriptionPackages.yearly_pro; - - const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; - const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; - const trialEndDate = useMemo(() => { - const date = new Date(); - date.setDate(date.getDate() + 7); - const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; - return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); - }, [language]); - - const topupLabels = useMemo(() => ({ - 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 handleBack = useCallback(() => { - if (showPaywallPlans) { - posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' }); - if (onboardingContext) { - router.replace('/auth/signup'); - return; - } - if (session) { - if (router.canGoBack()) router.back(); - else router.replace('/(tabs)'); - return; - } - router.replace('/onboarding'); - return; - } - if (router.canGoBack()) { - router.back(); - return; - } - router.replace('/(tabs)'); - }, [router, showPaywallPlans, onboardingContext, session, posthog]); - - const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)'; - - useFocusEffect( - useCallback(() => { - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - if (!showPaywallPlans) { - return false; - } - handleBack(); - return true; - }); - - return () => subscription.remove(); - }, [showPaywallPlans, handleBack]), - ); - - 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 }); - setSubModalVisible(false); - router.replace(postPurchaseRoute); - } else { - posthog.capture('topup_purchased', { product_id: productId, simulated: true }); - } - } finally { - setIsUpdating(false); - } - }; - - const handlePurchase = async (productId: PurchaseProductId) => { - // Guests can't sync purchases to an account; signed-in free users may - // buy top-ups (the server counts topupBalance for free plans too). - if (isTopupProductId(productId) && !session) { - return; - } - - if (!isExpoGo && storeError) { - Alert.alert('Purchases unavailable', storeError); - return; - } - - setIsUpdating(true); - posthog.capture('purchase_initiated', { product_id: productId }); - try { - if (isExpoGo) { - // ExpoGo has no native RevenueCat — use simulation for development only - setIsUpdating(false); - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ - { text: copy.continueWithoutPro, style: 'cancel' }, - { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, - ]); - return; - } - await completeExpoGoSimulation(productId); - return; - } else { - ensureRevenueCatConfigured(); - if (productId === 'monthly_pro' || productId === 'yearly_pro') { - if (planId === 'pro') { - await openAppleSubscriptions(); - setSubModalVisible(false); - return; - } - const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; - const latestOffering = !selectedPackage - ? await Purchases.getOfferings().then((offerings) => offerings.current) - : null; - if (!selectedPackage) { - console.warn('[Billing] Purchase blocked because subscription package was not resolved', { - productId, - offering: summarizeOfferingPackages(latestOffering), - }); - throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); - } - const purchaseResult = await Purchases.purchasePackage(selectedPackage); - // Apply RevenueCat entitlement locally and let backend sync finish in the background. - const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo - ?? await Purchases.getCustomerInfo(); - void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); - posthog.capture('subscription_started', { product_id: productId }); - posthog.capture('trial_started', { product_id: productId }); - setSubModalVisible(false); - setTimeout(() => router.replace(postPurchaseRoute), 0); - return; - } else { - const selectedProduct = topupProducts[productId]; - if (!selectedProduct) { - throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); - } - 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); - } 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 }); - 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; - // the current account stays free. The user can restore via "Käufe wiederherstellen". - const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; - if (rcErrorCode === 7) { - setSubModalVisible(false); - return; - } - - console.error('Payment failed', e); - posthog.capture('purchase_failed', { product_id: productId, error: msg }); - Alert.alert('Unerwarteter Fehler', msg); - } finally { - setIsUpdating(false); - } - }; - - const handleRestore = async () => { - setIsUpdating(true); - try { - if (!isExpoGo) { - ensureRevenueCatConfigured(); - const customerInfo = await Purchases.restorePurchases(); - await syncRevenueCatState(customerInfo as any, 'restore'); - } - Alert.alert(copy.restorePurchases, '✓'); - } catch (e) { - Alert.alert('Error', e instanceof Error ? e.message : String(e)); - } finally { - setIsUpdating(false); - } - }; - - const handleDowngrade = async () => { - if (planId === 'free') return; - if (!isExpoGo) { - await openAppleSubscriptions(); - return; - } - // Expo Go / dev only: simulate cancel flow - setCancelStep('survey'); - }; - - const finalizeCancel = async () => { - setIsUpdating(true); - try { - await simulateWebhookEvent('entitlement_revoked'); - setCancelStep('none'); - setSubModalVisible(false); - } catch (e) { - console.error('Downgrade failed', e); - } finally { - setIsUpdating(false); - } - }; - - if (showPaywallPlans) { - return ( - - - - - - - - - {copy.restorePurchases} - - - - - - - {copy.paywallEyebrow.toUpperCase()} - {copy.paywallHeadline} - {copy.paywallSub} - - - {copy.planCardTitle} - {copy.planCardBody} - - - {trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)} - - - - - {copy.trialToggleLabel} - setSelectedPaywallPlan(next ? 'yearly' : 'weekly')} - trackColor={{ true: colors.primary, false: colors.border }} - thumbColor="#FFFFFF" - /> - - - {trialEnabled ? ( - - - - {copy.dueTodayTrial} - {copy.dueTodayAmount} - - - - - {copy.dueLater(trialEndDate)} - {yearlyPrice} - - - ) : null} - - {storeError ? ( - {storeError} - ) : null} - - handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')} - disabled={isUpdating || !storeReady || Boolean(storeError)} - activeOpacity={0.86} - > - {isUpdating || !storeReady ? ( - - ) : ( - - {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} - - )} - - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy - - | - Linking.openURL('https://greenlenspro.com/terms')}> - Terms - - - {copy.cancelAnytime} - - - - - - - ); - } - - return ( - - - - - - - - {copy.title} - - - - - {isLoadingBilling && session ? ( - - ) : ( - <> - {session && ( - - {copy.planLabel} - - - {planId === 'pro' ? copy.planPro : copy.planFree} - - {planId === 'pro' && ( - setSubModalVisible(true)} - > - {copy.manageSubscription} - - )} - - - {copy.creditsAvailableLabel} - {credits} - - {planId !== 'pro' && ( - router.push('/profile/billing?view=paywall')} - activeOpacity={0.86} - > - - {copy.startTrial} - - )} - - )} - {session && !isExpoGo ? ( - - {copy.topupTitle} - {copy.topupHint} - - {([ - { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, - { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, - { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, - ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( - handlePurchase(pack.id)} - disabled={isUpdating || !storeReady || Boolean(storeError)} - > - - - - {isUpdating ? '...' : pack.label} - - - {pack.badge && ( - - {pack.badge} - - )} - - ))} - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : null} - - )} - - - - setSubModalVisible(false)}> - - - - - {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} - - { - setSubModalVisible(false); - setCancelStep('none'); - }}> - - - - - {cancelStep === 'none' ? ( - <> - {copy.subscriptionHint} - - - - {copy.freePlanName} - {copy.freePlanPrice} - - {planId === 'free' && } - - - handlePurchase('monthly_pro')} - disabled={isUpdating || !storeReady || Boolean(storeError)} - > - - - {copy.proPlanName} - - {copy.proBadgeText} - - - {monthlyPrice} - {copy.autoRenewMonthly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - handlePurchase('yearly_pro')} - disabled={isUpdating || !storeReady || Boolean(storeError)} - > - - - {copy.proYearlyPlanName} - - {copy.proYearlyBadgeText} - - - {yearlyPrice} - {copy.autoRenewYearly} - - - {copy.proBenefits.map((b, i) => ( - - - {b} - - ))} - - - {planId === 'pro' && } - - - - Linking.openURL('https://greenlenspro.com/privacy')}> - Privacy Policy - - · - Linking.openURL('https://greenlenspro.com/terms')}> - Terms of Use - - - - {copy.restorePurchases} - - - ) : cancelStep === 'survey' ? ( - - {copy.cancelQuestion} - - {[ - { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, - { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, - { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, - ].map((reason) => ( - { - setCancelStep('offer'); - }} - > - - - - {reason.label} - - - ))} - - - ) : ( - - - - - - {copy.offerText} - - { - // Handle applying discount here (future implementation) - Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); - setCancelStep('none'); - setSubModalVisible(false); - }} - > - {copy.offerAccept} - - - - - {copy.offerDecline} - - - )} - {(isUpdating || (!storeReady && cancelStep === 'none')) && } - - - - - ); -} - -const styles = StyleSheet.create({ - hardPaywallScreen: { - flex: 1, - backgroundColor: '#101411', - }, - hardPaywallHero: { - flex: 1, - }, - hardPaywallHeroImage: { - transform: [{ translateY: -38 }, { scale: 1.06 }], - }, - hardPaywallSafe: { - flex: 1, - }, - heroTopBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, - paddingTop: 4, - }, - heroIconButton: { - width: 42, - height: 42, - borderRadius: 21, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#00000066', - }, - heroRestoreText: { - color: '#FFFFFF', - fontSize: 13, - fontWeight: '700', - textShadowColor: '#00000066', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 3, - }, - paywallSheet: { - flex: 1, - borderTopLeftRadius: 30, - borderTopRightRadius: 30, - overflow: 'hidden', - zIndex: 5, - }, - sheetHandle: { - alignSelf: 'center', - width: 42, - height: 5, - borderRadius: 999, - marginTop: 10, - marginBottom: 8, - }, - paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 24 }, - paywallEyebrow: { fontSize: 12, fontWeight: '900', letterSpacing: 1.4, textAlign: 'center', marginBottom: 6 }, - paywallHeadline: { fontSize: 32, fontWeight: '900', textAlign: 'center', marginBottom: 6 }, - paywallSub: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18 }, - planCard: { borderRadius: 16, padding: 18, marginBottom: 14 }, - planCardTitle: { fontSize: 19, fontWeight: '800', marginBottom: 6 }, - planCardBody: { fontSize: 14, lineHeight: 20 }, - planCardDivider: { height: StyleSheet.hairlineWidth, marginVertical: 12 }, - planCardPrice: { fontSize: 15, fontWeight: '800' }, - trialToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, marginBottom: 16 }, - trialToggleLabel: { fontSize: 15, fontWeight: '800' }, - dueTimeline: { marginBottom: 18, paddingHorizontal: 4 }, - dueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - dueDot: { width: 10, height: 10, borderRadius: 5 }, - dueLine: { width: 2, height: 18, marginLeft: 4, marginVertical: 2 }, - dueLabel: { flex: 1, fontSize: 14, fontWeight: '700' }, - dueAmount: { fontSize: 14, fontWeight: '800' }, - paywallCta: { height: 58, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginBottom: 12 }, - paywallCtaText: { fontSize: 18, fontWeight: '800' }, - paywallFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - paywallFooterLinks: { flexDirection: 'row', alignItems: 'center' }, - paywallFooterText: { fontSize: 12, fontWeight: '600' }, - safeArea: { flex: 1 }, - header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, - backButton: { width: 40, height: 40, justifyContent: 'center' }, - title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, - scrollContent: { padding: 16, gap: 16 }, - card: { - padding: 16, - borderRadius: 16, - borderWidth: StyleSheet.hairlineWidth, - }, - sectionTitle: { - fontSize: 14, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 8, - }, - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - value: { - fontSize: 18, - fontWeight: '600', - }, - manageBtn: { - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - }, - manageBtnText: { - color: '#fff', - fontSize: 14, - fontWeight: '600', - }, - creditsValue: { - fontSize: 32, - fontWeight: '700', - }, - upgradeCta: { - marginTop: 16, - height: 50, - borderRadius: 14, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - upgradeCtaText: { - fontSize: 16, - fontWeight: '800', - }, - topupBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - borderRadius: 12, - borderWidth: 2, - gap: 8, - }, - topupText: { - fontSize: 16, - fontWeight: '600', - }, - modalOverlay: { - flex: 1, - backgroundColor: '#00000080', - justifyContent: 'flex-end', - }, - modalContent: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - borderTopWidth: StyleSheet.hairlineWidth, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - modalTitle: { - fontSize: 20, - fontWeight: '700', - }, - modalHint: { - fontSize: 14, - marginBottom: 24, - }, - plansContainer: { - gap: 12, - }, - planOption: { - padding: 16, - borderRadius: 12, - borderWidth: 2, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - planName: { - fontSize: 18, - fontWeight: '600', - }, - planPrice: { - fontSize: 14, - }, - planHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - marginBottom: 2, - }, - proBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - }, - proBadgeText: { - color: '#fff', - fontSize: 10, - fontWeight: '800', - }, - proBenefits: { - marginTop: 12, - gap: 6, - }, - benefitRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - benefitText: { - fontSize: 12, - fontWeight: '500', - }, - cancelFlowContainer: { - marginTop: 8, - }, - cancelHint: { - fontSize: 15, - marginBottom: 16, - }, - reasonList: { - gap: 12, - }, - reasonOption: { - flexDirection: 'row', - alignItems: 'center', - padding: 16, - borderWidth: 1, - borderRadius: 12, - }, - reasonIcon: { - width: 36, - height: 36, - borderRadius: 18, - justifyContent: 'center', - alignItems: 'center', - marginRight: 12, - }, - reasonText: { - flex: 1, - fontSize: 16, - fontWeight: '500', - }, - offerCard: { - borderRadius: 16, - padding: 24, - alignItems: 'center', - marginBottom: 16, - }, - offerIconWrap: { - width: 56, - height: 56, - borderRadius: 28, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 16, - }, - offerText: { - fontSize: 16, - textAlign: 'center', - lineHeight: 24, - marginBottom: 24, - fontWeight: '500', - }, - offerAcceptBtn: { - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 24, - width: '100%', - alignItems: 'center', - }, - offerAcceptBtnText: { - color: '#fff', - fontSize: 16, - fontWeight: '700', - }, - offerDeclineBtn: { - paddingVertical: 12, - alignItems: 'center', - }, - offerDeclineBtnText: { - fontSize: 15, - fontWeight: '500', - }, - disabledPlanCard: { - opacity: 0.72, - }, - legalLinksRow: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginTop: 16, - }, - legalLink: { - fontSize: 12, - fontWeight: '500', - textDecorationLine: 'underline', - }, - legalSep: { - fontSize: 12, - }, - restoreBtn: { - alignItems: 'center', - paddingVertical: 8, - }, - autoRenewText: { - fontSize: 11, - marginTop: 2, - marginBottom: 4, - }, -}); +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal, ActivityIndicator, Alert, Linking, BackHandler, ImageBackground, Platform, Switch } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useFocusEffect } from '@react-navigation/native'; +import Constants from 'expo-constants'; +import Purchases, { + LOG_LEVEL, + PACKAGE_TYPE, + PRODUCT_CATEGORY, + PurchasesOffering, + PurchasesPackage, + PurchasesStoreProduct, +} from 'react-native-purchases'; +import { useApp } from '../../context/AppContext'; +import { useSafeAnalytics } from '../../services/analytics'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { Language } from '../../types'; +import { PurchaseProductId } from '../../services/backend/contracts'; + +type SubscriptionProductId = 'monthly_pro' | 'yearly_pro'; +type TopupProductId = Extract; +type SubscriptionPackages = Partial>; +type TopupProducts = Partial>; +type PaywallPlanId = 'weekly' | 'yearly'; + +const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png'); + +const TOPUP_CREDITS_BY_PRODUCT: Record = { + 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, + productId: SubscriptionProductId, + expectedPackageType: PACKAGE_TYPE, +) => { + return ( + pkg.product.identifier === productId + || pkg.identifier === productId + || pkg.packageType === expectedPackageType + ); +}; + +const resolveSubscriptionPackages = (offering: PurchasesOffering | null): SubscriptionPackages => { + if (!offering) { + return {}; + } + + const availablePackages = [ + offering.monthly, + offering.annual, + ...offering.availablePackages, + ].filter((value): value is PurchasesPackage => Boolean(value)); + + return { + monthly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'monthly_pro', PACKAGE_TYPE.MONTHLY)), + yearly_pro: availablePackages.find((pkg) => isMatchingPackage(pkg, 'yearly_pro', PACKAGE_TYPE.ANNUAL)), + }; +}; + +const summarizeOfferingPackages = (offering: PurchasesOffering | null) => { + if (!offering) { + return { identifier: null, packages: [] as Array> }; + } + + return { + identifier: offering.identifier, + packages: offering.availablePackages.map((pkg) => ({ + identifier: pkg.identifier, + packageType: pkg.packageType, + productIdentifier: pkg.product.identifier, + priceString: pkg.product.priceString, + })), + }; +}; + +let revenueCatConfigured = false; + +const ensureRevenueCatConfigured = () => { + if (revenueCatConfigured || Constants.appOwnership === 'expo') { + return; + } + + Purchases.setLogLevel(LOG_LEVEL.WARN); + const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR'; + const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder'; + if (Platform.OS === 'ios') { + Purchases.configure({ apiKey: iosApiKey }); + } else if (Platform.OS === 'android') { + Purchases.configure({ apiKey: androidApiKey }); + } + revenueCatConfigured = true; +}; + +const getBillingCopy = (language: Language) => { + if (language === 'de') { + return { + title: 'Abo und Credits', + planLabel: 'Aktueller Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Verfügbare Credits', + manageSubscription: 'Abo verwalten', + subscriptionTitle: 'Abos', + subscriptionHint: 'Wähle ein Abo und schalte stärkere KI-Scans sowie mehr Credits frei.', + startTrial: '7 Tage kostenlos testen', + expoGoPurchaseTitle: 'Kauf nur im Dev Build', + expoGoPurchaseMessage: 'Expo Go kann keine Apple- oder RevenueCat-Kaufmaske anzeigen. Im Development Build oder TestFlight erscheint hier der echte 7-Tage-Trial. Fuer lokale Tests kannst du Pro simulieren.', + expoGoSimulate: 'Pro simulieren', + continueWithoutPro: 'Ohne Pro fortfahren', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Monat', + proPlanName: 'Pro', + proPlanPrice: '4,99 € / Monat', + proBadgeText: 'EMPFOHLEN', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39,99 € / Jahr', + proYearlyBadgeText: 'SPAREN', + proBenefits: [ + '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', + 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', + cancelTitle: 'Schade, dass du gehst', + cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', + reasonTooExpensive: 'Es ist mir zu teuer', + reasonNotUsing: 'Ich nutze die App zu selten', + reasonOther: 'Ein anderer Grund', + offerTitle: 'Ein Geschenk für dich!', + offerText: 'Bleib dabei und erhalte den nächsten Monat für nur 2,49 € (50% Rabatt).', + offerAccept: 'Rabatt sichern', + offerDecline: 'Nein, Kündigung fortsetzen', + confirmCancelBtn: 'Jetzt kündigen', + restorePurchases: 'Käufe wiederherstellen', + autoRenewMonthly: 'Verlängert sich monatlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + autoRenewYearly: 'Verlängert sich jährlich automatisch. Jederzeit über iOS-Einstellungen kündbar.', + manageInSettings: 'In iOS-Einstellungen verwalten', + paywallEyebrow: 'GreenLens Pro', + paywallHeadline: 'Unbegrenzter Zugriff', + paywallSub: 'Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.', + planCardTitle: 'GreenLens Pro', + planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat', + planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`, + planCardPriceMonthly: (price: string) => `${price}/Monat`, + trialToggleLabel: 'Gratis-Test aktiviert', + dueTodayTrial: 'Fällig heute — 7 Tage gratis', + dueTodayAmount: '0,00 €', + dueLater: (date: string) => `Fällig am ${date}`, + ctaTrial: 'Gratis testen', + ctaMonthly: 'Jetzt starten', + cancelAnytime: 'Jederzeit kündbar', + }; + } else if (language === 'es') { + return { + title: 'Suscripción y Créditos', + planLabel: 'Plan Actual', + planFree: 'Gratis', + planPro: 'Pro', + creditsAvailableLabel: 'Créditos Disponibles', + manageSubscription: 'Administrar Suscripción', + subscriptionTitle: 'Suscripciones', + subscriptionHint: 'Elige un plan y desbloquea escaneos con IA más potentes y más créditos.', + startTrial: 'Probar 7 dias gratis', + expoGoPurchaseTitle: 'Compra solo en Dev Build', + expoGoPurchaseMessage: 'Expo Go no puede mostrar la compra nativa de Apple o RevenueCat. En Development Build o TestFlight aparecera el trial real de 7 dias. Para pruebas locales puedes simular Pro.', + expoGoSimulate: 'Simular Pro', + continueWithoutPro: 'Continuar sin Pro', + freePlanName: 'Gratis', + freePlanPrice: '0 EUR / Mes', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Mes', + proBadgeText: 'RECOMENDADO', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Año', + proYearlyBadgeText: 'AHORRAR', + proBenefits: [ + '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', + 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', + cancelTitle: 'Lamentamos verte ir', + cancelQuestion: '¿Podemos saber por qué cancelas?', + reasonTooExpensive: 'Es muy caro', + reasonNotUsing: 'No lo uso suficiente', + reasonOther: 'Otra razón', + offerTitle: '¡Un regalo para ti!', + offerText: 'Quédate y obtén el próximo mes por solo 2,49 € (50% de descuento).', + offerAccept: 'Aceptar descuento', + offerDecline: 'No, continuar cancelando', + confirmCancelBtn: 'Cancelar ahora', + restorePurchases: 'Restaurar Compras', + autoRenewMonthly: 'Se renueva mensualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + autoRenewYearly: 'Se renueva anualmente de forma automática. Cancela cuando quieras en Ajustes de iOS.', + manageInSettings: 'Administrar en Ajustes de iOS', + paywallEyebrow: 'GreenLens Pro', + paywallHeadline: 'Acceso ilimitado', + paywallSub: 'Escaneos ilimitados, chequeos de salud y tu plan de cuidados personal.', + planCardTitle: 'GreenLens Pro', + planCardBody: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes', + planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`, + planCardPriceMonthly: (price: string) => `${price}/mes`, + trialToggleLabel: 'Prueba gratis activada', + dueTodayTrial: 'Hoy — 7 días gratis', + dueTodayAmount: '0,00 €', + dueLater: (date: string) => `El ${date}`, + ctaTrial: 'Probar gratis', + ctaMonthly: 'Empezar ahora', + cancelAnytime: 'Cancela cuando quieras', + }; + } + return { + title: 'Billing & Credits', + planLabel: 'Current Plan', + planFree: 'Free', + planPro: 'Pro', + creditsAvailableLabel: 'Available Credits', + manageSubscription: 'Manage Subscription', + subscriptionTitle: 'Subscriptions', + subscriptionHint: 'Choose a plan to unlock stronger AI scans and more credits.', + startTrial: 'Start 7-day free trial', + expoGoPurchaseTitle: 'Purchase requires a dev build', + expoGoPurchaseMessage: 'Expo Go cannot show the native Apple or RevenueCat purchase sheet. In a Development Build or TestFlight this opens the real 7-day trial. For local testing you can simulate Pro.', + expoGoSimulate: 'Simulate Pro', + continueWithoutPro: 'Continue without Pro', + freePlanName: 'Free', + freePlanPrice: '0 EUR / Month', + proPlanName: 'Pro', + proPlanPrice: '4.99 EUR / Month', + proBadgeText: 'RECOMMENDED', + proYearlyPlanName: 'Pro', + proYearlyPlanPrice: '39.99 EUR / Year', + proYearlyBadgeText: 'SAVE', + proBenefits: [ + '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', + 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', + cancelTitle: 'Sorry to see you go', + cancelQuestion: 'May we ask why you are cancelling?', + reasonTooExpensive: 'It is too expensive', + reasonNotUsing: 'I don\'t use it enough', + reasonOther: 'Other reason', + offerTitle: 'A gift for you!', + offerText: 'Stay with us and get your next month for just €2.49 (50% off).', + offerAccept: 'Claim discount', + offerDecline: 'No, continue cancelling', + confirmCancelBtn: 'Cancel now', + restorePurchases: 'Restore Purchases', + autoRenewMonthly: 'Auto-renews monthly. Cancel anytime in iOS Settings.', + autoRenewYearly: 'Auto-renews annually. Cancel anytime in iOS Settings.', + manageInSettings: 'Manage in iOS Settings', + paywallEyebrow: 'GreenLens Pro', + paywallHeadline: 'Get Unlimited Access', + paywallSub: 'Unlimited scans, health checks and your personal care plan.', + planCardTitle: 'GreenLens Pro', + planCardBody: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month', + planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`, + planCardPriceMonthly: (price: string) => `${price}/month`, + trialToggleLabel: 'Free Trial Enabled', + dueTodayTrial: 'Due today — 7 days free', + dueTodayAmount: '€0.00', + dueLater: (date: string) => `Due ${date}`, + ctaTrial: 'Try Free', + ctaMonthly: 'Start Now', + cancelAnytime: 'Cancel Anytime', + }; +}; + + + +export default function BillingScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ view?: string; context?: string }>(); + const paywallRequested = params.view === 'paywall'; + const onboardingContext = params.context === 'onboarding'; + const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const posthog = useSafeAnalytics(); + const copy = getBillingCopy(language); + const isExpoGo = Constants.appOwnership === 'expo'; + + const [subModalVisible, setSubModalVisible] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [storeReady, setStoreReady] = useState(isExpoGo); + const [storeError, setStoreError] = useState(null); + const [subscriptionPackages, setSubscriptionPackages] = useState({}); + const [topupProducts, setTopupProducts] = useState({}); + const [selectedPaywallPlan, setSelectedPaywallPlan] = useState('yearly'); + + // Cancel Flow State + const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none'); + + const planId = billingSummary?.entitlement?.plan || 'free'; + const credits = isLoadingBilling && !billingSummary ? '...' : (billingSummary?.credits?.available ?? 0); + const showPaywallPlans = (!session || paywallRequested) && (!isLoadingBilling || !session) && planId !== 'pro'; + + useEffect(() => { + let cancelled = false; + + const loadStoreProducts = async () => { + if (isExpoGo) { + setStoreReady(true); + return; + } + + try { + ensureRevenueCatConfigured(); + const [offerings, topups] = await Promise.all([ + Purchases.getOfferings(), + Purchases.getProducts(['topup_small', 'topup_medium', 'topup_large'], PRODUCT_CATEGORY.NON_SUBSCRIPTION), + ]); + + if (cancelled) return; + + const currentOffering = offerings.current; + const resolvedPackages = resolveSubscriptionPackages(currentOffering); + if (!resolvedPackages.monthly_pro || !resolvedPackages.yearly_pro) { + console.warn('[Billing] RevenueCat offering missing expected subscription packages', summarizeOfferingPackages(currentOffering)); + } + + setSubscriptionPackages(resolvedPackages); + + setTopupProducts({ + topup_small: topups.find((product) => product.identifier === 'topup_small'), + topup_medium: topups.find((product) => product.identifier === 'topup_medium'), + topup_large: topups.find((product) => product.identifier === 'topup_large'), + }); + setStoreError(null); + } catch (error) { + console.warn('Failed to load RevenueCat products', error); + if (!cancelled) { + setStoreError('Purchases are temporarily unavailable. Please try again later.'); + } + } finally { + if (!cancelled) { + setStoreReady(true); + } + } + }; + + loadStoreProducts(); + + return () => { + cancelled = true; + }; + }, [isExpoGo]); + + const trialEnabled = selectedPaywallPlan === 'yearly'; + + useEffect(() => { + try { + posthog.capture('paywall_viewed', { + plan_id: planId, + context: onboardingContext ? 'onboarding' : 'in_app', + trial_enabled: trialEnabled, + }); + } catch {} + if (showPaywallPlans) { + try { + posthog.capture('hard_paywall_viewed', { + plan_id: planId, + authenticated: Boolean(session), + }); + } catch {} + } + }, [posthog, planId, session?.serverUserId, showPaywallPlans, onboardingContext, trialEnabled]); + + const monthlyPackage = subscriptionPackages.monthly_pro; + const yearlyPackage = subscriptionPackages.yearly_pro; + + const monthlyPrice = monthlyPackage?.product.priceString ?? copy.proPlanPrice; + const yearlyPrice = yearlyPackage?.product.priceString ?? copy.proYearlyPlanPrice; + const trialEndDate = useMemo(() => { + const date = new Date(); + date.setDate(date.getDate() + 7); + const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; + return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); + }, [language]); + + const topupLabels = useMemo(() => ({ + 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 handleBack = useCallback(() => { + if (showPaywallPlans) { + posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' }); + if (onboardingContext) { + router.replace('/auth/signup'); + return; + } + if (session) { + if (router.canGoBack()) router.back(); + else router.replace('/(tabs)'); + return; + } + router.replace('/onboarding'); + return; + } + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/(tabs)'); + }, [router, showPaywallPlans, onboardingContext, session, posthog]); + + const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)'; + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (!showPaywallPlans) { + return false; + } + handleBack(); + return true; + }); + + return () => subscription.remove(); + }, [showPaywallPlans, handleBack]), + ); + + 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 }); + setSubModalVisible(false); + router.replace(postPurchaseRoute); + } else { + posthog.capture('topup_purchased', { product_id: productId, simulated: true }); + } + } finally { + setIsUpdating(false); + } + }; + + const handlePurchase = async (productId: PurchaseProductId) => { + // Guests can't sync purchases to an account; signed-in free users may + // buy top-ups (the server counts topupBalance for free plans too). + if (isTopupProductId(productId) && !session) { + return; + } + + if (!isExpoGo && storeError) { + Alert.alert('Purchases unavailable', storeError); + return; + } + + setIsUpdating(true); + posthog.capture('purchase_initiated', { product_id: productId }); + try { + if (isExpoGo) { + // ExpoGo has no native RevenueCat — use simulation for development only + setIsUpdating(false); + if (productId === 'monthly_pro' || productId === 'yearly_pro') { + Alert.alert(copy.expoGoPurchaseTitle, copy.expoGoPurchaseMessage, [ + { text: copy.continueWithoutPro, style: 'cancel' }, + { text: copy.expoGoSimulate, onPress: () => completeExpoGoSimulation(productId) }, + ]); + return; + } + await completeExpoGoSimulation(productId); + return; + } else { + ensureRevenueCatConfigured(); + if (productId === 'monthly_pro' || productId === 'yearly_pro') { + if (planId === 'pro') { + await openAppleSubscriptions(); + setSubModalVisible(false); + return; + } + const selectedPackage = productId === 'monthly_pro' ? monthlyPackage : yearlyPackage; + const latestOffering = !selectedPackage + ? await Purchases.getOfferings().then((offerings) => offerings.current) + : null; + if (!selectedPackage) { + console.warn('[Billing] Purchase blocked because subscription package was not resolved', { + productId, + offering: summarizeOfferingPackages(latestOffering), + }); + throw new Error('Abo-Paket konnte nicht geladen werden. Bitte RevenueCat Offering prüfen.'); + } + const purchaseResult = await Purchases.purchasePackage(selectedPackage); + // Apply RevenueCat entitlement locally and let backend sync finish in the background. + const customerInfo = (purchaseResult as { customerInfo?: unknown }).customerInfo + ?? await Purchases.getCustomerInfo(); + void syncRevenueCatState(customerInfo as any, 'subscription_purchase'); + posthog.capture('subscription_started', { product_id: productId }); + posthog.capture('trial_started', { product_id: productId }); + setSubModalVisible(false); + setTimeout(() => router.replace(postPurchaseRoute), 0); + return; + } else { + const selectedProduct = topupProducts[productId]; + if (!selectedProduct) { + throw new Error('Top-up Produkt konnte nicht geladen werden. Bitte Store-Produkt IDs prüfen.'); + } + 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); + } 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 }); + 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; + // the current account stays free. The user can restore via "Käufe wiederherstellen". + const rcErrorCode = typeof e === 'object' && e !== null ? (e as Record).code : undefined; + if (rcErrorCode === 7) { + setSubModalVisible(false); + return; + } + + console.error('Payment failed', e); + posthog.capture('purchase_failed', { product_id: productId, error: msg }); + Alert.alert('Unerwarteter Fehler', msg); + } finally { + setIsUpdating(false); + } + }; + + const handleRestore = async () => { + setIsUpdating(true); + try { + if (!isExpoGo) { + ensureRevenueCatConfigured(); + const customerInfo = await Purchases.restorePurchases(); + await syncRevenueCatState(customerInfo as any, 'restore'); + } + Alert.alert(copy.restorePurchases, '✓'); + } catch (e) { + Alert.alert('Error', e instanceof Error ? e.message : String(e)); + } finally { + setIsUpdating(false); + } + }; + + const handleDowngrade = async () => { + if (planId === 'free') return; + if (!isExpoGo) { + await openAppleSubscriptions(); + return; + } + // Expo Go / dev only: simulate cancel flow + setCancelStep('survey'); + }; + + const finalizeCancel = async () => { + setIsUpdating(true); + try { + await simulateWebhookEvent('entitlement_revoked'); + setCancelStep('none'); + setSubModalVisible(false); + } catch (e) { + console.error('Downgrade failed', e); + } finally { + setIsUpdating(false); + } + }; + + if (showPaywallPlans) { + return ( + + + + + + + + + {copy.restorePurchases} + + + + + + + {copy.paywallEyebrow.toUpperCase()} + {copy.paywallHeadline} + {copy.paywallSub} + + + {copy.planCardTitle} + {copy.planCardBody} + + + {trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)} + + + + + {copy.trialToggleLabel} + setSelectedPaywallPlan(next ? 'yearly' : 'weekly')} + trackColor={{ true: colors.primary, false: colors.border }} + thumbColor="#FFFFFF" + /> + + + {trialEnabled ? ( + + + + {copy.dueTodayTrial} + {copy.dueTodayAmount} + + + + + {copy.dueLater(trialEndDate)} + {yearlyPrice} + + + ) : null} + + {storeError ? ( + {storeError} + ) : null} + + handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')} + disabled={isUpdating || !storeReady || Boolean(storeError)} + activeOpacity={0.86} + > + {isUpdating || !storeReady ? ( + + ) : ( + + {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} + + )} + + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy + + | + Linking.openURL('https://greenlenspro.com/terms')}> + Terms + + + {copy.cancelAnytime} + + + + + + + ); + } + + return ( + + + + + + + + {copy.title} + + + + + {isLoadingBilling && session ? ( + + ) : ( + <> + {session && ( + + {copy.planLabel} + + + {planId === 'pro' ? copy.planPro : copy.planFree} + + {planId === 'pro' && ( + setSubModalVisible(true)} + > + {copy.manageSubscription} + + )} + + + {copy.creditsAvailableLabel} + {credits} + + {planId !== 'pro' && ( + router.push('/profile/billing?view=paywall')} + activeOpacity={0.86} + > + + {copy.startTrial} + + )} + + )} + {session && !isExpoGo ? ( + + {copy.topupTitle} + {copy.topupHint} + + {([ + { id: 'topup_small' as PurchaseProductId, label: topupLabels.topup_small }, + { id: 'topup_medium' as PurchaseProductId, label: topupLabels.topup_medium, badge: copy.topupBestValue }, + { id: 'topup_large' as PurchaseProductId, label: topupLabels.topup_large }, + ] as { id: PurchaseProductId; label: string; badge?: string }[]).map((pack) => ( + handlePurchase(pack.id)} + disabled={isUpdating || !storeReady || Boolean(storeError)} + > + + + + {isUpdating ? '...' : pack.label} + + + {pack.badge && ( + + {pack.badge} + + )} + + ))} + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + ) : null} + + )} + + + + setSubModalVisible(false)}> + + + + + {cancelStep === 'survey' ? copy.cancelTitle : cancelStep === 'offer' ? copy.offerTitle : copy.subscriptionTitle} + + { + setSubModalVisible(false); + setCancelStep('none'); + }}> + + + + + {cancelStep === 'none' ? ( + <> + {copy.subscriptionHint} + + + + {copy.freePlanName} + {copy.freePlanPrice} + + {planId === 'free' && } + + + handlePurchase('monthly_pro')} + disabled={isUpdating || !storeReady || Boolean(storeError)} + > + + + {copy.proPlanName} + + {copy.proBadgeText} + + + {monthlyPrice} + {copy.autoRenewMonthly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + handlePurchase('yearly_pro')} + disabled={isUpdating || !storeReady || Boolean(storeError)} + > + + + {copy.proYearlyPlanName} + + {copy.proYearlyBadgeText} + + + {yearlyPrice} + {copy.autoRenewYearly} + + + {copy.proBenefits.map((b, i) => ( + + + {b} + + ))} + + + {planId === 'pro' && } + + + + Linking.openURL('https://greenlenspro.com/privacy')}> + Privacy Policy + + · + Linking.openURL('https://greenlenspro.com/terms')}> + Terms of Use + + + + {copy.restorePurchases} + + + ) : cancelStep === 'survey' ? ( + + {copy.cancelQuestion} + + {[ + { id: 'expensive', label: copy.reasonTooExpensive, icon: 'cash-outline' }, + { id: 'not_using', label: copy.reasonNotUsing, icon: 'calendar-outline' }, + { id: 'other', label: copy.reasonOther, icon: 'ellipsis-horizontal-outline' }, + ].map((reason) => ( + { + setCancelStep('offer'); + }} + > + + + + {reason.label} + + + ))} + + + ) : ( + + + + + + {copy.offerText} + + { + // Handle applying discount here (future implementation) + Alert.alert('Erfolg', 'Rabatt angewendet! (Mock)'); + setCancelStep('none'); + setSubModalVisible(false); + }} + > + {copy.offerAccept} + + + + + {copy.offerDecline} + + + )} + {(isUpdating || (!storeReady && cancelStep === 'none')) && } + + + + + ); +} + +const styles = StyleSheet.create({ + hardPaywallScreen: { + flex: 1, + backgroundColor: '#101411', + }, + hardPaywallHero: { + flex: 1, + }, + hardPaywallHeroImage: { + transform: [{ translateY: -38 }, { scale: 1.06 }], + }, + hardPaywallSafe: { + flex: 1, + }, + heroTopBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 4, + }, + heroIconButton: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#00000066', + }, + heroRestoreText: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '700', + textShadowColor: '#00000066', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, + paywallSheet: { + flex: 1, + borderTopLeftRadius: 30, + borderTopRightRadius: 30, + overflow: 'hidden', + zIndex: 5, + }, + sheetHandle: { + alignSelf: 'center', + width: 42, + height: 5, + borderRadius: 999, + marginTop: 10, + marginBottom: 8, + }, + paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 24 }, + paywallEyebrow: { fontSize: 12, fontWeight: '900', letterSpacing: 1.4, textAlign: 'center', marginBottom: 6 }, + paywallHeadline: { fontSize: 32, fontWeight: '900', textAlign: 'center', marginBottom: 6 }, + paywallSub: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18 }, + planCard: { borderRadius: 16, padding: 18, marginBottom: 14 }, + planCardTitle: { fontSize: 19, fontWeight: '800', marginBottom: 6 }, + planCardBody: { fontSize: 14, lineHeight: 20 }, + planCardDivider: { height: StyleSheet.hairlineWidth, marginVertical: 12 }, + planCardPrice: { fontSize: 15, fontWeight: '800' }, + trialToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, marginBottom: 16 }, + trialToggleLabel: { fontSize: 15, fontWeight: '800' }, + dueTimeline: { marginBottom: 18, paddingHorizontal: 4 }, + dueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + dueDot: { width: 10, height: 10, borderRadius: 5 }, + dueLine: { width: 2, height: 18, marginLeft: 4, marginVertical: 2 }, + dueLabel: { flex: 1, fontSize: 14, fontWeight: '700' }, + dueAmount: { fontSize: 14, fontWeight: '800' }, + paywallCta: { height: 58, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginBottom: 12 }, + paywallCtaText: { fontSize: 18, fontWeight: '800' }, + paywallFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + paywallFooterLinks: { flexDirection: 'row', alignItems: 'center' }, + paywallFooterText: { fontSize: 12, fontWeight: '600' }, + safeArea: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', padding: 16 }, + backButton: { width: 40, height: 40, justifyContent: 'center' }, + title: { flex: 1, fontSize: 20, fontWeight: '700', textAlign: 'center' }, + scrollContent: { padding: 16, gap: 16 }, + card: { + padding: 16, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + }, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + value: { + fontSize: 18, + fontWeight: '600', + }, + manageBtn: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + manageBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + creditsValue: { + fontSize: 32, + fontWeight: '700', + }, + upgradeCta: { + marginTop: 16, + height: 50, + borderRadius: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + upgradeCtaText: { + fontSize: 16, + fontWeight: '800', + }, + topupBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + borderRadius: 12, + borderWidth: 2, + gap: 8, + }, + topupText: { + fontSize: 16, + fontWeight: '600', + }, + modalOverlay: { + flex: 1, + backgroundColor: '#00000080', + justifyContent: 'flex-end', + }, + modalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + modalHint: { + fontSize: 14, + marginBottom: 24, + }, + plansContainer: { + gap: 12, + }, + planOption: { + padding: 16, + borderRadius: 12, + borderWidth: 2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + planName: { + fontSize: 18, + fontWeight: '600', + }, + planPrice: { + fontSize: 14, + }, + planHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 2, + }, + proBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 6, + }, + proBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: '800', + }, + proBenefits: { + marginTop: 12, + gap: 6, + }, + benefitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + benefitText: { + fontSize: 12, + fontWeight: '500', + }, + cancelFlowContainer: { + marginTop: 8, + }, + cancelHint: { + fontSize: 15, + marginBottom: 16, + }, + reasonList: { + gap: 12, + }, + reasonOption: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderWidth: 1, + borderRadius: 12, + }, + reasonIcon: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + reasonText: { + flex: 1, + fontSize: 16, + fontWeight: '500', + }, + offerCard: { + borderRadius: 16, + padding: 24, + alignItems: 'center', + marginBottom: 16, + }, + offerIconWrap: { + width: 56, + height: 56, + borderRadius: 28, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + offerText: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + marginBottom: 24, + fontWeight: '500', + }, + offerAcceptBtn: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 24, + width: '100%', + alignItems: 'center', + }, + offerAcceptBtnText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + offerDeclineBtn: { + paddingVertical: 12, + alignItems: 'center', + }, + offerDeclineBtnText: { + fontSize: 15, + fontWeight: '500', + }, + disabledPlanCard: { + opacity: 0.72, + }, + legalLinksRow: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 16, + }, + legalLink: { + fontSize: 12, + fontWeight: '500', + textDecorationLine: 'underline', + }, + legalSep: { + fontSize: 12, + }, + restoreBtn: { + alignItems: 'center', + paddingVertical: 8, + }, + autoRenewText: { + fontSize: 11, + marginTop: 2, + marginBottom: 4, + }, +}); diff --git a/app/scanner.tsx b/app/scanner.tsx index 4fd87ee..31cef0e 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -1,1061 +1,1061 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing, -} from 'react-native'; -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 * as AppleAuthentication from 'expo-apple-authentication'; -import Constants from 'expo-constants'; -import { ShareIntentModule } from 'expo-share-intent'; -import { useSafeAnalytics } from '../services/analytics'; -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 { AuthService } from '../services/authService'; -import { getMockPlantByImage } from '../services/backend/mockCatalog'; -import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff'; -import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet'; - -const DEMO_SCAN_LIMIT = 5; - -const getBillingCopy = (language: 'de' | 'en' | 'es') => { - if (language === 'de') { - return { - creditsLabel: 'Credits', - managePlan: 'Plan verwalten', - dismiss: 'Schliessen', - genericErrorTitle: 'Fehler', - genericErrorMessage: 'Analyse fehlgeschlagen.', - noConnectionTitle: 'Keine Verbindung', - noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', - timeoutTitle: 'Scan zu langsam', - timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.', - retryLabel: 'Erneut versuchen', - notAPlantTitle: 'Keine Pflanze erkannt', - 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', - demoTitle: 'Rettungsplan bereit', - demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.', - demoNoCreditsTitle: 'Demo-Scans aufgebraucht', - demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.', - demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`, - creditsRemaining: (count: number) => `${count} Scans übrig`, - appleCta: 'Mit Apple fortfahren', - emailCta: 'Mit E-Mail fortfahren', - unlockCta: 'Vollständige Diagnose freischalten', - }; - } - - if (language === 'es') { - return { - creditsLabel: 'Creditos', - managePlan: 'Gestionar plan', - dismiss: 'Cerrar', - genericErrorTitle: 'Error', - genericErrorMessage: 'Analisis fallido.', - noConnectionTitle: 'Sin conexión', - noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', - timeoutTitle: 'Escaneo lento', - timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.', - retryLabel: 'Reintentar', - notAPlantTitle: 'No es una planta', - 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', - 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.', - demoNoCreditsTitle: 'Escaneos demo agotados', - demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.', - demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`, - creditsRemaining: (count: number) => `${count} escaneos restantes`, - appleCta: 'Continuar con Apple', - emailCta: 'Continuar con email', - unlockCta: 'Desbloquear diagnóstico completo', - }; - } - - return { - creditsLabel: 'Credits', - managePlan: 'Manage plan', - dismiss: 'Close', - genericErrorTitle: 'Error', - genericErrorMessage: 'Analysis failed.', - noConnectionTitle: 'No connection', - noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', - timeoutTitle: 'Scan Too Slow', - timeoutMessage: 'Analysis took too long. Please try again.', - retryLabel: 'Try again', - notAPlantTitle: 'No plant detected', - 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', - demoTitle: 'Rescue plan ready', - demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.', - demoNoCreditsTitle: 'Demo scans used', - demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.', - demoCreditsRemaining: (count: number) => `${count} demo scans left`, - creditsRemaining: (count: number) => `${count} scans left`, - appleCta: 'Continue with Apple', - emailCta: 'Continue with email', - unlockCta: 'Unlock full diagnosis', - }; -}; - -export default function ScannerScreen() { - const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>(); - const posthog = useSafeAnalytics(); - const { - isDarkMode, - colorPalette, - language, - t, - savePlant, - plants, - updatePlant, - billingSummary, - refreshBillingSummary, - isLoadingBilling, - session, - hydrateSession, - setPendingPlant, - guestScanCount, - incrementGuestScanCount, - } = 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 sharedImageUri = Array.isArray(params.sharedImageUri) - ? params.sharedImageUri[0] - : params.sharedImageUri; - const sharedImageKey = Array.isArray(params.sharedImageKey) - ? params.sharedImageKey[0] - : params.sharedImageKey; - const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' - && billingSummary?.entitlement?.status === 'active'; - const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits - const availableCredits = billingSummary?.credits.available ?? 0; - const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); - - const [permission, requestPermission] = useCameraPermissions(); - const [selectedImage, setSelectedImage] = useState(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(null); - const [demoResultVisible, setDemoResultVisible] = useState(false); - const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false); - const cameraRef = useRef(null); - const scanLineProgress = useRef(new Animated.Value(0)).current; - const scanPulse = useRef(new Animated.Value(0)).current; - const isExpoGo = Constants.appOwnership === 'expo'; - - useEffect(() => { - if (isExpoGo) { - setAppleAvailable(false); - return; - } - let mounted = true; - AppleAuthentication.isAvailableAsync() - .then((available) => { - if (mounted) setAppleAvailable(available); - }) - .catch(() => { - if (mounted) setAppleAvailable(false); - }); - return () => { - mounted = false; - }; - }, [isExpoGo]); - - const lastProcessedShareToken = useRef(null); - const sharedAnalysisInFlightToken = useRef(null); - const resizeForAnalysisRef = useRef<(uri: string) => Promise>(async (uri) => uri); - const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise>(async () => {}); - - useEffect(() => { - if (!isAnalyzing) { - scanLineProgress.stopAnimation(); - scanLineProgress.setValue(0); - scanPulse.stopAnimation(); - scanPulse.setValue(0); - return; - } - - const lineAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(scanLineProgress, { - toValue: 1, - duration: 1500, - easing: Easing.inOut(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(scanLineProgress, { - toValue: 0, - duration: 1500, - easing: Easing.inOut(Easing.quad), - useNativeDriver: true, - }), - ]) - ); - - const pulseAnimation = Animated.loop( - Animated.sequence([ - Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }), - Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }), - ]) - ); - - lineAnimation.start(); - pulseAnimation.start(); - - return () => { - lineAnimation.stop(); - pulseAnimation.stop(); - }; - }, [isAnalyzing, scanLineProgress, scanPulse]); - - const resizeForAnalysis = async (uri: string): Promise => { - if (uri.startsWith('data:')) return uri; - try { - const result = await ImageManipulator.manipulateAsync( - uri, - [{ resize: { width: 1280 } }], - { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true }, - ); - return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; - } catch { - return uri; - } - }; - - const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { - if (isAnalyzing) return; - - if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) { - Alert.alert( - billingCopy.demoNoCreditsTitle, - billingCopy.demoNoCreditsMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { - text: billingCopy.managePlan, - onPress: () => router.replace('/profile/billing'), - }, - ], - ); - return; - } - - // Only pre-block when the billing summary is actually known; if it's null - // (fetch failed / still loading) let the request proceed — the server 402 - // path opens the sheet as the safety net. - const requiredCredits = isHealthMode ? 2 : 1; - if (!isDemoMode && billingSummary && availableCredits < requiredCredits) { - posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' }); - setOutOfCreditsVisible(true); - return; - } - - setIsAnalyzing(true); - setAnalysisProgress(0); - setAnalysisResult(null); - setDemoResultVisible(false); - - const startTime = Date.now(); - - const progressInterval = setInterval(() => { - setAnalysisProgress((prev) => { - if (prev < 30) return prev + Math.random() * 8; - if (prev < 70) return prev + Math.random() * 2; - if (prev < 90) return prev + 0.5; - return prev; - }); - }, 150); - - try { - if (isDemoMode) { - posthog.capture('demo_scan_started', { - authenticated: Boolean(session), - scan_type: isHealthMode ? 'health_check' : 'identification', - demo_scans_used: guestScanCount, - demo_scans_remaining: demoScansRemaining, - }); - await new Promise(resolve => setTimeout(resolve, 2100)); - setAnalysisProgress(100); - await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - await new Promise(resolve => setTimeout(resolve, 350)); - const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true); - incrementGuestScanCount(); - setAnalysisResult(demoResult); - posthog.capture('demo_scan_completed', { - authenticated: Boolean(session), - latency_ms: Date.now() - startTime, - demo_scans_used_after: guestScanCount + 1, - }); - 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; - } - - const response = await backendApiClient.runHealthCheck({ - idempotencyKey: createIdempotencyKey('health-check', healthPlant.id), - imageUri, - language, - plantContext: { - name: healthPlant.name, - botanicalName: healthPlant.botanicalName, - careInfo: healthPlant.careInfo, - description: healthPlant.description, - }, - }); - - posthog.capture('llm_generation', { - scan_type: 'health_check', - success: true, - latency_ms: Date.now() - startTime, - }); - - const currentGallery = healthPlant.gallery || []; - const existingChecks = healthPlant.healthChecks || []; - const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); - const updatedPlant = { - ...healthPlant, - gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery, - healthChecks: updatedChecks, - }; - await updatePlant(updatedPlant); - } else { - const result = await PlantRecognitionService.identify(imageUri, language, { - idempotencyKey: createIdempotencyKey('scan-plant'), - }); - - posthog.capture('llm_generation', { - scan_type: 'identification', - success: true, - latency_ms: Date.now() - startTime, - }); - - 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}`) }, - ]); - } - } catch (error) { - console.error('Analysis failed', error); - - posthog.capture('llm_generation', { - scan_type: isHealthMode ? 'health_check' : 'identification', - success: false, - error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error', - latency_ms: Date.now() - startTime, - }); - - if (isInsufficientCreditsError(error)) { - posthog.capture('out_of_credits_shown', { trigger: 'server_402', scan_type: isHealthMode ? 'health_check' : 'identification' }); - setOutOfCreditsVisible(true); - } else if (isTimeoutError(error)) { - Alert.alert( - billingCopy.timeoutTitle, - billingCopy.timeoutMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else if (isNetworkError(error)) { - Alert.alert( - billingCopy.noConnectionTitle, - billingCopy.noConnectionMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') { - Alert.alert( - billingCopy.notAPlantTitle, - billingCopy.notAPlantMessage, - [{ text: billingCopy.dismiss, style: 'cancel' }], - ); - } else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') { - Alert.alert( - billingCopy.genericErrorTitle, - isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, - [ - { text: billingCopy.dismiss, style: 'cancel' }, - { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, - ], - ); - } else { - Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); - } - setSelectedImage(null); - setIsAnalyzing(false); - } finally { - clearInterval(progressInterval); - setIsAnalyzing(false); - if (!isDemoMode) { - await refreshBillingSummary(); - } - } - }; - - useEffect(() => { - resizeForAnalysisRef.current = resizeForAnalysis; - analyzeImageRef.current = analyzeImage; - }); - - useEffect(() => { - const shareToken = sharedImageKey || sharedImageUri; - if (!shareToken || isLoadingBilling || isAnalyzing) return; - if (lastProcessedShareToken.current === shareToken) return; - if (sharedAnalysisInFlightToken.current) return; - - const handoffImageUri = consumeSharedImageUri(sharedImageKey); - const nextSharedImageUri = handoffImageUri || sharedImageUri; - if (!nextSharedImageUri) return; - - lastProcessedShareToken.current = shareToken; - sharedAnalysisInFlightToken.current = shareToken; - ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); - - let cancelled = false; - (async () => { - try { - const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri); - if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return; - setDemoResultVisible(false); - setSelectedImage(analysisUri); - await analyzeImageRef.current(analysisUri, nextSharedImageUri); - } finally { - if (sharedAnalysisInFlightToken.current === shareToken) { - sharedAnalysisInFlightToken.current = null; - } - } - })(); - - return () => { - cancelled = true; - }; - }, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]); - - 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); - setDemoResultVisible(false); - setSelectedImage(analysisUri); - analyzeImage(analysisUri, photo.uri); - } - }; - - const pickImage = async () => { - if (isAnalyzing) return; - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ['images'], - quality: 1, - base64: false, - }); - 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 () => { - if (analysisResult && selectedImage) { - if (!session) { - // Guest mode: store result and go to signup - setPendingPlant(analysisResult, selectedImage); - router.replace('/auth/signup'); - return; - } - - try { - await savePlant(analysisResult, selectedImage); - if (router.canGoBack()) { - router.back(); - } else { - router.replace('/(tabs)'); - } - } catch (error) { - console.error('Saving identified plant failed', error); - Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); - } - } - }; - - 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(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)'); - } 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 = () => { - if (router.canGoBack()) { - router.back(); - return; - } - router.replace('/onboarding'); - }; - - const controlsPaddingBottom = Math.max(20, insets.bottom + 10); - const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; - const analysisBottomOffset = controlsPanelHeight + 12; - const scanLineTranslateY = scanLineProgress.interpolate({ - inputRange: [0, 1], - outputRange: [24, 280], - }); - const scanPulseScale = scanPulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.98, 1.02], - }); - const scanPulseOpacity = scanPulse.interpolate({ - inputRange: [0, 1], - outputRange: [0.22, 0.55], - }); - - // Show result - if (!isHealthMode && analysisResult && selectedImage) { - return ( - - ); - } - - // Camera permission - if (!permission?.granted) { - return ( - - - Camera access is required to scan plants. - - Continue - - - ); - } - - return ( - - {/* Header */} - - - - - - {isHealthMode ? billingCopy.healthTitle : t.scanner} - - - - - {isDemoMode - ? billingCopy.demoCreditsRemaining(demoScansRemaining) - : !hasActiveEntitlement - ? billingCopy.creditsRemaining(availableCredits) - : `${billingCopy.creditsLabel}: ${availableCredits}`} - - - - - {/* Camera */} - - {selectedImage ? ( - - ) : ( - - )} - - {/* Scan Frame */} - - {selectedImage && ( - - )} - {isAnalyzing && ( - <> - - - - )} - - - - - - - - {/* Analyzing Overlay */} - {isAnalyzing && ( - - - - - - {analysisProgress < 100 ? t.analyzing : t.result} - - - - {Math.round(analysisProgress)}% - - - - - - - - - {t.aiProcessing} - - - {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} - - - - )} - - {demoResultVisible && !isAnalyzing ? ( - - - - - {billingCopy.demoTitle} - {billingCopy.demoMessage} - - {!session && appleAvailable ? ( - - ) : ( - - - {isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta} - - - )} - - {!session ? ( - { - posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' }); - router.replace('/auth/signup'); - }} - activeOpacity={0.85} - > - {billingCopy.emailCta} - - ) : null} - - ) : null} - - {/* Bottom Controls */} - - - - {t.gallery} - - - - - - - - - {t.help} - - - - { - setOutOfCreditsVisible(false); - posthog.capture('paywall_opened', { source: 'out_of_credits' }); - router.push('/profile/billing?view=paywall'); - }} - onTopup={() => { - setOutOfCreditsVisible(false); - router.push('/profile/billing'); // topups live on the billing management screen - }} - onDismiss={() => { - posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' }); - setOutOfCreditsVisible(false); - }} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, - header: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 10, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingTop: 60, - paddingHorizontal: 24, - }, - headerTitle: { fontSize: 18, fontWeight: '600' }, - creditBadge: { - borderWidth: 1, - borderRadius: 14, - paddingHorizontal: 8, - paddingVertical: 4, - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - creditBadgeText: { fontSize: 10, fontWeight: '700' }, - cameraContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - scanFrame: { - width: 256, - height: 320, - borderWidth: 2.5, - borderColor: '#ffffff50', - borderRadius: 28, - overflow: 'hidden', - }, - scanPulseFrame: { - ...StyleSheet.absoluteFillObject, - borderWidth: 1.5, - borderRadius: 28, - }, - scanLine: { - position: 'absolute', - left: 16, - right: 16, - height: 2, - borderRadius: 999, - shadowColor: '#ffffff', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.8, - shadowRadius: 8, - elevation: 6, - }, - corner: { position: 'absolute', width: 24, height: 24 }, - tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 }, - tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 }, - bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 }, - br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 }, - controls: { - borderTopLeftRadius: 28, - borderTopRightRadius: 28, - paddingHorizontal: 32, - paddingTop: 28, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - controlBtn: { alignItems: 'center', gap: 6 }, - controlBtnDisabled: { opacity: 0.5 }, - controlLabel: { fontSize: 11, fontWeight: '500' }, - shutterBtn: { - width: 80, - height: 80, - borderRadius: 40, - borderWidth: 4, - justifyContent: 'center', - alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 8, - }, - shutterInner: { width: 64, height: 64, borderRadius: 32 }, - shutterBtnDisabled: { opacity: 0.6 }, - analysisSheet: { - position: 'absolute', - left: 16, - right: 16, - borderRadius: 20, - borderWidth: 1, - paddingHorizontal: 16, - paddingVertical: 14, - zIndex: 20, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.28, - shadowRadius: 14, - 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', - gap: 6, - borderRadius: 999, - paddingHorizontal: 10, - paddingVertical: 5, - }, - analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 }, - analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' }, - progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 }, - progressFill: { height: '100%', borderRadius: 4 }, - analysisFooter: { gap: 4 }, - analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - statusDot: { width: 8, height: 8, borderRadius: 4 }, - analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, - analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' }, - permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, - permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 }, - permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, - permissionBtnText: { fontWeight: '700', fontSize: 15 }, -}); +import React, { useEffect, useRef, useState } from 'react'; +import { + View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing, +} from 'react-native'; +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 * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; +import { ShareIntentModule } from 'expo-share-intent'; +import { useSafeAnalytics } from '../services/analytics'; +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 { AuthService } from '../services/authService'; +import { getMockPlantByImage } from '../services/backend/mockCatalog'; +import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff'; +import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet'; + +const DEMO_SCAN_LIMIT = 5; + +const getBillingCopy = (language: 'de' | 'en' | 'es') => { + if (language === 'de') { + return { + creditsLabel: 'Credits', + managePlan: 'Plan verwalten', + dismiss: 'Schliessen', + genericErrorTitle: 'Fehler', + genericErrorMessage: 'Analyse fehlgeschlagen.', + noConnectionTitle: 'Keine Verbindung', + noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', + timeoutTitle: 'Scan zu langsam', + timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.', + retryLabel: 'Erneut versuchen', + notAPlantTitle: 'Keine Pflanze erkannt', + 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', + demoTitle: 'Rettungsplan bereit', + demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.', + demoNoCreditsTitle: 'Demo-Scans aufgebraucht', + demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.', + demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`, + creditsRemaining: (count: number) => `${count} Scans übrig`, + appleCta: 'Mit Apple fortfahren', + emailCta: 'Mit E-Mail fortfahren', + unlockCta: 'Vollständige Diagnose freischalten', + }; + } + + if (language === 'es') { + return { + creditsLabel: 'Creditos', + managePlan: 'Gestionar plan', + dismiss: 'Cerrar', + genericErrorTitle: 'Error', + genericErrorMessage: 'Analisis fallido.', + noConnectionTitle: 'Sin conexión', + noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', + timeoutTitle: 'Escaneo lento', + timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.', + retryLabel: 'Reintentar', + notAPlantTitle: 'No es una planta', + 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', + 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.', + demoNoCreditsTitle: 'Escaneos demo agotados', + demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.', + demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`, + creditsRemaining: (count: number) => `${count} escaneos restantes`, + appleCta: 'Continuar con Apple', + emailCta: 'Continuar con email', + unlockCta: 'Desbloquear diagnóstico completo', + }; + } + + return { + creditsLabel: 'Credits', + managePlan: 'Manage plan', + dismiss: 'Close', + genericErrorTitle: 'Error', + genericErrorMessage: 'Analysis failed.', + noConnectionTitle: 'No connection', + noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', + timeoutTitle: 'Scan Too Slow', + timeoutMessage: 'Analysis took too long. Please try again.', + retryLabel: 'Try again', + notAPlantTitle: 'No plant detected', + 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', + demoTitle: 'Rescue plan ready', + demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.', + demoNoCreditsTitle: 'Demo scans used', + demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.', + demoCreditsRemaining: (count: number) => `${count} demo scans left`, + creditsRemaining: (count: number) => `${count} scans left`, + appleCta: 'Continue with Apple', + emailCta: 'Continue with email', + unlockCta: 'Unlock full diagnosis', + }; +}; + +export default function ScannerScreen() { + const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>(); + const posthog = useSafeAnalytics(); + const { + isDarkMode, + colorPalette, + language, + t, + savePlant, + plants, + updatePlant, + billingSummary, + refreshBillingSummary, + isLoadingBilling, + session, + hydrateSession, + setPendingPlant, + guestScanCount, + incrementGuestScanCount, + } = 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 sharedImageUri = Array.isArray(params.sharedImageUri) + ? params.sharedImageUri[0] + : params.sharedImageUri; + const sharedImageKey = Array.isArray(params.sharedImageKey) + ? params.sharedImageKey[0] + : params.sharedImageKey; + const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' + && billingSummary?.entitlement?.status === 'active'; + const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits + const availableCredits = billingSummary?.credits.available ?? 0; + const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); + + const [permission, requestPermission] = useCameraPermissions(); + const [selectedImage, setSelectedImage] = useState(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(null); + const [demoResultVisible, setDemoResultVisible] = useState(false); + const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false); + const cameraRef = useRef(null); + const scanLineProgress = useRef(new Animated.Value(0)).current; + const scanPulse = useRef(new Animated.Value(0)).current; + const isExpoGo = Constants.appOwnership === 'expo'; + + useEffect(() => { + if (isExpoGo) { + setAppleAvailable(false); + return; + } + let mounted = true; + AppleAuthentication.isAvailableAsync() + .then((available) => { + if (mounted) setAppleAvailable(available); + }) + .catch(() => { + if (mounted) setAppleAvailable(false); + }); + return () => { + mounted = false; + }; + }, [isExpoGo]); + + const lastProcessedShareToken = useRef(null); + const sharedAnalysisInFlightToken = useRef(null); + const resizeForAnalysisRef = useRef<(uri: string) => Promise>(async (uri) => uri); + const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise>(async () => {}); + + useEffect(() => { + if (!isAnalyzing) { + scanLineProgress.stopAnimation(); + scanLineProgress.setValue(0); + scanPulse.stopAnimation(); + scanPulse.setValue(0); + return; + } + + const lineAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(scanLineProgress, { + toValue: 1, + duration: 1500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(scanLineProgress, { + toValue: 0, + duration: 1500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]) + ); + + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }), + Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }), + ]) + ); + + lineAnimation.start(); + pulseAnimation.start(); + + return () => { + lineAnimation.stop(); + pulseAnimation.stop(); + }; + }, [isAnalyzing, scanLineProgress, scanPulse]); + + const resizeForAnalysis = async (uri: string): Promise => { + if (uri.startsWith('data:')) return uri; + try { + const result = await ImageManipulator.manipulateAsync( + uri, + [{ resize: { width: 1280 } }], + { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true }, + ); + return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; + } catch { + return uri; + } + }; + + const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { + if (isAnalyzing) return; + + if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) { + Alert.alert( + billingCopy.demoNoCreditsTitle, + billingCopy.demoNoCreditsMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { + text: billingCopy.managePlan, + onPress: () => router.replace('/profile/billing'), + }, + ], + ); + return; + } + + // Only pre-block when the billing summary is actually known; if it's null + // (fetch failed / still loading) let the request proceed — the server 402 + // path opens the sheet as the safety net. + const requiredCredits = isHealthMode ? 2 : 1; + if (!isDemoMode && billingSummary && availableCredits < requiredCredits) { + posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' }); + setOutOfCreditsVisible(true); + return; + } + + setIsAnalyzing(true); + setAnalysisProgress(0); + setAnalysisResult(null); + setDemoResultVisible(false); + + const startTime = Date.now(); + + const progressInterval = setInterval(() => { + setAnalysisProgress((prev) => { + if (prev < 30) return prev + Math.random() * 8; + if (prev < 70) return prev + Math.random() * 2; + if (prev < 90) return prev + 0.5; + return prev; + }); + }, 150); + + try { + if (isDemoMode) { + posthog.capture('demo_scan_started', { + authenticated: Boolean(session), + scan_type: isHealthMode ? 'health_check' : 'identification', + demo_scans_used: guestScanCount, + demo_scans_remaining: demoScansRemaining, + }); + await new Promise(resolve => setTimeout(resolve, 2100)); + setAnalysisProgress(100); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + await new Promise(resolve => setTimeout(resolve, 350)); + const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true); + incrementGuestScanCount(); + setAnalysisResult(demoResult); + posthog.capture('demo_scan_completed', { + authenticated: Boolean(session), + latency_ms: Date.now() - startTime, + demo_scans_used_after: guestScanCount + 1, + }); + 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; + } + + const response = await backendApiClient.runHealthCheck({ + idempotencyKey: createIdempotencyKey('health-check', healthPlant.id), + imageUri, + language, + plantContext: { + name: healthPlant.name, + botanicalName: healthPlant.botanicalName, + careInfo: healthPlant.careInfo, + description: healthPlant.description, + }, + }); + + posthog.capture('llm_generation', { + scan_type: 'health_check', + success: true, + latency_ms: Date.now() - startTime, + }); + + const currentGallery = healthPlant.gallery || []; + const existingChecks = healthPlant.healthChecks || []; + const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); + const updatedPlant = { + ...healthPlant, + gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery, + healthChecks: updatedChecks, + }; + await updatePlant(updatedPlant); + } else { + const result = await PlantRecognitionService.identify(imageUri, language, { + idempotencyKey: createIdempotencyKey('scan-plant'), + }); + + posthog.capture('llm_generation', { + scan_type: 'identification', + success: true, + latency_ms: Date.now() - startTime, + }); + + 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}`) }, + ]); + } + } catch (error) { + console.error('Analysis failed', error); + + posthog.capture('llm_generation', { + scan_type: isHealthMode ? 'health_check' : 'identification', + success: false, + error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error', + latency_ms: Date.now() - startTime, + }); + + if (isInsufficientCreditsError(error)) { + posthog.capture('out_of_credits_shown', { trigger: 'server_402', scan_type: isHealthMode ? 'health_check' : 'identification' }); + setOutOfCreditsVisible(true); + } else if (isTimeoutError(error)) { + Alert.alert( + billingCopy.timeoutTitle, + billingCopy.timeoutMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else if (isNetworkError(error)) { + Alert.alert( + billingCopy.noConnectionTitle, + billingCopy.noConnectionMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') { + Alert.alert( + billingCopy.notAPlantTitle, + billingCopy.notAPlantMessage, + [{ text: billingCopy.dismiss, style: 'cancel' }], + ); + } else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') { + Alert.alert( + billingCopy.genericErrorTitle, + isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, + [ + { text: billingCopy.dismiss, style: 'cancel' }, + { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, + ], + ); + } else { + Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); + } + setSelectedImage(null); + setIsAnalyzing(false); + } finally { + clearInterval(progressInterval); + setIsAnalyzing(false); + if (!isDemoMode) { + await refreshBillingSummary(); + } + } + }; + + useEffect(() => { + resizeForAnalysisRef.current = resizeForAnalysis; + analyzeImageRef.current = analyzeImage; + }); + + useEffect(() => { + const shareToken = sharedImageKey || sharedImageUri; + if (!shareToken || isLoadingBilling || isAnalyzing) return; + if (lastProcessedShareToken.current === shareToken) return; + if (sharedAnalysisInFlightToken.current) return; + + const handoffImageUri = consumeSharedImageUri(sharedImageKey); + const nextSharedImageUri = handoffImageUri || sharedImageUri; + if (!nextSharedImageUri) return; + + lastProcessedShareToken.current = shareToken; + sharedAnalysisInFlightToken.current = shareToken; + ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); + + let cancelled = false; + (async () => { + try { + const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri); + if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return; + setDemoResultVisible(false); + setSelectedImage(analysisUri); + await analyzeImageRef.current(analysisUri, nextSharedImageUri); + } finally { + if (sharedAnalysisInFlightToken.current === shareToken) { + sharedAnalysisInFlightToken.current = null; + } + } + })(); + + return () => { + cancelled = true; + }; + }, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]); + + 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); + setDemoResultVisible(false); + setSelectedImage(analysisUri); + analyzeImage(analysisUri, photo.uri); + } + }; + + const pickImage = async () => { + if (isAnalyzing) return; + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 1, + base64: false, + }); + 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 () => { + if (analysisResult && selectedImage) { + if (!session) { + // Guest mode: store result and go to signup + setPendingPlant(analysisResult, selectedImage); + router.replace('/auth/signup'); + return; + } + + try { + await savePlant(analysisResult, selectedImage); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/(tabs)'); + } + } catch (error) { + console.error('Saving identified plant failed', error); + Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); + } + } + }; + + 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(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)'); + } 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 = () => { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/onboarding'); + }; + + const controlsPaddingBottom = Math.max(20, insets.bottom + 10); + const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; + const analysisBottomOffset = controlsPanelHeight + 12; + const scanLineTranslateY = scanLineProgress.interpolate({ + inputRange: [0, 1], + outputRange: [24, 280], + }); + const scanPulseScale = scanPulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.98, 1.02], + }); + const scanPulseOpacity = scanPulse.interpolate({ + inputRange: [0, 1], + outputRange: [0.22, 0.55], + }); + + // Show result + if (!isHealthMode && analysisResult && selectedImage) { + return ( + + ); + } + + // Camera permission + if (!permission?.granted) { + return ( + + + Camera access is required to scan plants. + + Continue + + + ); + } + + return ( + + {/* Header */} + + + + + + {isHealthMode ? billingCopy.healthTitle : t.scanner} + + + + + {isDemoMode + ? billingCopy.demoCreditsRemaining(demoScansRemaining) + : !hasActiveEntitlement + ? billingCopy.creditsRemaining(availableCredits) + : `${billingCopy.creditsLabel}: ${availableCredits}`} + + + + + {/* Camera */} + + {selectedImage ? ( + + ) : ( + + )} + + {/* Scan Frame */} + + {selectedImage && ( + + )} + {isAnalyzing && ( + <> + + + + )} + + + + + + + + {/* Analyzing Overlay */} + {isAnalyzing && ( + + + + + + {analysisProgress < 100 ? t.analyzing : t.result} + + + + {Math.round(analysisProgress)}% + + + + + + + + + {t.aiProcessing} + + + {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} + + + + )} + + {demoResultVisible && !isAnalyzing ? ( + + + + + {billingCopy.demoTitle} + {billingCopy.demoMessage} + + {!session && appleAvailable ? ( + + ) : ( + + + {isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta} + + + )} + + {!session ? ( + { + posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' }); + router.replace('/auth/signup'); + }} + activeOpacity={0.85} + > + {billingCopy.emailCta} + + ) : null} + + ) : null} + + {/* Bottom Controls */} + + + + {t.gallery} + + + + + + + + + {t.help} + + + + { + setOutOfCreditsVisible(false); + posthog.capture('paywall_opened', { source: 'out_of_credits' }); + router.push('/profile/billing?view=paywall'); + }} + onTopup={() => { + setOutOfCreditsVisible(false); + router.push('/profile/billing'); // topups live on the billing management screen + }} + onDismiss={() => { + posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' }); + setOutOfCreditsVisible(false); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 60, + paddingHorizontal: 24, + }, + headerTitle: { fontSize: 18, fontWeight: '600' }, + creditBadge: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 8, + paddingVertical: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + creditBadgeText: { fontSize: 10, fontWeight: '700' }, + cameraContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scanFrame: { + width: 256, + height: 320, + borderWidth: 2.5, + borderColor: '#ffffff50', + borderRadius: 28, + overflow: 'hidden', + }, + scanPulseFrame: { + ...StyleSheet.absoluteFillObject, + borderWidth: 1.5, + borderRadius: 28, + }, + scanLine: { + position: 'absolute', + left: 16, + right: 16, + height: 2, + borderRadius: 999, + shadowColor: '#ffffff', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 8, + elevation: 6, + }, + corner: { position: 'absolute', width: 24, height: 24 }, + tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 }, + tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 }, + bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 }, + br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 }, + controls: { + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 32, + paddingTop: 28, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + controlBtn: { alignItems: 'center', gap: 6 }, + controlBtnDisabled: { opacity: 0.5 }, + controlLabel: { fontSize: 11, fontWeight: '500' }, + shutterBtn: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 4, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + shutterInner: { width: 64, height: 64, borderRadius: 32 }, + shutterBtnDisabled: { opacity: 0.6 }, + analysisSheet: { + position: 'absolute', + left: 16, + right: 16, + borderRadius: 20, + borderWidth: 1, + paddingHorizontal: 16, + paddingVertical: 14, + zIndex: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.28, + shadowRadius: 14, + 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', + gap: 6, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + }, + analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 }, + analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' }, + progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 }, + progressFill: { height: '100%', borderRadius: 4 }, + analysisFooter: { gap: 4 }, + analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + statusDot: { width: 8, height: 8, borderRadius: 4 }, + analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' }, + permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, + permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 }, + permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, + permissionBtnText: { fontWeight: '700', fontSize: 15 }, +}); diff --git a/components/OnboardingQuestion.tsx b/components/OnboardingQuestion.tsx index 967a747..40a27c1 100644 --- a/components/OnboardingQuestion.tsx +++ b/components/OnboardingQuestion.tsx @@ -1,107 +1,107 @@ -import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; -import { useColors } from '../constants/Colors'; - -type ColorsType = ReturnType; - -export type QuestionOption = { id: string; emoji: string; label: string; subtitle?: string }; - -type Props = { - colors: ColorsType; - isDarkMode: boolean; - step: number; // 1-based - totalSteps: number; - title: string; - subtitle: string; - options: QuestionOption[]; - selectedId: string | null; - onSelect: (id: string) => void; - onContinue: () => void; - onBack?: () => void; - continueLabel: string; - skipLabel?: string; - onSkip?: () => void; -}; - -export function OnboardingQuestion({ - colors, isDarkMode, step, totalSteps, title, subtitle, options, - selectedId, onSelect, onContinue, onBack, continueLabel, skipLabel, onSkip, -}: Props) { - return ( - - - {onBack ? ( - - - - ) : } - - - - - - {title} - {subtitle} - - {options.map((option) => { - const active = selectedId === option.id; - return ( - onSelect(option.id)} - activeOpacity={0.85} - style={[styles.card, { - backgroundColor: active ? colors.primarySoft : colors.surface, - borderColor: active ? colors.primary : 'transparent', - }]} - > - {option.emoji} - - {option.label} - {option.subtitle ? {option.subtitle} : null} - - - ); - })} - - - {skipLabel && onSkip ? ( - - {skipLabel} - - ) : null} - - {continueLabel} - - - - ); -} - -const styles = StyleSheet.create({ - safe: { flex: 1, paddingHorizontal: 22 }, - topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, - backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, - progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, - progressFill: { height: 6, borderRadius: 3 }, - title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 }, - subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 }, - options: { gap: 12, flex: 1 }, - card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 }, - emoji: { fontSize: 26 }, - cardCopy: { flex: 1, gap: 2 }, - cardLabel: { fontSize: 17, fontWeight: '800' }, - cardSubtitle: { fontSize: 12.5, lineHeight: 16 }, - footer: { gap: 8, paddingBottom: 6 }, - skipBtn: { alignItems: 'center', paddingVertical: 6 }, - skipText: { fontSize: 14, fontWeight: '700' }, - cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, - ctaText: { fontSize: 17, fontWeight: '800' }, -}); +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useColors } from '../constants/Colors'; + +type ColorsType = ReturnType; + +export type QuestionOption = { id: string; emoji: string; label: string; subtitle?: string }; + +type Props = { + colors: ColorsType; + isDarkMode: boolean; + step: number; // 1-based + totalSteps: number; + title: string; + subtitle: string; + options: QuestionOption[]; + selectedId: string | null; + onSelect: (id: string) => void; + onContinue: () => void; + onBack?: () => void; + continueLabel: string; + skipLabel?: string; + onSkip?: () => void; +}; + +export function OnboardingQuestion({ + colors, isDarkMode, step, totalSteps, title, subtitle, options, + selectedId, onSelect, onContinue, onBack, continueLabel, skipLabel, onSkip, +}: Props) { + return ( + + + {onBack ? ( + + + + ) : } + + + + + + {title} + {subtitle} + + {options.map((option) => { + const active = selectedId === option.id; + return ( + onSelect(option.id)} + activeOpacity={0.85} + style={[styles.card, { + backgroundColor: active ? colors.primarySoft : colors.surface, + borderColor: active ? colors.primary : 'transparent', + }]} + > + {option.emoji} + + {option.label} + {option.subtitle ? {option.subtitle} : null} + + + ); + })} + + + {skipLabel && onSkip ? ( + + {skipLabel} + + ) : null} + + {continueLabel} + + + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, paddingHorizontal: 22 }, + topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, + backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, + progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, + progressFill: { height: 6, borderRadius: 3 }, + title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 }, + subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 }, + options: { gap: 12, flex: 1 }, + card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 }, + emoji: { fontSize: 26 }, + cardCopy: { flex: 1, gap: 2 }, + cardLabel: { fontSize: 17, fontWeight: '800' }, + cardSubtitle: { fontSize: 12.5, lineHeight: 16 }, + footer: { gap: 8, paddingBottom: 6 }, + skipBtn: { alignItems: 'center', paddingVertical: 6 }, + skipText: { fontSize: 14, fontWeight: '700' }, + cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, + ctaText: { fontSize: 17, fontWeight: '800' }, +}); diff --git a/components/OutOfCreditsSheet.tsx b/components/OutOfCreditsSheet.tsx index 367ee48..c6afb32 100644 --- a/components/OutOfCreditsSheet.tsx +++ b/components/OutOfCreditsSheet.tsx @@ -1,167 +1,167 @@ -import React from 'react'; -import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { Language } from '../types'; -import { useColors } from '../constants/Colors'; - -type ColorsType = ReturnType; - -const getCopy = (language: Language, isPro: boolean) => { - if (language === 'de') { - return { - title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht', - body: (date: string) => (isPro - ? `Deine Credits erneuern sich am ${date}. Kauf Credits nach, um weiterzuscannen.` - : `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`), - bodyNoDate: isPro - ? 'Kauf Credits nach, um weiterzuscannen.' - : 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', - cta: 'Pro-Pläne ansehen', - topupsLabel: 'Oder einzelne Credits kaufen', - later: 'Vielleicht später', - best: 'BESTE WAHL', - credits: 'Credits', - }; - } - if (language === 'es') { - return { - title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis', - body: (date: string) => (isPro - ? `Tus créditos se renuevan el ${date}. Compra créditos para seguir escaneando.` - : `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`), - bodyNoDate: isPro - ? 'Compra créditos para seguir escaneando.' - : 'Pásate a Pro para escanear sin límites.', - cta: 'Ver planes Pro', - topupsLabel: 'O compra créditos sueltos', - later: 'Quizás más tarde', - best: 'MEJOR OPCIÓN', - credits: 'créditos', - }; - } - return { - title: isPro ? "You're out of credits" : "You're out of free scans", - body: (date: string) => (isPro - ? `Your credits renew on ${date}. Top up to keep scanning.` - : `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`), - bodyNoDate: isPro - ? 'Top up to keep scanning.' - : 'Upgrade to Pro for unlimited scanning.', - cta: 'See Pro Plans', - topupsLabel: 'Or buy single credits', - later: 'Maybe later', - best: 'BEST', - credits: 'credits', - }; -}; - -const formatRenewalDate = (iso: string | null | undefined, language: Language): string | null => { - if (!iso) return null; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return null; - const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; - return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); -}; - -type Props = { - visible: boolean; - language: Language; - colors: ColorsType; - isPro?: boolean; - renewsAtIso?: string | null; - onSeePlans: () => void; - onTopup: (productId: 'topup_small' | 'topup_medium' | 'topup_large') => void; - onDismiss: () => void; -}; - -const TOPUPS = [ - { id: 'topup_small' as const, amount: 30, best: false }, - { id: 'topup_medium' as const, amount: 100, best: false }, - { id: 'topup_large' as const, amount: 250, best: true }, -]; - -export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { - const copy = getCopy(language, isPro); - const renewalDate = formatRenewalDate(renewsAtIso, language); - - return ( - - - - - - - - - - - 0 - - - {copy.title} - - {renewalDate ? copy.body(renewalDate) : copy.bodyNoDate} - - {!isPro && ( - - - {copy.cta} - - )} - {copy.topupsLabel.toUpperCase()} - - {TOPUPS.map((topup) => ( - onTopup(topup.id)} - activeOpacity={0.85} - > - {topup.best && ( - - {copy.best} - - )} - +{topup.amount} - {copy.credits} - - ))} - - - {copy.later} - - - - - ); -} - -const styles = StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(10,17,11,0.45)', justifyContent: 'flex-end' }, - backdropTouchable: { flex: 1 }, - sheet: { borderTopLeftRadius: 26, borderTopRightRadius: 26, paddingHorizontal: 24, paddingTop: 10, paddingBottom: 34, alignItems: 'center' }, - handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 }, - iconWrap: { marginBottom: 14 }, - iconCircle: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center' }, - zeroBadge: { position: 'absolute', top: -2, right: -4, width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, - zeroBadgeText: { color: '#fff', fontSize: 13, fontWeight: '900' }, - title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 }, - body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 }, - cta: { alignSelf: 'stretch', height: 56, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 16 }, - ctaText: { fontSize: 17, fontWeight: '800' }, - topupsLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.8, marginBottom: 10 }, - topupRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch', marginBottom: 14 }, - topupChip: { flex: 1, borderWidth: 1.5, borderRadius: 14, paddingVertical: 14, alignItems: 'center', overflow: 'hidden' }, - bestBadge: { position: 'absolute', top: 0, left: 0, right: 0, paddingVertical: 3, alignItems: 'center' }, - bestBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.6 }, - topupAmount: { fontSize: 22, fontWeight: '900', marginTop: 6 }, - topupUnit: { fontSize: 12, fontWeight: '600' }, - laterBtn: { paddingVertical: 8 }, - laterText: { fontSize: 15, fontWeight: '800' }, -}); +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Language } from '../types'; +import { useColors } from '../constants/Colors'; + +type ColorsType = ReturnType; + +const getCopy = (language: Language, isPro: boolean) => { + if (language === 'de') { + return { + title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht', + body: (date: string) => (isPro + ? `Deine Credits erneuern sich am ${date}. Kauf Credits nach, um weiterzuscannen.` + : `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`), + bodyNoDate: isPro + ? 'Kauf Credits nach, um weiterzuscannen.' + : 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', + cta: 'Pro-Pläne ansehen', + topupsLabel: 'Oder einzelne Credits kaufen', + later: 'Vielleicht später', + best: 'BESTE WAHL', + credits: 'Credits', + }; + } + if (language === 'es') { + return { + title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis', + body: (date: string) => (isPro + ? `Tus créditos se renuevan el ${date}. Compra créditos para seguir escaneando.` + : `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`), + bodyNoDate: isPro + ? 'Compra créditos para seguir escaneando.' + : 'Pásate a Pro para escanear sin límites.', + cta: 'Ver planes Pro', + topupsLabel: 'O compra créditos sueltos', + later: 'Quizás más tarde', + best: 'MEJOR OPCIÓN', + credits: 'créditos', + }; + } + return { + title: isPro ? "You're out of credits" : "You're out of free scans", + body: (date: string) => (isPro + ? `Your credits renew on ${date}. Top up to keep scanning.` + : `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`), + bodyNoDate: isPro + ? 'Top up to keep scanning.' + : 'Upgrade to Pro for unlimited scanning.', + cta: 'See Pro Plans', + topupsLabel: 'Or buy single credits', + later: 'Maybe later', + best: 'BEST', + credits: 'credits', + }; +}; + +const formatRenewalDate = (iso: string | null | undefined, language: Language): string | null => { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; + return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); +}; + +type Props = { + visible: boolean; + language: Language; + colors: ColorsType; + isPro?: boolean; + renewsAtIso?: string | null; + onSeePlans: () => void; + onTopup: (productId: 'topup_small' | 'topup_medium' | 'topup_large') => void; + onDismiss: () => void; +}; + +const TOPUPS = [ + { id: 'topup_small' as const, amount: 30, best: false }, + { id: 'topup_medium' as const, amount: 100, best: false }, + { id: 'topup_large' as const, amount: 250, best: true }, +]; + +export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { + const copy = getCopy(language, isPro); + const renewalDate = formatRenewalDate(renewsAtIso, language); + + return ( + + + + + + + + + + + 0 + + + {copy.title} + + {renewalDate ? copy.body(renewalDate) : copy.bodyNoDate} + + {!isPro && ( + + + {copy.cta} + + )} + {copy.topupsLabel.toUpperCase()} + + {TOPUPS.map((topup) => ( + onTopup(topup.id)} + activeOpacity={0.85} + > + {topup.best && ( + + {copy.best} + + )} + +{topup.amount} + {copy.credits} + + ))} + + + {copy.later} + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(10,17,11,0.45)', justifyContent: 'flex-end' }, + backdropTouchable: { flex: 1 }, + sheet: { borderTopLeftRadius: 26, borderTopRightRadius: 26, paddingHorizontal: 24, paddingTop: 10, paddingBottom: 34, alignItems: 'center' }, + handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 }, + iconWrap: { marginBottom: 14 }, + iconCircle: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center' }, + zeroBadge: { position: 'absolute', top: -2, right: -4, width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, + zeroBadgeText: { color: '#fff', fontSize: 13, fontWeight: '900' }, + title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 }, + body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 }, + cta: { alignSelf: 'stretch', height: 56, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 16 }, + ctaText: { fontSize: 17, fontWeight: '800' }, + topupsLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.8, marginBottom: 10 }, + topupRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch', marginBottom: 14 }, + topupChip: { flex: 1, borderWidth: 1.5, borderRadius: 14, paddingVertical: 14, alignItems: 'center', overflow: 'hidden' }, + bestBadge: { position: 'absolute', top: 0, left: 0, right: 0, paddingVertical: 3, alignItems: 'center' }, + bestBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.6 }, + topupAmount: { fontSize: 22, fontWeight: '900', marginTop: 6 }, + topupUnit: { fontSize: 12, fontWeight: '600' }, + laterBtn: { paddingVertical: 8 }, + laterText: { fontSize: 15, fontWeight: '800' }, +}); diff --git a/docs/superpowers/specs/2026-07-06-onboarding-soft-paywall-free-tier-design.md b/docs/superpowers/specs/2026-07-06-onboarding-soft-paywall-free-tier-design.md index 8580cfa..4bfa911 100644 --- a/docs/superpowers/specs/2026-07-06-onboarding-soft-paywall-free-tier-design.md +++ b/docs/superpowers/specs/2026-07-06-onboarding-soft-paywall-free-tier-design.md @@ -1,71 +1,71 @@ -# Onboarding Redesign, Soft Paywall & Free Tier — Design Spec - -**Date:** 2026-07-06 -**Status:** Implemented -**Design reference:** `design/stitch-onboarding/` (Stitch export: 10 screens, light + dark variants, `code.html` per screen, design tokens in `botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark)) - -## Goal - -Replicate the Learna-style onboarding → soft paywall → sign-up flow, remove the hard paywall (client and server), and introduce a free tier with 3 monthly scan credits. Sign-up moves to the *end* of onboarding, after the paywall (pattern credited with +52% sales in the reference video). - -## 1. New user flow - -``` -Welcome (social proof) -→ Benefit slides (3) -→ Personalization questions (existing: source, goal, experience, customize) -→ "Personalizing your care plan…" progress screen -→ Paywall (dismissible ✕) -→ Sign-up (Continue with Apple / Email) -→ App (tabs), free plan with 3 credits/month -``` - -- The guest **demo scan stays** available from the welcome screen (existing `isGuest` server handling unchanged). -- Existing users: "Log in" link on the welcome screen → login. -- Paywall ✕ during onboarding → continues to sign-up. Paywall ✕ in-app → simply closes. - -## 2. Screens - -Each screen follows the Stitch mockup in `design/stitch-onboarding//screen.png` (+ `_dark_mode` variant). `code.html` is the source of truth for spacing/sizes. Design tokens (colors, Plus Jakarta Sans-style extra-bold typography) come from the two `DESIGN.md` files, mapped into the existing `constants/Colors.ts` light/dark system. - -| Screen | Stitch reference | Implementation | Imagery | -|---|---|---|---| -| Welcome | `welcome_to_greenlens` | Redesign `app/onboarding.tsx`: hero photo, rating badge "4.8", testimonial card, "Let's Go" CTA, "Log in" link, legal footer. Keep demo-scan entry. | Bright plant-room photo (new or existing asset) | -| Benefit slide 1 "Scan Any Plant" | `scan_any_plant` | New screen(s), progress dots, Continue | Reuse existing render style (`assets/onboarding_*_mockup.png` language); scan-frame + "Monstera identified · 98%" chip built natively in RN, NOT baked into the photo | -| Benefit slide 2 "Health Check & Care Plan" | `health_check_care_plan` | New screen | Photo + native overlay card (Overwatering detected / 7-day rescue plan ready) | -| Benefit slide 3 "Never Forget Watering" | `never_forget_watering` | New screen | Photo + native reminder chips | -| Personalization questions | `personalization_question` | Restyle existing `app/onboarding/{source,goal,experience,customize}.tsx`: top progress bar + back arrow, white option cards radius 16, selected = green border + tint, Continue button | Emoji/icon per option | -| Personalizing progress | `personalizing_your_plan` | New screen: animated % + progress ring, sequential checkmarks ("Analyzing your answers", "Building your care plan", "Preparing your scan credits", "Finalizing your plan"), testimonial card (filled stars), rating badge. Auto-advances to paywall. | Small round plant photo in ring | -| Paywall | `greenlens_pro_paywall` | Redesign paywall mode of `app/profile/billing.tsx`: ✕ top-left, Restore top-right, "GreenLens Pro / Get Unlimited Access", plan card, **Free Trial toggle** (ON = yearly with 7-day trial → "Due today €0 / Due €39.99"; OFF = monthly, no trial), CTA "Try Free"/"Start Now", Cancel Anytime, Privacy/Terms. Dates dynamic. Credit top-ups remain reachable. | `assets/paywall_scan_background.png` as header | -| Sign-up | `sign_up_for_greenlens` | Restyle `app/auth/signup.tsx`: dark botanical hero with ", Let's finish your setup!" (name from onboarding answers if given, else generic), "Continue with Apple" (exists), OR divider, "Continue with Email" expanding to email/password fields (two-step), "Already have an account? Log in", legal. | `assets/welcome_botanical_hero.png` (replaces Stitch photo with baked-in fake form) | -| Login | `login_to_greenlens` | Restyle `app/auth/login.tsx`: "Welcome back!", Apple button, email/password, Forgot password?, "New here? Create account". | Clean botanical photo (existing assets) | -| Out of credits | `out_of_credits` | New bottom sheet over scanner: leaf icon with "0" badge, "You're out of free scans", renewal date, "See Pro Plans" → paywall, top-up chips (+30/+100/+250, BEST badge on 250), "Maybe later". | — | - -Known mockup fixes (agreed): replace AI-artifact photos (garbled phone UI on scan slide, fake forms baked into sign-up/login backgrounds), filled stars on testimonials, dynamic dates. - -## 3. Free tier (backend, `server/lib/billing.js` + `server/index.js`) - -- `FREE_MONTHLY_CREDITS: 0 → 3`. Existing monthly-allowance reset logic already covers free accounts; verify reset works for plan `free`. -- **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error. -- **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans). -- **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge. -- Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial). - -## 4. Soft paywall (app) - -- `app/_layout.tsx`: remove the `!hasActiveEntitlement → Redirect /profile/billing` block (line ~169). Signed-in users always reach the tabs regardless of plan. -- Scanner: on 402 `INSUFFICIENT_CREDITS` → open the out-of-credits bottom sheet (dismissible) instead of blocking. -- Free users see a credits badge (e.g. "3 scans left") on Home/Scanner so the limit is visible before it bites. -- Existing PostHog events (`trial_started`, purchase events) stay; add events for paywall_shown / paywall_dismissed / onboarding step views. - -## 5. Out of scope - -- Instagram-story auto-advance on benefit slides (manual Continue first) -- Win-back / cancellation flows -- Android Google Sign-In (RevenueCat Android key still placeholder) -- Chat-style onboarding rebuild - -## 6. Open questions - -- Exact free-credit renewal display date source (billing summary already exposes renewal info for pro; confirm shape for free accounts). -- Whether existing signed-in free users (previously hard-walled) need a one-time "you now have 3 free scans" toast. Nice-to-have. +# Onboarding Redesign, Soft Paywall & Free Tier — Design Spec + +**Date:** 2026-07-06 +**Status:** Implemented +**Design reference:** `design/stitch-onboarding/` (Stitch export: 10 screens, light + dark variants, `code.html` per screen, design tokens in `botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark)) + +## Goal + +Replicate the Learna-style onboarding → soft paywall → sign-up flow, remove the hard paywall (client and server), and introduce a free tier with 3 monthly scan credits. Sign-up moves to the *end* of onboarding, after the paywall (pattern credited with +52% sales in the reference video). + +## 1. New user flow + +``` +Welcome (social proof) +→ Benefit slides (3) +→ Personalization questions (existing: source, goal, experience, customize) +→ "Personalizing your care plan…" progress screen +→ Paywall (dismissible ✕) +→ Sign-up (Continue with Apple / Email) +→ App (tabs), free plan with 3 credits/month +``` + +- The guest **demo scan stays** available from the welcome screen (existing `isGuest` server handling unchanged). +- Existing users: "Log in" link on the welcome screen → login. +- Paywall ✕ during onboarding → continues to sign-up. Paywall ✕ in-app → simply closes. + +## 2. Screens + +Each screen follows the Stitch mockup in `design/stitch-onboarding//screen.png` (+ `_dark_mode` variant). `code.html` is the source of truth for spacing/sizes. Design tokens (colors, Plus Jakarta Sans-style extra-bold typography) come from the two `DESIGN.md` files, mapped into the existing `constants/Colors.ts` light/dark system. + +| Screen | Stitch reference | Implementation | Imagery | +|---|---|---|---| +| Welcome | `welcome_to_greenlens` | Redesign `app/onboarding.tsx`: hero photo, rating badge "4.8", testimonial card, "Let's Go" CTA, "Log in" link, legal footer. Keep demo-scan entry. | Bright plant-room photo (new or existing asset) | +| Benefit slide 1 "Scan Any Plant" | `scan_any_plant` | New screen(s), progress dots, Continue | Reuse existing render style (`assets/onboarding_*_mockup.png` language); scan-frame + "Monstera identified · 98%" chip built natively in RN, NOT baked into the photo | +| Benefit slide 2 "Health Check & Care Plan" | `health_check_care_plan` | New screen | Photo + native overlay card (Overwatering detected / 7-day rescue plan ready) | +| Benefit slide 3 "Never Forget Watering" | `never_forget_watering` | New screen | Photo + native reminder chips | +| Personalization questions | `personalization_question` | Restyle existing `app/onboarding/{source,goal,experience,customize}.tsx`: top progress bar + back arrow, white option cards radius 16, selected = green border + tint, Continue button | Emoji/icon per option | +| Personalizing progress | `personalizing_your_plan` | New screen: animated % + progress ring, sequential checkmarks ("Analyzing your answers", "Building your care plan", "Preparing your scan credits", "Finalizing your plan"), testimonial card (filled stars), rating badge. Auto-advances to paywall. | Small round plant photo in ring | +| Paywall | `greenlens_pro_paywall` | Redesign paywall mode of `app/profile/billing.tsx`: ✕ top-left, Restore top-right, "GreenLens Pro / Get Unlimited Access", plan card, **Free Trial toggle** (ON = yearly with 7-day trial → "Due today €0 / Due €39.99"; OFF = monthly, no trial), CTA "Try Free"/"Start Now", Cancel Anytime, Privacy/Terms. Dates dynamic. Credit top-ups remain reachable. | `assets/paywall_scan_background.png` as header | +| Sign-up | `sign_up_for_greenlens` | Restyle `app/auth/signup.tsx`: dark botanical hero with ", Let's finish your setup!" (name from onboarding answers if given, else generic), "Continue with Apple" (exists), OR divider, "Continue with Email" expanding to email/password fields (two-step), "Already have an account? Log in", legal. | `assets/welcome_botanical_hero.png` (replaces Stitch photo with baked-in fake form) | +| Login | `login_to_greenlens` | Restyle `app/auth/login.tsx`: "Welcome back!", Apple button, email/password, Forgot password?, "New here? Create account". | Clean botanical photo (existing assets) | +| Out of credits | `out_of_credits` | New bottom sheet over scanner: leaf icon with "0" badge, "You're out of free scans", renewal date, "See Pro Plans" → paywall, top-up chips (+30/+100/+250, BEST badge on 250), "Maybe later". | — | + +Known mockup fixes (agreed): replace AI-artifact photos (garbled phone UI on scan slide, fake forms baked into sign-up/login backgrounds), filled stars on testimonials, dynamic dates. + +## 3. Free tier (backend, `server/lib/billing.js` + `server/index.js`) + +- `FREE_MONTHLY_CREDITS: 0 → 3`. Existing monthly-allowance reset logic already covers free accounts; verify reset works for plan `free`. +- **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error. +- **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans). +- **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge. +- Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial). + +## 4. Soft paywall (app) + +- `app/_layout.tsx`: remove the `!hasActiveEntitlement → Redirect /profile/billing` block (line ~169). Signed-in users always reach the tabs regardless of plan. +- Scanner: on 402 `INSUFFICIENT_CREDITS` → open the out-of-credits bottom sheet (dismissible) instead of blocking. +- Free users see a credits badge (e.g. "3 scans left") on Home/Scanner so the limit is visible before it bites. +- Existing PostHog events (`trial_started`, purchase events) stay; add events for paywall_shown / paywall_dismissed / onboarding step views. + +## 5. Out of scope + +- Instagram-story auto-advance on benefit slides (manual Continue first) +- Win-back / cancellation flows +- Android Google Sign-In (RevenueCat Android key still placeholder) +- Chat-style onboarding rebuild + +## 6. Open questions + +- Exact free-credit renewal display date source (billing summary already exposes renewal info for pro; confirm shape for free accounts). +- Whether existing signed-in free users (previously hard-walled) need a one-time "you now have 3 free scans" toast. Nice-to-have. diff --git a/package.json b/package.json index acf3672..e1f8e6c 100644 --- a/package.json +++ b/package.json @@ -1,86 +1,86 @@ -{ - "name": "greenlens", - "version": "2.2.9", - "main": "expo-router/entry", - "private": true, - "scripts": { - "start": "expo start --offline", - "android": "expo start --android --offline", - "ios": "expo start --ios --offline", - "web": "expo start --web --offline", - "build:dev": "eas build --profile development --platform android", - "build:preview": "eas build --profile preview --platform android", - "build:prod": "eas build --profile production --platform android", - "postinstall": "patch-package", - "test": "jest", - "audit:semantic": "node scripts/generate_semantic_audit.js" - }, - "jest": { - "preset": "jest-expo", - "transformIgnorePatterns": [ - "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" - ], - "setupFiles": [ - "./jest.setup.js" - ], - "testPathIgnorePatterns": [ - "/server/test/" - ] - }, - "dependencies": { - "@expo/vector-icons": "^15.0.3", - "@google/genai": "^1.38.0", - "@react-native-async-storage/async-storage": "2.2.0", - "expo": "^54.0.33", - "expo-apple-authentication": "~8.0.8", - "expo-application": "~7.0.8", - "expo-asset": "~12.0.12", - "expo-av": "^16.0.8", - "expo-blur": "~15.0.8", - "expo-build-properties": "^55.0.9", - "expo-camera": "~17.0.10", - "expo-constants": "~18.0.13", - "expo-dev-client": "~6.0.20", - "expo-device": "~8.0.10", - "expo-file-system": "~19.0.21", - "expo-font": "~14.0.11", - "expo-haptics": "~15.0.8", - "expo-image-manipulator": "~14.0.8", - "expo-image-picker": "~17.0.10", - "expo-linking": "~8.0.11", - "expo-localization": "~17.0.8", - "expo-notifications": "~0.32.16", - "expo-router": "~6.0.23", - "expo-secure-store": "~15.0.8", - "expo-share-intent": "^5.1.1", - "expo-splash-screen": "~31.0.13", - "expo-sqlite": "~16.0.10", - "expo-status-bar": "~3.0.9", - "expo-updates": "~29.0.16", - "expo-video": "~3.0.16", - "posthog-react-native": "^4.37.1", - "react": "19.1.0", - "react-dom": "19.1.0", - "react-native": "0.81.5", - "react-native-gesture-handler": "~2.28.0", - "react-native-purchases": "^9.10.5", - "react-native-purchases-ui": "^9.10.5", - "react-native-reanimated": "~4.1.1", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-svg": "^15.12.1", - "react-native-web": "^0.21.2", - "react-native-worklets": "0.5.1" - }, - "devDependencies": { - "@babel/core": "^7.25.0", - "@testing-library/jest-native": "^5.4.3", - "@testing-library/react-native": "^13.3.3", - "@types/jest": "^29.5.14", - "@types/react": "~19.1.0", - "jest": "^29.7.0", - "jest-expo": "^54.0.17", - "patch-package": "^8.0.1", - "typescript": "^5.3.0" - } -} +{ + "name": "greenlens", + "version": "2.2.9", + "main": "expo-router/entry", + "private": true, + "scripts": { + "start": "expo start --offline", + "android": "expo start --android --offline", + "ios": "expo start --ios --offline", + "web": "expo start --web --offline", + "build:dev": "eas build --profile development --platform android", + "build:preview": "eas build --profile preview --platform android", + "build:prod": "eas build --profile production --platform android", + "postinstall": "patch-package", + "test": "jest", + "audit:semantic": "node scripts/generate_semantic_audit.js" + }, + "jest": { + "preset": "jest-expo", + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" + ], + "setupFiles": [ + "./jest.setup.js" + ], + "testPathIgnorePatterns": [ + "/server/test/" + ] + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@google/genai": "^1.38.0", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "^54.0.33", + "expo-apple-authentication": "~8.0.8", + "expo-application": "~7.0.8", + "expo-asset": "~12.0.12", + "expo-av": "^16.0.8", + "expo-blur": "~15.0.8", + "expo-build-properties": "^55.0.9", + "expo-camera": "~17.0.10", + "expo-constants": "~18.0.13", + "expo-dev-client": "~6.0.20", + "expo-device": "~8.0.10", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-haptics": "~15.0.8", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "~17.0.10", + "expo-linking": "~8.0.11", + "expo-localization": "~17.0.8", + "expo-notifications": "~0.32.16", + "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", + "expo-share-intent": "^5.1.1", + "expo-splash-screen": "~31.0.13", + "expo-sqlite": "~16.0.10", + "expo-status-bar": "~3.0.9", + "expo-updates": "~29.0.16", + "expo-video": "~3.0.16", + "posthog-react-native": "^4.37.1", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-purchases": "^9.10.5", + "react-native-purchases-ui": "^9.10.5", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "^15.12.1", + "react-native-web": "^0.21.2", + "react-native-worklets": "0.5.1" + }, + "devDependencies": { + "@babel/core": "^7.25.0", + "@testing-library/jest-native": "^5.4.3", + "@testing-library/react-native": "^13.3.3", + "@types/jest": "^29.5.14", + "@types/react": "~19.1.0", + "jest": "^29.7.0", + "jest-expo": "^54.0.17", + "patch-package": "^8.0.1", + "typescript": "^5.3.0" + } +} diff --git a/server/test/billing.test.js b/server/test/billing.test.js index d27f088..fdf4360 100644 --- a/server/test/billing.test.js +++ b/server/test/billing.test.js @@ -1,90 +1,90 @@ -const test = require('node:test'); -const assert = require('node:assert/strict'); -const { - buildDefaultAccount, - alignAccountToCurrentCycle, - getAvailableCredits, - consumeCredits, - ensureSufficientCredits, - getMonthlyAllowanceForPlan, -} = require('../lib/billing'); - -const NOW = new Date('2026-07-06T12:00:00Z'); - -const freeAccount = (overrides = {}) => ({ - ...buildDefaultAccount('user-1', NOW), - ...overrides, -}); - -test('free plan gets 3 monthly credits', () => { - assert.equal(getMonthlyAllowanceForPlan('free'), 3); - assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3); -}); - -test('free account has available credits', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 }); - assert.equal(getAvailableCredits(account), 2); -}); - -test('free account topup balance counts as available', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 }); - assert.equal(getAvailableCredits(account), 10); -}); - -test('consumeCredits charges a free account from the monthly allowance', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 }); - const charged = consumeCredits(account, 1); - assert.equal(charged, 1); - assert.equal(account.usedThisCycle, 1); -}); - -test('consumeCredits throws 402 for an exhausted free account', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 }); - assert.throws(() => consumeCredits(account, 1), (error) => { - assert.equal(error.code, 'INSUFFICIENT_CREDITS'); - assert.equal(error.status, 402); - assert.deepEqual(error.metadata, { required: 1, available: 0 }); - return true; - }); -}); - -test('legacy free account with allowance 0 is migrated to 3', () => { - const account = freeAccount({ monthlyAllowance: 0 }); - const aligned = alignAccountToCurrentCycle(account, NOW); - assert.equal(aligned.monthlyAllowance, 3); -}); - -test('pro and trial allowances are unchanged', () => { - assert.equal(getMonthlyAllowanceForPlan('pro'), 100); - const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 }); - const aligned = alignAccountToCurrentCycle(trial, NOW); - assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed - assert.equal(getAvailableCredits(aligned), 25); -}); - -test('monthly cycle rollover resets free usage', () => { - const account = freeAccount({ - monthlyAllowance: 3, - usedThisCycle: 3, - cycleEndsAt: '2026-07-01T00:00:00.000Z', - }); - const aligned = alignAccountToCurrentCycle(account, NOW); - assert.equal(aligned.usedThisCycle, 0); - assert.equal(aligned.monthlyAllowance, 3); - assert.equal(getAvailableCredits(aligned), 3); -}); - -test('ensureSufficientCredits throws 402 when balance is below cost', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 }); - assert.throws(() => ensureSufficientCredits(account, 2), (error) => { - assert.equal(error.code, 'INSUFFICIENT_CREDITS'); - assert.equal(error.status, 402); - assert.deepEqual(error.metadata, { required: 2, available: 1 }); - return true; - }); -}); - -test('ensureSufficientCredits passes when balance covers cost', () => { - const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 }); - assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); -}); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildDefaultAccount, + alignAccountToCurrentCycle, + getAvailableCredits, + consumeCredits, + ensureSufficientCredits, + getMonthlyAllowanceForPlan, +} = require('../lib/billing'); + +const NOW = new Date('2026-07-06T12:00:00Z'); + +const freeAccount = (overrides = {}) => ({ + ...buildDefaultAccount('user-1', NOW), + ...overrides, +}); + +test('free plan gets 3 monthly credits', () => { + assert.equal(getMonthlyAllowanceForPlan('free'), 3); + assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3); +}); + +test('free account has available credits', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 }); + assert.equal(getAvailableCredits(account), 2); +}); + +test('free account topup balance counts as available', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 }); + assert.equal(getAvailableCredits(account), 10); +}); + +test('consumeCredits charges a free account from the monthly allowance', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 }); + const charged = consumeCredits(account, 1); + assert.equal(charged, 1); + assert.equal(account.usedThisCycle, 1); +}); + +test('consumeCredits throws 402 for an exhausted free account', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 }); + assert.throws(() => consumeCredits(account, 1), (error) => { + assert.equal(error.code, 'INSUFFICIENT_CREDITS'); + assert.equal(error.status, 402); + assert.deepEqual(error.metadata, { required: 1, available: 0 }); + return true; + }); +}); + +test('legacy free account with allowance 0 is migrated to 3', () => { + const account = freeAccount({ monthlyAllowance: 0 }); + const aligned = alignAccountToCurrentCycle(account, NOW); + assert.equal(aligned.monthlyAllowance, 3); +}); + +test('pro and trial allowances are unchanged', () => { + assert.equal(getMonthlyAllowanceForPlan('pro'), 100); + const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 }); + const aligned = alignAccountToCurrentCycle(trial, NOW); + assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed + assert.equal(getAvailableCredits(aligned), 25); +}); + +test('monthly cycle rollover resets free usage', () => { + const account = freeAccount({ + monthlyAllowance: 3, + usedThisCycle: 3, + cycleEndsAt: '2026-07-01T00:00:00.000Z', + }); + const aligned = alignAccountToCurrentCycle(account, NOW); + assert.equal(aligned.usedThisCycle, 0); + assert.equal(aligned.monthlyAllowance, 3); + assert.equal(getAvailableCredits(aligned), 3); +}); + +test('ensureSufficientCredits throws 402 when balance is below cost', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 }); + assert.throws(() => ensureSufficientCredits(account, 2), (error) => { + assert.equal(error.code, 'INSUFFICIENT_CREDITS'); + assert.equal(error.status, 402); + assert.deepEqual(error.metadata, { required: 2, available: 1 }); + return true; + }); +}); + +test('ensureSufficientCredits passes when balance covers cost', () => { + const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 }); + assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); +}); diff --git a/services/preAuthOnboardingService.ts b/services/preAuthOnboardingService.ts index c1b4f5d..b8fa2ea 100644 --- a/services/preAuthOnboardingService.ts +++ b/services/preAuthOnboardingService.ts @@ -1,36 +1,36 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { OnboardingProgressService } from './onboardingProgressService'; - -const STORAGE_KEY = 'greenlens_preauth_onboarding_v1'; - -export type PreAuthAnswers = { - acquisitionSource?: string; - primaryGoal?: string; - experienceLevel?: string; -}; - -export const PreAuthOnboardingService = { - async setAnswer(key: K, value: PreAuthAnswers[K]): Promise { - const answers = await this.getAnswers(); - answers[key] = value; - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers)); - }, - - async getAnswers(): Promise { - try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as PreAuthAnswers) : {}; - } catch { - return {}; - } - }, - - // Persist buffered answers into the per-user profile after sign-up/login. - async flushToProfile(userId: number): Promise { - const answers = await this.getAnswers(); - if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(userId, answers.acquisitionSource); - if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(userId, answers.primaryGoal); - if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(userId, answers.experienceLevel); - await AsyncStorage.removeItem(STORAGE_KEY); - }, -}; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { OnboardingProgressService } from './onboardingProgressService'; + +const STORAGE_KEY = 'greenlens_preauth_onboarding_v1'; + +export type PreAuthAnswers = { + acquisitionSource?: string; + primaryGoal?: string; + experienceLevel?: string; +}; + +export const PreAuthOnboardingService = { + async setAnswer(key: K, value: PreAuthAnswers[K]): Promise { + const answers = await this.getAnswers(); + answers[key] = value; + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers)); + }, + + async getAnswers(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as PreAuthAnswers) : {}; + } catch { + return {}; + } + }, + + // Persist buffered answers into the per-user profile after sign-up/login. + async flushToProfile(userId: number): Promise { + const answers = await this.getAnswers(); + if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(userId, answers.acquisitionSource); + if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(userId, answers.primaryGoal); + if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(userId, answers.experienceLevel); + await AsyncStorage.removeItem(STORAGE_KEY); + }, +};