208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../../stores/auth';
|
|
|
|
const OTP_LENGTH = 6;
|
|
|
|
const OTP_INPUT_STYLE = {
|
|
fontSize: 20,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#0a0a0a',
|
|
textAlign: 'center' as const,
|
|
width: 48,
|
|
height: 56,
|
|
borderRadius: 12,
|
|
};
|
|
|
|
export default function ConfirmOtpScreen() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const params = useLocalSearchParams<{ email: string }>();
|
|
const email = decodeURIComponent(params.email ?? '');
|
|
|
|
const { verifyOtp, resendConfirmation } = useAuthStore();
|
|
|
|
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(''));
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [resendCooldown, setResendCooldown] = useState(0);
|
|
|
|
const inputs = useRef<Array<TextInput | null>>([]);
|
|
|
|
useEffect(() => {
|
|
if (!email) {
|
|
router.replace('/signup');
|
|
}
|
|
}, [email]);
|
|
|
|
useEffect(() => {
|
|
if (resendCooldown <= 0) return;
|
|
const t = setInterval(() => {
|
|
setResendCooldown((c) => {
|
|
if (c <= 1) { clearInterval(t); return 0; }
|
|
return c - 1;
|
|
});
|
|
}, 1000);
|
|
return () => clearInterval(t);
|
|
}, [resendCooldown]);
|
|
|
|
const otp = digits.join('');
|
|
|
|
const handleDigit = (value: string, index: number) => {
|
|
if (value.length === OTP_LENGTH && /^\d+$/.test(value)) {
|
|
const next = value.split('');
|
|
setDigits(next);
|
|
inputs.current[OTP_LENGTH - 1]?.focus();
|
|
return;
|
|
}
|
|
const digit = value.replace(/\D/g, '').slice(-1);
|
|
const next = [...digits];
|
|
next[index] = digit;
|
|
setDigits(next);
|
|
if (digit && index < OTP_LENGTH - 1) {
|
|
inputs.current[index + 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (key: string, index: number) => {
|
|
if (key === 'Backspace' && !digits[index] && index > 0) {
|
|
inputs.current[index - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const verify = async () => {
|
|
if (otp.length < OTP_LENGTH || loading || success) return;
|
|
setError(null);
|
|
setLoading(true);
|
|
const res = await verifyOtp(email, otp);
|
|
setLoading(false);
|
|
if (res.error) {
|
|
setError(res.error);
|
|
setDigits(Array(OTP_LENGTH).fill(''));
|
|
inputs.current[0]?.focus();
|
|
return;
|
|
}
|
|
setSuccess(true);
|
|
router.replace('/(app)');
|
|
};
|
|
|
|
const resend = async () => {
|
|
if (resendCooldown > 0) return;
|
|
setError(null);
|
|
const res = await resendConfirmation(email);
|
|
if (res.error) {
|
|
setError(res.error);
|
|
return;
|
|
}
|
|
setResendCooldown(60);
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-white">
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
className="flex-1"
|
|
>
|
|
<View className="flex-1 px-6 justify-center">
|
|
{/* Header */}
|
|
<View className="items-center mb-8">
|
|
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
|
|
<Text className="text-3xl" style={{ fontFamily: 'Nunito_400Regular' }}>✉</Text>
|
|
</View>
|
|
<Text className="text-2xl text-neutral-900 text-center mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>
|
|
{t('auth.confirmEmailTitle')}
|
|
</Text>
|
|
<Text className="text-sm text-neutral-500 text-center leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
|
|
{t('auth.confirmEmailLine1')}{'\n'}
|
|
<Text className="text-neutral-900" style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>
|
|
{t('auth.confirmEmailLine2') ? `\n${t('auth.confirmEmailLine2')}` : ''}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* OTP Input */}
|
|
<View className="flex-row justify-center gap-3 mb-6">
|
|
{digits.map((digit, index) => (
|
|
<TextInput
|
|
key={index}
|
|
ref={(ref) => { inputs.current[index] = ref; }}
|
|
style={[
|
|
OTP_INPUT_STYLE,
|
|
{
|
|
backgroundColor: '#f5f5f5',
|
|
borderWidth: 2,
|
|
borderColor: digit ? '#f59e0b' : '#e5e5e5',
|
|
},
|
|
]}
|
|
value={digit}
|
|
onChangeText={(val) => handleDigit(val, index)}
|
|
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, index)}
|
|
keyboardType="number-pad"
|
|
maxLength={OTP_LENGTH}
|
|
editable={!loading && !success}
|
|
selectTextOnFocus
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{error && (
|
|
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
|
|
<Text className="text-red-600 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{success && (
|
|
<View className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 mb-4">
|
|
<Text className="text-green-700 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.confirmed')}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<Pressable
|
|
onPress={verify}
|
|
disabled={otp.length < OTP_LENGTH || loading || success}
|
|
className="bg-rebreak-500 rounded-xl items-center mb-4 active:opacity-80 disabled:opacity-40"
|
|
style={{ paddingVertical: 16 }}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color="white" size="small" />
|
|
) : (
|
|
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.confirmBtn')}</Text>
|
|
)}
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={resend}
|
|
disabled={resendCooldown > 0 || loading}
|
|
className="py-3 items-center"
|
|
>
|
|
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
|
|
{t('auth.noCode')}{' '}
|
|
<Text className={resendCooldown > 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
|
{resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resend')}
|
|
</Text>
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={() => router.back()}
|
|
className="py-3 items-center mt-2"
|
|
>
|
|
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToSignup')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|