feat(app): finish onboarding auth flow

This commit is contained in:
Timo Knuth
2026-07-06 14:32:20 +02:00
parent a1a3367ce4
commit 673ead2e3a
7 changed files with 708 additions and 817 deletions

View File

@@ -30,7 +30,7 @@ describe('server auth account deletion', () => {
)); ));
expect(billingAccountDeletes).toHaveLength(1); expect(billingAccountDeletes).toHaveLength(1);
const signupChecks = get.mock.calls.filter(([, sql], params) => ( const signupChecks = get.mock.calls.filter(([, sql, params]) => (
typeof sql === 'string' typeof sql === 'string'
&& sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)')
&& params?.[0] === email && params?.[0] === email

View File

@@ -17,8 +17,8 @@ describe('server billing timestamp normalization', () => {
userId: 'usr_mnjcdwpo_ax9lf68b', userId: 'usr_mnjcdwpo_ax9lf68b',
plan: 'free', plan: 'free',
provider: 'revenuecat', provider: 'revenuecat',
cycleStartedAt: new Date('2026-04-01T00:00:00.000Z'), cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'),
cycleEndsAt: new Date('2026-05-01T00:00:00.000Z'), cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'),
monthlyAllowance: 15, monthlyAllowance: 15,
usedThisCycle: 0, usedThisCycle: 0,
topupBalance: 0, topupBalance: 0,
@@ -37,8 +37,8 @@ describe('server billing timestamp normalization', () => {
expect(upsertCall).toBeTruthy(); expect(upsertCall).toBeTruthy();
const params = upsertCall[2]; const params = upsertCall[2];
expect(params[3]).toBe('2026-04-01T00:00:00.000Z'); expect(params[3]).toBe('2027-04-01T00:00:00.000Z');
expect(params[4]).toBe('2026-05-01T00:00:00.000Z'); expect(params[4]).toBe('2027-05-01T00:00:00.000Z');
expect(params[3]).not.toContain('Coordinated Universal Time'); expect(params[3]).not.toContain('Coordinated Universal Time');
expect(params[4]).not.toContain('Coordinated Universal Time'); expect(params[4]).not.toContain('Coordinated Universal Time');
}); });

View File

@@ -103,17 +103,17 @@ describe('StorageService', () => {
expect(result).toBe('en'); expect(result).toBe('en');
}); });
it('defaults to de when no language stored', async () => { it('defaults to en when no language stored', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const result = await StorageService.getLanguage(); const result = await StorageService.getLanguage();
expect(result).toBe('de'); expect(result).toBe('en');
}); });
it('defaults to de on error', async () => { it('defaults to en on error', async () => {
(AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('fail')); (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('fail'));
const result = await StorageService.getLanguage(); const result = await StorageService.getLanguage();
expect(result).toBe('de'); expect(result).toBe('en');
}); });
}); });
describe('saveLanguage', () => { describe('saveLanguage', () => {
@@ -175,11 +175,11 @@ describe('StorageService', () => {
expect(result).toBe('Taylor'); expect(result).toBe('Taylor');
}); });
it('falls back to default profile name when empty', async () => { it('falls back to default profile name when empty', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(' '); (AsyncStorage.getItem as jest.Mock).mockResolvedValue(' ');
const result = await StorageService.getProfileName(); const result = await StorageService.getProfileName();
expect(result).toBe('Alex Rivera'); expect(result).toBe('GreenLens User');
}); });
it('stores normalized profile name', async () => { it('stores normalized profile name', async () => {
(AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined);

View File

@@ -1,430 +1,385 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, ActivityIndicator,
Text, Alert,
TextInput, ImageBackground,
TouchableOpacity, KeyboardAvoidingView,
StyleSheet, Platform,
KeyboardAvoidingView, ScrollView,
Platform, StyleSheet,
ActivityIndicator, Text,
ScrollView, TextInput,
Image, TouchableOpacity,
} from 'react-native'; View,
import AsyncStorage from '@react-native-async-storage/async-storage'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons';
import { useApp } from '../../context/AppContext'; import { router } from 'expo-router';
import { useColors } from '../../constants/Colors'; import * as AppleAuthentication from 'expo-apple-authentication';
import { AuthService } from '../../services/authService'; import Constants from 'expo-constants';
import * as AppleAuthentication from 'expo-apple-authentication'; import { useApp } from '../../context/AppContext';
import Constants from 'expo-constants'; import { useColors } from '../../constants/Colors';
import { useSafeAnalytics } from '../../services/analytics'; import { AuthService } from '../../services/authService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
const ONBOARDING_AUTH_BACKGROUND = { import { Language } from '../../types';
light: '#fbfaf3',
dark: '#0a110b', const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
};
const getCopy = (language: Language) => {
export default function LoginScreen() { if (language === 'de') {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); return {
const colors = useColors(isDarkMode, colorPalette); headline: 'Welcome back!',
const posthog = useSafeAnalytics(); subline: 'Melde dich an und mache mit deiner Pflanzenpflege weiter.',
const screenBackground = isDarkMode loginCta: 'Anmelden',
? ONBOARDING_AUTH_BACKGROUND.dark forgot: 'Passwort vergessen?',
: ONBOARDING_AUTH_BACKGROUND.light; forgotTitle: 'Passwort zuruecksetzen',
forgotBody: 'Ein Reset-Link ist noch nicht in der App verfuegbar. Bitte nutze aktuell deine gespeicherten Login-Daten.',
const [email, setEmail] = useState(''); newHere: 'Neu hier?',
const [password, setPassword] = useState(''); create: 'Account erstellen',
const [showPassword, setShowPassword] = useState(false); emailLabel: 'E-Mail',
const [appleAvailable, setAppleAvailable] = useState(false); };
const [loading, setLoading] = useState(false); }
const [error, setError] = useState<string | null>(null); if (language === 'es') {
const isExpoGo = Constants.appOwnership === 'expo'; return {
headline: 'Welcome back!',
useEffect(() => { subline: 'Inicia sesion para continuar con el cuidado de tus plantas.',
if (isExpoGo) { loginCta: 'Iniciar sesion',
setAppleAvailable(false); forgot: 'Olvidaste tu contrasena?',
return; forgotTitle: 'Restablecer contrasena',
} forgotBody: 'El enlace de restablecimiento aun no esta disponible en la app. Usa tus datos guardados por ahora.',
let mounted = true; newHere: 'Nuevo aqui?',
AppleAuthentication.isAvailableAsync() create: 'Crear cuenta',
.then((available) => { emailLabel: 'Email',
if (mounted) setAppleAvailable(available); };
}) }
.catch(() => { return {
if (mounted) setAppleAvailable(false); headline: 'Welcome back!',
}); subline: 'Log in to keep scanning, saving and caring for your plants.',
return () => { loginCta: 'Log in',
mounted = false; forgot: 'Forgot password?',
}; forgotTitle: 'Reset password',
}, [isExpoGo]); forgotBody: 'Password reset is not available in the app yet. Please use your saved login details for now.',
newHere: 'New here?',
const handleLogin = async () => { create: 'Create account',
if (!email.trim() || !password) { emailLabel: 'Email',
setError(t.errFillAllFields); };
return; };
}
setLoading(true); export default function LoginScreen() {
setError(null); const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp();
try { const colors = useColors(isDarkMode, colorPalette);
const session = await AuthService.login(email, password); const copy = getCopy(language);
const billing = await hydrateSession(session); const posthog = useSafeAnalytics();
if (session?.userId) { const isExpoGo = Constants.appOwnership === 'expo';
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
} const [email, setEmail] = useState('');
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; const [password, setPassword] = useState('');
// Non-pro accounts land on the paywall with context instead of being const [showPassword, setShowPassword] = useState(false);
// bounced through the root redirect (which looks like an app restart). const [appleAvailable, setAppleAvailable] = useState(false);
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing'); const [loading, setLoading] = useState(false);
} catch (e: any) { const [error, setError] = useState<string | null>(null);
if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); useEffect(() => {
} else if (e.message === 'WRONG_PASSWORD') { if (isExpoGo) {
setError(t.errWrongPassword); setAppleAvailable(false);
} else if (e.message === 'BACKEND_URL_MISSING') { return;
setError(t.errNetworkError); }
} else if (e.message === 'NETWORK_ERROR') { let mounted = true;
setError(t.errNetworkError); AppleAuthentication.isAvailableAsync()
} else { .then((available) => {
setError(t.errLoginFailed); if (mounted) setAppleAvailable(available);
} })
} finally { .catch(() => {
setLoading(false); if (mounted) setAppleAvailable(false);
} });
}; return () => {
mounted = false;
const handleAppleSignIn = async () => { };
setLoading(true); }, [isExpoGo]);
setError(null);
posthog.capture('apple_login_started', { surface: 'login' }); const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.login>>) => {
try { await hydrateSession(session);
const credential = await AppleAuthentication.signInAsync({ if (session?.userId) {
requestedScopes: [ await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
AppleAuthentication.AppleAuthenticationScope.FULL_NAME, }
AppleAuthentication.AppleAuthenticationScope.EMAIL, if (session.isNewUser) {
], await AsyncStorage.setItem('greenlens_show_tour', 'true');
}); }
router.replace('/(tabs)');
if (!credential.identityToken) { };
throw new Error('APPLE_AUTH_INVALID');
} const handleLogin = async () => {
if (!email.trim() || !password) {
const fullName = [ setError(t.errFillAllFields);
credential.fullName?.givenName, return;
credential.fullName?.familyName, }
].filter(Boolean).join(' '); setLoading(true);
const session = await AuthService.signInWithApple({ setError(null);
identityToken: credential.identityToken, try {
appleUser: credential.user, const session = await AuthService.login(email, password);
email: credential.email, await finishAuth(session);
name: fullName || undefined, } catch (e: any) {
}); if (e.message === 'USER_NOT_FOUND') {
const billing = await hydrateSession(session); setError(t.errUserNotFound);
if (session?.userId) { } else if (e.message === 'WRONG_PASSWORD') {
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); setError(t.errWrongPassword);
} } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') {
if (session.isNewUser) { setError(t.errNetworkError);
await AsyncStorage.setItem('greenlens_show_tour', 'true'); } else {
} setError(t.errLoginFailed);
posthog.capture('apple_login_succeeded', { surface: 'login' }); }
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; } finally {
if (session.isNewUser) { setLoading(false);
router.replace('/onboarding/source'); }
} else { };
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
} const handleAppleSignIn = async () => {
} catch (e: any) { setLoading(true);
if (e?.code === 'ERR_REQUEST_CANCELED') { setError(null);
return; posthog.capture('apple_login_started', { surface: 'login' });
} try {
posthog.capture('apple_login_failed', { const credential = await AppleAuthentication.signInAsync({
surface: 'login', requestedScopes: [
error: e instanceof Error ? e.message : String(e), AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
}); AppleAuthentication.AppleAuthenticationScope.EMAIL,
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE' ],
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.' });
: t.errAuthError);
} finally { if (!credential.identityToken) {
setLoading(false); throw new Error('APPLE_AUTH_INVALID');
} }
};
const fullName = [
return ( credential.fullName?.givenName,
<KeyboardAvoidingView credential.fullName?.familyName,
style={[styles.flex, { backgroundColor: screenBackground }]} ].filter(Boolean).join(' ');
behavior={Platform.OS === 'ios' ? 'padding' : undefined} const session = await AuthService.signInWithApple({
> identityToken: credential.identityToken,
<ScrollView appleUser: credential.user,
contentContainerStyle={styles.scroll} email: credential.email,
keyboardShouldPersistTaps="handled" name: fullName || undefined,
showsVerticalScrollIndicator={false} });
> posthog.capture('apple_login_succeeded', { surface: 'login' });
{/* Logo / Header */} await finishAuth(session);
<View style={styles.header}> } catch (e: any) {
<TouchableOpacity if (e?.code === 'ERR_REQUEST_CANCELED') return;
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]} posthog.capture('apple_login_failed', {
onPress={() => router.back()} surface: 'login',
> error: e instanceof Error ? e.message : String(e),
<Ionicons name="arrow-back" size={20} color={colors.text} /> });
</TouchableOpacity> setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
<Image ? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
source={require('../../assets/icon.png')} : t.errAuthError);
style={styles.logoIcon} } finally {
resizeMode="contain" setLoading(false);
/> }
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text> };
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.welcomeBack} return (
</Text> <KeyboardAvoidingView
</View> style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
{/* Card */} >
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}> <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
{appleAvailable ? ( <ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
<AppleAuthentication.AppleAuthenticationButton <View style={styles.heroOverlay} />
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE} <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
buttonStyle={isDarkMode <Ionicons name="arrow-back" size={20} color="#ffffff" />
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE </TouchableOpacity>
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} <View style={styles.heroCopy}>
cornerRadius={12} <Text style={styles.heroTitle}>{copy.headline}</Text>
style={styles.appleButton} <Text style={styles.heroSubline}>{copy.subline}</Text>
onPress={handleAppleSignIn} </View>
/> </ImageBackground>
) : null}
<View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
{appleAvailable ? ( {appleAvailable ? (
<View style={styles.dividerRowCompact}> <AppleAuthentication.AppleAuthenticationButton
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> buttonStyle={isDarkMode
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
</View> : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
) : null} cornerRadius={14}
style={styles.appleButton}
{/* Email */} onPress={handleAppleSignIn}
<View style={styles.fieldGroup}> />
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text> ) : null}
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> {appleAvailable ? (
<TextInput <View style={styles.dividerRow}>
style={[styles.input, { color: colors.text }]} <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
placeholder={t.emailPlaceholder} <Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
placeholderTextColor={colors.textMuted} <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
value={email} </View>
onChangeText={setEmail} ) : null}
autoCapitalize="none"
keyboardType="email-address" <View style={styles.form}>
autoComplete="email" <View style={styles.fieldGroup}>
returnKeyType="next" <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
/> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
</View> <Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
</View> <TextInput
style={[styles.input, { color: colors.text }]}
{/* Password */} placeholder={t.emailPlaceholder}
<View style={styles.fieldGroup}> placeholderTextColor={colors.textMuted}
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text> value={email}
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> onChangeText={setEmail}
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> autoCapitalize="none"
<TextInput keyboardType="email-address"
style={[styles.input, { color: colors.text }]} autoComplete="email"
placeholder="••••••••" returnKeyType="next"
placeholderTextColor={colors.textMuted} />
value={password} </View>
onChangeText={setPassword} </View>
secureTextEntry={!showPassword}
autoComplete="password" <View style={styles.fieldGroup}>
returnKeyType="done" <Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
onSubmitEditing={handleLogin} <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
/> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <TextInput
<Ionicons style={[styles.input, { color: colors.text }]}
name={showPassword ? 'eye-off-outline' : 'eye-outline'} placeholder="Password"
size={18} placeholderTextColor={colors.textMuted}
color={colors.textMuted} value={password}
/> onChangeText={setPassword}
</TouchableOpacity> secureTextEntry={!showPassword}
</View> autoComplete="password"
</View> returnKeyType="done"
onSubmitEditing={handleLogin}
{/* Error */} />
{error && ( <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}> <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} /> </TouchableOpacity>
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text> </View>
</View> </View>
)} </View>
{/* Login Button */} <TouchableOpacity
<TouchableOpacity style={styles.forgotBtn}
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]} onPress={() => Alert.alert(copy.forgotTitle, copy.forgotBody)}
onPress={handleLogin} activeOpacity={0.78}
activeOpacity={0.82} >
disabled={loading} <Text style={[styles.forgotText, { color: colors.primary }]}>{copy.forgot}</Text>
> </TouchableOpacity>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" /> {error ? (
) : ( <View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingLogin}</Text> <Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
)} <Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
</TouchableOpacity> </View>
</View> ) : null}
{/* Divider */} <TouchableOpacity
<View style={styles.dividerRow}> style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> onPress={handleLogin}
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> activeOpacity={0.84}
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> disabled={loading}
</View> >
{loading ? (
{/* Sign Up Link */} <ActivityIndicator color={colors.onPrimary} size="small" />
<TouchableOpacity ) : (
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]} <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.loginCta}</Text>
onPress={() => router.replace('/auth/signup')} )}
activeOpacity={0.82} </TouchableOpacity>
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}> <TouchableOpacity style={styles.signupLink} onPress={() => router.replace('/auth/signup')} activeOpacity={0.78}>
{t.noAccountYet}{' '} <Text style={[styles.signupLinkText, { color: colors.textSecondary }]}>
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingRegister}</Text> {copy.newHere}{' '}
</Text> <Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.create}</Text>
</TouchableOpacity> </Text>
</ScrollView> </TouchableOpacity>
</KeyboardAvoidingView> </View>
); </ScrollView>
} </KeyboardAvoidingView>
);
const styles = StyleSheet.create({ }
flex: { flex: 1 },
scroll: { const styles = StyleSheet.create({
flexGrow: 1, flex: { flex: 1 },
justifyContent: 'center', scroll: { flexGrow: 1 },
paddingHorizontal: 24, hero: {
paddingVertical: 48, minHeight: 290,
}, justifyContent: 'flex-end',
header: { paddingHorizontal: 24,
alignItems: 'center', paddingTop: 56,
marginBottom: 32, paddingBottom: 32,
}, },
backBtn: { heroImage: { resizeMode: 'cover' },
position: 'absolute', heroOverlay: {
left: 0, ...StyleSheet.absoluteFillObject,
top: 0, backgroundColor: 'rgba(5, 12, 7, 0.48)',
width: 40, },
height: 40, backBtn: {
borderRadius: 20, position: 'absolute',
borderWidth: 1, top: 54,
justifyContent: 'center', left: 22,
alignItems: 'center', width: 42,
}, height: 42,
logoIcon: { borderRadius: 21,
width: 84, alignItems: 'center',
height: 84, justifyContent: 'center',
borderRadius: 20, backgroundColor: 'rgba(0, 0, 0, 0.28)',
marginBottom: 16, },
}, heroCopy: { gap: 10 },
appName: { heroTitle: {
fontSize: 30, color: '#ffffff',
fontWeight: '700', fontSize: 36,
letterSpacing: -0.5, lineHeight: 41,
marginBottom: 6, fontWeight: '900',
}, },
subtitle: { heroSubline: {
fontSize: 15, color: 'rgba(255, 255, 255, 0.88)',
fontWeight: '400', fontSize: 16,
}, lineHeight: 22,
card: { fontWeight: '700',
borderRadius: 20, },
borderWidth: 1, sheet: {
padding: 24, flex: 1,
gap: 16, marginTop: -24,
shadowOffset: { width: 0, height: 4 }, borderTopLeftRadius: 28,
shadowOpacity: 1, borderTopRightRadius: 28,
shadowRadius: 12, paddingHorizontal: 22,
elevation: 4, paddingTop: 24,
}, paddingBottom: 34,
appleButton: { gap: 14,
width: '100%', },
height: 50, appleButton: { width: '100%', height: 56 },
marginBottom: 2, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
}, dividerLine: { flex: 1, height: 1 },
dividerRowCompact: { dividerText: { fontSize: 12, fontWeight: '800' },
flexDirection: 'row', form: { gap: 12 },
alignItems: 'center', fieldGroup: { gap: 6 },
gap: 12, label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
marginVertical: 2, inputRow: {
}, height: 54,
fieldGroup: { borderWidth: 1,
gap: 6, borderRadius: 14,
}, flexDirection: 'row',
label: { alignItems: 'center',
fontSize: 13, paddingHorizontal: 14,
fontWeight: '500', },
marginLeft: 2, inputIcon: { marginRight: 10 },
}, input: { flex: 1, height: 54, fontSize: 15 },
inputRow: { eyeBtn: { padding: 5, marginLeft: 6 },
flexDirection: 'row', forgotBtn: { alignItems: 'flex-end', marginTop: -4 },
alignItems: 'center', forgotText: { fontSize: 14, fontWeight: '800' },
borderWidth: 1, errorBox: {
borderRadius: 12, flexDirection: 'row',
paddingHorizontal: 14, alignItems: 'center',
height: 50, gap: 7,
}, borderRadius: 12,
inputIcon: { paddingHorizontal: 12,
marginRight: 10, paddingVertical: 10,
}, },
input: { errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
flex: 1, primaryBtn: {
fontSize: 15, height: 56,
height: 50, borderRadius: 14,
}, alignItems: 'center',
eyeBtn: { justifyContent: 'center',
padding: 4, },
marginLeft: 6, primaryBtnText: { fontSize: 17, fontWeight: '900' },
}, signupLink: { alignItems: 'center', paddingTop: 8 },
errorBox: { signupLinkText: { fontSize: 15, fontWeight: '700' },
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,
},
});

View File

@@ -1,40 +1,80 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, ActivityIndicator,
ImageBackground,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
StyleSheet, View,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
Image,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService'; 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 { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types';
const ONBOARDING_AUTH_BACKGROUND = { const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
light: '#fbfaf3',
dark: '#0a110b', 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() { 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 colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant(); const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode const isExpoGo = Constants.appOwnership === 'expo';
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -42,10 +82,14 @@ export default function SignupScreen() {
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [emailExpanded, setEmailExpanded] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
posthog.capture('signup_screen_viewed', { context: 'onboarding' });
}, [posthog]);
useEffect(() => { useEffect(() => {
if (isExpoGo) { if (isExpoGo) {
@@ -65,6 +109,15 @@ export default function SignupScreen() {
}; };
}, [isExpoGo]); }, [isExpoGo]);
const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.signUp>>) => {
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 => { const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired; if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid; if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
@@ -77,30 +130,21 @@ export default function SignupScreen() {
const validationError = validate(); const validationError = validate();
if (validationError) { if (validationError) {
setError(validationError); setError(validationError);
setEmailExpanded(true);
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const session = await AuthService.signUp(email, name, password); const session = await AuthService.signUp(email, name, password);
await hydrateSession(session); await finishAuth(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');
} catch (e: any) { } catch (e: any) {
if (e.message === 'EMAIL_TAKEN') { if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken); setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING') { } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError); setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') { } else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError); setError(t.errServerError);
} else if (e.message === 'AUTH_ERROR') {
setError(t.errAuthError);
} else { } else {
setError(t.errAuthError); setError(t.errAuthError);
} }
@@ -135,24 +179,10 @@ export default function SignupScreen() {
email: credential.email, email: credential.email,
name: fullName || undefined, 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' }); posthog.capture('apple_login_succeeded', { surface: 'signup' });
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; await finishAuth(session);
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');
}
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') return;
return;
}
posthog.capture('apple_login_failed', { posthog.capture('apple_login_failed', {
surface: 'signup', surface: 'signup',
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
@@ -167,222 +197,177 @@ export default function SignupScreen() {
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]} style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
contentContainerStyle={styles.scroll} <ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
keyboardShouldPersistTaps="handled" <View style={styles.heroOverlay} />
showsVerticalScrollIndicator={false} <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
> <Ionicons name="arrow-back" size={20} color="#ffffff" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={20} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Image <View style={styles.heroCopy}>
source={require('../../assets/icon.png')} <Text style={styles.heroTitle}>{copy.headline}</Text>
style={styles.logoIcon} <Text style={styles.heroSubline}>{copy.subline}</Text>
resizeMode="contain"
/>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
{t.createAccount}
</Text>
</View>
{/* Pending Plant Hint */}
{pendingPlant && (
<View style={[styles.pendingHint, { backgroundColor: `${colors.primarySoft}40`, borderColor: `${colors.primaryDark}40` }]}>
<Ionicons name="sparkles" size={18} color={colors.primaryDark} />
<Text style={[styles.pendingHintText, { color: colors.primaryDark }]}>
{t.pendingPlantHint.replace('{0}', pendingPlant.result.name)}
</Text>
</View> </View>
)} </ImageBackground>
<View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
{pendingPlant ? (
<View style={[styles.pendingHint, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="sparkles" size={18} color={colors.primary} />
<Text style={[styles.pendingHintText, { color: colors.text }]}>
{copy.savePlantPrefix} {pendingPlant.result.name}
</Text>
</View>
) : null}
{/* Card */}
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
{appleAvailable ? ( {appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE} buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12} cornerRadius={14}
style={styles.appleButton} style={styles.appleButton}
onPress={handleAppleSignIn} onPress={handleAppleSignIn}
/> />
) : null} ) : null}
{appleAvailable ? ( {appleAvailable ? (
<View style={styles.dividerRowCompact}> <View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> <Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View> </View>
) : null} ) : null}
{/* Name */} {!emailExpanded ? (
<View style={styles.fieldGroup}> <TouchableOpacity
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text> style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]}
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> onPress={() => setEmailExpanded(true)}
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> activeOpacity={0.84}
<TextInput >
style={[styles.input, { color: colors.text }]} <Ionicons name="mail-outline" size={19} color={colors.primary} />
placeholder={t.namePlaceholder} <Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text>
placeholderTextColor={colors.textMuted} </TouchableOpacity>
value={name} ) : (
onChangeText={setName} <View style={styles.form}>
autoCapitalize="words" <View style={styles.fieldGroup}>
autoComplete="name" <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
returnKeyType="next" <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
/> <Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
</View> <TextInput
</View> style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{/* Email */} <View style={styles.fieldGroup}>
<View style={styles.fieldGroup}> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <TextInput
<TextInput style={[styles.input, { color: colors.text }]}
style={[styles.input, { color: colors.text }]} placeholder={t.emailPlaceholder}
placeholder={t.emailPlaceholder} placeholderTextColor={colors.textMuted}
placeholderTextColor={colors.textMuted} value={email}
value={email} onChangeText={setEmail}
onChangeText={setEmail} autoCapitalize="none"
autoCapitalize="none" keyboardType="email-address"
keyboardType="email-address" autoComplete="email"
autoComplete="email" returnKeyType="next"
returnKeyType="next" />
/> </View>
</View> </View>
</View>
{/* Password */} <View style={styles.fieldGroup}>
<View style={styles.fieldGroup}> <Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <TextInput
<TextInput style={[styles.input, { color: colors.text }]}
style={[styles.input, { color: colors.text }]} placeholder={t.passwordPlaceholder}
placeholder={t.passwordPlaceholder} placeholderTextColor={colors.textMuted}
placeholderTextColor={colors.textMuted} value={password}
value={password} onChangeText={setPassword}
onChangeText={setPassword} secureTextEntry={!showPassword}
secureTextEntry={!showPassword} autoComplete="new-password"
autoComplete="new-password" returnKeyType="next"
returnKeyType="next" />
/> <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
<Ionicons </TouchableOpacity>
name={showPassword ? 'eye-off-outline' : 'eye-outline'} </View>
size={18} </View>
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */} <View style={styles.fieldGroup}>
<View style={styles.fieldGroup}> <Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text> <View style={[
<View style={[ styles.inputRow,
styles.inputRow, {
{ backgroundColor: colors.surface,
backgroundColor: colors.inputBg, borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder, },
}, ]}>
]}> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <TextInput
<TextInput style={[styles.input, { color: colors.text }]}
style={[styles.input, { color: colors.text }]} placeholder={t.confirmPasswordPlaceholder}
placeholder={t.confirmPasswordPlaceholder} placeholderTextColor={colors.textMuted}
placeholderTextColor={colors.textMuted} value={passwordConfirm}
value={passwordConfirm} onChangeText={setPasswordConfirm}
onChangeText={setPasswordConfirm} secureTextEntry={!showPasswordConfirm}
secureTextEntry={!showPasswordConfirm} autoComplete="new-password"
autoComplete="new-password" returnKeyType="done"
returnKeyType="done" onSubmitEditing={handleSignup}
onSubmitEditing={handleSignup} />
/> <TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> <Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
<Ionicons </TouchableOpacity>
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} </View>
size={18} </View>
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
{/* Password strength hint */}
{password.length > 0 && (
<View style={styles.strengthRow}>
{[1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.strengthBar,
{
backgroundColor:
password.length >= level * 3
? level <= 1
? colors.danger
: level === 2
? colors.warning
: colors.success
: colors.border,
},
]}
/>
))}
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
{password.length < 4
? t.strengthTooShort
: password.length < 7
? t.strengthWeak
: password.length < 10
? t.strengthMedium
: t.strengthStrong}
</Text>
</View> </View>
)} )}
{/* Error */} {error ? (
{error && (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}> <View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} /> <Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text> <Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
</View> </View>
)} ) : null}
{/* Signup Button */} {emailExpanded ? (
<TouchableOpacity <TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]} style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
onPress={handleSignup} onPress={handleSignup}
activeOpacity={0.82} activeOpacity={0.84}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" /> <ActivityIndicator color={colors.onPrimary} size="small" />
) : ( ) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingRegister}</Text> <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.createCta}</Text>
)} )}
</TouchableOpacity>
) : null}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')} activeOpacity={0.78}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{copy.already}{' '}
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text>
</Text>
</TouchableOpacity> </TouchableOpacity>
</View>
{/* Login link */} <Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}> </View>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text>
</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
@@ -390,160 +375,108 @@ export default function SignupScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: { flex: 1 }, flex: { flex: 1 },
scroll: { scroll: { flexGrow: 1 },
flexGrow: 1, hero: {
justifyContent: 'center', minHeight: 330,
justifyContent: 'flex-end',
paddingHorizontal: 24, paddingHorizontal: 24,
paddingVertical: 48, paddingTop: 56,
paddingBottom: 34,
}, },
header: { heroImage: { resizeMode: 'cover' },
alignItems: 'center', heroOverlay: {
marginBottom: 32, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 12, 7, 0.46)',
}, },
backBtn: { backBtn: {
position: 'absolute', position: 'absolute',
left: 0, top: 54,
top: 0, left: 22,
width: 40, width: 42,
height: 40, height: 42,
borderRadius: 20, borderRadius: 21,
borderWidth: 1, alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.28)',
}, },
logoIcon: { heroCopy: { gap: 10 },
width: 84, heroTitle: {
height: 84, color: '#ffffff',
borderRadius: 20, fontSize: 34,
marginBottom: 16, lineHeight: 39,
fontWeight: '900',
}, },
appName: { heroSubline: {
fontSize: 30, color: 'rgba(255, 255, 255, 0.88)',
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: {
fontSize: 16, fontSize: 16,
fontWeight: '600', lineHeight: 22,
fontWeight: '700',
}, },
loginLink: { sheet: {
alignItems: 'center', flex: 1,
marginTop: 24, marginTop: -24,
paddingVertical: 8, borderTopLeftRadius: 28,
}, borderTopRightRadius: 28,
loginLinkText: { paddingHorizontal: 22,
fontSize: 15, paddingTop: 24,
paddingBottom: 32,
gap: 14,
}, },
pendingHint: { pendingHint: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
padding: 16, gap: 10,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
marginBottom: 20, paddingHorizontal: 14,
gap: 12, paddingVertical: 12,
}, },
pendingHintText: { pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
flex: 1, appleButton: { width: '100%', height: 56 },
fontSize: 13, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
fontWeight: '600', dividerLine: { flex: 1, height: 1 },
lineHeight: 18, 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 },
}); });

View File

@@ -1,7 +1,7 @@
# Onboarding Redesign, Soft Paywall & Free Tier — Design Spec # Onboarding Redesign, Soft Paywall & Free Tier — Design Spec
**Date:** 2026-07-06 **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)) **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 ## Goal

View File

@@ -22,6 +22,9 @@
], ],
"setupFiles": [ "setupFiles": [
"./jest.setup.js" "./jest.setup.js"
],
"testPathIgnorePatterns": [
"<rootDir>/server/test/"
] ]
}, },
"dependencies": { "dependencies": {