@opexa/portal-components 0.1.5 → 0.1.6

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.
Files changed (20) hide show
  1. package/dist/client/hooks/useCooldown.d.ts +2 -3
  2. package/dist/components/UpdateMobilePhoneNumber/UpdateMobilePhoneNumber.js +8 -171
  3. package/dist/components/UpdateMobilePhoneNumber/components/LogoutButton.d.ts +6 -0
  4. package/dist/components/UpdateMobilePhoneNumber/components/LogoutButton.js +26 -0
  5. package/dist/components/UpdateMobilePhoneNumber/components/Step1MobileNumberForm.d.ts +6 -0
  6. package/dist/components/UpdateMobilePhoneNumber/components/Step1MobileNumberForm.js +12 -0
  7. package/dist/components/UpdateMobilePhoneNumber/components/Step2VerificationForm.d.ts +12 -0
  8. package/dist/components/UpdateMobilePhoneNumber/components/Step2VerificationForm.js +19 -0
  9. package/dist/components/UpdateMobilePhoneNumber/hooks/useAutoOpenWhenUnverified.d.ts +6 -0
  10. package/dist/components/UpdateMobilePhoneNumber/hooks/useAutoOpenWhenUnverified.js +31 -0
  11. package/dist/components/UpdateMobilePhoneNumber/hooks/useLogout.d.ts +11 -0
  12. package/dist/components/UpdateMobilePhoneNumber/hooks/useLogout.js +54 -0
  13. package/dist/components/UpdateMobilePhoneNumber/hooks/useMobileNumberSchemas.d.ts +22 -0
  14. package/dist/components/UpdateMobilePhoneNumber/hooks/useMobileNumberSchemas.js +38 -0
  15. package/dist/components/UpdateMobilePhoneNumber/hooks/useUpdateMobileFlow.d.ts +39 -0
  16. package/dist/components/UpdateMobilePhoneNumber/hooks/useUpdateMobileFlow.js +156 -0
  17. package/dist/components/UpdateMobilePhoneNumber/utils/explainOtpError.d.ts +15 -0
  18. package/dist/components/UpdateMobilePhoneNumber/utils/explainOtpError.js +29 -0
  19. package/dist/services/httpRequest.js +20 -8
  20. 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 {};
@@ -1,192 +1,29 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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 accountQuery = useAccountQuery();
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 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "font-bold text-sm", children: ["Get ", _jsx("span", { className: "text-[#F05127]", children: "\u20B150 Bonus" }), " when you verify your account and play."] }), _jsxs("form", { className: "mt-3xl", onSubmit: step1Form.handleSubmit(async (data) => {
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,6 @@
1
+ /**
2
+ * Logout button used inside the UpdateMobilePhoneNumber dialog. Closes the
3
+ * related account dialogs alongside the mobile number dialog before signing
4
+ * the user out.
5
+ */
6
+ export declare function LogoutButton(): import("react/jsx-runtime").JSX.Element;
@@ -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,6 @@
1
+ /**
2
+ * Opens the UpdateMobilePhoneNumber dialog automatically (once per mount) if
3
+ * the account has finished loading and the user's mobile number is not yet
4
+ * verified. Closes it if it is verified.
5
+ */
6
+ export declare function useAutoOpenWhenUnverified(): void;
@@ -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
- // Shape returned by the Inplay `/v3/inplay/sessions` endpoint, e.g.:
109
- // { "error": { "type": "INVALID_CREDENTIALS", "message": "..." } }
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
- .object({
112
- type: z.string().optional(),
113
- message: z.string().optional(),
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
- return v?.code ?? v?.message ?? null;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opexa/portal-components",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "exports": {
5
5
  "./ui/*": {
6
6
  "types": "./dist/ui/*/index.d.ts",