From a30e16bf60ba623a08c3124fd44b54b1f080798f Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 19 Jun 2025 10:45:57 +0300 Subject: [PATCH] PM-1374 - require otp when withdrawing --- .../tabs/winnings/ConfirmPayment.modal.tsx | 161 +++++++++++++++++ .../src/home/tabs/winnings/WinningsTab.tsx | 166 +++--------------- .../src/lib/components/otp-modal/OtpModal.tsx | 43 ++--- .../src/lib/components/otp-modal/index.ts | 1 + .../components/otp-modal/use-otp-modal.tsx | 48 +++++ src/apps/wallet/src/lib/models/ApiResponse.ts | 1 + .../wallet/src/lib/models/ConfirmFlowData.ts | 6 - src/apps/wallet/src/lib/services/wallet.ts | 10 +- .../modals/confirm/ConfirmModal.tsx | 66 +++---- 9 files changed, 293 insertions(+), 209 deletions(-) create mode 100644 src/apps/wallet/src/home/tabs/winnings/ConfirmPayment.modal.tsx create mode 100644 src/apps/wallet/src/lib/components/otp-modal/use-otp-modal.tsx delete mode 100644 src/apps/wallet/src/lib/models/ConfirmFlowData.ts diff --git a/src/apps/wallet/src/home/tabs/winnings/ConfirmPayment.modal.tsx b/src/apps/wallet/src/home/tabs/winnings/ConfirmPayment.modal.tsx new file mode 100644 index 000000000..17061b962 --- /dev/null +++ b/src/apps/wallet/src/home/tabs/winnings/ConfirmPayment.modal.tsx @@ -0,0 +1,161 @@ +/* eslint-disable max-len */ +/* eslint-disable react/jsx-no-bind */ +import { AxiosError } from 'axios' +import { Link } from 'react-router-dom' +import { toast } from 'react-toastify' +import { FC, useMemo, useState } from 'react' + +import { ConfirmModal } from '~/libs/ui' + +import { processWinningsPayments } from '../../../lib/services/wallet' +import { WalletDetails } from '../../../lib/models/WalletDetails' +import { Winning } from '../../../lib/models/WinningDetail' +import { nullToZero } from '../../../lib/util' +import { useOtpModal } from '../../../lib/components/otp-modal' + +import styles from './Winnings.module.scss' + +interface ConfirmPaymentModalProps { + userEmail: string; + payments: Winning[] + walletDetails: WalletDetails + onClose: (done?: boolean) => void +} + +const ConfirmPaymentModal: FC = props => { + const [otpModal, collectOtp] = useOtpModal(props.userEmail) + const [isProcessing, setIsProcessing] = useState(false) + + const winningIds = useMemo(() => props.payments.map(p => p.id), [props.payments]) + const totalAmount = useMemo(() => props.payments.reduce((acc, payment) => acc + parseFloat(payment.grossPayment.replace(/[^0-9.-]+/g, '')), 0), [props.payments]) + const taxWithholdAmount = (parseFloat(nullToZero(props.walletDetails.taxWithholdingPercentage ?? '0')) * totalAmount) / 100 + const feesAmount = parseFloat(nullToZero(props.walletDetails.estimatedFees ?? '0')) + const netAmount = totalAmount - taxWithholdAmount - feesAmount + + const processPayouts = async (otpCode?: string): Promise => { + setIsProcessing(true) + if (!otpCode) { + toast.info('Processing payments...', { + position: toast.POSITION.BOTTOM_RIGHT, + }) + } + + try { + await processWinningsPayments(winningIds, otpCode) + toast.success('Payments processed successfully!', { + position: toast.POSITION.BOTTOM_RIGHT, + }) + props.onClose(true) + } catch (error) { + if ((error as any)?.code?.startsWith('otp_')) { + toast.info((error as any).message) + const code = await collectOtp((error as any)?.message) + if (code) { + processPayouts(code as string) + } else { + setIsProcessing(false) + } + + return + } + + let message = 'Failed to process payments. Please try again later.' + + if (error instanceof AxiosError) { + message = error.response?.data?.error?.message ?? error.response?.data?.message ?? error.message ?? '' + + message = message.charAt(0) + .toUpperCase() + message.slice(1) + } + + toast.error(message, { + position: toast.POSITION.BOTTOM_RIGHT, + }) + } + + setIsProcessing(false) + } + + return ( + <> + props.onClose()} + onConfirm={processPayouts} + isProcessing={isProcessing} + open + > +
+ Processing Payment: $ + {totalAmount.toFixed(2)} + {' '} +
+ {props.walletDetails && ( + <> +
+

Payment Breakdown:

+
    +
  • + Base amount: + + $ + {totalAmount.toFixed(2)} + +
  • +
  • + + Tax Witholding ( + {nullToZero(props.walletDetails.taxWithholdingPercentage)} + %): + + + $ + {taxWithholdAmount.toFixed(2)} + +
  • +
  • + Processing fee: + + $ + {feesAmount.toFixed(2)} + +
  • +
+
+
+ Net amount after fees: + + $ + {netAmount.toFixed(2)} + +
+ {props.walletDetails?.primaryCurrency && props.walletDetails.primaryCurrency !== 'USD' && ( +
+ Net amount will be converted to + {' '} + {props.walletDetails.primaryCurrency} + {' '} + with a 2% conversion fee applied. +
+ )} +
+
+ You can adjust your payout settings to customize your estimated payment fee + and tax withholding percentage in the + {' '} + Payout + {' '} + section. +
+ + )} +
+ {otpModal} + + ) +} + +export default ConfirmPaymentModal diff --git a/src/apps/wallet/src/home/tabs/winnings/WinningsTab.tsx b/src/apps/wallet/src/home/tabs/winnings/WinningsTab.tsx index ea3a25a95..bd12c0324 100644 --- a/src/apps/wallet/src/home/tabs/winnings/WinningsTab.tsx +++ b/src/apps/wallet/src/home/tabs/winnings/WinningsTab.tsx @@ -1,22 +1,19 @@ /* eslint-disable max-len */ /* eslint-disable react/jsx-no-bind */ -import { toast } from 'react-toastify' -import { AxiosError } from 'axios' -import { Link } from 'react-router-dom' import React, { FC, useCallback, useEffect } from 'react' -import { Collapsible, ConfirmModal, LoadingCircles } from '~/libs/ui' +import { Collapsible, LoadingCircles } from '~/libs/ui' import { UserProfile } from '~/libs/core' -import { getPayments, processWinningsPayments } from '../../../lib/services/wallet' +import { getPayments } from '../../../lib/services/wallet' import { Winning, WinningDetail } from '../../../lib/models/WinningDetail' import { FilterBar } from '../../../lib' -import { ConfirmFlowData } from '../../../lib/models/ConfirmFlowData' import { PaginationInfo } from '../../../lib/models/PaginationInfo' import { useWalletDetails, WalletDetailsResponse } from '../../../lib/hooks/use-wallet-details' -import { nullToZero } from '../../../lib/util' +import { WalletDetails } from '../../../lib/models/WalletDetails' import PaymentsTable from '../../../lib/components/payments-table/PaymentTable' +import ConfirmPaymentModal from './ConfirmPayment.modal' import styles from './Winnings.module.scss' interface ListViewProps { @@ -78,7 +75,7 @@ const formatCurrency = (amountStr: string, currency: string): string => { } const ListView: FC = (props: ListViewProps) => { - const [confirmFlow, setConfirmFlow] = React.useState(undefined) + const [confirmPayments, setConfirmPayments] = React.useState() const [winnings, setWinnings] = React.useState>([]) const [selectedPayments, setSelectedPayments] = React.useState<{ [paymentId: string]: Winning }>({}) const [isLoading, setIsLoading] = React.useState(false) @@ -146,133 +143,22 @@ const ListView: FC = (props: ListViewProps) => { } }, [props.profile.userId, convertToWinnings, filters, pagination.currentPage, pagination.pageSize]) - const renderConfirmModalContent = React.useMemo(() => { - if (confirmFlow?.content === undefined) { - return undefined - } - - if (typeof confirmFlow?.content === 'function') { - return confirmFlow?.content() - } - - return confirmFlow?.content - }, [confirmFlow]) - useEffect(() => { fetchWinnings() }, [fetchWinnings]) - const processPayouts = async (winningIds: string[]): Promise => { - setSelectedPayments({}) - - toast.info('Processing payments...', { - position: toast.POSITION.BOTTOM_RIGHT, - }) - try { - await processWinningsPayments(winningIds) - toast.success('Payments processed successfully!', { - position: toast.POSITION.BOTTOM_RIGHT, - }) - } catch (error) { - let message = 'Failed to process payments. Please try again later.' - - if (error instanceof AxiosError) { - message = error.response?.data?.error?.message ?? error.response?.data?.message ?? error.message ?? '' - - message = message.charAt(0) - .toUpperCase() + message.slice(1) - } - - toast.error(message, { - position: toast.POSITION.BOTTOM_RIGHT, - }) - } - - fetchWinnings() - } - function handlePayMeClick( - paymentIds: { [paymentId: string]: Winning }, - totalAmountStr: string, + payments: { [paymentId: string]: Winning }, ): void { - const totalAmount = parseFloat(totalAmountStr) - const taxWithholdAmount = (parseFloat(nullToZero(walletDetails?.taxWithholdingPercentage ?? '0')) * totalAmount) / 100 - const feesAmount = parseFloat(nullToZero(walletDetails?.estimatedFees ?? '0')) - const netAmount = totalAmount - taxWithholdAmount - feesAmount + setConfirmPayments(Object.values(payments)) + } - setConfirmFlow({ - action: 'Confirm Payment', - callback: () => processPayouts(Object.keys(paymentIds)), - content: ( - <> -
- Processing Payment: $ - {totalAmountStr} - {' '} -
- {walletDetails && ( - <> -
-

Payment Breakdown:

-
    -
  • - Base amount: - - $ - {totalAmountStr} - -
  • -
  • - - Tax Witholding ( - {nullToZero(walletDetails.taxWithholdingPercentage)} - %): - - - $ - {taxWithholdAmount.toFixed(2)} - -
  • -
  • - Processing fee: - - $ - {feesAmount.toFixed(2)} - -
  • -
-
-
- Net amount after fees: - - $ - {netAmount.toFixed(2)} - -
- {walletDetails?.primaryCurrency && walletDetails.primaryCurrency !== 'USD' && ( -
- Net amount will be converted to - {' '} - {walletDetails.primaryCurrency} - {' '} - with a 2% conversion fee applied. -
- )} -
-
- You can adjust your payout settings to customize your estimated payment fee - and tax withholding percentage in the - {' '} - Payout - {' '} - section. -
- - )} - - ), - title: 'Payment Confirmation', - }) + function handleCloseConfirmModal(isDone?: boolean): void { + setConfirmPayments(undefined) + setSelectedPayments({}) + if (isDone) { + fetchWinnings() + } } return ( @@ -461,23 +347,13 @@ const ListView: FC = (props: ListViewProps) => { - {confirmFlow && ( - -
{renderConfirmModalContent}
-
+ {confirmPayments && ( + )} ) diff --git a/src/apps/wallet/src/lib/components/otp-modal/OtpModal.tsx b/src/apps/wallet/src/lib/components/otp-modal/OtpModal.tsx index d91a0e976..b1db71928 100644 --- a/src/apps/wallet/src/lib/components/otp-modal/OtpModal.tsx +++ b/src/apps/wallet/src/lib/components/otp-modal/OtpModal.tsx @@ -1,11 +1,7 @@ -import { toast } from 'react-toastify' import OTPInput, { InputProps } from 'react-otp-input' -import React, { FC } from 'react' +import React, { FC, useEffect } from 'react' -import { BaseModal, LinkButton, LoadingCircles } from '~/libs/ui' -import { resendOtp, verifyOtp } from '~/apps/wallet/src/lib/services/wallet' - -import { OtpVerificationResponse } from '../../models/OtpVerificationResponse' +import { BaseModal, LoadingCircles } from '~/libs/ui' import styles from './OtpModal.module.scss' @@ -13,19 +9,17 @@ const RESEND_OTP_TIMEOUT = 60000 interface OtpModalProps { isOpen: boolean - key: string - transactionId: string + error?: string; userEmail?: string - isBlob?: boolean onClose: () => void - onOtpVerified: (data: unknown) => void + onOtpEntered: (code: string) => void } const OtpModal: FC = (props: OtpModalProps) => { const [otp, setOtp] = React.useState('') const [loading, setLoading] = React.useState(false) - const [error, setError] = React.useState('') - const [showResendButton, setShowResendButton] = React.useState(false) + const [error, setError] = React.useState() + const [, setShowResendButton] = React.useState(false) // eslint-disable-next-line consistent-return React.useEffect(() => { @@ -44,7 +38,7 @@ const OtpModal: FC = (props: OtpModalProps) => { } }, [props.isOpen]) - React.useEffect(() => { + useEffect(() => { if (!props.isOpen) { setOtp('') setError('') @@ -55,20 +49,16 @@ const OtpModal: FC = (props: OtpModalProps) => { setOtp(code) if (code.length === 6) { setLoading(true) - verifyOtp(props.transactionId, code, props.isBlob) - .then((response: OtpVerificationResponse | Blob) => { - setLoading(false) - props.onOtpVerified(response) - }) - .catch((err: Error) => { - setLoading(false) - setError(err.message) - }) + props.onOtpEntered(code) } else if (code.length < 6) { setError('') } } + useEffect(() => { + setError(props.error) + }, [props.error]) + return ( = (props: OtpModalProps) => {
{error &&

{error}

}

- For added security we’ve sent a 6-digit code to your + For added security we've sent a 6-digit code to your {' '} {props.userEmail ?? '***@gmail.com'} - {' '} - email. The code +  email. The code expires shortly, so please enter it soon.

= (props: OtpModalProps) => {

Can't find the code? Check your spam folder.

{loading && } - {!loading && showResendButton && ( + {/* {!loading && showResendButton && ( = (props: OtpModalProps) => { } }} /> - )} + )} */}
) diff --git a/src/apps/wallet/src/lib/components/otp-modal/index.ts b/src/apps/wallet/src/lib/components/otp-modal/index.ts index 9d973de1e..eb386ba0e 100644 --- a/src/apps/wallet/src/lib/components/otp-modal/index.ts +++ b/src/apps/wallet/src/lib/components/otp-modal/index.ts @@ -1 +1,2 @@ export { default as OtpModal } from './OtpModal' +export * from './use-otp-modal' diff --git a/src/apps/wallet/src/lib/components/otp-modal/use-otp-modal.tsx b/src/apps/wallet/src/lib/components/otp-modal/use-otp-modal.tsx new file mode 100644 index 000000000..95fc245e8 --- /dev/null +++ b/src/apps/wallet/src/lib/components/otp-modal/use-otp-modal.tsx @@ -0,0 +1,48 @@ +import { noop } from 'lodash' +import { ReactNode, useCallback, useState } from 'react' + +import OtpModal from './OtpModal' + +export const useOtpModal = (email: string): [ + ReactNode, + (error?: string) => Promise, + ] => { + const [isVisible, setIsVisible] = useState(false) + const [error, setError] = useState() + const [resolvePromise, setResolvePromise] = useState<(value?: boolean | string) => void + >(() => noop) + + const collectOtpCode = useCallback( + (errorMessage?: string) => { + setIsVisible(true) + setError(errorMessage) + + return new Promise(resolve => { + setResolvePromise(() => resolve) + }) + }, + [], + ) + + const handleCodeEntered = useCallback((code: string) => { + setIsVisible(false) + resolvePromise(code) + }, [resolvePromise]) + + const handleClose = useCallback(() => { + setIsVisible(false) + resolvePromise() + }, [resolvePromise]) + + const modal = isVisible ? ( + + ) : <> + + return [modal, collectOtpCode] +} diff --git a/src/apps/wallet/src/lib/models/ApiResponse.ts b/src/apps/wallet/src/lib/models/ApiResponse.ts index 3221e7793..e70769f9a 100644 --- a/src/apps/wallet/src/lib/models/ApiResponse.ts +++ b/src/apps/wallet/src/lib/models/ApiResponse.ts @@ -1,4 +1,5 @@ export default interface ApiResponse { status: 'success' | 'error' + error?: { code: string; message: string } data: T } diff --git a/src/apps/wallet/src/lib/models/ConfirmFlowData.ts b/src/apps/wallet/src/lib/models/ConfirmFlowData.ts deleted file mode 100644 index 29c7753e5..000000000 --- a/src/apps/wallet/src/lib/models/ConfirmFlowData.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface ConfirmFlowData { - title: string; - action: string; - content: React.ReactNode | (() => React.ReactNode) - callback?: () => void; -} diff --git a/src/apps/wallet/src/lib/services/wallet.ts b/src/apps/wallet/src/lib/services/wallet.ts index e557332b1..034a8490b 100644 --- a/src/apps/wallet/src/lib/services/wallet.ts +++ b/src/apps/wallet/src/lib/services/wallet.ts @@ -60,13 +60,21 @@ export async function getPayments(userId: string, limit: number, offset: number, return response.data } -export async function processWinningsPayments(winningsIds: string[]): Promise<{ processed: boolean }> { +export async function processWinningsPayments( + winningsIds: string[], + otpCode?: string, +): Promise<{ processed: boolean }> { const body = JSON.stringify({ + otpCode, winningsIds, }) const url = `${WALLET_API_BASE_URL}/withdraw` const response = await xhrPostAsync>(url, body) + if (response.status === 'error' && response.error?.code?.startsWith('otp_')) { + throw response.error + } + if (response.status === 'error') { throw new Error('Error processing payments') } diff --git a/src/libs/ui/lib/components/modals/confirm/ConfirmModal.tsx b/src/libs/ui/lib/components/modals/confirm/ConfirmModal.tsx index 75c5ccfba..388e3d723 100644 --- a/src/libs/ui/lib/components/modals/confirm/ConfirmModal.tsx +++ b/src/libs/ui/lib/components/modals/confirm/ConfirmModal.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useCallback } from 'react' import { ModalProps } from 'react-responsive-modal' import { Button } from '../../button' @@ -12,36 +12,42 @@ export interface ConfirmModalProps extends ModalProps { showButtons?: boolean maxWidth?: string size?: 'sm' | 'md' | 'lg' + isProcessing?: boolean } -const ConfirmModal: FC = (props: ConfirmModalProps) => ( - -