283 lines
9.1 KiB
TypeScript
283 lines
9.1 KiB
TypeScript
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<NativeStackScreenProps<AuthStackParamList, 'VerifyOtp'>>;
|
|
|
|
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<number | null>(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 (
|
|
<ScreenShell
|
|
title="Enter the code"
|
|
subtitle={`Sent to ${maskedMobile}`}
|
|
withTabBar={false}
|
|
contentContainerStyle={styles.container}
|
|
>
|
|
<Card style={styles.formCard}>
|
|
<CodeInput
|
|
label="6-digit code"
|
|
value={otp}
|
|
onChangeText={setOtp}
|
|
length={6}
|
|
autoFocus
|
|
editable={!isLoading}
|
|
onSubmit={handleVerify}
|
|
error={fieldErrors?.otp}
|
|
/>
|
|
|
|
<Pressable onPress={handleResend} disabled={cooldown > 0 || isLoading} hitSlop={8}>
|
|
<Text
|
|
style={[
|
|
styles.resendText,
|
|
{ color: cooldown > 0 ? theme.colors.textMuted : theme.colors.primary },
|
|
]}
|
|
>
|
|
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
|
</Text>
|
|
</Pressable>
|
|
</Card>
|
|
|
|
{registration.accountsLinked > 0 && (
|
|
<View style={[styles.noteCard, { backgroundColor: `${theme.colors.success}0D` }]}>
|
|
<Ionicons name="checkmark-circle-outline" size={18} color={theme.colors.success} />
|
|
<Text style={[styles.noteText, { color: theme.colors.textSecondary }]}>
|
|
{registration.accountsLinked === 1
|
|
? '1 account found at your bank and ready to link.'
|
|
: `${registration.accountsLinked} accounts found at your bank and ready to link.`}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
<Button
|
|
title="Verify"
|
|
onPress={handleVerify}
|
|
disabled={!isValidOtp(otp) || isLoading}
|
|
loading={isLoading}
|
|
style={styles.submitButton}
|
|
/>
|
|
</ScreenShell>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
padding: 16,
|
|
gap: 16,
|
|
flexGrow: 1,
|
|
},
|
|
formCard: {
|
|
padding: 16,
|
|
gap: 16,
|
|
},
|
|
resendText: {
|
|
fontSize: 14,
|
|
fontFamily: fontFamily.semibold,
|
|
textAlign: 'center',
|
|
},
|
|
noteCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 10,
|
|
padding: 14,
|
|
borderRadius: 12,
|
|
},
|
|
noteText: {
|
|
flex: 1,
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
},
|
|
submitButton: {
|
|
marginTop: 'auto',
|
|
height: 54,
|
|
},
|
|
});
|