initial
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user