2025-07-07 13:42:11 +08:00

212 lines
8.2 KiB
TypeScript

import Error from "@/assets/icons/svg/error.svg";
import { fetchApi } from "@/lib/server-api-util";
import { User } from "@/types/user";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Animated, TextInput as RNTextInput, TextInput, TouchableOpacity, View } from "react-native";
import { useAuth } from "../../contexts/auth-context";
import { ThemedText } from "../ThemedText";
import { Steps } from "./phoneLogin";
interface LoginProps {
setSteps: (steps: Steps) => void;
phone: string;
}
const Code = ({ setSteps, phone }: LoginProps) => {
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
if (text.length === 6 && /^\d{6}$/.test(text)) {
const digits = text.split('');
setCode(digits);
refs.current[5]?.focus(); // Focus on the last input after autofill
return;
}
// Handle manual input
if (text.length <= 1) {
newCode[index] = text;
setCode(newCode);
if (text) {
focusNext(index, text);
}
}
};
const sendVerificationCode = async () => {
try {
// 发送验证码
await fetchApi(`/iam/veritification-code`, {
method: 'POST',
body: JSON.stringify({ phone: phone }),
})
} catch (error) {
// console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), 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) => {
// console.log(error);
setError(t("auth.telLogin.codeVaild", { ns: 'login' }));
})
setIsLoading(false);
} catch (error) {
setIsLoading(false);
// console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error);
}
}
// 60s倒计时
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
return (
<View className="flex-1 bg-white p-6">
<View className="flex-1 justify-center">
<View className="items-center mb-8">
<ThemedText className="text-2xl font-semibold mb-2 text-gray-900">
{t("auth.telLogin.title", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base text-gray-600 text-center mb-1">
{t("auth.telLogin.secondTitle", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base font-medium !text-buttonFill">
{phone}
</ThemedText>
</View>
<Animated.View
style={{
transform: [{ translateX: shakeAnim }],
display: 'flex',
flexDirection: 'row',
gap: 24,
marginBottom: 16,
alignItems: 'center',
justifyContent: 'center',
}}
>
{code.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => {
if (ref) {
refs.current[index] = ref;
}
}}
style={{ width: 40, height: 40 }}
className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center"
keyboardType="number-pad"
maxLength={1}
textContentType="oneTimeCode" // For iOS autofill
autoComplete='sms-otp' // For Android autofill
value={digit}
onChangeText={text => handleCodeChange(text, index)}
onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)}
selectTextOnFocus
caretHidden={true}
/>
))}
</Animated.View>
<View className={`w-full flex-row justify-end mb-[1rem] items-center ${error ? 'opacity-100' : 'opacity-0'}`}>
<Error />
<ThemedText className="text-base font-medium !text-buttonFill ml-2">
{error}
</ThemedText>
</View>
<TouchableOpacity
className="bg-buttonFill py-3 rounded-full items-center justify-center"
onPress={handleTelLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#ffffff" />
) : (
<ThemedText className="!text-white font-medium text-base">
{t("auth.telLogin.continue", { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View className="flex-row justify-center mt-4">
<ThemedText className="!text-textPrimary">
{t("auth.telLogin.sendAgain", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
if (countdown > 0) {
return
} else {
sendVerificationCode()
}
}}>
<ThemedText className={`!text-buttonFill font-medium ml-1 ${countdown > 0 ? '!text-gray-400' : ''}`}>
{countdown > 0 ? `${countdown}s${t("auth.telLogin.resend", { ns: 'login' })}` : t("auth.telLogin.resend", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View className="py-4">
<TouchableOpacity
className="py-3 items-center"
onPress={() => setSteps('phone')}
>
<ThemedText className="!text-buttonFill font-medium">
{t("auth.telLogin.goBack", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
)
}
export default Code