import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import Ionicons from '@react-native-vector-icons/ionicons'; import { Button, Card, ScreenShell } from '../../../components'; import { useAppDispatch, useAppSelector } from '../../../app/hooks'; import { useTheme, fontFamily } from '../../../theme'; import { useApiErrorToast } from '../../../hooks/useApiErrorToast'; import { useToast } from '../../../hooks/useToast'; import { authActions } from '../store/authSlice'; import { selectRegistration } from '../store/authSelectors'; import { rememberRegisteredProfile, startRegistration, verifyRegistrationOtp, } from '../store/authThunk'; import { isValidOtp } from '../utils/authValidation'; import { CodeInput } from '../components/CodeInput'; import type { AuthStackParamList } from '../../../navigation/types'; type Props = Readonly>; const RESEND_COOLDOWN_SECONDS = 30; export default function VerifyOtpScreen({ navigation }: Props) { const dispatch = useAppDispatch(); const theme = useTheme(); const toast = useToast(); const showApiError = useApiErrorToast(); const registration = useAppSelector(selectRegistration); const [otp, setOtp] = useState(''); const [cooldown, setCooldown] = useState(RESEND_COOLDOWN_SECONDS); const pending = useRef<'verify' | 'resend' | null>(null); /** The deferred navigation frame — see `afterStateSettles`. */ const navFrame = useRef(null); useEffect( () => () => { if (navFrame.current !== null) { cancelAnimationFrame(navFrame.current); } }, [], ); /** * Runs a navigation action one frame after the dispatches that sit beside it. * * React batches an effect's work into a single commit, and the branches below * each dispatch *and* move the stack. That one commit then tells Fabric to do * two incompatible things at once: the dispatch re-renders the Register * screen still mounted under this one — its footer link, its error row — while * the reset removes that very subtree. Mounting the result throws "The * specified child already has a parent", which is a red box in debug and a * blank screen with no way to sign in once it is dismissed. * * The navigator already froze `initialRouteName` against the same collision; * this is the other half of it. A frame's gap splits the work into two * commits that each describe one change: the state settles, then the stack * moves. It is not a race — `pending` is cleared before the frame is * queued, so nothing re-enters in between. */ const afterStateSettles = useCallback((run: () => void) => { if (navFrame.current !== null) { cancelAnimationFrame(navFrame.current); } navFrame.current = requestAnimationFrame(() => { navFrame.current = null; run(); }); }, []); const isLoading = registration.status === 'loading'; const fieldErrors = registration.error?.fieldErrors; useEffect(() => { if (cooldown <= 0) { return; } const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); return () => clearTimeout(timer); }, [cooldown]); useEffect(() => { if (!pending.current) { return; } if (registration.status === 'succeeded') { const action = pending.current; pending.current = null; if (action === 'resend') { setOtp(''); setCooldown(RESEND_COOLDOWN_SECONDS); toast('Code sent', 'success', 'Check your messages for a new code.'); return; } /** * Where verification lands depends on whether this profile owes an MPIN. * * `mpinSetupRequired: false` means one is already set — the customer is * re-binding a device or has reinstalled — so asking them to choose a new * MPIN would be wrong twice over: the create endpoint refuses it with * `MPIN_ALREADY_SET`, and offering the choice implies the old one no * longer works, which sends people to a branch for a reset they do not * need. They go to sign-in and enter the MPIN they have. * * The stack is reset rather than pushed. The setup token is spent either * way, so every screen behind this one is a dead end — and on the * already-registered path there is nothing left to go back FOR. */ if (!registration.mpinSetupRequired) { dispatch(authActions.resetRegistration()); toast( 'You are already registered', 'info', 'This device is set up. Sign in with your existing MPIN.', ); afterStateSettles(() => navigation.reset({ index: 0, routes: [{ name: 'Login' }] })); return; } // The setup token is single use and lasts five minutes, so MPIN creation // follows immediately rather than sitting behind another screen. if (registration.setupToken) { navigation.navigate('CreateMpin'); } return; } if (registration.status === 'failed' && registration.error) { pending.current = null; showApiError(registration.error); setOtp(''); const { code } = registration.error; // Out of attempts, or the window closed — either way the OTP on this // screen can never succeed, so start over rather than let them keep trying. if (code === 'OTP_ATTEMPTS_EXCEEDED' || code === 'OTP_EXPIRED') { dispatch(authActions.resetRegistration()); afterStateSettles(() => navigation.navigate('Register')); return; } // Same profile, already set up — sign-in is the only step left. Recorded // before the reset clears the number, so sign-in asks for the MPIN // instead of reading a blank profile as "never registered" and sending // them back to the start of registration. if (code === 'ALREADY_REGISTERED' || code === 'MPIN_ALREADY_SET') { void dispatch( rememberRegisteredProfile({ orgId: registration.orgId, mobileNumber: registration.mobileNumber, }), ); dispatch(authActions.resetRegistration()); afterStateSettles(() => navigation.navigate('Login')); } } }, [afterStateSettles, dispatch, navigation, registration, showApiError, toast]); const handleVerify = () => { if (!isValidOtp(otp) || isLoading) { return; } pending.current = 'verify'; void dispatch(verifyRegistrationOtp({ otp })); }; const handleResend = () => { if (cooldown > 0 || isLoading) { return; } pending.current = 'resend'; void dispatch( startRegistration({ mobileNumber: registration.mobileNumber, orgId: registration.orgId, }), ); }; const maskedMobile = registration.mobileNumber ? `••••••${registration.mobileNumber.slice(-4)}` : ''; return ( 0 || isLoading} hitSlop={8}> 0 ? theme.colors.textMuted : theme.colors.primary }, ]} > {cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'} {registration.accountsLinked > 0 && ( {registration.accountsLinked === 1 ? '1 account found at your bank and ready to link.' : `${registration.accountsLinked} accounts found at your bank and ready to link.`} )}