@opexa/portal-components 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/hooks/useCooldown.d.ts +2 -3
- package/dist/components/DepositWithdrawal/Withdrawal/Withdrawal.js +13 -1
- package/dist/components/UpdateMobilePhoneNumber/UpdateMobilePhoneNumber.js +8 -171
- package/dist/components/UpdateMobilePhoneNumber/components/LogoutButton.d.ts +6 -0
- package/dist/components/UpdateMobilePhoneNumber/components/LogoutButton.js +26 -0
- package/dist/components/UpdateMobilePhoneNumber/components/Step1MobileNumberForm.d.ts +6 -0
- package/dist/components/UpdateMobilePhoneNumber/components/Step1MobileNumberForm.js +12 -0
- package/dist/components/UpdateMobilePhoneNumber/components/Step2VerificationForm.d.ts +12 -0
- package/dist/components/UpdateMobilePhoneNumber/components/Step2VerificationForm.js +19 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useAutoOpenWhenUnverified.d.ts +6 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useAutoOpenWhenUnverified.js +31 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useLogout.d.ts +11 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useLogout.js +54 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useMobileNumberSchemas.d.ts +22 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useMobileNumberSchemas.js +38 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useUpdateMobileFlow.d.ts +39 -0
- package/dist/components/UpdateMobilePhoneNumber/hooks/useUpdateMobileFlow.js +156 -0
- package/dist/components/UpdateMobilePhoneNumber/utils/explainOtpError.d.ts +15 -0
- package/dist/components/UpdateMobilePhoneNumber/utils/explainOtpError.js +29 -0
- package/dist/services/httpRequest.js +20 -8
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
interface UseCooldownOptions {
|
|
1
|
+
export interface UseCooldownOptions {
|
|
2
2
|
/** @default 0 */
|
|
3
3
|
min?: number;
|
|
4
4
|
/** @default 10 */
|
|
@@ -14,7 +14,7 @@ interface UseCooldownOptions {
|
|
|
14
14
|
allowPause?: boolean;
|
|
15
15
|
onCooldown?: () => void;
|
|
16
16
|
}
|
|
17
|
-
interface UseCooldownReturn {
|
|
17
|
+
export interface UseCooldownReturn {
|
|
18
18
|
/** Starts or pauses the cooldown */
|
|
19
19
|
start: () => void;
|
|
20
20
|
/** Restarts the cooldown */
|
|
@@ -40,4 +40,3 @@ interface UseCooldownReturn {
|
|
|
40
40
|
*
|
|
41
41
|
*/
|
|
42
42
|
export declare function useCooldown(opts?: UseCooldownOptions): UseCooldownReturn;
|
|
43
|
-
export {};
|
|
@@ -17,6 +17,14 @@ import { onMobileDevice } from '../../../utils/onMobileDevice.js';
|
|
|
17
17
|
import { parseDecimal } from '../../../utils/parseDecimal.js';
|
|
18
18
|
import { useDepositWithdrawalPropsContext } from '../DepositWithdrawalContext.js';
|
|
19
19
|
import { PaymentMethods } from '../PaymentMethods.js';
|
|
20
|
+
/** True when identity is verified on the member account, including Sumsub flows where nested `verification` is null. */
|
|
21
|
+
function isMemberWithdrawalVerifiedByAccount(account) {
|
|
22
|
+
const nested = account.verification?.status;
|
|
23
|
+
return (account.verificationStatus === 'VERIFIED' ||
|
|
24
|
+
account.verified === true ||
|
|
25
|
+
nested === 'VERIFIED' ||
|
|
26
|
+
nested === 'APPROVED');
|
|
27
|
+
}
|
|
20
28
|
const GCashStandardCashInWithdrawal = lazy(() => import('./GCashStandardCashInWithdrawal/GCashStandardCashInWithdrawal.js').then((m) => ({
|
|
21
29
|
default: m.GCashStandardCashInWithdrawal,
|
|
22
30
|
})));
|
|
@@ -145,17 +153,21 @@ export function Withdrawal() {
|
|
|
145
153
|
if (!isMayaSessionValid) {
|
|
146
154
|
return _jsx(MayaSessionSessionExpired, {});
|
|
147
155
|
}
|
|
148
|
-
|
|
156
|
+
const withdrawalVerified = account != null ? isMemberWithdrawalVerifiedByAccount(account) : false;
|
|
157
|
+
if (account?.status === 'VERIFICATION_LOCKED' &&
|
|
158
|
+
!withdrawalVerified) {
|
|
149
159
|
return _jsx(AccountVerificationLockRequired, {});
|
|
150
160
|
}
|
|
151
161
|
if (parseDecimal(wallet?.balance, 0) < 1) {
|
|
152
162
|
return _jsx(InsufficientBalance, {});
|
|
153
163
|
}
|
|
154
164
|
if (restrictWithdrawalsToVerifiedMembers &&
|
|
165
|
+
!withdrawalVerified &&
|
|
155
166
|
memberVerification?.status === 'PENDING') {
|
|
156
167
|
return _jsx(_AccountVerificationPending, {});
|
|
157
168
|
}
|
|
158
169
|
if (restrictWithdrawalsToVerifiedMembers &&
|
|
170
|
+
!withdrawalVerified &&
|
|
159
171
|
(!memberVerification ||
|
|
160
172
|
(memberVerification.status !== 'APPROVED' &&
|
|
161
173
|
memberVerification.status !== 'VERIFIED' &&
|
|
@@ -1,192 +1,29 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
3
|
-
import { zodResolver } from '@hookform/resolvers/zod';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
4
3
|
import Image from 'next/image';
|
|
5
|
-
import { useEffect, useRef, useState } from 'react';
|
|
6
|
-
import { Controller, useForm } from 'react-hook-form';
|
|
7
|
-
import z from 'zod';
|
|
8
4
|
import { useShallow } from 'zustand/shallow';
|
|
9
|
-
import { useAccountQuery } from '../../client/hooks/useAccountQuery.js';
|
|
10
|
-
import { useCooldown } from '../../client/hooks/useCooldown.js';
|
|
11
5
|
import { useFeatureFlag } from '../../client/hooks/useFeatureFlag.js';
|
|
12
6
|
import { useGlobalStore } from '../../client/hooks/useGlobalStore.js';
|
|
13
|
-
import { useLocaleInfo } from '../../client/hooks/useLocaleInfo.js';
|
|
14
|
-
import { useMobileNumberParser } from '../../client/hooks/useMobileNumberParser.js';
|
|
15
|
-
import { useSendVerificationCodeMutation } from '../../client/hooks/useSendVerificationCodeMutation.js';
|
|
16
|
-
import { useUpdateMobileNumber } from '../../client/hooks/useUpdateMobileNumber.js';
|
|
17
|
-
import { toaster } from '../../client/utils/toaster.js';
|
|
18
|
-
import { ArrowLeftIcon } from '../../icons/ArrowLeftIcon.js';
|
|
19
7
|
import inplayLogo from '../../images/inplay-logo.png';
|
|
20
8
|
import lightBg from '../../images/light-bg.png';
|
|
21
|
-
import { Button } from '../../ui/Button/index.js';
|
|
22
9
|
import { Dialog } from '../../ui/Dialog/index.js';
|
|
23
|
-
import { Field } from '../../ui/Field/index.js';
|
|
24
|
-
import { PinInput } from '../../ui/PinInput/index.js';
|
|
25
10
|
import { Portal } from '../../ui/Portal/index.js';
|
|
11
|
+
import { Step1MobileNumberForm } from './components/Step1MobileNumberForm.js';
|
|
12
|
+
import { Step2VerificationForm } from './components/Step2VerificationForm.js';
|
|
13
|
+
import { useAutoOpenWhenUnverified } from './hooks/useAutoOpenWhenUnverified.js';
|
|
14
|
+
import { useUpdateMobileFlow } from './hooks/useUpdateMobileFlow.js';
|
|
26
15
|
export function UpdateMobilePhoneNumber() {
|
|
16
|
+
useAutoOpenWhenUnverified();
|
|
27
17
|
const featureFlag = useFeatureFlag();
|
|
28
18
|
const globalStore = useGlobalStore(useShallow((ctx) => ({
|
|
29
19
|
updateMobilePhoneNumber: ctx.updateMobilePhoneNumber,
|
|
30
20
|
kyc: ctx.kyc,
|
|
31
21
|
})));
|
|
32
|
-
const
|
|
33
|
-
const account = accountQuery.data;
|
|
34
|
-
const isAccountLoading = accountQuery.isLoading;
|
|
35
|
-
const isMobileNumberVerified = account?.mobileNumberVerified === true;
|
|
36
|
-
const hasExecuted = useRef(false);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!isAccountLoading && !!account && !hasExecuted.current) {
|
|
39
|
-
if (!isMobileNumberVerified) {
|
|
40
|
-
globalStore.updateMobilePhoneNumber.setOpen(true);
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
43
|
-
globalStore.updateMobilePhoneNumber.setOpen(false);
|
|
44
|
-
}
|
|
45
|
-
hasExecuted.current = true;
|
|
46
|
-
}
|
|
47
|
-
}, [
|
|
48
|
-
isAccountLoading,
|
|
49
|
-
account,
|
|
50
|
-
isMobileNumberVerified,
|
|
51
|
-
globalStore.updateMobilePhoneNumber,
|
|
52
|
-
]);
|
|
53
|
-
const [step, setStep] = useState(1);
|
|
54
|
-
const sendVerificationCodeMutation = useSendVerificationCodeMutation({
|
|
55
|
-
onSuccess: () => {
|
|
56
|
-
setStep(2);
|
|
57
|
-
cooldown.start();
|
|
58
|
-
},
|
|
59
|
-
onError: (err) => {
|
|
60
|
-
toaster.error({
|
|
61
|
-
title: 'Sign In Failed',
|
|
62
|
-
description: err.message,
|
|
63
|
-
});
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
const updateMobileNumberMutation = useUpdateMobileNumber({
|
|
67
|
-
onSuccess: async () => {
|
|
68
|
-
step1Form.reset();
|
|
69
|
-
step2Form.reset();
|
|
70
|
-
setStep(1);
|
|
71
|
-
toaster.success({
|
|
72
|
-
title: 'Verification Successful',
|
|
73
|
-
description: 'Your mobile number has been verified.',
|
|
74
|
-
});
|
|
75
|
-
globalStore.updateMobilePhoneNumber.setOpen(false);
|
|
76
|
-
// Refetch the account to get the latest verification status before
|
|
77
|
-
// deciding whether to open the KYC modal. The `account` captured in
|
|
78
|
-
// this closure may be stale (e.g. a `verification: null` snapshot)
|
|
79
|
-
// even though the user is already VERIFIED.
|
|
80
|
-
const { data: freshAccount } = await accountQuery.refetch();
|
|
81
|
-
// If the user is already verified by any of the available signals,
|
|
82
|
-
// do NOT open the KYC modal. Mirrors `isKycCompleted` in
|
|
83
|
-
// KycOpenOnHomeMount.tsx so behavior stays consistent.
|
|
84
|
-
const isKycCompleted = freshAccount?.verified === true ||
|
|
85
|
-
freshAccount?.verification?.status === 'APPROVED' ||
|
|
86
|
-
freshAccount?.verification?.status === 'VERIFIED' ||
|
|
87
|
-
freshAccount?.verificationStatus === 'VERIFIED';
|
|
88
|
-
if (isKycCompleted) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Only open KYC if the user is explicitly UNVERIFIED.
|
|
92
|
-
const isUnverified = freshAccount?.verification === null ||
|
|
93
|
-
freshAccount?.verification === undefined ||
|
|
94
|
-
freshAccount?.verification?.status === 'UNVERIFIED' ||
|
|
95
|
-
freshAccount?.verification?.status === 'CREATED' ||
|
|
96
|
-
freshAccount?.verificationStatus === 'UNVERIFIED';
|
|
97
|
-
if (isUnverified) {
|
|
98
|
-
globalStore.kyc.setOpen(true);
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
onError: (err) => {
|
|
102
|
-
const errorMessage = err.message === 'Internal Server Error'
|
|
103
|
-
? `mobile number ${mobileNumberParser.format(step1Form.getValues('mobileNumber'))} is not available`
|
|
104
|
-
: err.message;
|
|
105
|
-
toaster.error({
|
|
106
|
-
title: 'Sign In Failed',
|
|
107
|
-
description: errorMessage,
|
|
108
|
-
});
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
const localeInfo = useLocaleInfo();
|
|
112
|
-
const mobileNumberParser = useMobileNumberParser();
|
|
113
|
-
const Step1Definition = z.object({
|
|
114
|
-
mobileNumber: z
|
|
115
|
-
.string()
|
|
116
|
-
.min(1, 'Mobile number is required')
|
|
117
|
-
.superRefine((v, ctx) => {
|
|
118
|
-
if (!mobileNumberParser.validate(v)) {
|
|
119
|
-
ctx.addIssue({
|
|
120
|
-
code: z.ZodIssueCode.custom,
|
|
121
|
-
message: 'Invalid mobile number',
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}),
|
|
125
|
-
});
|
|
126
|
-
const Step2Definition = z.object({
|
|
127
|
-
verificationCode: z.array(z.string()).superRefine((val, ctx) => {
|
|
128
|
-
if (val.length !== 6 || val.some((v) => v.length !== 1)) {
|
|
129
|
-
ctx.addIssue({
|
|
130
|
-
code: z.ZodIssueCode.custom,
|
|
131
|
-
message: 'Please enter your 6-digit verification code.',
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
}),
|
|
135
|
-
});
|
|
136
|
-
const step1Form = useForm({
|
|
137
|
-
resolver: zodResolver(Step1Definition),
|
|
138
|
-
defaultValues: {
|
|
139
|
-
mobileNumber: '',
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
const step2Form = useForm({
|
|
143
|
-
resolver: zodResolver(Step2Definition),
|
|
144
|
-
defaultValues: {
|
|
145
|
-
verificationCode: Array.from({ length: 6 }).fill(''),
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
const cooldown = useCooldown({
|
|
149
|
-
max: 60,
|
|
150
|
-
duration: 1000 * 60,
|
|
151
|
-
});
|
|
152
|
-
const formRef = useRef(null);
|
|
22
|
+
const { step, step1Form, step2Form, cooldown, formRef, mobileNumberParser, submitStep1, submitStep2, resend, goBackToStep1, } = useUpdateMobileFlow();
|
|
153
23
|
const isOpen = globalStore.updateMobilePhoneNumber.open &&
|
|
154
24
|
featureFlag.enabled &&
|
|
155
25
|
!globalStore.kyc.open;
|
|
156
26
|
return (_jsx(Dialog.Root, { open: isOpen, lazyMount: true, unmountOnExit: true, closeOnEscape: false, closeOnInteractOutside: false, children: _jsxs(Portal, { children: [_jsx(Dialog.Backdrop, {}), _jsx(Dialog.Positioner, { className: "flex items-center", children: _jsxs(Dialog.Content, { className: "flex w-[375px] flex-col items-center space-y-4 rounded-xl bg-[#111827] p-6 text-center", style: {
|
|
157
27
|
backgroundImage: `url(${lightBg.src})`,
|
|
158
|
-
}, children: [_jsx(Image, { src: inplayLogo, alt: "inplay logo", width: 82, height: 34, className: "h-auto w-[82px]" }), _jsxs("div", { children: [step === 1 && (
|
|
159
|
-
sendVerificationCodeMutation.mutateAsync({
|
|
160
|
-
channel: 'SMS',
|
|
161
|
-
recipient: mobileNumberParser.format(data.mobileNumber),
|
|
162
|
-
});
|
|
163
|
-
}), children: [_jsxs(Field.Root, { invalid: !!step1Form.formState.errors.mobileNumber, className: "text-left", children: [_jsx(Field.Label, { children: "Mobile Number" }), _jsxs("div", { className: "relative", children: [_jsx("div", { className: "-translate-y-1/2 absolute top-1/2 left-3.5 flex shrink-0 items-center gap-md", children: _jsx("span", { className: "text-text-placeholder", children: localeInfo.mobileNumber.areaCode }) }), _jsx(Field.Input, { style: {
|
|
164
|
-
paddingLeft: `calc(1.25rem + ${localeInfo.mobileNumber.areaCode.length}ch)`,
|
|
165
|
-
}, ...step1Form.register('mobileNumber') })] }), _jsx(Field.ErrorText, { children: step1Form.formState.errors.mobileNumber?.message })] }), _jsx(Button, { type: "submit", className: "mt-3xl", disabled: step1Form.formState.isSubmitting, children: "Send Code" })] })] })), step === 2 && (_jsxs(_Fragment, { children: [_jsx("h2", { className: "mt-xl text-center font-semibold text-lg", children: "Check your Phone" }), _jsxs("p", { className: "mt-xs text-center text-sm text-text-secondary-700", children: ["We\u2019ve sent a verification code to your mobile number", ' ', _jsx("span", { className: "font-semibold text-[#F05127]", children: mobileNumberParser.format(step1Form.getValues('mobileNumber')) }), ' ', "via text"] }), _jsxs("form", { ref: formRef, className: "mt-5", onSubmit: step2Form.handleSubmit(async ({ verificationCode }) => {
|
|
166
|
-
updateMobileNumberMutation.mutateAsync({
|
|
167
|
-
mobileNumber: mobileNumberParser.format(step1Form.getValues('mobileNumber')),
|
|
168
|
-
verificationCode: verificationCode.join(''),
|
|
169
|
-
});
|
|
170
|
-
}), children: [_jsx(Controller, { name: "verificationCode", control: step2Form.control, render: (o) => (_jsxs(Field.Root, { invalid: o.fieldState.invalid, children: [_jsxs(PinInput.Root, { placeholder: "0", onKeyDown: (e) => {
|
|
171
|
-
if (e.key === 'Backspace') {
|
|
172
|
-
step2Form.reset();
|
|
173
|
-
}
|
|
174
|
-
}, value: o.field.value, onValueChange: (details) => {
|
|
175
|
-
o.field.onChange(details.value);
|
|
176
|
-
o.field.onBlur();
|
|
177
|
-
}, otp: true, onValueComplete: () => {
|
|
178
|
-
formRef.current?.requestSubmit();
|
|
179
|
-
}, blurOnComplete: true, readOnly: step2Form.formState.isSubmitting, type: "numeric", children: [_jsxs(PinInput.Control, { className: "grid-cols-[1fr_1fr_1fr_auto_1fr_1fr_1fr] items-center gap-md", children: [_jsx(PinInput.Input, { index: 0 }), _jsx(PinInput.Input, { index: 1 }), _jsx(PinInput.Input, { index: 2 }), _jsx("span", { className: "font-medium text-2xl text-text-placeholder-subtle", children: "\u2013" }), _jsx(PinInput.Input, { index: 3 }), _jsx(PinInput.Input, { index: 4 }), _jsx(PinInput.Input, { index: 5 })] }), _jsx(PinInput.HiddenInput, {})] }), _jsx(Field.ErrorText, { children: o.fieldState.error?.message })] })) }), _jsx(Button, { type: "submit", className: "mt-4xl", disabled: step2Form.formState.isSubmitting, children: "Verify" }), _jsxs("div", { className: "mt-4 flex w-full items-center justify-center gap-xs text-xs", children: [_jsx("span", { className: "text-[#9CA3AF]", children: "Didn't receive the code?" }), _jsx("button", { type: "button", className: "font-semibold text-[#C084FC]", disabled: cooldown.cooling, onClick: async () => {
|
|
180
|
-
await sendVerificationCodeMutation.mutateAsync({
|
|
181
|
-
channel: 'SMS',
|
|
182
|
-
recipient: mobileNumberParser.format(step1Form.getValues('mobileNumber')),
|
|
183
|
-
});
|
|
184
|
-
cooldown.start();
|
|
185
|
-
}, children: cooldown.cooling
|
|
186
|
-
? `Resend in ${cooldown.countdown}s`
|
|
187
|
-
: 'Resend' })] }), _jsx("button", { type: "button", className: "absolute top-0 left-6 mx-auto mt-3xl flex h-8 w-8 items-center gap-1 rounded-full bg-[#1f2638] font-semibold text-sm text-text-tertiary-600", onClick: () => {
|
|
188
|
-
setStep(1);
|
|
189
|
-
step2Form.reset();
|
|
190
|
-
cooldown.stop();
|
|
191
|
-
}, children: _jsx(ArrowLeftIcon, { className: "mx-auto size-5" }) })] })] }))] })] }) })] }) }));
|
|
28
|
+
}, children: [_jsx(Image, { src: inplayLogo, alt: "inplay logo", width: 82, height: 34, className: "h-auto w-[82px]" }), _jsxs("div", { children: [step === 1 && (_jsx(Step1MobileNumberForm, { step1Form: step1Form, submitStep1: submitStep1 })), step === 2 && (_jsx(Step2VerificationForm, { step1Form: step1Form, step2Form: step2Form, cooldown: cooldown, formRef: formRef, mobileNumberParser: mobileNumberParser, submitStep2: submitStep2, resend: resend, goBackToStep1: goBackToStep1 }))] })] }) })] }) }));
|
|
192
29
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useShallow } from 'zustand/shallow';
|
|
4
|
+
import { useGlobalStore } from '../../../client/hooks/useGlobalStore.js';
|
|
5
|
+
import { Button } from '../../../ui/Button/index.js';
|
|
6
|
+
import { useLogout } from '../hooks/useLogout.js';
|
|
7
|
+
/**
|
|
8
|
+
* Logout button used inside the UpdateMobilePhoneNumber dialog. Closes the
|
|
9
|
+
* related account dialogs alongside the mobile number dialog before signing
|
|
10
|
+
* the user out.
|
|
11
|
+
*/
|
|
12
|
+
export function LogoutButton() {
|
|
13
|
+
const globalStore = useGlobalStore(useShallow((ctx) => ({
|
|
14
|
+
updateMobilePhoneNumber: ctx.updateMobilePhoneNumber,
|
|
15
|
+
account: ctx.account,
|
|
16
|
+
account__mobile: ctx.account__mobile,
|
|
17
|
+
})));
|
|
18
|
+
const { logout, isPending } = useLogout({
|
|
19
|
+
onBeforeRedirect: () => {
|
|
20
|
+
globalStore.updateMobilePhoneNumber.setOpen(false);
|
|
21
|
+
globalStore.account.setOpen(false);
|
|
22
|
+
globalStore.account__mobile.setOpen(false);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return (_jsx(Button, { type: "button", className: "mt-lg bg-transparent text-text-brand-primary-600", disabled: isPending, onClick: logout, children: "Log Out" }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { UseUpdateMobileFlowReturn } from '../hooks/useUpdateMobileFlow';
|
|
2
|
+
export interface Step1MobileNumberFormProps {
|
|
3
|
+
step1Form: UseUpdateMobileFlowReturn['step1Form'];
|
|
4
|
+
submitStep1: UseUpdateMobileFlowReturn['submitStep1'];
|
|
5
|
+
}
|
|
6
|
+
export declare function Step1MobileNumberForm({ step1Form, submitStep1, }: Step1MobileNumberFormProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useLocaleInfo } from '../../../client/hooks/useLocaleInfo.js';
|
|
4
|
+
import { Button } from '../../../ui/Button/index.js';
|
|
5
|
+
import { Field } from '../../../ui/Field/index.js';
|
|
6
|
+
import { LogoutButton } from './LogoutButton.js';
|
|
7
|
+
export function Step1MobileNumberForm({ step1Form, submitStep1, }) {
|
|
8
|
+
const localeInfo = useLocaleInfo();
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: 'flex flex-col items-center justify-center gap-1', children: [_jsx("h2", { className: "mt-xl text-center font-semibold text-lg", children: "Verify Your Account" }), _jsx("div", { className: "text-text-secondary-700 text-xs", children: "Please enter your mobile phone number to verify your account." })] }), _jsxs("form", { className: "mt-3xl", onSubmit: submitStep1, children: [_jsxs(Field.Root, { invalid: !!step1Form.formState.errors.mobileNumber, className: "text-left", children: [_jsx(Field.Label, { children: "Mobile Number" }), _jsxs("div", { className: "relative", children: [_jsx("div", { className: "-translate-y-1/2 absolute top-1/2 left-3.5 flex shrink-0 items-center gap-md", children: _jsx("span", { className: "text-text-placeholder", children: localeInfo.mobileNumber.areaCode }) }), _jsx(Field.Input, { style: {
|
|
10
|
+
paddingLeft: `calc(1.25rem + ${localeInfo.mobileNumber.areaCode.length}ch)`,
|
|
11
|
+
}, ...step1Form.register('mobileNumber') })] }), _jsx(Field.ErrorText, { children: step1Form.formState.errors.mobileNumber?.message })] }), _jsx(Button, { type: "submit", className: "mt-3xl", disabled: step1Form.formState.isSubmitting, children: "Send Code" }), _jsx(LogoutButton, {})] })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { UseUpdateMobileFlowReturn } from '../hooks/useUpdateMobileFlow';
|
|
2
|
+
export interface Step2VerificationFormProps {
|
|
3
|
+
step1Form: UseUpdateMobileFlowReturn['step1Form'];
|
|
4
|
+
step2Form: UseUpdateMobileFlowReturn['step2Form'];
|
|
5
|
+
cooldown: UseUpdateMobileFlowReturn['cooldown'];
|
|
6
|
+
formRef: UseUpdateMobileFlowReturn['formRef'];
|
|
7
|
+
mobileNumberParser: UseUpdateMobileFlowReturn['mobileNumberParser'];
|
|
8
|
+
submitStep2: UseUpdateMobileFlowReturn['submitStep2'];
|
|
9
|
+
resend: UseUpdateMobileFlowReturn['resend'];
|
|
10
|
+
goBackToStep1: UseUpdateMobileFlowReturn['goBackToStep1'];
|
|
11
|
+
}
|
|
12
|
+
export declare function Step2VerificationForm({ step1Form, step2Form, cooldown, formRef, mobileNumberParser, submitStep2, resend, goBackToStep1, }: Step2VerificationFormProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Controller } from 'react-hook-form';
|
|
4
|
+
import { ArrowLeftIcon } from '../../../icons/ArrowLeftIcon.js';
|
|
5
|
+
import { Button } from '../../../ui/Button/index.js';
|
|
6
|
+
import { Field } from '../../../ui/Field/index.js';
|
|
7
|
+
import { PinInput } from '../../../ui/PinInput/index.js';
|
|
8
|
+
export function Step2VerificationForm({ step1Form, step2Form, cooldown, formRef, mobileNumberParser, submitStep2, resend, goBackToStep1, }) {
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsx("h2", { className: "mt-xl text-center font-semibold text-lg", children: "Check your Phone" }), _jsxs("p", { className: "mt-xs text-center text-sm text-text-secondary-700", children: ["We\u2019ve sent a verification code to your mobile number", ' ', _jsx("span", { className: "font-semibold text-[#F05127]", children: mobileNumberParser.format(step1Form.getValues('mobileNumber')) }), ' ', "via text"] }), _jsxs("form", { ref: formRef, className: "mt-5", onSubmit: submitStep2, children: [_jsx(Controller, { name: "verificationCode", control: step2Form.control, render: (o) => (_jsxs(Field.Root, { invalid: o.fieldState.invalid, children: [_jsxs(PinInput.Root, { placeholder: "0", onKeyDown: (e) => {
|
|
10
|
+
if (e.key === 'Backspace') {
|
|
11
|
+
step2Form.reset();
|
|
12
|
+
}
|
|
13
|
+
}, value: o.field.value, onValueChange: (details) => {
|
|
14
|
+
o.field.onChange(details.value);
|
|
15
|
+
o.field.onBlur();
|
|
16
|
+
}, otp: true, onValueComplete: () => {
|
|
17
|
+
formRef.current?.requestSubmit();
|
|
18
|
+
}, blurOnComplete: true, readOnly: step2Form.formState.isSubmitting, type: "numeric", children: [_jsxs(PinInput.Control, { className: "grid-cols-[1fr_1fr_1fr_auto_1fr_1fr_1fr] items-center gap-md", children: [_jsx(PinInput.Input, { index: 0 }), _jsx(PinInput.Input, { index: 1 }), _jsx(PinInput.Input, { index: 2 }), _jsx("span", { className: "font-medium text-2xl text-text-placeholder-subtle", children: "\u2013" }), _jsx(PinInput.Input, { index: 3 }), _jsx(PinInput.Input, { index: 4 }), _jsx(PinInput.Input, { index: 5 })] }), _jsx(PinInput.HiddenInput, {})] }), _jsx(Field.ErrorText, { children: o.fieldState.error?.message })] })) }), _jsx(Button, { type: "submit", className: "mt-4xl", disabled: step2Form.formState.isSubmitting, children: "Verify" }), _jsxs("div", { className: "mt-4 flex w-full items-center justify-center gap-xs text-xs", children: [_jsx("span", { className: "text-[#9CA3AF]", children: "Didn't receive the code?" }), _jsx("button", { type: "button", className: "font-semibold text-button-secondary-fg disabled:cursor-not-allowed disabled:opacity-75", disabled: cooldown.cooling, onClick: resend, children: cooldown.cooling ? `Resend in ${cooldown.countdown}s` : 'Resend' })] }), _jsx("button", { type: "button", className: "absolute top-0 left-6 mx-auto mt-3xl flex h-8 w-8 items-center gap-1 rounded-full bg-[#1f2638] font-semibold text-sm text-text-tertiary-600", onClick: goBackToStep1, children: _jsx(ArrowLeftIcon, { className: "mx-auto size-5" }) })] })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { useShallow } from 'zustand/shallow';
|
|
4
|
+
import { useAccountQuery } from '../../../client/hooks/useAccountQuery.js';
|
|
5
|
+
import { useGlobalStore } from '../../../client/hooks/useGlobalStore.js';
|
|
6
|
+
/**
|
|
7
|
+
* Opens the UpdateMobilePhoneNumber dialog automatically (once per mount) if
|
|
8
|
+
* the account has finished loading and the user's mobile number is not yet
|
|
9
|
+
* verified. Closes it if it is verified.
|
|
10
|
+
*/
|
|
11
|
+
export function useAutoOpenWhenUnverified() {
|
|
12
|
+
const { setOpen } = useGlobalStore(useShallow((ctx) => ({
|
|
13
|
+
setOpen: ctx.updateMobilePhoneNumber.setOpen,
|
|
14
|
+
})));
|
|
15
|
+
const accountQuery = useAccountQuery();
|
|
16
|
+
const account = accountQuery.data;
|
|
17
|
+
const isAccountLoading = accountQuery.isLoading;
|
|
18
|
+
const isMobileNumberVerified = account?.mobileNumberVerified === true;
|
|
19
|
+
const hasExecuted = useRef(false);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!isAccountLoading && !!account && !hasExecuted.current) {
|
|
22
|
+
if (!isMobileNumberVerified) {
|
|
23
|
+
setOpen(true);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
setOpen(false);
|
|
27
|
+
}
|
|
28
|
+
hasExecuted.current = true;
|
|
29
|
+
}
|
|
30
|
+
}, [isAccountLoading, account, isMobileNumberVerified, setOpen]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface UseLogoutOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Called after FCM unregister and signOut.mutate() but before the final
|
|
4
|
+
* router navigation. Use this to close any related dialogs.
|
|
5
|
+
*/
|
|
6
|
+
onBeforeRedirect?: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function useLogout(options?: UseLogoutOptions): {
|
|
9
|
+
logout: () => Promise<void>;
|
|
10
|
+
isPending: boolean;
|
|
11
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Capacitor } from '@capacitor/core';
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import invariant from 'tiny-invariant';
|
|
5
|
+
import { useSignOutMutation } from '../../../client/hooks/useSignOutMutation.js';
|
|
6
|
+
import { getSession } from '../../../client/services/getSession.js';
|
|
7
|
+
import { BIOMETRIC_STORAGE_KEY } from '../../../client/utils/biometric.js';
|
|
8
|
+
import { unregisterFCMDevice } from '../../../services/trigger.js';
|
|
9
|
+
import { getQueryClient } from '../../../utils/getQueryClient.js';
|
|
10
|
+
import { getSessionQueryKey } from '../../../utils/queryKeys.js';
|
|
11
|
+
export function useLogout(options = {}) {
|
|
12
|
+
const { onBeforeRedirect } = options;
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const signOutMutation = useSignOutMutation({
|
|
15
|
+
async onSuccess() {
|
|
16
|
+
// Clear everything except the 'biometric' entry
|
|
17
|
+
const keep = new Set([BIOMETRIC_STORAGE_KEY]);
|
|
18
|
+
for (let i = 0; i < localStorage.length;) {
|
|
19
|
+
const key = localStorage.key(i);
|
|
20
|
+
if (key && !keep.has(key)) {
|
|
21
|
+
localStorage.removeItem(key);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
sessionStorage.clear();
|
|
28
|
+
router.replace('/');
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const logout = async () => {
|
|
32
|
+
if (Capacitor.isNativePlatform()) {
|
|
33
|
+
const session = await getQueryClient().fetchQuery({
|
|
34
|
+
queryKey: getSessionQueryKey(),
|
|
35
|
+
queryFn: async () => getSession(),
|
|
36
|
+
});
|
|
37
|
+
invariant(session.status === 'authenticated');
|
|
38
|
+
await unregisterFCMDevice({
|
|
39
|
+
type: ['IOS', 'ANDROID'],
|
|
40
|
+
}, {
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${session.token}`,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
signOutMutation.mutate();
|
|
47
|
+
onBeforeRedirect?.();
|
|
48
|
+
router.push('/');
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
logout,
|
|
52
|
+
isPending: signOutMutation.isPending,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the zod schemas used by the two-step mobile number update form.
|
|
4
|
+
* Schemas are memoized against the current mobile number parser so the
|
|
5
|
+
* `superRefine` validation always uses the latest parser instance.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useMobileNumberSchemas(): {
|
|
8
|
+
Step1Definition: z.ZodObject<{
|
|
9
|
+
mobileNumber: z.ZodEffects<z.ZodString, string, string>;
|
|
10
|
+
}, "strip", z.ZodTypeAny, {
|
|
11
|
+
mobileNumber: string;
|
|
12
|
+
}, {
|
|
13
|
+
mobileNumber: string;
|
|
14
|
+
}>;
|
|
15
|
+
Step2Definition: z.ZodObject<{
|
|
16
|
+
verificationCode: z.ZodEffects<z.ZodArray<z.ZodString, "many">, string[], string[]>;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
verificationCode: string[];
|
|
19
|
+
}, {
|
|
20
|
+
verificationCode: string[];
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import z from 'zod';
|
|
4
|
+
import { useMobileNumberParser } from '../../../client/hooks/useMobileNumberParser.js';
|
|
5
|
+
/**
|
|
6
|
+
* Returns the zod schemas used by the two-step mobile number update form.
|
|
7
|
+
* Schemas are memoized against the current mobile number parser so the
|
|
8
|
+
* `superRefine` validation always uses the latest parser instance.
|
|
9
|
+
*/
|
|
10
|
+
export function useMobileNumberSchemas() {
|
|
11
|
+
const mobileNumberParser = useMobileNumberParser();
|
|
12
|
+
return useMemo(() => {
|
|
13
|
+
const Step1Definition = z.object({
|
|
14
|
+
mobileNumber: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1, 'Mobile number is required')
|
|
17
|
+
.superRefine((v, ctx) => {
|
|
18
|
+
if (!mobileNumberParser.validate(v)) {
|
|
19
|
+
ctx.addIssue({
|
|
20
|
+
code: z.ZodIssueCode.custom,
|
|
21
|
+
message: 'Invalid mobile number',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
const Step2Definition = z.object({
|
|
27
|
+
verificationCode: z.array(z.string()).superRefine((val, ctx) => {
|
|
28
|
+
if (val.length !== 6 || val.some((v) => v.length !== 1)) {
|
|
29
|
+
ctx.addIssue({
|
|
30
|
+
code: z.ZodIssueCode.custom,
|
|
31
|
+
message: 'Please enter your 6-digit verification code.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
return { Step1Definition, Step2Definition };
|
|
37
|
+
}, [mobileNumberParser]);
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encapsulates the entire two-step mobile number update flow:
|
|
3
|
+
* - step state
|
|
4
|
+
* - step 1 / step 2 forms (with their zod resolvers)
|
|
5
|
+
* - send-verification-code mutation (advances to step 2 + starts cooldown)
|
|
6
|
+
* - update-mobile-number mutation (closes dialog, refetches account,
|
|
7
|
+
* opens KYC if appropriate)
|
|
8
|
+
* - resend cooldown
|
|
9
|
+
* - helpers used by the two step components
|
|
10
|
+
*/
|
|
11
|
+
export declare function useUpdateMobileFlow(): {
|
|
12
|
+
step: number;
|
|
13
|
+
step1Form: import("react-hook-form").UseFormReturn<{
|
|
14
|
+
mobileNumber: string;
|
|
15
|
+
}, unknown, {
|
|
16
|
+
mobileNumber: string;
|
|
17
|
+
}>;
|
|
18
|
+
step2Form: import("react-hook-form").UseFormReturn<{
|
|
19
|
+
verificationCode: string[];
|
|
20
|
+
}, unknown, {
|
|
21
|
+
verificationCode: string[];
|
|
22
|
+
}>;
|
|
23
|
+
cooldown: import("../../../client/hooks/useCooldown").UseCooldownReturn;
|
|
24
|
+
formRef: import("react").RefObject<HTMLFormElement | null>;
|
|
25
|
+
mobileNumberParser: {
|
|
26
|
+
validate: (value: unknown) => value is string;
|
|
27
|
+
format: (value: string) => string;
|
|
28
|
+
parse: (value: string) => {
|
|
29
|
+
mobileNumber: string;
|
|
30
|
+
areaCode: string;
|
|
31
|
+
};
|
|
32
|
+
equals: (mobileNumber0: unknown, mobileNumber1: unknown) => boolean;
|
|
33
|
+
};
|
|
34
|
+
submitStep1: (e?: React.BaseSyntheticEvent) => Promise<void>;
|
|
35
|
+
submitStep2: (e?: React.BaseSyntheticEvent) => Promise<void>;
|
|
36
|
+
resend: () => Promise<void>;
|
|
37
|
+
goBackToStep1: () => void;
|
|
38
|
+
};
|
|
39
|
+
export type UseUpdateMobileFlowReturn = ReturnType<typeof useUpdateMobileFlow>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { useForm } from 'react-hook-form';
|
|
5
|
+
import { useShallow } from 'zustand/shallow';
|
|
6
|
+
import { useAccountQuery } from '../../../client/hooks/useAccountQuery.js';
|
|
7
|
+
import { useCooldown } from '../../../client/hooks/useCooldown.js';
|
|
8
|
+
import { useGlobalStore } from '../../../client/hooks/useGlobalStore.js';
|
|
9
|
+
import { useMobileNumberParser } from '../../../client/hooks/useMobileNumberParser.js';
|
|
10
|
+
import { useSendVerificationCodeMutation } from '../../../client/hooks/useSendVerificationCodeMutation.js';
|
|
11
|
+
import { useUpdateMobileNumber } from '../../../client/hooks/useUpdateMobileNumber.js';
|
|
12
|
+
import { toaster } from '../../../client/utils/toaster.js';
|
|
13
|
+
import { explainOtpError } from '../utils/explainOtpError.js';
|
|
14
|
+
import { useMobileNumberSchemas } from './useMobileNumberSchemas.js';
|
|
15
|
+
/**
|
|
16
|
+
* Encapsulates the entire two-step mobile number update flow:
|
|
17
|
+
* - step state
|
|
18
|
+
* - step 1 / step 2 forms (with their zod resolvers)
|
|
19
|
+
* - send-verification-code mutation (advances to step 2 + starts cooldown)
|
|
20
|
+
* - update-mobile-number mutation (closes dialog, refetches account,
|
|
21
|
+
* opens KYC if appropriate)
|
|
22
|
+
* - resend cooldown
|
|
23
|
+
* - helpers used by the two step components
|
|
24
|
+
*/
|
|
25
|
+
export function useUpdateMobileFlow() {
|
|
26
|
+
const globalStore = useGlobalStore(useShallow((ctx) => ({
|
|
27
|
+
updateMobilePhoneNumber: ctx.updateMobilePhoneNumber,
|
|
28
|
+
kyc: ctx.kyc,
|
|
29
|
+
})));
|
|
30
|
+
const accountQuery = useAccountQuery();
|
|
31
|
+
const mobileNumberParser = useMobileNumberParser();
|
|
32
|
+
const { Step1Definition, Step2Definition } = useMobileNumberSchemas();
|
|
33
|
+
const [step, setStep] = useState(1);
|
|
34
|
+
const step1Form = useForm({
|
|
35
|
+
resolver: zodResolver(Step1Definition),
|
|
36
|
+
defaultValues: {
|
|
37
|
+
mobileNumber: '',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const step2Form = useForm({
|
|
41
|
+
resolver: zodResolver(Step2Definition),
|
|
42
|
+
defaultValues: {
|
|
43
|
+
verificationCode: Array.from({ length: 6 }).fill(''),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const cooldown = useCooldown({
|
|
47
|
+
max: 60,
|
|
48
|
+
duration: 1000 * 60,
|
|
49
|
+
});
|
|
50
|
+
const formRef = useRef(null);
|
|
51
|
+
const sendVerificationCodeMutation = useSendVerificationCodeMutation({
|
|
52
|
+
onSuccess: () => {
|
|
53
|
+
setStep(2);
|
|
54
|
+
cooldown.start();
|
|
55
|
+
},
|
|
56
|
+
onError: (err) => {
|
|
57
|
+
toaster.error({
|
|
58
|
+
title: 'Unable to Send Code',
|
|
59
|
+
description: explainOtpError(err, {
|
|
60
|
+
cooldown: {
|
|
61
|
+
cooling: cooldown.cooling,
|
|
62
|
+
countdown: cooldown.countdown,
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const updateMobileNumberMutation = useUpdateMobileNumber({
|
|
69
|
+
onSuccess: async () => {
|
|
70
|
+
step1Form.reset();
|
|
71
|
+
step2Form.reset();
|
|
72
|
+
setStep(1);
|
|
73
|
+
toaster.success({
|
|
74
|
+
title: 'Verification Successful',
|
|
75
|
+
description: 'Your mobile number has been verified.',
|
|
76
|
+
});
|
|
77
|
+
globalStore.updateMobilePhoneNumber.setOpen(false);
|
|
78
|
+
// Refetch the account to get the latest verification status before
|
|
79
|
+
// deciding whether to open the KYC modal. The `account` captured in
|
|
80
|
+
// this closure may be stale (e.g. a `verification: null` snapshot)
|
|
81
|
+
// even though the user is already VERIFIED.
|
|
82
|
+
const { data: freshAccount } = await accountQuery.refetch();
|
|
83
|
+
// If the user is already verified by any of the available signals,
|
|
84
|
+
// do NOT open the KYC modal. Mirrors `isKycCompleted` in
|
|
85
|
+
// KycOpenOnHomeMount.tsx so behavior stays consistent.
|
|
86
|
+
const isKycCompleted = freshAccount?.verified === true ||
|
|
87
|
+
freshAccount?.verification?.status === 'APPROVED' ||
|
|
88
|
+
freshAccount?.verification?.status === 'VERIFIED' ||
|
|
89
|
+
freshAccount?.verificationStatus === 'VERIFIED';
|
|
90
|
+
if (isKycCompleted) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Only open KYC if the user is explicitly UNVERIFIED.
|
|
94
|
+
const isUnverified = freshAccount?.verification === null ||
|
|
95
|
+
freshAccount?.verification === undefined ||
|
|
96
|
+
freshAccount?.verification?.status === 'UNVERIFIED' ||
|
|
97
|
+
freshAccount?.verification?.status === 'CREATED' ||
|
|
98
|
+
freshAccount?.verificationStatus === 'UNVERIFIED';
|
|
99
|
+
if (isUnverified) {
|
|
100
|
+
globalStore.kyc.setOpen(true);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onError: (err) => {
|
|
104
|
+
const errorMessage = err.message === 'Internal Server Error'
|
|
105
|
+
? `mobile number ${mobileNumberParser.format(step1Form.getValues('mobileNumber'))} is not available`
|
|
106
|
+
: explainOtpError(err);
|
|
107
|
+
toaster.error({
|
|
108
|
+
title: 'Verification Failed',
|
|
109
|
+
description: errorMessage,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
// -- Helpers consumed by the step components ------------------------------
|
|
114
|
+
const submitStep1 = step1Form.handleSubmit(async (data) => {
|
|
115
|
+
sendVerificationCodeMutation.mutateAsync({
|
|
116
|
+
channel: 'SMS',
|
|
117
|
+
recipient: mobileNumberParser.format(data.mobileNumber),
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
const submitStep2 = step2Form.handleSubmit(async ({ verificationCode }) => {
|
|
121
|
+
updateMobileNumberMutation.mutateAsync({
|
|
122
|
+
mobileNumber: mobileNumberParser.format(step1Form.getValues('mobileNumber')),
|
|
123
|
+
verificationCode: verificationCode.join(''),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
const resend = async () => {
|
|
127
|
+
await sendVerificationCodeMutation.mutateAsync({
|
|
128
|
+
channel: 'SMS',
|
|
129
|
+
recipient: mobileNumberParser.format(step1Form.getValues('mobileNumber')),
|
|
130
|
+
});
|
|
131
|
+
cooldown.start();
|
|
132
|
+
};
|
|
133
|
+
const goBackToStep1 = () => {
|
|
134
|
+
setStep(1);
|
|
135
|
+
step2Form.reset();
|
|
136
|
+
// NOTE: deliberately do NOT stop the cooldown here. The server still
|
|
137
|
+
// enforces its own resend window, so keeping our local timer running
|
|
138
|
+
// lets us surface an accurate "wait Xs" message if the user tries to
|
|
139
|
+
// send the code again from step 1 before the window elapses.
|
|
140
|
+
// Dismiss any lingering toasts (e.g. error toast from a previous send)
|
|
141
|
+
// so they don't carry over to step 1.
|
|
142
|
+
toaster.dismiss();
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
step,
|
|
146
|
+
step1Form,
|
|
147
|
+
step2Form,
|
|
148
|
+
cooldown,
|
|
149
|
+
formRef,
|
|
150
|
+
mobileNumberParser,
|
|
151
|
+
submitStep1,
|
|
152
|
+
submitStep2,
|
|
153
|
+
resend,
|
|
154
|
+
goBackToStep1,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ExplainOtpErrorContext {
|
|
2
|
+
/**
|
|
3
|
+
* Live cooldown info from the OTP flow. When the cooldown is actively
|
|
4
|
+
* running, NOT_READY_TO_SEND_VERIFICATION_ERROR will be rendered with the
|
|
5
|
+
* remaining seconds so the user knows exactly how long to wait.
|
|
6
|
+
*/
|
|
7
|
+
cooldown?: {
|
|
8
|
+
cooling: boolean;
|
|
9
|
+
countdown: number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export declare function explainOtpError(error: {
|
|
13
|
+
name?: string;
|
|
14
|
+
message?: string;
|
|
15
|
+
}, context?: ExplainOtpErrorContext): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-friendly messages for OTP / verification error codes thrown by
|
|
3
|
+
* `httpRequest` (see services/httpRequest.ts -> getErrorMessage).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the `explainError` pattern used by the DepositWithdrawal feature.
|
|
6
|
+
* The lookup is by the thrown `Error.name` (which `httpRequest` sets to the
|
|
7
|
+
* upstream error code, e.g. "NOT_READY_TO_SEND_VERIFICATION_ERROR").
|
|
8
|
+
*
|
|
9
|
+
* For unknown codes, callers should fall back to `error.message` so that
|
|
10
|
+
* `getErrorMessage` in httpRequest.ts still drives the wording.
|
|
11
|
+
*/
|
|
12
|
+
const errorMap = {
|
|
13
|
+
NOT_READY_TO_SEND_VERIFICATION_ERROR: 'Please wait a moment before requesting another verification code.',
|
|
14
|
+
RATE_LIMIT_REACH: 'You have made too many attempts. Please wait a few minutes and try again.',
|
|
15
|
+
};
|
|
16
|
+
export function explainOtpError(error, context = {}) {
|
|
17
|
+
const code = error.name;
|
|
18
|
+
if (code === 'NOT_READY_TO_SEND_VERIFICATION_ERROR') {
|
|
19
|
+
const cooldown = context.cooldown;
|
|
20
|
+
if (cooldown?.cooling && cooldown.countdown > 0) {
|
|
21
|
+
return `Please wait ${Math.ceil(cooldown.countdown)}s before requesting another verification code.`;
|
|
22
|
+
}
|
|
23
|
+
return errorMap.NOT_READY_TO_SEND_VERIFICATION_ERROR;
|
|
24
|
+
}
|
|
25
|
+
if (code && errorMap[code]) {
|
|
26
|
+
return errorMap[code];
|
|
27
|
+
}
|
|
28
|
+
return error.message ?? 'An unexpected error occurred. Please try again.';
|
|
29
|
+
}
|
|
@@ -105,13 +105,22 @@ const KnownErrorSchema = z
|
|
|
105
105
|
message: z.string().optional(),
|
|
106
106
|
})
|
|
107
107
|
.optional(),
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// `error` can be either:
|
|
109
|
+
// - an object (Inplay `/v3/inplay/sessions`):
|
|
110
|
+
// { "error": { "type": "INVALID_CREDENTIALS", "message": "..." } }
|
|
111
|
+
// - a plain string (e.g. NestJS default 403 body):
|
|
112
|
+
// { "message": "NOT_READY_TO_SEND_VERIFICATION_ERROR",
|
|
113
|
+
// "error": "Forbidden", "statusCode": 403 }
|
|
114
|
+
// In the latter case the actual error code lives in `message`, so we
|
|
115
|
+
// must fall through to it instead of dropping the whole payload.
|
|
110
116
|
error: z
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
.union([
|
|
118
|
+
z.string(),
|
|
119
|
+
z.object({
|
|
120
|
+
type: z.string().optional(),
|
|
121
|
+
message: z.string().optional(),
|
|
122
|
+
}),
|
|
123
|
+
])
|
|
115
124
|
.optional(),
|
|
116
125
|
})
|
|
117
126
|
.nullable()
|
|
@@ -126,10 +135,13 @@ const KnownErrorSchema = z
|
|
|
126
135
|
v.message ??
|
|
127
136
|
null);
|
|
128
137
|
}
|
|
129
|
-
if (v?.error) {
|
|
138
|
+
if (v?.error && typeof v.error === 'object') {
|
|
130
139
|
return v.error.type ?? v.error.message ?? null;
|
|
131
140
|
}
|
|
132
|
-
|
|
141
|
+
// `error` is a string (e.g. "Forbidden") or absent: prefer the explicit
|
|
142
|
+
// `code` / `message` fields (which carry the actual error code), and
|
|
143
|
+
// only fall back to the `error` string itself as a last resort.
|
|
144
|
+
return (v?.code ?? v?.message ?? (typeof v?.error === 'string' ? v.error : null));
|
|
133
145
|
});
|
|
134
146
|
function getErrorMessage(code) {
|
|
135
147
|
switch (code) {
|