192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { Button, Field, TextField } from '@/components/ui';
|
|
import { useRouter } from 'expo-router';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { USE_MOCK } from '@/api';
|
|
import { useT } from '@/i18n';
|
|
import { colors, fontSize, radii, spacing } from '@/theme';
|
|
|
|
export default function LoginScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const { signIn } = useAuth();
|
|
const router = useRouter();
|
|
const t = useT();
|
|
|
|
const [email, setEmail] = useState(USE_MOCK ? 'j.smith@casadoc.app' : '');
|
|
const [password, setPassword] = useState(USE_MOCK ? 'demo' : '');
|
|
const [showPass, setShowPass] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function onSignIn() {
|
|
if (!email.trim() || !password) {
|
|
setError(t('login.missingFields'));
|
|
return;
|
|
}
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
await signIn(email.trim(), password);
|
|
} catch (e: any) {
|
|
// Unverified is not a wrong password: send them to the code screen.
|
|
if (e?.code === 'email_not_verified') {
|
|
router.push(`/verify?email=${encodeURIComponent(email.trim())}`);
|
|
return;
|
|
}
|
|
// The backend's message is English-only; ours says the same, localized.
|
|
setError(e?.status === 401 || e?.status === 422 ? t('login.failed') : (e?.message ?? t('login.failed')));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={{ flex: 1, backgroundColor: colors.surface }}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={[
|
|
styles.container,
|
|
{ paddingTop: insets.top + spacing[8], paddingBottom: insets.bottom + spacing[6] },
|
|
]}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
<View style={styles.brandWrap}>
|
|
<View style={styles.brandMark}>
|
|
<Ionicons name="home" size={30} color={colors.onDark} />
|
|
</View>
|
|
<Text style={styles.brand}>
|
|
Casa<Text style={{ color: colors.teal500 }}>Doc</Text>
|
|
</Text>
|
|
<Text style={styles.tagline}>
|
|
{t('login.tagline')}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={{ marginTop: spacing[9] }}>
|
|
<Field label={t('login.email')}>
|
|
<TextField
|
|
icon="mail-outline"
|
|
value={email}
|
|
onChangeText={setEmail}
|
|
placeholder={t('login.emailPlaceholder')}
|
|
autoCapitalize="none"
|
|
keyboardType="email-address"
|
|
autoComplete="email"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label={t('login.password')}>
|
|
<TextField
|
|
icon="lock-closed-outline"
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
placeholder={t('login.passwordPlaceholder')}
|
|
secureTextEntry={!showPass}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Pressable onPress={() => setShowPass((s) => !s)} style={styles.showPass} hitSlop={8}>
|
|
<Text style={styles.showPassText}>{showPass ? t('login.hide') : t('login.show')}</Text>
|
|
</Pressable>
|
|
</Field>
|
|
|
|
{error ? (
|
|
<View style={styles.errorBox}>
|
|
<Ionicons name="alert-circle-outline" size={18} color={colors.dangerText} />
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<Button
|
|
title={t('login.signIn')}
|
|
iconRight="arrow-forward"
|
|
size="lg"
|
|
block
|
|
loading={loading}
|
|
onPress={onSignIn}
|
|
style={{ marginTop: spacing[3] }}
|
|
/>
|
|
|
|
<Pressable onPress={() => router.push('/forgot')} style={styles.forgot}>
|
|
<Text style={styles.linkText}>{t('login.forgot')}</Text>
|
|
</Pressable>
|
|
|
|
<Pressable onPress={() => router.push('/register')} style={styles.registerRow}>
|
|
<Text style={styles.muted}>
|
|
{t('login.noAccount')} <Text style={styles.linkText}>{t('register.submit')}</Text>
|
|
</Text>
|
|
</Pressable>
|
|
|
|
{USE_MOCK ? (
|
|
<Text style={styles.demoHint}>{t('login.demoHint')}</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.trust}>
|
|
<Ionicons name="shield-checkmark-outline" size={16} color={colors.slate500} />
|
|
<Text style={styles.trustText}>{t('login.trust')}</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { paddingHorizontal: spacing[5], flexGrow: 1 },
|
|
brandWrap: { alignItems: 'center' },
|
|
brandMark: {
|
|
width: 68,
|
|
height: 68,
|
|
borderRadius: 20,
|
|
backgroundColor: colors.ink900,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
brand: { fontSize: fontSize['3xl'], fontWeight: '700', color: colors.ink, marginTop: spacing[4] },
|
|
tagline: {
|
|
fontSize: fontSize.sm,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
marginTop: 8,
|
|
maxWidth: 260,
|
|
},
|
|
showPass: { position: 'absolute', right: 14, top: 40 },
|
|
showPassText: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
|
|
errorBox: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
backgroundColor: colors.dangerBg,
|
|
borderRadius: radii.md,
|
|
padding: spacing[3],
|
|
marginBottom: spacing[3],
|
|
},
|
|
errorText: { color: colors.dangerText, fontSize: fontSize.sm, flex: 1 },
|
|
forgot: { alignSelf: 'center', marginTop: spacing[4] },
|
|
registerRow: { alignItems: 'center', marginTop: spacing[5] },
|
|
linkText: { color: colors.teal700, fontWeight: '600', fontSize: fontSize.sm },
|
|
muted: { fontSize: fontSize.sm, color: colors.textMuted },
|
|
demoHint: { textAlign: 'center', color: colors.textMuted2, fontSize: fontSize.xs, marginTop: spacing[4] },
|
|
trust: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 7,
|
|
marginTop: 'auto',
|
|
paddingTop: spacing[7],
|
|
},
|
|
trustText: { fontSize: fontSize.sm, color: colors.slate500 },
|
|
});
|