resend code logic

This commit is contained in:
Samuel Newman
2025-11-28 16:22:46 +02:00
parent 5ae90b35b1
commit 407de6edfc
2 changed files with 62 additions and 10 deletions
@@ -1,11 +1,12 @@
import {useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import {View} from 'react-native' import {Text as NestedText, View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
import {getPhoneCodeFromCountryCode} from '#/lib/international-telephone-codes' import {getPhoneCodeFromCountryCode} from '#/lib/international-telephone-codes'
import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {atoms as a, useGutters, useTheme} from '#/alf' import {atoms as a, useGutters, useTheme} from '#/alf'
@@ -15,6 +16,7 @@ import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/componen
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput' import {OTPInput} from '../components/OTPInput'
@@ -88,11 +90,14 @@ export function VerifyNumber({
}, },
}) })
const {mutate: resendCode} = useMutation({ const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => { mutationFn: async () => {
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise(resolve => setTimeout(resolve, 2000))
}, },
onSuccess: () => Toast.show(_(msg`Code resent`)), onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`Code resent`))
},
onMutate: () => { onMutate: () => {
setError(null) setError(null)
}, },
@@ -164,9 +169,11 @@ export function VerifyNumber({
<OTPStatus <OTPStatus
error={error} error={error}
isPending={isPending} isPending={isPending}
isResendingCode={isResendingCode}
isSuccess={isSuccess} isSuccess={isSuccess}
onResend={() => resendCode()} onResend={() => resendCode()}
onRetry={() => verifyNumber(otpCode)} onRetry={() => verifyNumber(otpCode)}
lastCodeSentAt={state.lastSentAt}
/> />
</View> </View>
</Layout.Content> </Layout.Content>
@@ -181,9 +188,11 @@ export function VerifyNumber({
function OTPStatus({ function OTPStatus({
error, error,
isPending, isPending,
isResendingCode,
isSuccess, isSuccess,
onResend, onResend,
onRetry, onRetry,
lastCodeSentAt,
}: { }: {
error: { error: {
retryable: boolean retryable: boolean
@@ -191,13 +200,29 @@ function OTPStatus({
message: string message: string
} | null } | null
isPending: boolean isPending: boolean
isResendingCode: boolean
isSuccess: boolean isSuccess: boolean
onResend: () => void onResend: () => void
onRetry: () => void onRetry: () => void
lastCodeSentAt: Date | null
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const [time, setTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setTime(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [])
const timeUntilCanResend = Math.max(
0,
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
)
const isWaiting = timeUntilCanResend > 0
let Icon: React.ComponentType<SVGIconProps> | null = null let Icon: React.ComponentType<SVGIconProps> | null = null
let text = '' let text = ''
let textColor = t.atoms.text_contrast_medium.color let textColor = t.atoms.text_contrast_medium.color
@@ -209,7 +234,8 @@ function OTPStatus({
text = _(msg`Phone verified`) text = _(msg`Phone verified`)
textColor = t.palette.positive_500 textColor = t.palette.positive_500
} else if (isPending) { } else if (isPending) {
text = _(msg`Wait a moment...`) Icon = Loader
text = _(msg`Verifying...`)
} else if (error) { } else if (error) {
Icon = WarningIcon Icon = WarningIcon
text = error.message text = error.message
@@ -226,7 +252,7 @@ function OTPStatus({
} }
return ( return (
<View style={[a.w_full, a.gap_2xl, a.align_center]}> <View style={[a.w_full, a.align_center]}>
{text && ( {text && (
<View style={[a.gap_xs, a.flex_row, a.align_center]}> <View style={[a.gap_xs, a.flex_row, a.align_center]}>
{Icon && <Icon size="xs" color={textColor} />} {Icon && <Icon size="xs" color={textColor} />}
@@ -247,7 +273,8 @@ function OTPStatus({
size="small" size="small"
color="secondary_inverted" color="secondary_inverted"
label={_(msg`Retry`)} label={_(msg`Retry`)}
onPress={onRetry}> onPress={onRetry}
style={[a.mt_2xl]}>
<ButtonIcon icon={RetryIcon} /> <ButtonIcon icon={RetryIcon} />
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
@@ -257,13 +284,28 @@ function OTPStatus({
{showResendButton && ( {showResendButton && (
<Button <Button
size="small" size="large"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
label={_(msg`Resend code`)} label={_(msg`Resend code`)}
onPress={onResend}> disabled={isResendingCode || isWaiting}
onPress={onResend}
style={[a.mt_2xl]}>
{isResendingCode && <ButtonIcon icon={Loader} />}
<ButtonText> <ButtonText>
{isWaiting ? (
<Trans>
Resend code in{' '}
<NestedText style={{fontVariant: ['tabular-nums']}}>
00:
{String(
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
).padStart(2, '0')}
</NestedText>
</Trans>
) : (
<Trans>Resend code</Trans> <Trans>Resend code</Trans>
)}
</ButtonText> </ButtonText>
</Button> </Button>
)} )}
+10
View File
@@ -48,6 +48,9 @@ export type Action =
phoneNumber: string phoneNumber: string
} }
} }
| {
type: 'RESEND_VERIFICATION_CODE'
}
| { | {
type: 'VERIFY_PHONE_NUMBER_SUCCESS' type: 'VERIFY_PHONE_NUMBER_SUCCESS'
} }
@@ -77,6 +80,13 @@ function reducer(state: State, action: Action): State {
lastSentAt: new Date(), lastSentAt: new Date(),
} }
} }
case 'RESEND_VERIFICATION_CODE': {
assertCurrentStep(state, '2: verify number')
return {
...state,
lastSentAt: new Date(),
}
}
case 'VERIFY_PHONE_NUMBER_SUCCESS': { case 'VERIFY_PHONE_NUMBER_SUCCESS': {
assertCurrentStep(state, '2: verify number') assertCurrentStep(state, '2: verify number')
return { return {