- Voice tab: simplified interface, voice picker improvements - Subscription: Stripe integration, purchase flow updates - Beneficiaries: dashboard, sharing, improved management - Profile: drawer, edit, help, privacy sections - Theme: expanded constants, new colors - New components: MockDashboard, ProfileDrawer, Toast - Backend: Stripe routes additions - Auth: activate, add-loved-one, purchase screens
427 lines
11 KiB
TypeScript
427 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;
|
|
|
|
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 = () => {
|
|
if (isNewUser) {
|
|
// New user - continue registration flow (ask name first)
|
|
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',
|
|
},
|
|
});
|