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);
const signupChecks = get.mock.calls.filter(([, sql], params) => (
const signupChecks = get.mock.calls.filter(([, sql, params]) => (
typeof sql === 'string'
&& sql.includes('SELECT id FROM auth_users WHERE LOWER(email)')
&& params?.[0] === email

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,80 @@
import React, { useEffect, useState } from 'react';
import {
View,
ActivityIndicator,
ImageBackground,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
Image,
View,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants';
import { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types';
const ONBOARDING_AUTH_BACKGROUND = {
light: '#fbfaf3',
dark: '#0a110b',
const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
const getCopy = (language: Language) => {
if (language === 'de') {
return {
headline: "Let's finish your setup!",
subline: 'Erstelle einen Account, speichere deine Pflanzen und sichere dir 3 Gratis-Scans pro Monat.',
emailCta: 'Mit E-Mail fortfahren',
createCta: 'Account erstellen',
already: 'Schon einen Account?',
login: 'Anmelden',
legal: 'Mit dem Fortfahren akzeptierst du Datenschutz und Nutzungsbedingungen.',
nameLabel: 'Name',
emailLabel: 'E-Mail',
savePlantPrefix: 'Dein Scan wird nach der Registrierung gespeichert:',
};
}
if (language === 'es') {
return {
headline: "Let's finish your setup!",
subline: 'Crea una cuenta para guardar tus plantas y recibir 3 escaneos gratis al mes.',
emailCta: 'Continuar con email',
createCta: 'Crear cuenta',
already: 'Ya tienes cuenta?',
login: 'Iniciar sesion',
legal: 'Al continuar aceptas la Politica de privacidad y los Terminos.',
nameLabel: 'Nombre',
emailLabel: 'Email',
savePlantPrefix: 'Guardaremos tu escaneo despues del registro:',
};
}
return {
headline: "Let's finish your setup!",
subline: 'Create an account to save your plants and 3 free scans per month.',
emailCta: 'Continue with Email',
createCta: 'Create Account',
already: 'Already have an account?',
login: 'Log in',
legal: 'By continuing you agree to our Privacy Policy and Terms.',
nameLabel: 'Name',
emailLabel: 'Email',
savePlantPrefix: 'Your scan will be saved after signup:',
};
};
export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, t } = useApp();
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language);
const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant();
const screenBackground = isDarkMode
? ONBOARDING_AUTH_BACKGROUND.dark
: ONBOARDING_AUTH_BACKGROUND.light;
const isExpoGo = Constants.appOwnership === 'expo';
const [name, setName] = useState('');
const [email, setEmail] = useState('');
@@ -42,10 +82,14 @@ export default function SignupScreen() {
const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [emailExpanded, setEmailExpanded] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isExpoGo = Constants.appOwnership === 'expo';
useEffect(() => {
posthog.capture('signup_screen_viewed', { context: 'onboarding' });
}, [posthog]);
useEffect(() => {
if (isExpoGo) {
@@ -65,6 +109,15 @@ export default function SignupScreen() {
};
}, [isExpoGo]);
const finishAuth = async (session: Awaited<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 => {
if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
@@ -77,30 +130,21 @@ export default function SignupScreen() {
const validationError = validate();
if (validationError) {
setError(validationError);
setEmailExpanded(true);
return;
}
setLoading(true);
setError(null);
try {
const session = await AuthService.signUp(email, name, password);
await hydrateSession(session);
if (session?.userId) {
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
}
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/onboarding/source');
await finishAuth(session);
} catch (e: any) {
if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING') {
setError(t.errNetworkError);
} else if (e.message === 'NETWORK_ERROR') {
} else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError);
} else if (e.message === 'AUTH_ERROR') {
setError(t.errAuthError);
} else {
setError(t.errAuthError);
}
@@ -135,24 +179,10 @@ export default function SignupScreen() {
email: credential.email,
name: fullName || undefined,
});
const billing = await hydrateSession(session);
if (session?.userId) {
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
}
await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' });
const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
// Same routing as login: existing non-pro accounts go to the paywall
// directly instead of bouncing through the root entitlement redirect.
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
}
await finishAuth(session);
} catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') {
return;
}
if (e?.code === 'ERR_REQUEST_CANCELED') return;
posthog.capture('apple_login_failed', {
surface: 'signup',
error: e instanceof Error ? e.message : String(e),
@@ -167,222 +197,177 @@ export default function SignupScreen() {
return (
<KeyboardAvoidingView
style={[styles.flex, { backgroundColor: screenBackground }]}
style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* 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} />
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
<View style={styles.heroOverlay} />
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
<Ionicons name="arrow-back" size={20} color="#ffffff" />
</TouchableOpacity>
<Image
source={require('../../assets/icon.png')}
style={styles.logoIcon}
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 style={styles.heroCopy}>
<Text style={styles.heroTitle}>{copy.headline}</Text>
<Text style={styles.heroSubline}>{copy.subline}</Text>
</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 ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
cornerRadius={14}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
) : null}
{appleAvailable ? (
<View style={styles.dividerRowCompact}>
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
) : null}
{/* Name */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted}
value={name}
onChangeText={setName}
autoCapitalize="words"
autoComplete="name"
returnKeyType="next"
/>
</View>
</View>
{!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}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
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}>
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
returnKeyType="next"
/>
</View>
</View>
{/* Password */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
</View>
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
returnKeyType="next"
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
</TouchableOpacity>
</View>
</View>
{/* Password Confirm */}
<View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.inputBg,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
size={18}
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 style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[
styles.inputRow,
{
backgroundColor: colors.surface,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
},
]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted}
value={passwordConfirm}
onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm}
autoComplete="new-password"
returnKeyType="done"
onSubmitEditing={handleSignup}
/>
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
</TouchableOpacity>
</View>
</View>
</View>
)}
{/* Error */}
{error && (
{error ? (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<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>
)}
) : null}
{/* Signup Button */}
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
onPress={handleSignup}
activeOpacity={0.82}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{t.onboardingRegister}</Text>
)}
{emailExpanded ? (
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
onPress={handleSignup}
activeOpacity={0.84}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<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>
</View>
{/* Login link */}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{t.alreadyHaveAccount}{' '}
<Text style={{ color: colors.primary, fontWeight: '600' }}>{t.onboardingLogin}</Text>
</Text>
</TouchableOpacity>
<Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
@@ -390,160 +375,108 @@ export default function SignupScreen() {
const styles = StyleSheet.create({
flex: { flex: 1 },
scroll: {
flexGrow: 1,
justifyContent: 'center',
scroll: { flexGrow: 1 },
hero: {
minHeight: 330,
justifyContent: 'flex-end',
paddingHorizontal: 24,
paddingVertical: 48,
paddingTop: 56,
paddingBottom: 34,
},
header: {
alignItems: 'center',
marginBottom: 32,
heroImage: { resizeMode: 'cover' },
heroOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 12, 7, 0.46)',
},
backBtn: {
position: 'absolute',
left: 0,
top: 0,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
top: 54,
left: 22,
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.28)',
},
logoIcon: {
width: 84,
height: 84,
borderRadius: 20,
marginBottom: 16,
heroCopy: { gap: 10 },
heroTitle: {
color: '#ffffff',
fontSize: 34,
lineHeight: 39,
fontWeight: '900',
},
appName: {
fontSize: 30,
fontWeight: '700',
letterSpacing: -0.5,
marginBottom: 6,
},
subtitle: {
fontSize: 15,
fontWeight: '400',
},
card: {
borderRadius: 20,
borderWidth: 1,
padding: 24,
gap: 14,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 1,
shadowRadius: 12,
elevation: 4,
},
appleButton: {
width: '100%',
height: 50,
marginBottom: 2,
},
dividerRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginVertical: 2,
},
dividerLine: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 12,
fontWeight: '500',
},
fieldGroup: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: '500',
marginLeft: 2,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
height: 50,
},
inputIcon: {
marginRight: 10,
},
input: {
flex: 1,
fontSize: 15,
height: 50,
},
eyeBtn: {
padding: 4,
marginLeft: 6,
},
strengthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: -4,
},
strengthBar: {
flex: 1,
height: 3,
borderRadius: 2,
},
strengthText: {
fontSize: 11,
marginLeft: 4,
width: 40,
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
fontSize: 13,
flex: 1,
},
primaryBtn: {
height: 52,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
},
primaryBtnText: {
heroSubline: {
color: 'rgba(255, 255, 255, 0.88)',
fontSize: 16,
fontWeight: '600',
lineHeight: 22,
fontWeight: '700',
},
loginLink: {
alignItems: 'center',
marginTop: 24,
paddingVertical: 8,
},
loginLinkText: {
fontSize: 15,
sheet: {
flex: 1,
marginTop: -24,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 22,
paddingTop: 24,
paddingBottom: 32,
gap: 14,
},
pendingHint: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
gap: 10,
borderRadius: 16,
borderWidth: 1,
marginBottom: 20,
gap: 12,
paddingHorizontal: 14,
paddingVertical: 12,
},
pendingHintText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
appleButton: { width: '100%', height: 56 },
dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
dividerLine: { flex: 1, height: 1 },
dividerText: { fontSize: 12, fontWeight: '800' },
emailChoiceBtn: {
height: 56,
borderRadius: 14,
borderWidth: 1.5,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
},
emailChoiceText: { fontSize: 16, fontWeight: '800' },
form: { gap: 12 },
fieldGroup: { gap: 6 },
label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
inputRow: {
height: 52,
borderWidth: 1,
borderRadius: 14,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
},
inputIcon: { marginRight: 10 },
input: { flex: 1, height: 52, fontSize: 15 },
eyeBtn: { padding: 5, marginLeft: 6 },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 7,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
primaryBtn: {
height: 56,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
primaryBtnText: { fontSize: 17, fontWeight: '900' },
loginLink: { alignItems: 'center', paddingTop: 4, paddingBottom: 2 },
loginLinkText: { fontSize: 15, fontWeight: '700' },
legal: { textAlign: 'center', fontSize: 11.5, lineHeight: 16, paddingHorizontal: 12 },
});

View File

@@ -1,7 +1,7 @@
# Onboarding Redesign, Soft Paywall & Free Tier — Design Spec
**Date:** 2026-07-06
**Status:** Approved design basis (Stitch export approved by Timo)
**Status:** Implemented
**Design reference:** `design/stitch-onboarding/` (Stitch export: 10 screens, light + dark variants, `code.html` per screen, design tokens in `botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark))
## Goal

View File

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