WellNuo/app/(auth)/verify-otp.tsx
Sergei fe4ff1a932 Simplify DB schema (name/address single fields) + subscription flow
Database:
- Simplified beneficiary schema: single `name` field instead of first_name/last_name
- Single `address` field instead of 5 separate address columns
- Added migration 008_update_notification_settings.sql

Backend:
- Updated all beneficiaries routes for new schema
- Fixed admin routes for simplified fields
- Updated notification settings routes
- Improved stripe and webhook handlers

Frontend:
- Updated all forms to use single name/address fields
- Added new equipment-status and purchase screens
- Added BeneficiaryDetailController service
- Added subscription service
- Improved navigation and auth flow
- Various UI improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 10:35:15 -08:00

463 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;
}>();
const email = params.email || '';
const skipOtp = params.skipOtp === '1';
const inviteCode = params.inviteCode || '';
// 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();
}, []);
// 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');
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]);
// Navigate after successful verification
const navigateAfterSuccess = useCallback(async () => {
try {
const profileResponse = await api.getProfile();
console.log('[VerifyOTP] getProfile response:', JSON.stringify(profileResponse.data));
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;
console.log('[VerifyOTP] Extracted userData:', JSON.stringify(userData));
const profile = {
id: userData.id,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
phone: userData.phone,
};
console.log('[VerifyOTP] Profile for navigation:', JSON.stringify(profile));
console.log('[VerifyOTP] Beneficiaries count:', beneficiariesResponse.data?.length || 0);
const result = nav.controller.getRouteAfterLogin(
profile,
beneficiariesResponse.data || []
);
console.log('[VerifyOTP] Navigation result:', JSON.stringify(result));
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]);
// 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);
if (success) {
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);
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',
},
});