400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
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, useLocalSearchParams } 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_hero_vertical.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 params = useLocalSearchParams<{ returnTo?: string; topup?: string }>();
|
|
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');
|
|
}
|
|
const returnTo = Array.isArray(params.returnTo) ? params.returnTo[0] : params.returnTo;
|
|
const topup = Array.isArray(params.topup) ? params.topup[0] : params.topup;
|
|
if (returnTo === 'billing') {
|
|
router.replace({ pathname: '/profile/billing', params: topup ? { topup } : undefined });
|
|
return;
|
|
}
|
|
if (returnTo === 'paywall') {
|
|
router.replace('/profile/billing?view=paywall&context=out_of_credits');
|
|
return;
|
|
}
|
|
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}
|
|
>
|
|
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
|
|
<Ionicons name="arrow-back" size={20} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
|
|
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
|
|
<ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
|
|
<View style={styles.heroOverlay} />
|
|
<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({ pathname: '/auth/signup', params: { returnTo: params.returnTo, topup: params.topup } })} 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)',
|
|
zIndex: 10,
|
|
elevation: 10,
|
|
},
|
|
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' },
|
|
});
|