- Backend: Update Legacy API credentials to robster/rob2 - Frontend: ROOM_LOCATIONS with icons and legacyCode mapping - Device Settings: Modal picker for room selection - api.ts: Bidirectional conversion (code ↔ name) - Various UI/UX improvements across screens PRD-DEPLOYMENT.md completed (Score: 9/10) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
469 lines
12 KiB
TypeScript
469 lines
12 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';
|
|
import { useNavigationFlow } from '@/hooks/useNavigationFlow';
|
|
import { api } from '@/services/api';
|
|
|
|
const CODE_LENGTH = 6;
|
|
|
|
export default function VerifyOTPScreen() {
|
|
// Params from previous screens
|
|
const params = useLocalSearchParams<{
|
|
email: string;
|
|
skipOtp: string;
|
|
inviteCode: string;
|
|
isNewUser: string;
|
|
}>();
|
|
|
|
const email = params.email || '';
|
|
const skipOtp = params.skipOtp === '1';
|
|
const inviteCode = params.inviteCode || '';
|
|
const isNewUser = params.isNewUser === '1';
|
|
|
|
// Auth context
|
|
const { verifyOtp, requestOtp, isLoading: authLoading, error: authError, clearError } = useAuth();
|
|
const nav = useNavigationFlow();
|
|
|
|
// 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();
|
|
}, []);
|
|
|
|
// Navigate after successful verification
|
|
const navigateAfterSuccess = useCallback(async () => {
|
|
try {
|
|
// SIMPLIFIED FLOW:
|
|
// - Login (existing user) → go straight to beneficiaries
|
|
// - Registration (new user) → follow onboarding flow
|
|
if (!isNewUser) {
|
|
router.replace('/(tabs)');
|
|
return;
|
|
}
|
|
|
|
// New user registration flow
|
|
const profileResponse = await api.getProfile();
|
|
|
|
if (!profileResponse.ok || !profileResponse.data) {
|
|
throw new Error(profileResponse.error?.message || 'Failed to load profile');
|
|
}
|
|
|
|
const beneficiariesResponse = await api.getAllBeneficiaries();
|
|
if (!beneficiariesResponse.ok) {
|
|
throw new Error(beneficiariesResponse.error?.message || 'Failed to load beneficiaries');
|
|
}
|
|
|
|
// /auth/me returns { user: {...}, beneficiaries: [...] }
|
|
// We need to extract user data from the nested 'user' object
|
|
const userData = profileResponse.data.user || profileResponse.data;
|
|
|
|
const profile = {
|
|
id: userData.id,
|
|
email: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
phone: userData.phone,
|
|
};
|
|
|
|
const result = nav.controller.getRouteAfterLogin(
|
|
profile,
|
|
beneficiariesResponse.data || []
|
|
);
|
|
|
|
if (
|
|
result.path === nav.ROUTES.AUTH.ENTER_NAME ||
|
|
result.path === nav.ROUTES.AUTH.ADD_LOVED_ONE
|
|
) {
|
|
result.params = { ...result.params, email, inviteCode };
|
|
}
|
|
|
|
nav.navigate(result, true);
|
|
} catch (error) {
|
|
setLocalError(error instanceof Error ? error.message : 'Failed to load profile data');
|
|
setVerifying(false);
|
|
}
|
|
}, [email, inviteCode, nav, isNewUser]);
|
|
|
|
// Auto-login for skipOtp (dev mode)
|
|
useEffect(() => {
|
|
if (!skipOtp || !email || hasAutoLoggedIn.current) return;
|
|
|
|
hasAutoLoggedIn.current = true;
|
|
|
|
const autoLogin = async () => {
|
|
setVerifying(true);
|
|
const success = await verifyOtp(email, '000000');
|
|
if (success) {
|
|
await navigateAfterSuccess();
|
|
} else {
|
|
setLocalError('Auto-login failed');
|
|
setVerifying(false);
|
|
}
|
|
};
|
|
|
|
autoLogin();
|
|
}, [skipOtp, email, verifyOtp, navigateAfterSuccess]);
|
|
|
|
// 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]);
|
|
|
|
// 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);
|
|
|
|
const success = await verifyOtp(email, codeToVerify);
|
|
|
|
if (success) {
|
|
// If user has invite code, try to accept it (silent - don't block flow)
|
|
if (inviteCode) {
|
|
await api.acceptInvitation(inviteCode);
|
|
// Don't block - continue with registration flow regardless of result
|
|
}
|
|
await navigateAfterSuccess();
|
|
return;
|
|
}
|
|
|
|
setVerifying(false);
|
|
setLocalError('Invalid verification code. Please try again.');
|
|
setCode('');
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}, [code, email, verifyOtp, navigateAfterSuccess]);
|
|
|
|
// Resend OTP
|
|
const handleResend = useCallback(async () => {
|
|
if (resendCooldown > 0) return;
|
|
|
|
setResending(true);
|
|
setLocalError(null);
|
|
|
|
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',
|
|
},
|
|
});
|