Files
casadoc-app/app/(auth)/login.tsx
T
2026-08-26 23:01:07 +03:00

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 },
});