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,16 +103,16 @@ 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');
}); });
}); });
@@ -178,7 +178,7 @@ describe('StorageService', () => {
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 () => {

View File

@@ -1,39 +1,77 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, ActivityIndicator,
Alert,
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 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 * 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: '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() { export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const screenBackground = isDarkMode const isExpoGo = Constants.appOwnership === 'expo';
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -41,7 +79,6 @@ export default function LoginScreen() {
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => { useEffect(() => {
if (isExpoGo) { if (isExpoGo) {
@@ -61,6 +98,17 @@ export default function LoginScreen() {
}; };
}, [isExpoGo]); }, [isExpoGo]);
const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.login>>) => {
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 () => { const handleLogin = async () => {
if (!email.trim() || !password) { if (!email.trim() || !password) {
setError(t.errFillAllFields); setError(t.errFillAllFields);
@@ -70,22 +118,13 @@ export default function LoginScreen() {
setError(null); setError(null);
try { try {
const session = await AuthService.login(email, password); const session = await AuthService.login(email, password);
const billing = await hydrateSession(session); await finishAuth(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) { } catch (e: any) {
if (e.message === 'USER_NOT_FOUND') { if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); setError(t.errUserNotFound);
} else if (e.message === 'WRONG_PASSWORD') { } else if (e.message === 'WRONG_PASSWORD') {
setError(t.errWrongPassword); setError(t.errWrongPassword);
} 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 { } else {
setError(t.errLoginFailed); setError(t.errLoginFailed);
@@ -121,24 +160,10 @@ export default function LoginScreen() {
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(() => {});
}
if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true');
}
posthog.capture('apple_login_succeeded', { surface: 'login' }); posthog.capture('apple_login_succeeded', { surface: 'login' });
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active'; await finishAuth(session);
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
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: 'login', surface: 'login',
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
@@ -153,59 +178,46 @@ export default function LoginScreen() {
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" />
{/* Logo / 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.welcomeBack}
</Text>
</View> </View>
</ImageBackground>
{/* Card */} <View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
<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}
{/* Email */} <View style={styles.form}>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<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 }]}
@@ -221,14 +233,13 @@ export default function LoginScreen() {
</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.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<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="••••••••" placeholder="Password"
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
@@ -238,56 +249,47 @@ export default function LoginScreen() {
onSubmitEditing={handleLogin} onSubmitEditing={handleLogin}
/> />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View>
{/* Error */} <TouchableOpacity
{error && ( style={styles.forgotBtn}
onPress={() => Alert.alert(copy.forgotTitle, copy.forgotBody)}
activeOpacity={0.78}
>
<Text style={[styles.forgotText, { color: colors.primary }]}>{copy.forgot}</Text>
</TouchableOpacity>
{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}
{/* Login Button */}
<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={handleLogin} onPress={handleLogin}
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.onboardingLogin}</Text> <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.loginCta}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{/* Divider */} <TouchableOpacity style={styles.signupLink} onPress={() => router.replace('/auth/signup')} activeOpacity={0.78}>
<View style={styles.dividerRow}> <Text style={[styles.signupLinkText, { color: colors.textSecondary }]}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> {copy.newHere}{' '}
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> <Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.create}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
{/* Sign Up Link */}
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => router.replace('/auth/signup')}
activeOpacity={0.82}
>
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
{t.noAccountYet}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingRegister}</Text>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
@@ -295,136 +297,89 @@ export default function LoginScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: { flex: 1 }, flex: { flex: 1 },
scroll: { scroll: { flexGrow: 1 },
flexGrow: 1, hero: {
justifyContent: 'center', minHeight: 290,
justifyContent: 'flex-end',
paddingHorizontal: 24, paddingHorizontal: 24,
paddingVertical: 48, paddingTop: 56,
paddingBottom: 32,
}, },
header: { heroImage: { resizeMode: 'cover' },
alignItems: 'center', heroOverlay: {
marginBottom: 32, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 12, 7, 0.48)',
}, },
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: 36,
marginBottom: 16, lineHeight: 41,
fontWeight: '900',
}, },
appName: { heroSubline: {
fontSize: 30, color: 'rgba(255, 255, 255, 0.88)',
fontSize: 16,
lineHeight: 22,
fontWeight: '700', fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
}, },
subtitle: { sheet: {
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, flex: 1,
fontSize: 15, marginTop: -24,
height: 50, borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 22,
paddingTop: 24,
paddingBottom: 34,
gap: 14,
}, },
eyeBtn: { appleButton: { width: '100%', height: 56 },
padding: 4, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
marginLeft: 6, 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: { errorBox: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 7,
borderRadius: 10, borderRadius: 12,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
}, },
errorText: { errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
fontSize: 13,
flex: 1,
},
primaryBtn: { primaryBtn: {
height: 52, height: 56,
borderRadius: 14, borderRadius: 14,
alignItems: 'center',
justifyContent: 'center', 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,
}, },
primaryBtnText: { fontSize: 17, fontWeight: '900' },
signupLink: { alignItems: 'center', paddingTop: 8 },
signupLinkText: { fontSize: 15, fontWeight: '700' },
}); });

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,69 +197,65 @@ 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" </View>
/> </ImageBackground>
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}> <View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
{t.createAccount} {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> </Text>
</View> </View>
) : null}
{/* 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>
)}
{/* 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 ? (
<TouchableOpacity
style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]}
onPress={() => setEmailExpanded(true)}
activeOpacity={0.84}
>
<Ionicons name="mail-outline" size={19} color={colors.primary} />
<Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text>
</TouchableOpacity>
) : (
<View style={styles.form}>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
@@ -244,10 +270,9 @@ export default function SignupScreen() {
</View> </View>
</View> </View>
{/* Email */}
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<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 }]}
@@ -263,10 +288,9 @@ export default function SignupScreen() {
</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.inputBg, borderColor: colors.inputBorder }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<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 }]}
@@ -279,23 +303,18 @@ export default function SignupScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</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.inputBg, backgroundColor: colors.surface,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder, borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
}, },
]}> ]}>
<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} />
@@ -311,78 +330,44 @@ export default function SignupScreen() {
onSubmitEditing={handleSignup} onSubmitEditing={handleSignup}
/> />
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons <Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</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> </TouchableOpacity>
</View> ) : null}
{/* Login link */} <TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')} activeOpacity={0.78}>
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}> <Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '} {copy.already}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text> <Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
</View>
</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": {