2025-08-08 18:55:18 +08:00

242 lines
7.9 KiB
TypeScript

import Error from "@/assets/icons/svg/error.svg";
import { fetchApi } from "@/lib/server-api-util";
import { User } from "@/types/user";
import OTPInputView from '@twotalltotems/react-native-otp-input';
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, TextInput as RNTextInput, StyleSheet, TouchableOpacity, View } from "react-native";
import { useAuth } from "../../contexts/auth-context";
import { ThemedText } from "../ThemedText";
interface CodeProps {
phone: string;
}
const Code = ({ phone }: CodeProps) => {
const { t } = useTranslation();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const refs = useRef<Array<RNTextInput | null>>(Array(6).fill(null));
const shakeAnim = useRef(new Animated.Value(0)).current;
const [code, setCode] = useState<string[]>([]);
const [error, setError] = useState<string>('');
const focusNext = (index: number, value: string) => {
if (value && index < 5) {
refs?.current?.[index + 1]?.focus();
}
};
const focusPrevious = (index: number, key: string) => {
if (key === 'Backspace' && index > 0 && !code[index]) {
refs?.current?.[index - 1]?.focus();
}
};
const handleCodeChange = (text: string, index: number) => {
setError('');
const newCode = [...code];
// Handle pasted code from SMS or autofill
if ((text.length === 6 || text.length > 1) && /^\d+$/.test(text)) {
const digits = text.split('').slice(0, 6); // Ensure we only take first 6 digits
setCode(digits);
refs.current[5]?.focus(); // Focus on the last input
// Auto-submit if we have exactly 6 digits
if (digits.length === 6) {
handleTelLogin();
}
return;
}
// Handle single digit input
if (text.length <= 1 && /^\d?$/.test(text)) {
newCode[index] = text;
setCode(newCode);
// Auto-submit if this is the last digit
if (text && index === 5) {
handleTelLogin();
} else if (text) {
focusNext(index, text);
}
}
};
const sendVerificationCode = async () => {
try {
// 发送验证码
await fetchApi(`/iam/veritification-code`, {
method: 'POST',
body: JSON.stringify({ phone: phone }),
})
} catch (error) {
}
}
const handleTelLogin = async () => {
setError('');
if (!code.join('')) {
setError(t("auth.telLogin.codeRequired", { ns: 'login' }));
return;
}
// 如果验证码不是六位,提示错误
if (code.join('').length !== 6) {
setError(t("auth.telLogin.codeInvalid", { ns: 'login' }));
return;
}
setIsLoading(true);
setCountdown(60);
try {
await fetchApi<User>(`/iam/login/phone-login`, {
method: 'POST',
body: JSON.stringify({ phone: phone, code: code.join('') }),
}).then((res) => {
login(res, res.access_token || '')
router.replace('/user-message')
}).catch((error) => {
setError(t("auth.telLogin.codeVaild", { ns: 'login' }));
})
setIsLoading(false);
} catch (error) {
setIsLoading(false);
}
}
// 60s倒计时
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
return (
<View style={styles.container}>
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<ThemedText style={styles.title} color="textSecondary" size="xl" weight="bold">
{t("auth.telLogin.codeTitle", { ns: 'login' })}
</ThemedText>
<ThemedText style={styles.subtitle} type="sfPro" color="textPrimary" size="sm">
{t("auth.telLogin.secondTitle", { ns: 'login' })}
</ThemedText>
<ThemedText color="bgSecondary" size="sm" weight="bold">
{phone}
</ThemedText>
</View>
<OTPInputView
pinCount={6}
onCodeChanged={(code) => {
setCode([code]);
}}
onCodeFilled={() => {
handleTelLogin()
}}
code={code.join('')}
autoFocusOnLoad={false}
codeInputFieldStyle={styles.underlineStyleBase}
codeInputHighlightStyle={styles.underlineStyleHighLighted}
style={styles.otpContainer}
placeholderCharacter="-"
placeholderTextColor="#AC7E35"
/>
<View style={[styles.errorContainer, { opacity: error ? 1 : 0 }]}>
<Error />
<ThemedText style={styles.errorText} size="xxs" color="bgSecondary" type="inter">
{error}
</ThemedText>
</View>
<View style={styles.footerContainer}>
<ThemedText size="sm" color="textPrimary" type="sfPro">
{t("auth.telLogin.sendAgain", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
if (countdown <= 0) {
sendVerificationCode()
}
}}>
<ThemedText
style={[
styles.resendText,
countdown > 0 && styles.disabledResendText
]}
size="sm"
color="bgSecondary"
type="inter"
weight="bold"
>
{countdown > 0 ? `${countdown}s${t("auth.telLogin.resend", { ns: 'login' })}` : t("auth.telLogin.resend", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
contentContainer: {
flex: 1,
justifyContent: 'center',
},
headerContainer: {
alignItems: 'center',
marginBottom: 16,
},
title: {
marginBottom: 8,
paddingTop: 4,
},
subtitle: {
textAlign: 'center',
marginBottom: 4,
},
otpContainer: {
width: '100%',
height: 80,
},
underlineStyleBase: {
width: 50,
height: 50,
borderWidth: 0,
borderRadius: 16,
fontSize: 18,
color: '#000000',
textAlign: 'center',
backgroundColor: '#FFF8DE',
},
underlineStyleHighLighted: {
borderColor: '#E2793F',
backgroundColor: '#FFF8DE',
borderWidth: 2,
},
errorContainer: {
width: '100%',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
errorText: {
marginLeft: 8,
},
footerContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 8,
},
resendText: {
marginLeft: 4,
},
disabledResendText: {
color: '#9CA3AF',
},
});
export default Code;