WellNuo/app/(auth)/verify-otp.tsx

434 lines
11 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
TouchableOpacity,
TextInput,
ActivityIndicator,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
const CODE_LENGTH = 6;
const DEV_EMAIL = 'serter2069@gmail.com';
export default function VerifyOTPScreen() {
// Params from previous screens
const params = useLocalSearchParams<{
email: string;
skipOtp: string;
isNewUser: string;
partnerCode: string;
}>();
const email = params.email || '';
const skipOtp = params.skipOtp === '1';
const isNewUser = params.isNewUser === '1';
const partnerCode = params.partnerCode || '';
// Auth context
const { verifyOtp, requestOtp, isLoading: authLoading, error: authError, clearError } = useAuth();
// Local state
const [code, setCode] = useState('');
const [verifying, setVerifying] = useState(false);
const [resending, setResending] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [resendCooldown, setResendCooldown] = useState(0);
// Refs
const inputRef = useRef<TextInput>(null);
const hasAutoLoggedIn = useRef(false);
// Clear errors on mount
useEffect(() => {
clearError();
}, []);
// Auto-login for skipOtp (dev mode)
useEffect(() => {
if (!skipOtp || !email || hasAutoLoggedIn.current) return;
hasAutoLoggedIn.current = true;
console.log('[VerifyOTP] Auto-login for dev email');
const autoLogin = async () => {
setVerifying(true);
const success = await verifyOtp(email, '000000');
setVerifying(false);
if (success) {
navigateAfterSuccess();
} else {
setLocalError('Auto-login failed');
}
};
autoLogin();
}, [skipOtp, email]);
// Focus input on mount (if not skipping OTP)
useEffect(() => {
if (!skipOtp) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [skipOtp]);
// Resend cooldown timer
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
// Navigate after successful verification
const navigateAfterSuccess = () => {
const isDevEmail = email.toLowerCase() === DEV_EMAIL;
if (isDevEmail) {
// Dev email - go to main app
console.log('[VerifyOTP] -> tabs (dev email)');
router.replace('/(tabs)');
} else if (isNewUser) {
// New user - continue registration flow
console.log('[VerifyOTP] -> enter-name (new user)');
router.replace({
pathname: '/(auth)/enter-name',
params: { email, partnerCode },
});
} else {
// Existing user - go to main app
console.log('[VerifyOTP] -> tabs (existing user)');
router.replace('/(tabs)');
}
};
// Handle code input
const handleCodeChange = (text: string) => {
const digits = text.replace(/\D/g, '').slice(0, CODE_LENGTH);
setCode(digits);
setLocalError(null);
// Auto-submit when complete
if (digits.length === CODE_LENGTH) {
handleVerify(digits);
}
};
// Verify OTP
const handleVerify = useCallback(async (verifyCode?: string) => {
const codeToVerify = verifyCode || code;
if (codeToVerify.length !== CODE_LENGTH) {
setLocalError('Please enter the 6-digit code');
return;
}
setVerifying(true);
setLocalError(null);
console.log('[VerifyOTP] Verifying code for:', email);
const success = await verifyOtp(email, codeToVerify);
setVerifying(false);
if (success) {
navigateAfterSuccess();
} else {
setLocalError('Invalid verification code. Please try again.');
setCode('');
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [code, email, verifyOtp, isNewUser, partnerCode]);
// Resend OTP
const handleResend = useCallback(async () => {
if (resendCooldown > 0) return;
setResending(true);
setLocalError(null);
console.log('[VerifyOTP] Resending OTP to:', email);
const result = await requestOtp(email);
setResending(false);
if (result.success) {
setResendCooldown(60);
setCode('');
} else {
setLocalError('Failed to resend code');
}
}, [email, resendCooldown, requestOtp]);
// Go back
const handleBack = () => {
router.back();
};
// Render code boxes
const renderCodeBoxes = () => {
return Array.from({ length: CODE_LENGTH }).map((_, i) => {
const isActive = i === code.length;
const isFilled = i < code.length;
return (
<View
key={i}
style={[
styles.codeBox,
isActive && styles.codeBoxActive,
isFilled && styles.codeBoxFilled,
]}
>
<Text style={styles.codeDigit}>{code[i] || ''}</Text>
</View>
);
});
};
// Auto-login loading screen
if (skipOtp) {
return (
<View style={styles.autoLoginContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.autoLoginText}>Signing in...</Text>
</View>
);
}
const displayError = localError || authError?.message;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<View style={styles.iconContainer}>
<View style={styles.iconCircle}>
<Ionicons name="mail-open" size={48} color={AppColors.primary} />
</View>
</View>
<View style={styles.header}>
<Text style={styles.title}>Check your email</Text>
<Text style={styles.subtitle}>We sent a verification code to</Text>
<Text style={styles.email}>{email}</Text>
</View>
{displayError && (
<ErrorMessage
message={displayError}
onDismiss={() => {
setLocalError(null);
clearError();
}}
/>
)}
{/* Code Input Boxes */}
<TouchableOpacity
style={styles.codeContainer}
activeOpacity={1}
onPress={() => inputRef.current?.focus()}
>
{renderCodeBoxes()}
</TouchableOpacity>
{/* Hidden Input */}
<TextInput
ref={inputRef}
style={styles.hiddenInput}
value={code}
onChangeText={handleCodeChange}
keyboardType="number-pad"
maxLength={CODE_LENGTH}
autoComplete="one-time-code"
textContentType="oneTimeCode"
/>
<View style={styles.buttonContainer}>
<Button
title="Verify"
onPress={() => handleVerify()}
loading={verifying || authLoading}
disabled={code.length !== CODE_LENGTH}
fullWidth
size="lg"
/>
</View>
{/* Resend */}
<View style={styles.resendContainer}>
<Text style={styles.resendText}>Didn't receive the code? </Text>
{resendCooldown > 0 ? (
<Text style={styles.resendCooldownText}>Resend in {resendCooldown}s</Text>
) : (
<TouchableOpacity onPress={handleResend} disabled={resending}>
<Text style={[styles.resendLink, resending && styles.resendLinkDisabled]}>
{resending ? 'Sending...' : 'Resend'}
</Text>
</TouchableOpacity>
)}
</View>
{/* Use different email */}
<TouchableOpacity style={styles.changeEmailLink} onPress={handleBack}>
<Text style={styles.changeEmailText}>Use a different email</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.xl,
paddingBottom: Spacing.xl,
},
autoLoginContainer: {
flex: 1,
backgroundColor: AppColors.background,
justifyContent: 'center',
alignItems: 'center',
gap: Spacing.md,
},
autoLoginText: {
fontSize: FontSizes.lg,
color: AppColors.textSecondary,
},
backButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'flex-start',
marginBottom: Spacing.xl,
},
iconContainer: {
alignItems: 'center',
marginBottom: Spacing.xl,
},
iconCircle: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: `${AppColors.primary}15`,
justifyContent: 'center',
alignItems: 'center',
},
header: {
alignItems: 'center',
marginBottom: Spacing.lg,
},
title: {
fontSize: FontSizes['2xl'],
fontWeight: '700',
color: AppColors.textPrimary,
marginBottom: Spacing.md,
textAlign: 'center',
},
subtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
},
email: {
fontSize: FontSizes.base,
fontWeight: '600',
color: AppColors.textPrimary,
marginTop: Spacing.xs,
},
codeContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: Spacing.sm,
marginVertical: Spacing.xl,
},
codeBox: {
width: 48,
height: 56,
borderRadius: BorderRadius.md,
borderWidth: 2,
borderColor: AppColors.border,
backgroundColor: AppColors.surface,
justifyContent: 'center',
alignItems: 'center',
},
codeBoxActive: {
borderColor: AppColors.primary,
},
codeBoxFilled: {
borderColor: AppColors.primary,
backgroundColor: `${AppColors.primary}10`,
},
codeDigit: {
fontSize: FontSizes['2xl'],
fontWeight: '700',
color: AppColors.textPrimary,
},
hiddenInput: {
position: 'absolute',
opacity: 0,
height: 0,
width: 0,
},
buttonContainer: {
marginBottom: Spacing.lg,
},
resendContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: Spacing.md,
},
resendText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
resendLink: {
fontSize: FontSizes.base,
color: AppColors.primary,
fontWeight: '600',
},
resendLinkDisabled: {
opacity: 0.5,
},
resendCooldownText: {
fontSize: FontSizes.base,
color: AppColors.textMuted,
},
changeEmailLink: {
alignItems: 'center',
paddingVertical: Spacing.sm,
},
changeEmailText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
fontWeight: '500',
},
});