diff --git a/__tests__/server/authAccountDeletion.test.js b/__tests__/server/authAccountDeletion.test.js index f415257..1ac31c7 100644 --- a/__tests__/server/authAccountDeletion.test.js +++ b/__tests__/server/authAccountDeletion.test.js @@ -30,7 +30,7 @@ describe('server auth account deletion', () => { )); expect(billingAccountDeletes).toHaveLength(1); - const signupChecks = get.mock.calls.filter(([, sql], params) => ( + const signupChecks = get.mock.calls.filter(([, sql, params]) => ( typeof sql === 'string' && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') && params?.[0] === email diff --git a/__tests__/server/billingTimestampNormalization.test.js b/__tests__/server/billingTimestampNormalization.test.js index 28d0d69..9045ce9 100644 --- a/__tests__/server/billingTimestampNormalization.test.js +++ b/__tests__/server/billingTimestampNormalization.test.js @@ -17,8 +17,8 @@ describe('server billing timestamp normalization', () => { userId: 'usr_mnjcdwpo_ax9lf68b', plan: 'free', provider: 'revenuecat', - cycleStartedAt: new Date('2026-04-01T00:00:00.000Z'), - cycleEndsAt: new Date('2026-05-01T00:00:00.000Z'), + cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'), + cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'), monthlyAllowance: 15, usedThisCycle: 0, topupBalance: 0, @@ -37,8 +37,8 @@ describe('server billing timestamp normalization', () => { expect(upsertCall).toBeTruthy(); const params = upsertCall[2]; - expect(params[3]).toBe('2026-04-01T00:00:00.000Z'); - expect(params[4]).toBe('2026-05-01T00:00:00.000Z'); + 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/__tests__/services/storageService.test.ts b/__tests__/services/storageService.test.ts index 268a07d..1c24a66 100644 --- a/__tests__/services/storageService.test.ts +++ b/__tests__/services/storageService.test.ts @@ -103,17 +103,17 @@ describe('StorageService', () => { expect(result).toBe('en'); }); - it('defaults to de when no language stored', async () => { - (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); - const result = await StorageService.getLanguage(); - expect(result).toBe('de'); - }); - - it('defaults to de on error', async () => { - (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('fail')); - const result = await StorageService.getLanguage(); - expect(result).toBe('de'); - }); + it('defaults to en when no language stored', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + const result = await StorageService.getLanguage(); + expect(result).toBe('en'); + }); + + it('defaults to en on error', async () => { + (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('fail')); + const result = await StorageService.getLanguage(); + expect(result).toBe('en'); + }); }); describe('saveLanguage', () => { @@ -175,11 +175,11 @@ describe('StorageService', () => { expect(result).toBe('Taylor'); }); - it('falls back to default profile name when empty', async () => { - (AsyncStorage.getItem as jest.Mock).mockResolvedValue(' '); - const result = await StorageService.getProfileName(); - expect(result).toBe('Alex Rivera'); - }); + it('falls back to default profile name when empty', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(' '); + const result = await StorageService.getProfileName(); + expect(result).toBe('GreenLens User'); + }); it('stores normalized profile name', async () => { (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 9d47ffa..96a152c 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,430 +1,385 @@ -import React, { useEffect, useState } from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - StyleSheet, - KeyboardAvoidingView, - Platform, - ActivityIndicator, - ScrollView, - Image, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; -import { useApp } from '../../context/AppContext'; -import { useColors } from '../../constants/Colors'; -import { AuthService } from '../../services/authService'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import Constants from 'expo-constants'; -import { useSafeAnalytics } from '../../services/analytics'; -import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; - -const ONBOARDING_AUTH_BACKGROUND = { - light: '#fbfaf3', - dark: '#0a110b', -}; - -export default function LoginScreen() { - const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); - const colors = useColors(isDarkMode, colorPalette); - const posthog = useSafeAnalytics(); - const screenBackground = isDarkMode - ? ONBOARDING_AUTH_BACKGROUND.dark - : ONBOARDING_AUTH_BACKGROUND.light; - - 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); - 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 handleLogin = async () => { - if (!email.trim() || !password) { - setError(t.errFillAllFields); - return; - } - setLoading(true); - setError(null); - try { - const session = await AuthService.login(email, password); - const billing = await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; - // Non-pro accounts land on the paywall with context instead of being - // bounced through the root redirect (which looks like an app restart). - router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing'); - } 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') { - setError(t.errNetworkError); - } else if (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, - }); - const billing = await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - if (session.isNewUser) { - await AsyncStorage.setItem('greenlens_show_tour', 'true'); - } - posthog.capture('apple_login_succeeded', { surface: 'login' }); - const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; - if (session.isNewUser) { - router.replace('/onboarding/source'); - } else { - router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing'); - } - } catch (e: any) { - if (e?.code === 'ERR_REQUEST_CANCELED') { - return; - } - posthog.capture('apple_login_failed', { - surface: 'login', - error: e instanceof Error ? e.message : String(e), - }); - setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE' - ? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.' - : t.errAuthError); - } finally { - setLoading(false); - } - }; - - return ( - - - {/* Logo / Header */} - - router.back()} - > - - - - GreenLens - - {t.welcomeBack} - - - - {/* Card */} - - {appleAvailable ? ( - - ) : null} - - {appleAvailable ? ( - - - {t.orDivider} - - - ) : null} - - {/* Email */} - - E-Mail - - - - - - - {/* Password */} - - {t.passwordLabel} - - - - setShowPassword((v) => !v)} style={styles.eyeBtn}> - - - - - - {/* Error */} - {error && ( - - - {error} - - )} - - {/* Login Button */} - - {loading ? ( - - ) : ( - {t.onboardingLogin} - )} - - - - {/* Divider */} - - - {t.orDivider} - - - - {/* Sign Up Link */} - router.replace('/auth/signup')} - activeOpacity={0.82} - > - - {t.noAccountYet}{' '} - {t.onboardingRegister} - - - - - ); -} - -const styles = StyleSheet.create({ - flex: { flex: 1 }, - scroll: { - flexGrow: 1, - justifyContent: 'center', - paddingHorizontal: 24, - paddingVertical: 48, - }, - header: { - alignItems: 'center', - marginBottom: 32, - }, - backBtn: { - position: 'absolute', - left: 0, - top: 0, - width: 40, - height: 40, - borderRadius: 20, - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - }, - logoIcon: { - width: 84, - height: 84, - borderRadius: 20, - marginBottom: 16, - }, - appName: { - fontSize: 30, - fontWeight: '700', - letterSpacing: -0.5, - marginBottom: 6, - }, - subtitle: { - fontSize: 15, - fontWeight: '400', - }, - card: { - borderRadius: 20, - borderWidth: 1, - padding: 24, - gap: 16, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 1, - shadowRadius: 12, - elevation: 4, - }, - appleButton: { - width: '100%', - height: 50, - marginBottom: 2, - }, - dividerRowCompact: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - marginVertical: 2, - }, - fieldGroup: { - gap: 6, - }, - label: { - fontSize: 13, - fontWeight: '500', - marginLeft: 2, - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 14, - height: 50, - }, - inputIcon: { - marginRight: 10, - }, - input: { - flex: 1, - fontSize: 15, - height: 50, - }, - eyeBtn: { - padding: 4, - marginLeft: 6, - }, - errorBox: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 10, - }, - errorText: { - fontSize: 13, - flex: 1, - }, - primaryBtn: { - height: 52, - borderRadius: 14, - justifyContent: 'center', - alignItems: 'center', - marginTop: 4, - }, - primaryBtnText: { - fontSize: 16, - fontWeight: '600', - }, - dividerRow: { - flexDirection: 'row', - alignItems: 'center', - marginVertical: 20, - gap: 12, - }, - dividerLine: { - flex: 1, - height: 1, - }, - dividerText: { - fontSize: 13, - }, - secondaryBtn: { - height: 52, - borderRadius: 14, - borderWidth: 1, - justifyContent: 'center', - alignItems: 'center', - }, - secondaryBtnText: { - fontSize: 15, - }, -}); +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 4ff41fb..bcfaad0 100644 --- a/app/auth/signup.tsx +++ b/app/auth/signup.tsx @@ -1,40 +1,80 @@ import React, { useEffect, useState } from 'react'; import { - View, + ActivityIndicator, + ImageBackground, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, Text, TextInput, TouchableOpacity, - StyleSheet, - KeyboardAvoidingView, - Platform, - ActivityIndicator, - ScrollView, - Image, + 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 AsyncStorage from '@react-native-async-storage/async-storage'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import Constants from 'expo-constants'; import { useSafeAnalytics } from '../../services/analytics'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; +import { Language } from '../../types'; -const ONBOARDING_AUTH_BACKGROUND = { - light: '#fbfaf3', - dark: '#0a110b', +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, t } = useApp(); + const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp(); const colors = useColors(isDarkMode, colorPalette); + const copy = getCopy(language); const posthog = useSafeAnalytics(); const pendingPlant = getPendingPlant(); - const screenBackground = isDarkMode - ? ONBOARDING_AUTH_BACKGROUND.dark - : ONBOARDING_AUTH_BACKGROUND.light; + const isExpoGo = Constants.appOwnership === 'expo'; const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -42,10 +82,14 @@ export default function SignupScreen() { 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); - const isExpoGo = Constants.appOwnership === 'expo'; + + useEffect(() => { + posthog.capture('signup_screen_viewed', { context: 'onboarding' }); + }, [posthog]); useEffect(() => { if (isExpoGo) { @@ -65,6 +109,15 @@ export default function SignupScreen() { }; }, [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; @@ -77,30 +130,21 @@ export default function SignupScreen() { const validationError = validate(); if (validationError) { setError(validationError); + setEmailExpanded(true); return; } setLoading(true); setError(null); try { const session = await AuthService.signUp(email, name, password); - await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - // Flag setzen: Tour beim nächsten App-Öffnen anzeigen - await AsyncStorage.setItem('greenlens_show_tour', 'true'); - router.replace('/onboarding/source'); + await finishAuth(session); } catch (e: any) { if (e.message === 'EMAIL_TAKEN') { setError(t.errEmailTaken); - } else if (e.message === 'BACKEND_URL_MISSING') { - setError(t.errNetworkError); - } else if (e.message === 'NETWORK_ERROR') { + } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { setError(t.errNetworkError); } else if (e.message === 'SERVER_ERROR') { setError(t.errServerError); - } else if (e.message === 'AUTH_ERROR') { - setError(t.errAuthError); } else { setError(t.errAuthError); } @@ -135,24 +179,10 @@ export default function SignupScreen() { email: credential.email, name: fullName || undefined, }); - const billing = await hydrateSession(session); - if (session?.userId) { - await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); - } - await AsyncStorage.setItem('greenlens_show_tour', 'true'); posthog.capture('apple_login_succeeded', { surface: 'signup' }); - const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; - if (session.isNewUser) { - router.replace('/onboarding/source'); - } else { - // Same routing as login: existing non-pro accounts go to the paywall - // directly instead of bouncing through the root entitlement redirect. - router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing'); - } + await finishAuth(session); } catch (e: any) { - if (e?.code === 'ERR_REQUEST_CANCELED') { - return; - } + if (e?.code === 'ERR_REQUEST_CANCELED') return; posthog.capture('apple_login_failed', { surface: 'signup', error: e instanceof Error ? e.message : String(e), @@ -167,222 +197,177 @@ export default function SignupScreen() { return ( - - {/* Header */} - - router.back()} - > - + + + + router.back()} activeOpacity={0.8}> + - - GreenLens - - {t.createAccount} - - - - {/* Pending Plant Hint */} - {pendingPlant && ( - - - - {t.pendingPlantHint.replace('{0}', pendingPlant.result.name)} - + + {copy.headline} + {copy.subline} - )} + + + + {pendingPlant ? ( + + + + {copy.savePlantPrefix} {pendingPlant.result.name} + + + ) : null} - {/* Card */} - {appleAvailable ? ( ) : null} {appleAvailable ? ( - + {t.orDivider} ) : null} - {/* Name */} - - Name - - - - - + {!emailExpanded ? ( + setEmailExpanded(true)} + activeOpacity={0.84} + > + + {copy.emailCta} + + ) : ( + + + {copy.nameLabel} + + + + + - {/* Email */} - - E-Mail - - - - - + + {copy.emailLabel} + + + + + - {/* Password */} - - {t.passwordLabel} - - - - setShowPassword((v) => !v)} style={styles.eyeBtn}> - - - - + + {t.passwordLabel} + + + + setShowPassword((v) => !v)} style={styles.eyeBtn}> + + + + - {/* Password Confirm */} - - {t.confirmPasswordLabel} - - - - setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> - - - - - - {/* Password strength hint */} - {password.length > 0 && ( - - {[1, 2, 3, 4].map((level) => ( - = level * 3 - ? level <= 1 - ? colors.danger - : level === 2 - ? colors.warning - : colors.success - : colors.border, - }, - ]} - /> - ))} - - {password.length < 4 - ? t.strengthTooShort - : password.length < 7 - ? t.strengthWeak - : password.length < 10 - ? t.strengthMedium - : t.strengthStrong} - + + {t.confirmPasswordLabel} + + + + setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> + + + + )} - {/* Error */} - {error && ( + {error ? ( - {error} + {error} - )} + ) : null} - {/* Signup Button */} - - {loading ? ( - - ) : ( - {t.onboardingRegister} - )} + {emailExpanded ? ( + + {loading ? ( + + ) : ( + {copy.createCta} + )} + + ) : null} + + router.replace('/auth/login')} activeOpacity={0.78}> + + {copy.already}{' '} + {copy.login} + - - {/* Login link */} - router.replace('/auth/login')}> - - {t.alreadyHaveAccount}{' '} - {t.onboardingLogin} - - + {copy.legal} + ); @@ -390,160 +375,108 @@ export default function SignupScreen() { const styles = StyleSheet.create({ flex: { flex: 1 }, - scroll: { - flexGrow: 1, - justifyContent: 'center', + scroll: { flexGrow: 1 }, + hero: { + minHeight: 330, + justifyContent: 'flex-end', paddingHorizontal: 24, - paddingVertical: 48, + paddingTop: 56, + paddingBottom: 34, }, - header: { - alignItems: 'center', - marginBottom: 32, + heroImage: { resizeMode: 'cover' }, + heroOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(5, 12, 7, 0.46)', }, backBtn: { position: 'absolute', - left: 0, - top: 0, - width: 40, - height: 40, - borderRadius: 20, - borderWidth: 1, + top: 54, + left: 22, + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', justifyContent: 'center', - alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.28)', }, - logoIcon: { - width: 84, - height: 84, - borderRadius: 20, - marginBottom: 16, + heroCopy: { gap: 10 }, + heroTitle: { + color: '#ffffff', + fontSize: 34, + lineHeight: 39, + fontWeight: '900', }, - appName: { - fontSize: 30, - fontWeight: '700', - letterSpacing: -0.5, - marginBottom: 6, - }, - subtitle: { - fontSize: 15, - fontWeight: '400', - }, - card: { - borderRadius: 20, - borderWidth: 1, - padding: 24, - gap: 14, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 1, - shadowRadius: 12, - elevation: 4, - }, - appleButton: { - width: '100%', - height: 50, - marginBottom: 2, - }, - dividerRowCompact: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - marginVertical: 2, - }, - dividerLine: { - flex: 1, - height: 1, - }, - dividerText: { - fontSize: 12, - fontWeight: '500', - }, - fieldGroup: { - gap: 6, - }, - label: { - fontSize: 13, - fontWeight: '500', - marginLeft: 2, - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 14, - height: 50, - }, - inputIcon: { - marginRight: 10, - }, - input: { - flex: 1, - fontSize: 15, - height: 50, - }, - eyeBtn: { - padding: 4, - marginLeft: 6, - }, - strengthRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - marginTop: -4, - }, - strengthBar: { - flex: 1, - height: 3, - borderRadius: 2, - }, - strengthText: { - fontSize: 11, - marginLeft: 4, - width: 40, - }, - errorBox: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - borderRadius: 10, - paddingHorizontal: 12, - paddingVertical: 10, - }, - errorText: { - fontSize: 13, - flex: 1, - }, - primaryBtn: { - height: 52, - borderRadius: 14, - justifyContent: 'center', - alignItems: 'center', - marginTop: 4, - }, - primaryBtnText: { + heroSubline: { + color: 'rgba(255, 255, 255, 0.88)', fontSize: 16, - fontWeight: '600', + lineHeight: 22, + fontWeight: '700', }, - loginLink: { - alignItems: 'center', - marginTop: 24, - paddingVertical: 8, - }, - loginLinkText: { - fontSize: 15, + sheet: { + flex: 1, + marginTop: -24, + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 22, + paddingTop: 24, + paddingBottom: 32, + gap: 14, }, pendingHint: { flexDirection: 'row', alignItems: 'center', - padding: 16, + gap: 10, borderRadius: 16, borderWidth: 1, - marginBottom: 20, - gap: 12, + paddingHorizontal: 14, + paddingVertical: 12, }, - pendingHintText: { - flex: 1, - fontSize: 13, - fontWeight: '600', - lineHeight: 18, + 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/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 aeb7adf..8580cfa 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,7 +1,7 @@ # Onboarding Redesign, Soft Paywall & Free Tier — Design Spec **Date:** 2026-07-06 -**Status:** Approved design basis (Stitch export approved by Timo) +**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 diff --git a/package.json b/package.json index 26ee346..acf3672 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ ], "setupFiles": [ "./jest.setup.js" + ], + "testPathIgnorePatterns": [ + "/server/test/" ] }, "dependencies": {