From 58df746160fb9c3e1fc57c150133458303bbb756 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:26:19 +0530 Subject: [PATCH 01/11] refactor: unify all KYC flows through Sumsub via useMultiPhaseKycFlow - Delete Bridge iframe integration: useBridgeKycFlow, InitiateBridgeKYCModal - Delete Manteca widget integration: useMantecaKycFlow, InitiateMantecaKYCModal - Extract shared useMultiPhaseKycFlow hook + SumsubKycModals component - Replace all KYC entry points with inline Sumsub flow: - Bridge bank flows use regionIntent: 'STANDARD' - Manteca flows use regionIntent: 'LATAM' - Add polling fallback in useSumsubKycFlow for missed WebSocket events - Relocate KycHistoryEntry type + isKycStatusItem guard to KycStatusItem - Net reduction: ~970 lines Co-Authored-By: Claude Opus 4.6 --- .../add-money/[country]/bank/page.tsx | 26 +- src/app/(mobile-ui)/history/page.tsx | 2 +- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 62 +--- .../AddMoney/components/MantecaAddMoney.tsx | 74 ++--- .../AddWithdraw/AddWithdrawCountriesList.tsx | 35 +-- .../Claim/Link/MantecaFlowManager.tsx | 49 +-- .../Claim/Link/views/BankFlowManager.view.tsx | 37 +-- src/components/Home/HomeHistory.tsx | 3 +- src/components/Kyc/InitiateBridgeKYCModal.tsx | 96 ------ .../Kyc/InitiateMantecaKYCModal.tsx | 164 ---------- src/components/Kyc/KycStatusDrawer.tsx | 81 +---- src/components/Kyc/KycStatusItem.tsx | 13 + src/components/Kyc/SumsubKycFlow.tsx | 276 +--------------- src/components/Kyc/SumsubKycModals.tsx | 47 +++ .../views/RegionsVerification.view.tsx | 68 +--- src/hooks/useBridgeKycFlow.ts | 188 ----------- src/hooks/useMantecaKycFlow.ts | 110 ------- src/hooks/useMultiPhaseKycFlow.ts | 295 ++++++++++++++++++ src/hooks/useSumsubKycFlow.ts | 24 ++ 19 files changed, 506 insertions(+), 1144 deletions(-) delete mode 100644 src/components/Kyc/InitiateBridgeKYCModal.tsx delete mode 100644 src/components/Kyc/InitiateMantecaKYCModal.tsx create mode 100644 src/components/Kyc/SumsubKycModals.tsx delete mode 100644 src/hooks/useBridgeKycFlow.ts delete mode 100644 src/hooks/useMantecaKycFlow.ts create mode 100644 src/hooks/useMultiPhaseKycFlow.ts diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index fb8fe265d..fb2fb5764 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -24,13 +24,14 @@ import { updateUserById } from '@/app/actions/users' import AddMoneyBankDetails from '@/components/AddMoney/components/AddMoneyBankDetails' import { getCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/bridge.utils' import { OnrampConfirmationModal } from '@/components/AddMoney/components/OnrampConfirmationModal' -import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' import InfoCard from '@/components/Global/InfoCard' import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { getLimitsWarningCardProps } from '@/features/limits/utils' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails' @@ -68,6 +69,18 @@ export default function OnrampBankPage() { const { user, fetchUser } = useAuth() const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp() + // inline sumsub kyc flow for bridge bank onramp + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'STANDARD', + onKycSuccess: () => { + setIsKycModalOpen(false) + setUrlState({ step: 'inputAmount' }) + }, + onManualClose: () => { + setIsKycModalOpen(false) + }, + }) + const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { @@ -312,7 +325,7 @@ export default function OnrampBankPage() { useEffect(() => { if (urlState.step === 'kyc') { - setIsKycModalOpen(true) + sumsubFlow.handleInitiateKyc() } }, [urlState.step]) @@ -374,13 +387,8 @@ export default function OnrampBankPage() { if (urlState.step === 'kyc') { return (
- router.push(`/add-money/${selectedCountry.path}`)} - flow="add" - /> + setUrlState({ step: 'collectUserDetails' })} /> +
) } diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 2ace526be..e9d9bae9a 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -12,7 +12,7 @@ import { useTransactionHistory } from '@/hooks/useTransactionHistory' import { useUserStore } from '@/redux/hooks' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import * as Sentry from '@sentry/nextjs' -import { isKycStatusItem } from '@/hooks/useBridgeKycFlow' +import { isKycStatusItem } from '@/components/Kyc/KycStatusItem' import { useAuth } from '@/context/authContext' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 8371f74f8..af29ed048 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -23,16 +23,15 @@ import AmountInput from '@/components/Global/AmountInput' import { formatUnits, parseUnits } from 'viem' import type { TransactionReceipt, Hash } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' import { useAuth } from '@/context/authContext' -import { useWebSocket } from '@/hooks/useWebSocket' import { useModalsContext } from '@/context/ModalsContext' import Select from '@/components/Global/Select' import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient } from '@tanstack/react-query' import { captureException } from '@sentry/nextjs' import useKycStatus from '@/hooks/useKycStatus' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' @@ -68,7 +67,6 @@ export default function MantecaWithdrawFlow() { const [selectedBank, setSelectedBank] = useState(null) const [accountType, setAccountType] = useState(null) const [errorMessage, setErrorMessage] = useState(null) - const [isKycModalOpen, setIsKycModalOpen] = useState(false) const [isDestinationAddressValid, setIsDestinationAddressValid] = useState(false) const [isDestinationAddressChanging, setIsDestinationAddressChanging] = useState(false) // price lock state - holds the locked price from /withdraw/init @@ -78,11 +76,16 @@ export default function MantecaWithdrawFlow() { const { sendMoney, balance } = useWallet() const { signTransferUserOp } = useSignUserOp() const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) - const { user, fetchUser } = useAuth() + const { user } = useAuth() const { setIsSupportModalOpen } = useModalsContext() const queryClient = useQueryClient() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserMantecaKycApproved } = useKycStatus() const { hasPendingTransactions } = usePendingTransactions() + + // inline sumsub kyc flow for manteca users who need LATAM verification + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'LATAM', + }) // Get method and country from URL parameters const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc. const countryFromUrl = searchParams.get('country') // argentina, brazil, etc. @@ -106,9 +109,6 @@ export default function MantecaWithdrawFlow() { isLoading: isCurrencyLoading, } = useCurrency(selectedCountry?.currency!) - // Initialize KYC flow hook - const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry }) - // validates withdrawal against user's limits // currency comes from country config - hook normalizes it internally const limitsValidation = useLimitsValidation({ @@ -117,19 +117,6 @@ export default function MantecaWithdrawFlow() { currency: selectedCountry?.currency, }) - // WebSocket listener for KYC status updates - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onMantecaKycStatusUpdate: (newStatus) => { - if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') { - fetchUser() - setIsKycModalOpen(false) - setStep('review') // Proceed to review after successful KYC - } - }, - }) - // Get country flag code const countryFlagCode = useMemo(() => { return selectedCountry?.iso2?.toLowerCase() @@ -200,14 +187,8 @@ export default function MantecaWithdrawFlow() { } setErrorMessage(null) - // check if we still need to determine KYC status - if (isMantecaKycRequired === null) { - return - } - - // check KYC status before proceeding to review - if (isMantecaKycRequired === true) { - setIsKycModalOpen(true) + if (!isUserMantecaKycApproved) { + await sumsubFlow.handleInitiateKyc() return } @@ -250,8 +231,9 @@ export default function MantecaWithdrawFlow() { usdAmount, currencyCode, currencyAmount, - isMantecaKycRequired, + isUserMantecaKycApproved, isLockingPrice, + sumsubFlow.handleInitiateKyc, ]) const handleWithdraw = async () => { @@ -342,7 +324,6 @@ export default function MantecaWithdrawFlow() { setSelectedBank(null) setAccountType(null) setErrorMessage(null) - setIsKycModalOpen(false) setIsDestinationAddressValid(false) setIsDestinationAddressChanging(false) setBalanceErrorMessage(null) @@ -468,6 +449,7 @@ export default function MantecaWithdrawFlow() { } return (
+ { @@ -650,22 +632,6 @@ export default function MantecaWithdrawFlow() { {errorMessage && }
- {/* KYC Modal */} - {isKycModalOpen && selectedCountry && ( - setIsKycModalOpen(false)} - onManualClose={() => setIsKycModalOpen(false)} - onKycSuccess={() => { - setIsKycModalOpen(false) - fetchUser() - setStep('review') - }} - selectedCountry={selectedCountry} - /> - )} )} diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index f7e902f18..62dbc1598 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -2,18 +2,17 @@ import { type FC, useEffect, useMemo, useState, useCallback } from 'react' import MantecaDepositShareDetails from '@/components/AddMoney/components/MantecaDepositShareDetails' import InputAmountStep from '@/components/AddMoney/components/InputAmountStep' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { type CountryData, countryData } from '@/components/AddMoney/consts' import { type MantecaDepositResponseData } from '@/types/manteca.types' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' import { useCurrency } from '@/hooks/useCurrency' import { useAuth } from '@/context/authContext' -import { useWebSocket } from '@/hooks/useWebSocket' import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' import { useQueryClient } from '@tanstack/react-query' import useKycStatus from '@/hooks/useKycStatus' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { MIN_MANTECA_DEPOSIT_AMOUNT } from '@/constants/payment.consts' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { TRANSACTIONS } from '@/constants/query.consts' @@ -28,7 +27,6 @@ type CurrencyDenomination = 'USD' | 'ARS' | 'BRL' | 'MXN' | 'EUR' const MantecaAddMoney: FC = () => { const params = useParams() - const router = useRouter() const queryClient = useQueryClient() // URL state - persisted in query params @@ -57,16 +55,19 @@ const MantecaAddMoney: FC = () => { const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [error, setError] = useState(null) const [depositDetails, setDepositDetails] = useState() - const [isKycModalOpen, setIsKycModalOpen] = useState(false) const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry as CountryData }) - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserMantecaKycApproved } = useKycStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') - const { user, fetchUser } = useAuth() + const { user } = useAuth() + + // inline sumsub kyc flow for manteca users who need LATAM verification + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'LATAM', + }) // validates deposit amount against user's limits // currency comes from country config - hook normalizes it internally @@ -76,18 +77,6 @@ const MantecaAddMoney: FC = () => { currency: selectedCountry?.currency, }) - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onMantecaKycStatusUpdate: (newStatus) => { - // listen for manteca kyc status updates, either when the user is approved or when the widget is finished to continue with the flow - if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') { - fetchUser() - setIsKycModalOpen(false) - } - }, - }) - // Validate USD amount (min check only - max is handled by limits validation) useEffect(() => { // if user hasn't entered any amount yet, don't show error @@ -118,13 +107,6 @@ const MantecaAddMoney: FC = () => { } }, [step, queryClient]) - const handleKycCancel = () => { - setIsKycModalOpen(false) - if (selectedCountry?.path) { - router.push(`/add-money/${selectedCountry.path}`) - } - } - // Handle displayed amount change - save to URL // This is called by AmountInput with the currently DISPLAYED value const handleDisplayedAmountChange = useCallback( @@ -156,14 +138,8 @@ const MantecaAddMoney: FC = () => { if (!selectedCountry?.currency) return if (isCreatingDeposit) return - // check if we still need to determine KYC status - if (isMantecaKycRequired === null) { - // still loading/determining KYC status, don't proceed yet - return - } - - if (isMantecaKycRequired === true) { - setIsKycModalOpen(true) + if (!isUserMantecaKycApproved) { + await sumsubFlow.handleInitiateKyc() return } @@ -191,14 +167,14 @@ const MantecaAddMoney: FC = () => { } finally { setIsCreatingDeposit(false) } - }, [currentDenomination, selectedCountry, displayedAmount, isMantecaKycRequired, isCreatingDeposit, setUrlState]) + }, [currentDenomination, selectedCountry, displayedAmount, isUserMantecaKycApproved, isCreatingDeposit, setUrlState, sumsubFlow.handleInitiateKyc]) - // handle verification modal opening + // auto-start KYC if user hasn't completed manteca verification useEffect(() => { - if (isMantecaKycRequired) { - setIsKycModalOpen(true) + if (!isUserMantecaKycApproved) { + sumsubFlow.handleInitiateKyc() } - }, [isMantecaKycRequired]) + }, [isUserMantecaKycApproved]) // eslint-disable-line react-hooks/exhaustive-deps // Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation) useEffect(() => { @@ -212,6 +188,7 @@ const MantecaAddMoney: FC = () => { if (step === 'inputAmount') { return ( <> + { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} /> - {isKycModalOpen && ( - { - // close the modal and let the user continue with amount input - setIsKycModalOpen(false) - fetchUser() - }} - selectedCountry={selectedCountry} - /> - )} ) } diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index de668ef0d..3707f0b9e 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -22,11 +22,12 @@ import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' -import { InitiateBridgeKYCModal } from '../Kyc/InitiateBridgeKYCModal' import useKycStatus from '@/hooks/useKycStatus' import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal' import { ActionListCard } from '@/components/ActionListCard' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -48,6 +49,16 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const { setSelectedBankAccount, amountToWithdraw, setSelectedMethod, setAmountToWithdraw } = useWithdrawFlow() const dispatch = useAppDispatch() + // inline sumsub kyc flow for bridge bank users who need verification + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'STANDARD', + onKycSuccess: () => { + setIsKycModalOpen(false) + setView('form') + }, + onManualClose: () => setIsKycModalOpen(false), + }) + // component level states const [view, setView] = useState<'list' | 'form'>(flow === 'withdraw' && amountToWithdraw ? 'form' : 'list') const [isKycModalOpen, setIsKycModalOpen] = useState(false) @@ -168,20 +179,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } } - setIsKycModalOpen(true) + await sumsubFlow.handleInitiateKyc() } return {} } - const handleKycSuccess = () => { - // only transition to form if this component initiated the KYC modal - if (isKycModalOpen) { - setIsKycModalOpen(false) - setView('form') - } - } - const handleWithdrawMethodClick = (method: SpecificPaymentMethod) => { // preserve method param only if coming from bank send flow (not crypto) const methodQueryParam = isBankFromSend ? `?method=${methodParam}` : '' @@ -312,11 +315,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) } @@ -431,11 +430,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { isKycApprovedModalOpen={showKycStatusModal} onClose={() => setShowKycStatusModal(false)} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) } diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index b27e54a4d..cb3e072a3 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -12,9 +12,8 @@ import MantecaReviewStep from './views/MantecaReviewStep' import { Button } from '@/components/0_Bruddle/Button' import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useAuth } from '@/context/authContext' -import { type CountryData } from '@/components/AddMoney/consts' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' interface MantecaFlowManagerProps { claimLinkData: ClaimLinkData @@ -27,35 +26,24 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS) const router = useRouter() const [destinationAddress, setDestinationAddress] = useState('') - const [isKYCModalOpen, setIsKYCModalOpen] = useState(false) - const argentinaCountryData = { - id: 'AR', - type: 'country', - title: 'Argentina', - currency: 'ARS', - path: 'argentina', - iso2: 'AR', - iso3: 'ARG', - } as CountryData + const { isUserMantecaKycApproved } = useKycStatus() - const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus() - const { fetchUser } = useAuth() + // inline sumsub kyc flow for manteca users who need LATAM verification + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'LATAM', + }) const isSuccess = currentStep === MercadoPagoStep.SUCCESS const selectedCurrency = selectedCountry?.currency || 'ARS' const regionalMethodLogo = regionalMethodType === 'mercadopago' ? MERCADO_PAGO : PIX const logo = selectedCountry?.id ? undefined : regionalMethodLogo - const handleKycCancel = () => { - setIsKYCModalOpen(false) - onPrev() - } - + // auto-start KYC if user hasn't completed manteca verification useEffect(() => { if (!isUserMantecaKycApproved) { - setIsKYCModalOpen(true) + sumsubFlow.handleInitiateKyc() } - }, [isUserMantecaKycApproved]) + }, [isUserMantecaKycApproved]) // eslint-disable-line react-hooks/exhaustive-deps const renderStepDetails = () => { if (currentStep === MercadoPagoStep.DETAILS) { @@ -125,23 +113,8 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount /> {renderStepDetails()} - - {isKYCModalOpen && ( - { - // close the modal and let the user continue with amount input - setIsKYCModalOpen(false) - fetchUser() - }} - selectedCountry={selectedCountry || argentinaCountryData} - /> - )} + ) } diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 2e6519249..50de618d1 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -31,8 +31,9 @@ import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' -import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' import { useSearchParams } from 'next/navigation' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' type BankAccountWithId = IBankAccountDetails & ( @@ -76,6 +77,19 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const { claimLink } = useClaimLink() const dispatch = useAppDispatch() + // inline sumsub kyc flow for users who need verification + const sumsubFlow = useMultiPhaseKycFlow({ + regionIntent: 'STANDARD', + onKycSuccess: async () => { + if (justCompletedKyc) return + setIsKycModalOpen(false) + await fetchUser() + setJustCompletedKyc(true) + setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) + }, + onManualClose: () => setIsKycModalOpen(false), + }) + // local states for this component const [localBankDetails, setLocalBankDetails] = useState(null) const [receiverFullName, setReceiverFullName] = useState('') @@ -257,7 +271,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } } - setIsKycModalOpen(true) + await sumsubFlow.handleInitiateKyc() return {} } @@ -391,19 +405,6 @@ export const BankFlowManager = (props: IClaimScreenProps) => { return {} } - /** - * @name handleKycSuccess - * @description callback for when the KYC process is successfully completed. - */ - const handleKycSuccess = useCallback(async () => { - if (justCompletedKyc) return - - setIsKycModalOpen(false) - await fetchUser() - setJustCompletedKyc(true) - setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) - }, [fetchUser, setClaimBankFlowStep, setIsKycModalOpen, setJustCompletedKyc, justCompletedKyc]) - // main render logic based on the current flow step switch (claimBankFlowStep) { case ClaimBankFlowStep.SavedAccountsList: @@ -492,11 +493,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { initialData={{}} error={error} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) case ClaimBankFlowStep.BankConfirmClaim: diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index c89f606b1..f0e5893d9 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -13,9 +13,8 @@ import { twMerge } from 'tailwind-merge' import Card from '../Global/Card' import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' -import { KycStatusItem } from '../Kyc/KycStatusItem' +import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem' import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' -import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow' import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx deleted file mode 100644 index dfd8b1887..000000000 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import ActionModal from '@/components/Global/ActionModal' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' -import IframeWrapper from '@/components/Global/IframeWrapper' -import { KycVerificationInProgressModal } from './KycVerificationInProgressModal' -import { type IconName } from '@/components/Global/Icons/Icon' -import { saveRedirectUrl } from '@/utils/general.utils' -import useClaimLink from '../Claim/useClaimLink' -import { useEffect } from 'react' - -interface BridgeKycModalFlowProps { - isOpen: boolean - onClose: () => void - onKycSuccess?: () => void - onManualClose?: () => void - flow?: 'add' | 'withdraw' | 'request_fulfillment' -} - -export const InitiateBridgeKYCModal = ({ - isOpen, - onClose, - onKycSuccess, - onManualClose, - flow, -}: BridgeKycModalFlowProps) => { - const { - isLoading, - error, - iframeOptions, - isVerificationProgressModalOpen, - handleInitiateKyc, - handleIframeClose, - closeVerificationProgressModal, - resetError, - } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) - const { addParamStep } = useClaimLink() - - // Reset error when modal opens to ensure clean state - useEffect(() => { - if (isOpen) { - resetError() - } - }, [isOpen, resetError]) - - const handleVerifyClick = async () => { - // Only add step param for claim flows (not add-money flow which has its own URL state) - if (flow !== 'add') { - addParamStep('bank') - } - const result = await handleInitiateKyc() - if (result?.success) { - saveRedirectUrl() - onClose() - } - } - - return ( - <> - - - - - ) -} diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx deleted file mode 100644 index 6f547e6f3..000000000 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client' - -import ActionModal from '@/components/Global/ActionModal' -import IframeWrapper from '@/components/Global/IframeWrapper' -import { type IconName } from '@/components/Global/Icons/Icon' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' -import { type CountryData } from '@/components/AddMoney/consts' -import { Button } from '@/components/0_Bruddle/Button' -import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' -import { useEffect } from 'react' - -interface Props { - isOpen: boolean - onClose: () => void - onKycSuccess?: () => void - onManualClose?: () => void - country: CountryData - title?: string | React.ReactNode - description?: string | React.ReactNode - ctaText?: string - footer?: React.ReactNode - autoStartKyc?: boolean -} - -const InitiateMantecaKYCModal = ({ - isOpen, - onClose, - onKycSuccess, - onManualClose, - country, - title, - description, - ctaText, - footer, - autoStartKyc, -}: Props) => { - const { isLoading, iframeOptions, openMantecaKyc, handleIframeClose } = useMantecaKycFlow({ - onClose: onManualClose, // any non-success close from iframe is a manual close in case of Manteca KYC - onSuccess: onKycSuccess, - onManualClose, - country, - }) - - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.data.source === 'peanut-kyc-success') { - onKycSuccess?.() - } - } - - window.addEventListener('message', handleMessage) - - return () => { - window.removeEventListener('message', handleMessage) - } - }, []) - - useEffect(() => { - if (autoStartKyc) { - openMantecaKyc(country) - } - }, [autoStartKyc]) - - const isAutoStarting = autoStartKyc && isLoading - const displayTitle = isAutoStarting ? 'Starting verification...' : (title ?? 'Verify your identity first') - const displayDescription = isAutoStarting - ? 'Please wait while we start your verification...' - : (description ?? - 'To continue, you need to complete identity verification. This usually takes just a few minutes.') - - return ( - <> - openMantecaKyc(country), - variant: 'purple', - disabled: isLoading, - shadowSize: '4', - icon: 'check-circle', - className: 'h-11', - }, - ]} - footer={footer} - /> - - - ) -} - -export const MantecaGeoSpecificKycModal = ({ - isUserBridgeKycApproved, - selectedCountry, - setIsMantecaModalOpen, - isMantecaModalOpen, - onKycSuccess, - onClose, - onManualClose, -}: { - isUserBridgeKycApproved: boolean - selectedCountry: { id: string; title: string } - setIsMantecaModalOpen: (isOpen: boolean) => void - isMantecaModalOpen: boolean - onKycSuccess: () => void - onClose?: () => void - onManualClose?: () => void -}) => { - return ( - - You're already verified in Europe, USA, and Mexico, but to use features in{' '} - {selectedCountry.title} you need to complete a separate verification.
Since{' '} - we don't keep personal data, your previous KYC can't be reused. -

- ) : ( -

- Verify your identity to start using features like Mercado Pago payments in{' '} - {selectedCountry.title}.{' '} -

- ) - } - footer={ - isUserBridgeKycApproved ? ( - - ) : ( - - ) - } - ctaText="Start Verification" - isOpen={isMantecaModalOpen} - onClose={() => { - setIsMantecaModalOpen(false) - onClose?.() - }} - onKycSuccess={() => { - setIsMantecaModalOpen(false) - onKycSuccess?.() - }} - onManualClose={() => { - setIsMantecaModalOpen(false) - onManualClose?.() - }} - country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }} - /> - ) -} diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 07b6b292d..74c21757c 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -3,17 +3,12 @@ import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' import { KycRequiresDocuments } from './states/KycRequiresDocuments' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type IUserKycVerification } from '@/interfaces' import { useUserStore } from '@/redux/hooks' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' -import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' -import { type CountryData, countryData } from '@/components/AddMoney/consts' -import IFrameWrapper from '@/components/Global/IframeWrapper' -import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' -import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' @@ -38,52 +33,17 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined ) as KYCRegionIntent | undefined - const { - handleInitiateKyc: initiateBridgeKyc, - iframeOptions: bridgeIframeOptions, - handleIframeClose: handleBridgeIframeClose, - isLoading: isBridgeLoading, - } = useBridgeKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) - - const country = countryCode ? countryData.find((c) => c.id.toUpperCase() === countryCode.toUpperCase()) : undefined - - const { - openMantecaKyc, - iframeOptions: mantecaIframeOptions, - handleIframeClose: handleMantecaIframeClose, - isLoading: isMantecaLoading, - } = useMantecaKycFlow({ - onSuccess: onClose, - onClose: onClose, + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: onClose, onManualClose: onClose, - country: country as CountryData, + regionIntent: sumsubRegionIntent, }) - const { - handleInitiateKyc: initiateSumsub, - showWrapper: showSumsubWrapper, - accessToken: sumsubAccessToken, - handleSdkComplete: handleSumsubComplete, - handleClose: handleSumsubClose, - refreshToken: sumsubRefreshToken, - isLoading: isSumsubLoading, - isVerificationProgressModalOpen: isSumsubProgressModalOpen, - closeVerificationProgressModal: closeSumsubProgressModal, - error: sumsubError, - } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose, regionIntent: sumsubRegionIntent }) - + // all kyc retries now go through sumsub const onRetry = async () => { - if (provider === 'SUMSUB') { - await initiateSumsub() - } else if (provider === 'MANTECA') { - await openMantecaKyc(country as CountryData) - } else { - await initiateBridgeKyc() - } + await sumsubFlow.handleInitiateKyc() } - const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading - // check if any bridge rail needs additional documents const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' @@ -102,14 +62,11 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus : [] // count sumsub rejections for failure lockout. - // counts total REJECTED entries — accurate if backend creates a new row per attempt. - // if backend updates in-place (single row), this will be 0 or 1 and the lockout - // won't trigger from count alone (terminal labels and rejectType still work). const sumsubFailureCount = user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 const handleSubmitAdditionalDocs = async () => { - await initiateSumsub(undefined, 'peanut-additional-docs') + await sumsubFlow.handleInitiateKyc(undefined, 'peanut-additional-docs') } const renderContent = () => { @@ -119,7 +76,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus ) } @@ -145,7 +102,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus return ( ) @@ -161,7 +118,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus countryCode={countryCode ?? undefined} isBridge={isBridgeKyc} onRetry={onRetry} - isLoading={isLoadingKyc} + isLoading={sumsubFlow.isLoading} /> ) default: @@ -181,22 +138,12 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus KYC Status {renderContent()} - {sumsubError && provider === 'SUMSUB' && ( -

{sumsubError}

+ {sumsubFlow.error && provider === 'SUMSUB' && ( +

{sumsubFlow.error}

)}
- - - - + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 7eae92316..bfb6f54a9 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -18,6 +18,19 @@ import { isKycStatusActionRequired, } from '@/constants/kyc.consts' +// kyc history entry type + type guard — used by HomeHistory and history page +export interface KycHistoryEntry { + isKyc: true + uuid: string + timestamp: string + verification?: IUserKycVerification + bridgeKycStatus?: BridgeKycStatus +} + +export const isKycStatusItem = (entry: object): entry is KycHistoryEntry => { + return 'isKyc' in entry && entry.isKyc === true +} + // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ position = 'first', diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index e5d01ea85..2c1ab001c 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -1,16 +1,7 @@ -import { useState, useCallback, useEffect, useRef } from 'react' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' -import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' -import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' -import IframeWrapper from '@/components/Global/IframeWrapper' -import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' -import { useRailStatusTracking } from '@/hooks/useRailStatusTracking' -import { useAuth } from '@/context/authContext' -import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' -import { type KycModalPhase } from '@/interfaces' - -const PREPARING_TIMEOUT_MS = 30000 interface SumsubKycFlowProps extends ButtonProps { onKycSuccess?: () => void @@ -25,270 +16,17 @@ interface SumsubKycFlowProps extends ButtonProps { * verifying → preparing → bridge_tos (if applicable) → complete */ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { - const { fetchUser } = useAuth() - - // multi-phase modal state - const [modalPhase, setModalPhase] = useState('verifying') - const [forceShowModal, setForceShowModal] = useState(false) - const [preparingTimedOut, setPreparingTimedOut] = useState(false) - const preparingTimerRef = useRef(null) - const isRealtimeFlowRef = useRef(false) - - // bridge ToS state - const [tosLink, setTosLink] = useState(null) - const [showTosIframe, setShowTosIframe] = useState(false) - const [tosError, setTosError] = useState(null) - const [isLoadingTos, setIsLoadingTos] = useState(false) - - // ref for closeVerificationProgressModal (avoids circular dep with completeFlow) - const closeVerificationModalRef = useRef<() => void>(() => {}) - - // rail tracking - const { allSettled, needsBridgeTos, startTracking, stopTracking } = useRailStatusTracking() - - const clearPreparingTimer = useCallback(() => { - if (preparingTimerRef.current) { - clearTimeout(preparingTimerRef.current) - preparingTimerRef.current = null - } - }, []) - - // complete the flow — close everything, call original onKycSuccess - const completeFlow = useCallback(() => { - isRealtimeFlowRef.current = false - setForceShowModal(false) - setModalPhase('verifying') - setPreparingTimedOut(false) - setTosLink(null) - setShowTosIframe(false) - setTosError(null) - clearPreparingTimer() - stopTracking() - closeVerificationModalRef.current() - onKycSuccess?.() - }, [onKycSuccess, clearPreparingTimer, stopTracking]) - - // called by useSumsubKycFlow when sumsub status transitions to APPROVED - const handleSumsubApproved = useCallback(async () => { - // for real-time flow, optimistically show "Identity verified!" while we check rails - if (isRealtimeFlowRef.current) { - setModalPhase('preparing') - setForceShowModal(true) - } - - const updatedUser = await fetchUser() - const rails = updatedUser?.rails ?? [] - - const bridgeNeedsTos = rails.some( - (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' - ) - - if (bridgeNeedsTos) { - setModalPhase('bridge_tos') - setForceShowModal(true) - clearPreparingTimer() - return - } - - const anyPending = rails.some((r) => r.status === 'PENDING') - - if (anyPending || (rails.length === 0 && isRealtimeFlowRef.current)) { - // rails still being set up — show preparing and start tracking - setModalPhase('preparing') - setForceShowModal(true) - startTracking() - return - } - - // all settled — done - completeFlow() - }, [fetchUser, startTracking, clearPreparingTimer, completeFlow]) - - const { - isLoading, - error, - showWrapper, - accessToken, - liveKycStatus, - handleInitiateKyc: originalHandleInitiateKyc, - handleSdkComplete: originalHandleSdkComplete, - handleClose, - refreshToken, - isVerificationProgressModalOpen, - closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) - - // keep ref in sync - useEffect(() => { - closeVerificationModalRef.current = closeVerificationProgressModal - }, [closeVerificationProgressModal]) - - // refresh user store when kyc status transitions to a non-success state - // so the drawer/status item reads the updated verification record - useEffect(() => { - if (liveKycStatus === 'ACTION_REQUIRED' || liveKycStatus === 'REJECTED') { - fetchUser() - } - }, [liveKycStatus, fetchUser]) - - // wrap handleSdkComplete to track real-time flow - const handleSdkComplete = useCallback(() => { - isRealtimeFlowRef.current = true - originalHandleSdkComplete() - }, [originalHandleSdkComplete]) - - // wrap handleInitiateKyc to reset state for new attempts - const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent) => { - setModalPhase('verifying') - setForceShowModal(false) - setPreparingTimedOut(false) - setTosLink(null) - setShowTosIframe(false) - setTosError(null) - isRealtimeFlowRef.current = false - clearPreparingTimer() - - await originalHandleInitiateKyc(overrideIntent) - }, - [originalHandleInitiateKyc, clearPreparingTimer] - ) - - // 30s timeout for preparing phase - useEffect(() => { - if (modalPhase === 'preparing' && !preparingTimedOut) { - clearPreparingTimer() - preparingTimerRef.current = setTimeout(() => { - setPreparingTimedOut(true) - }, PREPARING_TIMEOUT_MS) - } else { - clearPreparingTimer() - } - }, [modalPhase, preparingTimedOut, clearPreparingTimer]) - - // phase transitions driven by rail tracking - useEffect(() => { - if (modalPhase === 'preparing') { - if (needsBridgeTos) { - setModalPhase('bridge_tos') - clearPreparingTimer() - } else if (allSettled) { - setModalPhase('complete') - clearPreparingTimer() - stopTracking() - } - } else if (modalPhase === 'bridge_tos') { - // after ToS accepted, rails transition to ENABLED - if (allSettled && !needsBridgeTos) { - setModalPhase('complete') - stopTracking() - } - } - }, [modalPhase, needsBridgeTos, allSettled, clearPreparingTimer, stopTracking]) - - // handle "Accept Terms" click in bridge_tos phase - const handleAcceptTerms = useCallback(async () => { - setIsLoadingTos(true) - setTosError(null) - - try { - const response = await getBridgeTosLink() - - if (response.error || !response.data?.tosLink) { - setTosError( - response.error || 'Could not load terms. You can accept them later from your activity feed.' - ) - return - } - - setTosLink(response.data.tosLink) - setShowTosIframe(true) - } catch { - setTosError('Something went wrong. You can accept terms later from your activity feed.') - } finally { - setIsLoadingTos(false) - } - }, []) - - // handle ToS iframe close - const handleTosIframeClose = useCallback( - async (source?: 'manual' | 'completed' | 'tos_accepted') => { - setShowTosIframe(false) - - if (source === 'tos_accepted') { - // confirm with backend - const result = await confirmBridgeTos() - - if (!result.data?.accepted) { - // bridge may not have registered acceptance yet — retry after short delay - await new Promise((resolve) => setTimeout(resolve, 2000)) - const retryResult = await confirmBridgeTos() - if (!retryResult.data?.accepted) { - console.warn('[SumsubKycFlow] bridge ToS confirmation failed after retry') - } - } - - // refetch user — the phase-transition effect will handle moving to 'complete' - await fetchUser() - } - // if manual close, stay on bridge_tos phase (user can try again or skip) - }, - [fetchUser] - ) - - // handle "Skip for now" in bridge_tos phase - const handleSkipTerms = useCallback(() => { - completeFlow() - }, [completeFlow]) - - // handle modal close (Go to Home, etc.) - const handleModalClose = useCallback(() => { - isRealtimeFlowRef.current = false - setForceShowModal(false) - clearPreparingTimer() - stopTracking() - closeVerificationProgressModal() - }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal]) - - // cleanup on unmount - useEffect(() => { - return () => { - clearPreparingTimer() - stopTracking() - } - }, [clearPreparingTimer, stopTracking]) - - const isModalOpen = isVerificationProgressModalOpen || forceShowModal + const flow = useMultiPhaseKycFlow({ onKycSuccess, onManualClose, regionIntent }) return ( <> - - {error &&

{error}

} - - - - + {flow.error &&

{flow.error}

} - {tosLink && } + ) } diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx new file mode 100644 index 000000000..b2c72fa6d --- /dev/null +++ b/src/components/Kyc/SumsubKycModals.tsx @@ -0,0 +1,47 @@ +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { type useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' + +interface SumsubKycModalsProps { + flow: ReturnType + autoStartSdk?: boolean +} + +/** + * shared modal rendering for the multi-phase kyc flow. + * renders the sumsub SDK wrapper, the multi-phase verification modal, + * and the bridge ToS iframe. + * + * pair with useMultiPhaseKycFlow hook for the logic. + */ +export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => { + return ( + <> + + + + + {flow.tosLink && ( + + )} + + ) +} diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 1a48238eb..5fe754743 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -6,15 +6,13 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' -import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' -import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' import { KycRejectedModal } from '@/components/Kyc/modals/KycRejectedModal' -import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' -import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' @@ -51,7 +49,7 @@ function getModalVariant( const RegionsVerification = () => { const router = useRouter() - const { user, fetchUser } = useAuth() + const { user } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus() const [selectedRegion, setSelectedRegion] = useState(null) @@ -61,7 +59,6 @@ const RegionsVerification = () => { // persist region intent for the duration of the kyc session so token refresh // and status checks use the correct template after the confirmation modal closes const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) - const [showBridgeTos, setShowBridgeTos] = useState(false) // skip StartVerificationView when re-submitting (user already consented) const [autoStartSdk, setAutoStartSdk] = useState(false) @@ -79,39 +76,12 @@ const RegionsVerification = () => { const handleFinalKycSuccess = useCallback(() => { setSelectedRegion(null) setActiveRegionIntent(undefined) - setShowBridgeTos(false) setAutoStartSdk(false) }, []) - // intercept sumsub approval to check for bridge ToS - const handleKycApproved = useCallback(async () => { - const updatedUser = await fetchUser() - const rails = updatedUser?.rails ?? [] - const bridgeNeedsTos = rails.some( - (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' - ) - - if (bridgeNeedsTos) { - setShowBridgeTos(true) - } else { - handleFinalKycSuccess() - } - }, [fetchUser, handleFinalKycSuccess]) - - const { - isLoading, - error, - showWrapper, - accessToken, - handleInitiateKyc, - handleSdkComplete, - handleClose: handleSumsubClose, - refreshToken, - isVerificationProgressModalOpen, - closeVerificationProgressModal, - } = useSumsubKycFlow({ + const flow = useMultiPhaseKycFlow({ regionIntent: activeRegionIntent, - onKycSuccess: handleKycApproved, + onKycSuccess: handleFinalKycSuccess, onManualClose: () => { setSelectedRegion(null) setActiveRegionIntent(undefined) @@ -131,8 +101,8 @@ const RegionsVerification = () => { const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined if (intent) setActiveRegionIntent(intent) setSelectedRegion(null) - await handleInitiateKyc(intent) - }, [handleInitiateKyc, selectedRegion]) + await flow.handleInitiateKyc(intent) + }, [flow.handleInitiateKyc, selectedRegion]) // re-submission: skip StartVerificationView since user already consented const handleResubmitKyc = useCallback(async () => { @@ -179,7 +149,7 @@ const RegionsVerification = () => { onClose={handleModalClose} onStartVerification={handleStartKyc} selectedRegion={displayRegionRef.current} - isLoading={isLoading} + isLoading={flow.isLoading} /> @@ -188,7 +158,7 @@ const RegionsVerification = () => { visible={modalVariant === 'action_required'} onClose={handleModalClose} onResubmit={handleResubmitKyc} - isLoading={isLoading} + isLoading={flow.isLoading} rejectLabels={sumsubRejectLabels} /> @@ -196,29 +166,15 @@ const RegionsVerification = () => { visible={modalVariant === 'rejected'} onClose={handleModalClose} onRetry={handleResubmitKyc} - isLoading={isLoading} + isLoading={flow.isLoading} rejectLabels={sumsubRejectLabels} rejectType={sumsubRejectType} failureCount={sumsubFailureCount} /> - {error &&

{error}

} - - - - + {flow.error &&

{flow.error}

} - + ) } diff --git a/src/hooks/useBridgeKycFlow.ts b/src/hooks/useBridgeKycFlow.ts deleted file mode 100644 index 03feb4724..000000000 --- a/src/hooks/useBridgeKycFlow.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { useRouter } from 'next/navigation' -import { type IFrameWrapperProps } from '@/components/Global/IframeWrapper' -import { useWebSocket } from '@/hooks/useWebSocket' -import { useUserStore } from '@/redux/hooks' -import { type BridgeKycStatus, convertPersonaUrl } from '@/utils/bridge-accounts.utils' -import { type InitiateKycResponse } from '@/app/actions/types/users.types' -import { getKycDetails, updateUserById } from '@/app/actions/users' -import { type IUserKycVerification } from '@/interfaces' - -interface UseKycFlowOptions { - onKycSuccess?: () => void - flow?: 'add' | 'withdraw' | 'request_fulfillment' - onManualClose?: () => void -} - -export interface KycHistoryEntry { - isKyc: true - uuid: string - timestamp: string - verification?: IUserKycVerification - bridgeKycStatus?: BridgeKycStatus -} - -// type guard to check if an entry is a KYC status item in history section -export const isKycStatusItem = (entry: object): entry is KycHistoryEntry => { - return 'isKyc' in entry && entry.isKyc === true -} - -export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFlowOptions = {}) => { - const { user } = useUserStore() - const router = useRouter() - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [apiResponse, setApiResponse] = useState(null) - const [liveKycStatus, setLiveKycStatus] = useState( - user?.user?.bridgeKycStatus as BridgeKycStatus - ) - const prevStatusRef = useRef(liveKycStatus) - - const [iframeOptions, setIframeOptions] = useState>({ - src: '', - visible: false, - closeConfirmMessage: undefined, - }) - const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false) - - // listen for websocket updates - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: true, - onKycStatusUpdate: (newStatus) => { - setLiveKycStatus(newStatus as BridgeKycStatus) - }, - onTosUpdate: (data) => { - if (data.accepted) { - handleIframeClose('tos_accepted') - } - }, - }) - - // when the final status is received, close the verification modal - useEffect(() => { - // We only want to run this effect on updates, not on the initial mount - // to prevent `onKycSuccess` from being called when the component first renders - // with an already-approved status. - const prevStatus = prevStatusRef.current - prevStatusRef.current = liveKycStatus - if (prevStatus !== 'approved' && liveKycStatus === 'approved') { - setIsVerificationProgressModalOpen(false) - onKycSuccess?.() - } else if (prevStatus !== 'rejected' && liveKycStatus === 'rejected') { - setIsVerificationProgressModalOpen(false) - } - prevStatusRef.current = liveKycStatus - }, [liveKycStatus, onKycSuccess]) - - const handleInitiateKyc = async () => { - setIsLoading(true) - setError(null) - - try { - const response = await getKycDetails() - - if (response.error) { - setError(response.error) - setIsLoading(false) - return { success: false, error: response.error } - } - - if (response.data) { - setApiResponse(response.data) - // if there's a tos link and it's not yet approved, show it first. - if (response.data.tosLink && response.data.tosStatus !== 'approved') { - setIframeOptions({ src: response.data.tosLink, visible: true }) - } else if (response.data.kycLink) { - const kycUrl = convertPersonaUrl(response.data.kycLink) - setIframeOptions({ - src: kycUrl, - visible: true, - closeConfirmMessage: 'Are you sure? Your KYC progress will be lost.', - }) - } else { - const errorMsg = 'Could not retrieve verification links. Please contact support.' - setError(errorMsg) - return { success: false, error: errorMsg } - } - return { success: true, data: response.data } - } - } catch (e: any) { - setError(e.message) - return { success: false, error: e.message } - } finally { - setIsLoading(false) - } - } - - const handleIframeClose = useCallback( - (source: 'completed' | 'manual' | 'tos_accepted' = 'manual') => { - const wasShowingTos = iframeOptions.src === apiResponse?.tosLink - - // handle tos acceptance: only act if the tos iframe is currently shown. - if (source === 'tos_accepted') { - if (wasShowingTos && apiResponse?.kycLink) { - const kycUrl = convertPersonaUrl(apiResponse.kycLink) - setIframeOptions({ - src: kycUrl, - visible: true, - closeConfirmMessage: 'Are you sure? Your KYC progress will be lost.', - }) - } - // ignore late ToS events when KYC is already open - return - } - - // When KYC signals completion, close iframe and show progress modal - if (source === 'completed') { - setIframeOptions((prev) => ({ ...prev, visible: false })) - setIsVerificationProgressModalOpen(true) - // set the status to under review explicitly to avoild delays from bridge webhook - updateUserById({ - userId: user?.user.userId, - bridgeKycStatus: 'under_review' as BridgeKycStatus, - }) - return - } - - // manual abort: close modal; optionally redirect in add flow - if (source === 'manual') { - setIframeOptions((prev) => ({ ...prev, visible: false })) - if (flow === 'add') { - router.push('/add-money') - } else if (flow === 'request_fulfillment') { - onManualClose?.() - } - return - } - - // for any other sources, do nothing - }, - [iframeOptions.src, apiResponse, flow, router] - ) - - const closeVerificationProgressModal = () => { - setIsVerificationProgressModalOpen(false) - } - - const closeVerificationModalAndGoHome = () => { - setIsVerificationProgressModalOpen(false) - router.push('/home') - } - - const resetError = useCallback(() => { - setError(null) - }, []) - - return { - isLoading, - error, - iframeOptions, - isVerificationProgressModalOpen, - handleInitiateKyc, - handleIframeClose, - closeVerificationProgressModal, - closeVerificationModalAndGoHome, - resetError, - } -} diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts deleted file mode 100644 index b0f0864ab..000000000 --- a/src/hooks/useMantecaKycFlow.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import type { IFrameWrapperProps } from '@/components/Global/IframeWrapper' -import { mantecaApi } from '@/services/manteca' -import { useAuth } from '@/context/authContext' -import { type CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts' -import { MantecaKycStatus } from '@/interfaces' -import { useWebSocket } from './useWebSocket' -import { BASE_URL } from '@/constants/general.consts' - -type UseMantecaKycFlowOptions = { - onClose?: () => void - onSuccess?: () => void - onManualClose?: () => void - country?: CountryData -} - -export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }: UseMantecaKycFlowOptions) => { - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [iframeOptions, setIframeOptions] = useState>({ - src: '', - visible: false, - closeConfirmMessage: undefined, - }) - const { user, fetchUser } = useAuth() - const [isMantecaKycRequired, setNeedsMantecaKyc] = useState(false) - - const userKycVerifications = user?.user?.kycVerifications - - const handleIframeClose = useCallback( - async (source?: 'manual' | 'completed' | 'tos_accepted') => { - setIframeOptions((prev) => ({ ...prev, visible: false })) - await fetchUser() - if (source === 'completed') { - onSuccess?.() - return - } - if (source === 'manual') { - onManualClose?.() - return - } - onClose?.() - }, - [onClose, onSuccess, onManualClose] - ) - - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: true, - onMantecaKycStatusUpdate: async (status) => { - if (status === MantecaKycStatus.ACTIVE || status === 'WIDGET_FINISHED') { - await handleIframeClose('completed') - } - }, - }) - - useEffect(() => { - // determine if manteca kyc is required based on geo data available in kycVerifications - const selectedGeo = country?.id - - if (selectedGeo && Array.isArray(userKycVerifications) && userKycVerifications.length > 0) { - const isuserActiveForSelectedGeo = userKycVerifications.some( - (v) => - v.provider === 'MANTECA' && - (v.mantecaGeo || '').toUpperCase() === selectedGeo.toUpperCase() && - v.status === MantecaKycStatus.ACTIVE - ) - setNeedsMantecaKyc(!isuserActiveForSelectedGeo) - return - } - - // if no verifications data available, keep as null (undetermined) - // only set to true if we have user data but no matching verification - if (user && userKycVerifications !== undefined) { - setNeedsMantecaKyc(true) - } - }, [userKycVerifications, country?.id, user]) - - const openMantecaKyc = useCallback(async (countryParam?: CountryData) => { - setIsLoading(true) - setError(null) - try { - const exchange = countryParam?.id - ? MantecaSupportedExchanges[countryParam.id as keyof typeof MantecaSupportedExchanges] - : MantecaSupportedExchanges.AR - const returnUrl = BASE_URL + '/kyc/success' - const { url } = await mantecaApi.initiateOnboarding({ returnUrl, exchange }) - setIframeOptions({ - src: url, - visible: true, - }) - return { success: true as const } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : 'Failed to initiate onboarding' - setError(message) - return { success: false as const, error: message } - } finally { - setIsLoading(false) - } - }, []) - - return { - isLoading, - error, - iframeOptions, - openMantecaKyc, - handleIframeClose, - isMantecaKycRequired, - } -} diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts new file mode 100644 index 000000000..b9ea99ac3 --- /dev/null +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -0,0 +1,295 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { useAuth } from '@/context/authContext' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useRailStatusTracking } from '@/hooks/useRailStatusTracking' +import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { type KycModalPhase } from '@/interfaces' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' + +const PREPARING_TIMEOUT_MS = 30000 + +interface UseMultiPhaseKycFlowOptions { + onKycSuccess?: () => void + onManualClose?: () => void + regionIntent?: KYCRegionIntent +} + +/** + * reusable hook that wraps useSumsubKycFlow + useRailStatusTracking + * to provide a complete multi-phase kyc flow: + * verifying → preparing → bridge_tos (if applicable) → complete + * + * use this hook anywhere kyc is initiated. pair with SumsubKycModals + * for the modal rendering. + */ +export const useMultiPhaseKycFlow = ({ + onKycSuccess, + onManualClose, + regionIntent, +}: UseMultiPhaseKycFlowOptions) => { + const { fetchUser } = useAuth() + + // multi-phase modal state + const [modalPhase, setModalPhase] = useState('verifying') + const [forceShowModal, setForceShowModal] = useState(false) + const [preparingTimedOut, setPreparingTimedOut] = useState(false) + const preparingTimerRef = useRef(null) + const isRealtimeFlowRef = useRef(false) + + // bridge ToS state + const [tosLink, setTosLink] = useState(null) + const [showTosIframe, setShowTosIframe] = useState(false) + const [tosError, setTosError] = useState(null) + const [isLoadingTos, setIsLoadingTos] = useState(false) + + // ref for closeVerificationProgressModal (avoids circular dep with completeFlow) + const closeVerificationModalRef = useRef<() => void>(() => {}) + + // rail tracking + const { allSettled, needsBridgeTos, startTracking, stopTracking } = useRailStatusTracking() + + const clearPreparingTimer = useCallback(() => { + if (preparingTimerRef.current) { + clearTimeout(preparingTimerRef.current) + preparingTimerRef.current = null + } + }, []) + + // complete the flow — close everything, call original onKycSuccess + const completeFlow = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + setModalPhase('verifying') + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + clearPreparingTimer() + stopTracking() + closeVerificationModalRef.current() + onKycSuccess?.() + }, [onKycSuccess, clearPreparingTimer, stopTracking]) + + // called when sumsub status transitions to APPROVED + const handleSumsubApproved = useCallback(async () => { + // for real-time flow, optimistically show "Identity verified!" while we check rails + if (isRealtimeFlowRef.current) { + setModalPhase('preparing') + setForceShowModal(true) + } + + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setModalPhase('bridge_tos') + setForceShowModal(true) + clearPreparingTimer() + return + } + + const anyPending = rails.some((r) => r.status === 'PENDING') + + if (anyPending || (rails.length === 0 && isRealtimeFlowRef.current)) { + // rails still being set up — show preparing and start tracking + setModalPhase('preparing') + setForceShowModal(true) + startTracking() + return + } + + // all settled — done + completeFlow() + }, [fetchUser, startTracking, clearPreparingTimer, completeFlow]) + + const { + isLoading, + error, + showWrapper, + accessToken, + liveKycStatus, + handleInitiateKyc: originalHandleInitiateKyc, + handleSdkComplete: originalHandleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) + + // keep ref in sync + useEffect(() => { + closeVerificationModalRef.current = closeVerificationProgressModal + }, [closeVerificationProgressModal]) + + // refresh user store when kyc status transitions to a non-success state + // so the drawer/status item reads the updated verification record + useEffect(() => { + if (liveKycStatus === 'ACTION_REQUIRED' || liveKycStatus === 'REJECTED') { + fetchUser() + } + }, [liveKycStatus, fetchUser]) + + // wrap handleSdkComplete to track real-time flow + const handleSdkComplete = useCallback(() => { + isRealtimeFlowRef.current = true + originalHandleSdkComplete() + }, [originalHandleSdkComplete]) + + // wrap handleInitiateKyc to reset state for new attempts + const handleInitiateKyc = useCallback( + async (overrideIntent?: KYCRegionIntent, levelName?: string) => { + setModalPhase('verifying') + setForceShowModal(false) + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + isRealtimeFlowRef.current = false + clearPreparingTimer() + + await originalHandleInitiateKyc(overrideIntent, levelName) + }, + [originalHandleInitiateKyc, clearPreparingTimer] + ) + + // 30s timeout for preparing phase + useEffect(() => { + if (modalPhase === 'preparing' && !preparingTimedOut) { + clearPreparingTimer() + preparingTimerRef.current = setTimeout(() => { + setPreparingTimedOut(true) + }, PREPARING_TIMEOUT_MS) + } else { + clearPreparingTimer() + } + }, [modalPhase, preparingTimedOut, clearPreparingTimer]) + + // phase transitions driven by rail tracking + useEffect(() => { + if (modalPhase === 'preparing') { + if (needsBridgeTos) { + setModalPhase('bridge_tos') + clearPreparingTimer() + } else if (allSettled) { + setModalPhase('complete') + clearPreparingTimer() + stopTracking() + } + } else if (modalPhase === 'bridge_tos') { + // after ToS accepted, rails transition to ENABLED + if (allSettled && !needsBridgeTos) { + setModalPhase('complete') + stopTracking() + } + } + }, [modalPhase, needsBridgeTos, allSettled, clearPreparingTimer, stopTracking]) + + // handle "Accept Terms" click in bridge_tos phase + const handleAcceptTerms = useCallback(async () => { + setIsLoadingTos(true) + setTosError(null) + + try { + const response = await getBridgeTosLink() + + if (response.error || !response.data?.tosLink) { + setTosError( + response.error || 'Could not load terms. You can accept them later from your activity feed.' + ) + return + } + + setTosLink(response.data.tosLink) + setShowTosIframe(true) + } catch { + setTosError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoadingTos(false) + } + }, []) + + // handle ToS iframe close + const handleTosIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + setShowTosIframe(false) + + if (source === 'tos_accepted') { + // confirm with backend + const result = await confirmBridgeTos() + + if (!result.data?.accepted) { + // bridge may not have registered acceptance yet — retry after short delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + const retryResult = await confirmBridgeTos() + if (!retryResult.data?.accepted) { + console.warn('[useMultiPhaseKycFlow] bridge ToS confirmation failed after retry') + } + } + + // refetch user — the phase-transition effect will handle moving to 'complete' + await fetchUser() + } + // if manual close, stay on bridge_tos phase (user can try again or skip) + }, + [fetchUser] + ) + + // handle "Skip for now" in bridge_tos phase + const handleSkipTerms = useCallback(() => { + completeFlow() + }, [completeFlow]) + + // handle modal close (Go to Home, etc.) + const handleModalClose = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + clearPreparingTimer() + stopTracking() + closeVerificationProgressModal() + }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal]) + + // cleanup on unmount + useEffect(() => { + return () => { + clearPreparingTimer() + stopTracking() + } + }, [clearPreparingTimer, stopTracking]) + + const isModalOpen = isVerificationProgressModalOpen || forceShowModal + + return { + // initiation + handleInitiateKyc, + isLoading, + error, + liveKycStatus, + + // SDK wrapper + showWrapper, + accessToken, + handleSdkClose: handleClose, + handleSdkComplete, + refreshToken, + + // multi-phase modal + isModalOpen, + modalPhase, + handleModalClose, + handleAcceptTerms, + handleSkipTerms, + completeFlow, + tosError, + isLoadingTos, + preparingTimedOut, + + // ToS iframe + tosLink, + showTosIframe, + handleTosIframeClose, + } +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 7ea320706..08b1a8781 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -80,6 +80,30 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: fetchCurrentStatus() }, [regionIntent]) + // polling fallback for missed websocket events. + // when the verification progress modal is open, poll status every 5s + // so the flow can transition even if the websocket event never arrives. + useEffect(() => { + if (!isVerificationProgressModalOpen) return + + const pollStatus = async () => { + try { + const response = await initiateSumsubKyc({ + regionIntent: regionIntentRef.current, + levelName: levelNameRef.current, + }) + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + } catch { + // silent — polling is a best-effort fallback + } + } + + const interval = setInterval(pollStatus, 5000) + return () => clearInterval(interval) + }, [isVerificationProgressModalOpen]) + const handleInitiateKyc = useCallback( async (overrideIntent?: KYCRegionIntent, levelName?: string) => { setIsLoading(true) From e8a97695dca7b443c2800490b2749e28f3571284 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:28:20 +0530 Subject: [PATCH 02/11] chore: format --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 1 - src/components/AddMoney/components/MantecaAddMoney.tsx | 10 +++++++++- src/hooks/useMultiPhaseKycFlow.ts | 6 +----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index af29ed048..212b16248 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -631,7 +631,6 @@ export default function MantecaWithdrawFlow() { {errorMessage && } - )} diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index 62dbc1598..62db055a5 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -167,7 +167,15 @@ const MantecaAddMoney: FC = () => { } finally { setIsCreatingDeposit(false) } - }, [currentDenomination, selectedCountry, displayedAmount, isUserMantecaKycApproved, isCreatingDeposit, setUrlState, sumsubFlow.handleInitiateKyc]) + }, [ + currentDenomination, + selectedCountry, + displayedAmount, + isUserMantecaKycApproved, + isCreatingDeposit, + setUrlState, + sumsubFlow.handleInitiateKyc, + ]) // auto-start KYC if user hasn't completed manteca verification useEffect(() => { diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index b9ea99ac3..50f588322 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -22,11 +22,7 @@ interface UseMultiPhaseKycFlowOptions { * use this hook anywhere kyc is initiated. pair with SumsubKycModals * for the modal rendering. */ -export const useMultiPhaseKycFlow = ({ - onKycSuccess, - onManualClose, - regionIntent, -}: UseMultiPhaseKycFlowOptions) => { +export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseMultiPhaseKycFlowOptions) => { const { fetchUser } = useAuth() // multi-phase modal state From 47b2661c4afd9e8e5e3d606b896a9e9f295691d7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:35:34 +0530 Subject: [PATCH 03/11] fix: skip intermediate modal in bridge ToS flow from home page When user clicks "Accept terms of service" from the activity feed, auto-fetch the ToS link and open the iframe directly instead of showing a redundant "Accept Terms" confirmation modal first. The modal now only appears as an error fallback. Co-Authored-By: Claude Opus 4.6 --- src/components/Kyc/BridgeTosStep.tsx | 44 +++++++++++++--------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index ac23576ab..4f9590deb 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -22,14 +22,17 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) - // reset state when visibility changes + // auto-fetch ToS link when step becomes visible so the iframe opens directly + // (skips the intermediate "Accept Terms" confirmation modal) useEffect(() => { - if (!visible) { + if (visible) { + handleAcceptTerms() + } else { setShowIframe(false) setTosLink(null) setError(null) } - }, [visible]) + }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps const handleAcceptTerms = useCallback(async () => { setIsLoading(true) @@ -92,36 +95,29 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp return ( <> - {!showIframe && ( + {/* only show modal on error — normal flow goes straight to iframe */} + {error && !showIframe && ( )} From 9af38d1f310e2130f6ca66cf3ba0304e431743a2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:56:14 +0530 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20fix:=20show=20confirmation?= =?UTF-8?q?=20modal=20before=20KYC=20and=20prevent=20backend=20record=20on?= =?UTF-8?q?=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add InitiateKycModal shown before opening Sumsub SDK in Manteca flows - Remove regionIntent from hook initialization in all consumers to prevent useSumsubKycFlow from calling initiateSumsubKyc() on mount (which created backend UserKycVerification records even when user never started KYC) - Pass regionIntent at call time: handleInitiateKyc('LATAM'/'STANDARD') Fixes: KYC auto-opening without confirmation, phantom pending KYC entries Co-Authored-By: Claude Opus 4.6 --- .../add-money/[country]/bank/page.tsx | 7 ++-- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 20 +++++++--- .../AddMoney/components/MantecaAddMoney.tsx | 36 ++++++++---------- .../AddWithdraw/AddWithdrawCountriesList.tsx | 5 ++- .../Claim/Link/MantecaFlowManager.tsx | 23 ++++++++--- .../Claim/Link/views/BankFlowManager.view.tsx | 5 ++- src/components/Kyc/InitiateKycModal.tsx | 38 +++++++++++++++++++ 7 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 src/components/Kyc/InitiateKycModal.tsx diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index fb2fb5764..546963360 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -70,8 +70,9 @@ export default function OnrampBankPage() { const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp() // inline sumsub kyc flow for bridge bank onramp + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'STANDARD', onKycSuccess: () => { setIsKycModalOpen(false) setUrlState({ step: 'inputAmount' }) @@ -325,9 +326,9 @@ export default function OnrampBankPage() { useEffect(() => { if (urlState.step === 'kyc') { - sumsubFlow.handleInitiateKyc() + sumsubFlow.handleInitiateKyc('STANDARD') } - }, [urlState.step]) + }, [urlState.step]) // eslint-disable-line react-hooks/exhaustive-deps // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) useEffect(() => { diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 212b16248..712d8b0bc 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -32,6 +32,7 @@ import { captureException } from '@sentry/nextjs' import useKycStatus from '@/hooks/useKycStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' @@ -83,9 +84,10 @@ export default function MantecaWithdrawFlow() { const { hasPendingTransactions } = usePendingTransactions() // inline sumsub kyc flow for manteca users who need LATAM verification - const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'LATAM', - }) + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) // Get method and country from URL parameters const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc. const countryFromUrl = searchParams.get('country') // argentina, brazil, etc. @@ -188,7 +190,7 @@ export default function MantecaWithdrawFlow() { setErrorMessage(null) if (!isUserMantecaKycApproved) { - await sumsubFlow.handleInitiateKyc() + setShowKycModal(true) return } @@ -233,7 +235,6 @@ export default function MantecaWithdrawFlow() { currencyAmount, isUserMantecaKycApproved, isLockingPrice, - sumsubFlow.handleInitiateKyc, ]) const handleWithdraw = async () => { @@ -449,6 +450,15 @@ export default function MantecaWithdrawFlow() { } return (
+ setShowKycModal(false)} + onVerify={async () => { + setShowKycModal(false) + await sumsubFlow.handleInitiateKyc('LATAM') + }} + isLoading={sumsubFlow.isLoading} + /> { const { user } = useAuth() // inline sumsub kyc flow for manteca users who need LATAM verification - const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'LATAM', - }) + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) // validates deposit amount against user's limits // currency comes from country config - hook normalizes it internally @@ -139,7 +141,7 @@ const MantecaAddMoney: FC = () => { if (isCreatingDeposit) return if (!isUserMantecaKycApproved) { - await sumsubFlow.handleInitiateKyc() + setShowKycModal(true) return } @@ -167,22 +169,7 @@ const MantecaAddMoney: FC = () => { } finally { setIsCreatingDeposit(false) } - }, [ - currentDenomination, - selectedCountry, - displayedAmount, - isUserMantecaKycApproved, - isCreatingDeposit, - setUrlState, - sumsubFlow.handleInitiateKyc, - ]) - - // auto-start KYC if user hasn't completed manteca verification - useEffect(() => { - if (!isUserMantecaKycApproved) { - sumsubFlow.handleInitiateKyc() - } - }, [isUserMantecaKycApproved]) // eslint-disable-line react-hooks/exhaustive-deps + }, [currentDenomination, selectedCountry, displayedAmount, isUserMantecaKycApproved, isCreatingDeposit, setUrlState]) // Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation) useEffect(() => { @@ -196,6 +183,15 @@ const MantecaAddMoney: FC = () => { if (step === 'inputAmount') { return ( <> + setShowKycModal(false)} + onVerify={async () => { + setShowKycModal(false) + await sumsubFlow.handleInitiateKyc('LATAM') + }} + isLoading={sumsubFlow.isLoading} + /> { const dispatch = useAppDispatch() // inline sumsub kyc flow for bridge bank users who need verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'STANDARD', onKycSuccess: () => { setIsKycModalOpen(false) setView('form') @@ -179,7 +180,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } } - await sumsubFlow.handleInitiateKyc() + await sumsubFlow.handleInitiateKyc('STANDARD') } return {} diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index cb3e072a3..e014c639b 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' interface MantecaFlowManagerProps { claimLinkData: ClaimLinkData @@ -29,21 +30,22 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount const { isUserMantecaKycApproved } = useKycStatus() // inline sumsub kyc flow for manteca users who need LATAM verification - const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'LATAM', - }) + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) const isSuccess = currentStep === MercadoPagoStep.SUCCESS const selectedCurrency = selectedCountry?.currency || 'ARS' const regionalMethodLogo = regionalMethodType === 'mercadopago' ? MERCADO_PAGO : PIX const logo = selectedCountry?.id ? undefined : regionalMethodLogo - // auto-start KYC if user hasn't completed manteca verification + // show confirmation modal if user hasn't completed manteca verification useEffect(() => { if (!isUserMantecaKycApproved) { - sumsubFlow.handleInitiateKyc() + setShowKycModal(true) } - }, [isUserMantecaKycApproved]) // eslint-disable-line react-hooks/exhaustive-deps + }, [isUserMantecaKycApproved]) const renderStepDetails = () => { if (currentStep === MercadoPagoStep.DETAILS) { @@ -114,6 +116,15 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount {renderStepDetails()}
+ setShowKycModal(false)} + onVerify={async () => { + setShowKycModal(false) + await sumsubFlow.handleInitiateKyc('LATAM') + }} + isLoading={sumsubFlow.isLoading} + /> ) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 50de618d1..700eb4f11 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -78,8 +78,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const dispatch = useAppDispatch() // inline sumsub kyc flow for users who need verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ - regionIntent: 'STANDARD', onKycSuccess: async () => { if (justCompletedKyc) return setIsKycModalOpen(false) @@ -271,7 +272,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } } - await sumsubFlow.handleInitiateKyc() + await sumsubFlow.handleInitiateKyc('STANDARD') return {} } diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx new file mode 100644 index 000000000..3d9a8ea55 --- /dev/null +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -0,0 +1,38 @@ +import ActionModal from '@/components/Global/ActionModal' +import { type IconName } from '@/components/Global/Icons/Icon' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' + +interface InitiateKycModalProps { + visible: boolean + onClose: () => void + onVerify: () => void + isLoading?: boolean +} + +// confirmation modal shown before starting KYC. +// user must click "Start Verification" to proceed to the sumsub SDK. +export const InitiateKycModal = ({ visible, onClose, onVerify, isLoading }: InitiateKycModalProps) => { + return ( + } + /> + ) +} From fd28c3b18d68d42b75012eaee531e4318dae0c60 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:01:35 +0530 Subject: [PATCH 05/11] chore: format --- src/components/AddMoney/components/MantecaAddMoney.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index db00c02e2..c5f88bf6b 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -169,7 +169,14 @@ const MantecaAddMoney: FC = () => { } finally { setIsCreatingDeposit(false) } - }, [currentDenomination, selectedCountry, displayedAmount, isUserMantecaKycApproved, isCreatingDeposit, setUrlState]) + }, [ + currentDenomination, + selectedCountry, + displayedAmount, + isUserMantecaKycApproved, + isCreatingDeposit, + setUrlState, + ]) // Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation) useEffect(() => { From 753b695e734f704313061a57bb59fcfbf2885c9b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:48:53 +0530 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=90=9B=20fix:=20remove=20pre-KYC=20?= =?UTF-8?q?name/email=20collection=20=E2=80=94=20now=20handled=20by=20Sums?= =?UTF-8?q?ub=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove collectUserDetails step from bridge bank onramp flow (page.tsx) Non-KYC'd users now go directly to KYC step - Remove updateUserById calls for name/email before handleInitiateKyc in AddWithdrawCountriesList and BankFlowManager - Sumsub SDK now collects name and email, backend fills them in DB Co-Authored-By: Claude Opus 4.6 --- .../add-money/[country]/bank/page.tsx | 92 +------------------ .../AddWithdraw/AddWithdrawCountriesList.tsx | 35 +------ .../Claim/Link/views/BankFlowManager.view.tsx | 18 +--- 3 files changed, 9 insertions(+), 136 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 546963360..eda417834 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -14,13 +14,11 @@ import { useWebSocket } from '@/hooks/useWebSocket' import { useAuth } from '@/context/authContext' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping' import { formatUnits } from 'viem' import PeanutLoading from '@/components/Global/PeanutLoading' import EmptyState from '@/components/Global/EmptyStates/EmptyState' -import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' -import { updateUserById } from '@/app/actions/users' import AddMoneyBankDetails from '@/components/AddMoney/components/AddMoneyBankDetails' import { getCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/bridge.utils' import { OnrampConfirmationModal } from '@/components/AddMoney/components/OnrampConfirmationModal' @@ -34,7 +32,7 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' // Step type for URL state -type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails' +type BridgeBankStep = 'inputAmount' | 'kyc' | 'showDetails' export default function OnrampBankPage() { const router = useRouter() @@ -44,7 +42,7 @@ export default function OnrampBankPage() { // Example: /add-money/mexico/bank?step=inputAmount&amount=500 const [urlState, setUrlState] = useQueryStates( { - step: parseAsStringEnum(['inputAmount', 'kyc', 'collectUserDetails', 'showDetails']), + step: parseAsStringEnum(['inputAmount', 'kyc', 'showDetails']), amount: parseAsString, }, { history: 'push' } @@ -56,14 +54,8 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) - const [isKycModalOpen, setIsKycModalOpen] = useState(false) const [liveKycStatus, setLiveKycStatus] = useState(undefined) - const [isUpdatingUser, setIsUpdatingUser] = useState(false) - const [userUpdateError, setUserUpdateError] = useState(null) - const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) - const { setError, error, setOnrampData, onrampData } = useOnrampFlow() - const formRef = useRef<{ handleSubmit: () => void }>(null) const { balance } = useWallet() const { user, fetchUser } = useAuth() @@ -74,12 +66,8 @@ export default function OnrampBankPage() { // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { - setIsKycModalOpen(false) setUrlState({ step: 'inputAmount' }) }, - onManualClose: () => { - setIsKycModalOpen(false) - }, }) const selectedCountryPath = params.country as string @@ -176,7 +164,7 @@ export default function OnrampBankPage() { const isUserKycVerified = currentKycStatus === 'approved' if (!isUserKycVerified) { - setUrlState({ step: 'collectUserDetails' }) + setUrlState({ step: 'kyc' }) } else { setUrlState({ step: 'inputAmount' }) } @@ -275,39 +263,6 @@ export default function OnrampBankPage() { setIsRiskAccepted(false) } - const handleKycSuccess = () => { - setIsKycModalOpen(false) - setUrlState({ step: 'inputAmount' }) - } - - const handleKycModalClose = () => { - setIsKycModalOpen(false) - } - - const handleUserDetailsSubmit = async (data: UserDetailsFormData) => { - setIsUpdatingUser(true) - setUserUpdateError(null) - try { - if (!user?.user.userId) throw new Error('User not found') - const result = await updateUserById({ - userId: user.user.userId, - fullName: data.fullName, - email: data.email, - }) - if (result.error) { - throw new Error(result.error) - } - await fetchUser() - setUrlState({ step: 'kyc' }) - } catch (error: any) { - setUserUpdateError(error.message) - return { error: error.message } - } finally { - setIsUpdatingUser(false) - } - return {} - } - const handleBack = () => { if (selectedCountry) { router.push(`/add-money/${selectedCountry.path}`) @@ -316,14 +271,6 @@ export default function OnrampBankPage() { } } - const initialUserDetails: Partial = useMemo( - () => ({ - fullName: user?.user.fullName ?? '', - email: user?.user.email ?? '', - }), - [user?.user.fullName, user?.user.email] - ) - useEffect(() => { if (urlState.step === 'kyc') { sumsubFlow.handleInitiateKyc('STANDARD') @@ -356,39 +303,10 @@ export default function OnrampBankPage() { return } - if (urlState.step === 'collectUserDetails') { - return ( -
- -
-

Verify your details

- - - {userUpdateError && } -
-
- ) - } - if (urlState.step === 'kyc') { return (
- setUrlState({ step: 'collectUserDetails' })} /> +
) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c6c938852..639b02fd2 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -12,7 +12,7 @@ import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' import { useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' -import { addBankAccount, updateUserById } from '@/app/actions/users' +import { addBankAccount } from '@/app/actions/users' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useWebSocket } from '@/hooks/useWebSocket' @@ -147,39 +147,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } // scenario (2): if the user hasn't completed kyc yet + // name and email are now collected by sumsub sdk — no need to save them beforehand if (!isUserKycVerified) { - // update user's name and email if they are not present - const hasNameOnLoad = !!user?.user.fullName - const hasEmailOnLoad = !!user?.user.email - - if (!hasNameOnLoad || !hasEmailOnLoad) { - if (user?.user.userId) { - // Build update payload to only update missing fields - const updatePayload: Record = { userId: user.user.userId } - - if (!hasNameOnLoad && rawData.accountOwnerName) { - updatePayload.fullName = rawData.accountOwnerName.trim() - } - - if (!hasEmailOnLoad && rawData.email) { - updatePayload.email = rawData.email.trim() - } - - // Only call update if we have fields to update - if (Object.keys(updatePayload).length > 1) { - const result = await updateUserById(updatePayload) - if (result.error) { - return { error: result.error } - } - try { - await fetchUser() - } catch (err) { - console.error('Failed to refresh user data after update:', err) - } - } - } - } - await sumsubFlow.handleInitiateKyc('STANDARD') } diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 700eb4f11..6d601ac8e 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -18,7 +18,7 @@ import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/servi import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import peanut from '@squirrel-labs/peanut-sdk' -import { addBankAccount, getUserById, updateUserById } from '@/app/actions/users' +import { addBankAccount, getUserById } from '@/app/actions/users' import SavedAccountsView from '../../../Common/SavedAccountsView' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' @@ -256,22 +256,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setError(null) // scenario 1: receiver needs KYC + // name and email are now collected by sumsub sdk — no need to save them beforehand if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { - // update user's name and email if they are not present - const hasNameOnLoad = !!user?.user.fullName - const hasEmailOnLoad = !!user?.user.email - if (!hasNameOnLoad || !hasEmailOnLoad) { - if (user?.user.userId && rawData.firstName && rawData.lastName && rawData.email) { - const result = await updateUserById({ - userId: user.user.userId, - fullName: `${rawData.firstName} ${rawData.lastName}`.trim(), - email: rawData.email, - }) - if (result.error) return { error: result.error } - await fetchUser() - } - } - await sumsubFlow.handleInitiateKyc('STANDARD') return {} } From aedabec06db64088ba85aeb6cc597c7bf7278ed9 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:02:58 +0530 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore:=20delete?= =?UTF-8?q?=20unused=20UserDetailsForm=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed — name and email are now collected by Sumsub SDK. Co-Authored-By: Claude Opus 4.6 --- src/components/AddMoney/UserDetailsForm.tsx | 99 --------------------- 1 file changed, 99 deletions(-) delete mode 100644 src/components/AddMoney/UserDetailsForm.tsx diff --git a/src/components/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx deleted file mode 100644 index c9a585839..000000000 --- a/src/components/AddMoney/UserDetailsForm.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' -import { forwardRef, useEffect, useImperativeHandle } from 'react' -import { useForm, Controller } from 'react-hook-form' -import BaseInput from '@/components/0_Bruddle/BaseInput' -import ErrorAlert from '@/components/Global/ErrorAlert' - -export type UserDetailsFormData = { - fullName: string - email: string -} - -interface UserDetailsFormProps { - onSubmit: (data: UserDetailsFormData) => Promise<{ error?: string }> - isSubmitting: boolean - onValidChange?: (isValid: boolean) => void - initialData?: Partial -} - -export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>( - ({ onSubmit, onValidChange, initialData }, ref) => { - const { - control, - handleSubmit, - formState: { errors, isValid }, - } = useForm({ - defaultValues: { - fullName: initialData?.fullName ?? '', - email: initialData?.email ?? '', - }, - mode: 'all', - }) - - useEffect(() => { - onValidChange?.(isValid) - }, [isValid, onValidChange]) - - // Note: Submission errors are handled by the parent component - useImperativeHandle(ref, () => ({ - handleSubmit: handleSubmit(async (data) => { - await onSubmit(data) - }), - })) - - const renderInput = ( - name: keyof UserDetailsFormData, - placeholder: string, - rules: any, - type: string = 'text' - ) => { - return ( -
-
- ( - - )} - /> -
-
- {errors[name] && } -
-
- ) - } - return ( -
-
-
{ - e.preventDefault() - }} - className="space-y-4" - > -
- {renderInput('fullName', 'Full Name', { required: 'Full name is required' })} - {renderInput('email', 'E-mail', { - required: 'Email is required', - pattern: { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: 'Invalid email address', - }, - })} -
-
-
-
- ) - } -) - -UserDetailsForm.displayName = 'UserDetailsForm' From c463dbe2f78781cbb98490b63a02a1ab69725a17 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:38:29 +0530 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20fix:=20bridge=20ToS=20pers?= =?UTF-8?q?istence=20+=20consolidate=20KYC=20activity=20by=20region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optimistic local state to BridgeTosReminder so it hides immediately after ToS acceptance (backend rail transition is async) - Remove BridgeTosReminder from KycCompleted drawer (avoid duplication) - Consolidate duplicate KYC activity entries into one per region (STANDARD, LATAM) via new groupKycByRegion() utility - Add region prop to KycStatusItem/KycStatusDrawer for region-aware titles Co-Authored-By: Claude Opus 4.6 --- src/app/(mobile-ui)/history/page.tsx | 30 ++-------- src/components/Home/HomeHistory.tsx | 65 ++++++-------------- src/components/Kyc/BridgeTosReminder.tsx | 4 ++ src/components/Kyc/KycStatusDrawer.tsx | 3 +- src/components/Kyc/KycStatusItem.tsx | 11 +++- src/components/Kyc/states/KycCompleted.tsx | 5 -- src/utils/kyc-grouping.utils.ts | 69 ++++++++++++++++++++++ 7 files changed, 108 insertions(+), 79 deletions(-) create mode 100644 src/utils/kyc-grouping.utils.ts diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index e9d9bae9a..2caf96b09 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -13,6 +13,7 @@ import { useUserStore } from '@/redux/hooks' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import * as Sentry from '@sentry/nextjs' import { isKycStatusItem } from '@/components/Kyc/KycStatusItem' +import { groupKycByRegion } from '@/utils/kyc-grouping.utils' import { useAuth } from '@/context/authContext' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -165,30 +166,10 @@ const HistoryPage = () => { }) }) - if (user) { - if (user.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { - // Use appropriate timestamp based on KYC status - const bridgeKycTimestamp = (() => { - const status = user.user.bridgeKycStatus - if (status === 'approved') return user.user.bridgeKycApprovedAt - if (status === 'rejected') return user.user.bridgeKycRejectedAt - return user.user.bridgeKycStartedAt - })() - entries.push({ - isKyc: true, - timestamp: bridgeKycTimestamp ?? user.user.createdAt ?? new Date().toISOString(), - uuid: 'bridge-kyc-status-item', - bridgeKycStatus: user.user.bridgeKycStatus, - }) - } - user.user.kycVerifications?.forEach((verification) => { - entries.push({ - isKyc: true, - timestamp: verification.approvedAt ?? verification.updatedAt ?? verification.createdAt, - uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, - verification, - }) - }) + // add one kyc entry per region (STANDARD, LATAM) + if (user?.user) { + const regionEntries = groupKycByRegion(user.user) + entries.push(...regionEntries) } entries.sort((a, b) => { @@ -272,6 +253,7 @@ const HistoryPage = () => { bridgeKycStartedAt={ item.bridgeKycStatus ? user?.user.bridgeKycStartedAt : undefined } + region={item.region} /> ) : isBadgeHistoryItem(item) ? ( diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index f0e5893d9..356991e97 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -14,6 +14,7 @@ import Card from '../Global/Card' import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem' +import { groupKycByRegion } from '@/utils/kyc-grouping.utils' import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' @@ -179,32 +180,10 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h } } - // Add KYC status item if applicable and the user is - // viewing their own history - if (isViewingOwnHistory) { - if (user?.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { - // Use appropriate timestamp based on KYC status - const bridgeKycTimestamp = (() => { - const status = user.user.bridgeKycStatus - if (status === 'approved') return user.user.bridgeKycApprovedAt - if (status === 'rejected') return user.user.bridgeKycRejectedAt - return user.user.bridgeKycStartedAt - })() - entries.push({ - isKyc: true, - timestamp: bridgeKycTimestamp ?? user.user.createdAt ?? new Date().toISOString(), - uuid: 'bridge-kyc-status-item', - bridgeKycStatus: user.user.bridgeKycStatus, - }) - } - user?.user.kycVerifications?.forEach((verification) => { - entries.push({ - isKyc: true, - timestamp: verification.approvedAt ?? verification.updatedAt ?? verification.createdAt, - uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, - verification, - }) - }) + // add one kyc entry per region (STANDARD, LATAM) + if (isViewingOwnHistory && user?.user) { + const regionEntries = groupKycByRegion(user.user) + entries.push(...regionEntries) } // Check cancellation before setting state @@ -273,39 +252,28 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h

Activity

{isViewingOwnHistory && needsBridgeTos && } - {isViewingOwnHistory && - ((user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started') || - (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( + {isViewingOwnHistory && user?.user && (() => { + const regionEntries = groupKycByRegion(user.user) + return regionEntries.length > 0 ? (
- {user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started' && ( - - )} - {user?.user.kycVerifications?.map((verification) => ( + {regionEntries.map((entry) => ( ))}
- )} - - {isViewingOwnHistory && - !user?.user.bridgeKycStatus && - (!user?.user.kycVerifications || user?.user.kycVerifications.length === 0) && ( + ) : ( - )} + ) + })()} {!isViewingOwnHistory && ( ) } diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx index e2965a62e..7db489a4b 100644 --- a/src/components/Kyc/BridgeTosReminder.tsx +++ b/src/components/Kyc/BridgeTosReminder.tsx @@ -16,6 +16,7 @@ interface BridgeTosReminderProps { export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProps) => { const { fetchUser } = useAuth() const [showTosStep, setShowTosStep] = useState(false) + const [tosJustAccepted, setTosJustAccepted] = useState(false) const handleClick = useCallback(() => { setShowTosStep(true) @@ -23,6 +24,7 @@ export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProp const handleComplete = useCallback(async () => { setShowTosStep(false) + setTosJustAccepted(true) // optimistically hide — backend rail transition is async await fetchUser() }, [fetchUser]) @@ -30,6 +32,8 @@ export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProp setShowTosStep(false) }, []) + if (tosJustAccepted) return null + return ( <> diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 74c21757c..2ef606b7a 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -17,10 +17,11 @@ interface KycStatusDrawerProps { onClose: () => void verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus + region?: 'STANDARD' | 'LATAM' } // this component determines which kyc state to show inside the drawer and fetches rejection reasons if the kyc has failed. -export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus }: KycStatusDrawerProps) => { +export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus, region }: KycStatusDrawerProps) => { const { user } = useUserStore() const status = verification ? verification.status : bridgeKycStatus diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index bfb6f54a9..359e53a1f 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -25,6 +25,7 @@ export interface KycHistoryEntry { timestamp: string verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus + region?: 'STANDARD' | 'LATAM' } export const isKycStatusItem = (entry: object): entry is KycHistoryEntry => { @@ -38,12 +39,14 @@ export const KycStatusItem = ({ verification, bridgeKycStatus, bridgeKycStartedAt, + region, }: { position?: CardPosition className?: HTMLAttributes['className'] verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus bridgeKycStartedAt?: string + region?: 'STANDARD' | 'LATAM' }) => { const { user } = useUserStore() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -82,6 +85,11 @@ export const KycStatusItem = ({ return 'Unknown' }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) + const title = useMemo(() => { + if (region === 'LATAM') return 'LATAM verification' + return 'Identity verification' + }, [region]) + // only hide for bridge's default "not_started" state. // if a verification record exists, the user has initiated KYC — show it. if (!verification && isKycStatusNotStarted(kycStatus)) { @@ -101,7 +109,7 @@ export const KycStatusItem = ({
-

Identity verification

+

{title}

{subtitle}

)} diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index a28420426..0c59259b9 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -1,8 +1,6 @@ import Card from '@/components/Global/Card' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { BridgeTosReminder } from '../BridgeTosReminder' -import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -23,8 +21,6 @@ export const KycCompleted = ({ countryCode?: string | null isBridge?: boolean }) => { - const { needsBridgeTos } = useBridgeTosStatus() - const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' try { @@ -38,7 +34,6 @@ export const KycCompleted = ({ return (
- {needsBridgeTos && } diff --git a/src/utils/kyc-grouping.utils.ts b/src/utils/kyc-grouping.utils.ts new file mode 100644 index 000000000..883634dbe --- /dev/null +++ b/src/utils/kyc-grouping.utils.ts @@ -0,0 +1,69 @@ +import { type User, type BridgeKycStatus } from '@/interfaces' +import { type KycHistoryEntry } from '@/components/Kyc/KycStatusItem' + +export type KycRegion = 'STANDARD' | 'LATAM' + +export interface RegionKycEntry extends KycHistoryEntry { + region: KycRegion +} + +/** + * groups kyc data into one activity entry per region. + * STANDARD = bridgeKycStatus + sumsub verifications with regionIntent STANDARD + * LATAM = manteca/sumsub verifications with regionIntent LATAM + */ +export function groupKycByRegion(user: User): RegionKycEntry[] { + const entries: RegionKycEntry[] = [] + const verifications = user.kycVerifications ?? [] + + // --- STANDARD region --- + const standardVerification = verifications.find( + (v) => v.provider === 'SUMSUB' && v.metadata?.regionIntent === 'STANDARD' + ) + + if (standardVerification) { + entries.push({ + isKyc: true, + region: 'STANDARD', + uuid: 'region-STANDARD', + timestamp: standardVerification.approvedAt ?? standardVerification.updatedAt ?? standardVerification.createdAt, + verification: standardVerification, + bridgeKycStatus: user.bridgeKycStatus as BridgeKycStatus | undefined, + }) + } else if (user.bridgeKycStatus && user.bridgeKycStatus !== 'not_started') { + // legacy: user only has bridgeKycStatus (pre-sumsub migration) + const bridgeKycTimestamp = (() => { + if (user.bridgeKycStatus === 'approved') return user.bridgeKycApprovedAt + if (user.bridgeKycStatus === 'rejected') return user.bridgeKycRejectedAt + return user.bridgeKycStartedAt + })() + entries.push({ + isKyc: true, + region: 'STANDARD', + uuid: 'region-STANDARD', + timestamp: bridgeKycTimestamp ?? user.createdAt ?? new Date().toISOString(), + bridgeKycStatus: user.bridgeKycStatus as BridgeKycStatus, + }) + } + + // --- LATAM region --- + const latamVerifications = verifications.filter( + (v) => v.metadata?.regionIntent === 'LATAM' || v.provider === 'MANTECA' + ) + // pick the most recently updated one + const latamVerification = [...latamVerifications].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + )[0] + + if (latamVerification) { + entries.push({ + isKyc: true, + region: 'LATAM', + uuid: 'region-LATAM', + timestamp: latamVerification.approvedAt ?? latamVerification.updatedAt ?? latamVerification.createdAt, + verification: latamVerification, + }) + } + + return entries +} From 045aed935cea63183cb1bc878825db0d822dddb8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:42:15 +0530 Subject: [PATCH 09/11] chore: format --- src/components/Home/HomeHistory.tsx | 46 +++++++++++++++-------------- src/utils/kyc-grouping.utils.ts | 3 +- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 356991e97..9a5a67c57 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -252,28 +252,30 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h

Activity

{isViewingOwnHistory && needsBridgeTos && } - {isViewingOwnHistory && user?.user && (() => { - const regionEntries = groupKycByRegion(user.user) - return regionEntries.length > 0 ? ( -
- {regionEntries.map((entry) => ( - - ))} -
- ) : ( - - ) - })()} + {isViewingOwnHistory && + user?.user && + (() => { + const regionEntries = groupKycByRegion(user.user) + return regionEntries.length > 0 ? ( +
+ {regionEntries.map((entry) => ( + + ))} +
+ ) : ( + + ) + })()} {!isViewingOwnHistory && ( Date: Wed, 25 Feb 2026 15:16:01 +0530 Subject: [PATCH 10/11] fix: qa bugs --- src/components/Global/IframeWrapper/index.tsx | 5 +++-- src/components/Kyc/BridgeTosStep.tsx | 2 +- src/components/Kyc/KycStatusDrawer.tsx | 5 +++++ .../Kyc/KycVerificationInProgressModal.tsx | 12 +--------- src/components/Kyc/SumsubKycModals.tsx | 7 +++++- src/components/Kyc/states/KycCompleted.tsx | 22 +++++++++++++++++++ src/hooks/useMultiPhaseKycFlow.ts | 7 +++--- 7 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index f41e820d3..e33a74c9b 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -12,14 +12,15 @@ export type IFrameWrapperProps = { visible: boolean onClose: (source?: 'manual' | 'completed' | 'tos_accepted') => void closeConfirmMessage?: string + skipStartView?: boolean } -const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWrapperProps) => { +const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartView }: IFrameWrapperProps) => { const enableConfirmationPrompt = closeConfirmMessage !== undefined const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') const [copied, setCopied] = useState(false) - const [isVerificationStarted, setIsVerificationStarted] = useState(false) + const [isVerificationStarted, setIsVerificationStarted] = useState(skipStartView ?? false) const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 4f9590deb..12152dbb3 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -122,7 +122,7 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp /> )} - {tosLink && } + {tosLink && } ) } diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 2ef606b7a..5b8b7e808 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -97,6 +97,11 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus bridgeKycApprovedAt={verification?.approvedAt ?? user?.user?.bridgeKycApprovedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc} + rails={user?.rails?.filter((r) => { + if (region === 'STANDARD') return r.rail.provider.code === 'BRIDGE' + if (region === 'LATAM') return r.rail.provider.code === 'MANTECA' + return true + })} /> ) case 'action_required': diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index c7f23d04c..40095eeea 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -113,22 +113,12 @@ export const KycVerificationInProgressModal = ({ ctas={[ { text: tosError ? 'Continue' : 'Accept Terms', - onClick: tosError ? (onSkipTerms ?? onClose) : (onAcceptTerms ?? onClose), + onClick: tosError ? onClose : (onAcceptTerms ?? onClose), disabled: isLoadingTos, variant: 'purple', className: 'w-full', shadowSize: '4', }, - ...(!tosError - ? [ - { - text: 'Skip for now', - onClick: onSkipTerms ?? onClose, - variant: 'transparent' as const, - className: 'underline text-sm font-medium w-full h-fit mt-3', - }, - ] - : []), ]} preventClose hideModalCloseButton diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx index b2c72fa6d..a38672b94 100644 --- a/src/components/Kyc/SumsubKycModals.tsx +++ b/src/components/Kyc/SumsubKycModals.tsx @@ -40,7 +40,12 @@ export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => /> {flow.tosLink && ( - + )} ) diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..29edc4db1 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -6,6 +6,7 @@ import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' +import { type IUserRail } from '@/interfaces' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -16,10 +17,12 @@ export const KycCompleted = ({ bridgeKycApprovedAt, countryCode, isBridge, + rails, }: { bridgeKycApprovedAt?: string countryCode?: string | null isBridge?: boolean + rails?: IUserRail[] }) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -31,6 +34,8 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) + const enabledRails = useMemo(() => (rails ?? []).filter((r) => r.status === 'ENABLED'), [rails]) + return (
@@ -48,6 +53,23 @@ export const KycCompleted = ({ /> + {enabledRails.length > 0 && ( + + + {enabledRails.map((r) => ( + + {r.rail.method.name} ({r.rail.method.currency}) + + ))} +
+ } + hideBottomBorder + /> + + )}
) } diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 50f588322..0e722345a 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -226,12 +226,13 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent } } - // refetch user — the phase-transition effect will handle moving to 'complete' + // optimistically complete — don't wait for rail status WebSocket await fetchUser() + completeFlow() } - // if manual close, stay on bridge_tos phase (user can try again or skip) + // if manual close, stay on bridge_tos phase (user can try again) }, - [fetchUser] + [fetchUser, completeFlow] ) // handle "Skip for now" in bridge_tos phase From 0cba1de9194da2893a4ed764e270a051096d8382 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:12:05 +0530 Subject: [PATCH 11/11] fix: auto close drawer bug + activity drawer ui --- src/components/Kyc/KycStatusDrawer.tsx | 5 ++- src/components/Kyc/states/KycCompleted.tsx | 36 ++++++++++++++-------- src/context/authContext.tsx | 4 +-- src/hooks/useSumsubKycFlow.ts | 9 +++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 5b8b7e808..25f2249a9 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -37,7 +37,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: onClose, onManualClose: onClose, - regionIntent: sumsubRegionIntent, + // don't pass regionIntent for completed kyc — prevents the mount effect + // in useSumsubKycFlow from calling initiateSumsubKyc(), which triggers + // the undefined->APPROVED transition that auto-closes the drawer + regionIntent: statusCategory === 'completed' ? undefined : sumsubRegionIntent, }) // all kyc retries now go through sumsub diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 29edc4db1..f1fc3a79d 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -7,6 +7,7 @@ import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' import { type IUserRail } from '@/interfaces' +import { getCurrencyFlagUrl } from '@/constants/countryCurrencyMapping' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -55,19 +56,28 @@ export const KycCompleted = ({
{enabledRails.length > 0 && ( - - {enabledRails.map((r) => ( - - {r.rail.method.name} ({r.rail.method.currency}) - - ))} -
- } - hideBottomBorder - /> + {enabledRails.map((r, index) => ( + + {getCurrencyFlagUrl(r.rail.method.currency) && ( + {`${r.rail.method.currency} + )} + {r.rail.method.name} +
+ } + value={r.rail.method.currency} + hideBottomBorder={index === enabledRails.length - 1} + /> + ))} )}
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 69d7ad7c6..9b92b434d 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -78,10 +78,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } }, [user]) - const legacy_fetchUser = async () => { + const legacy_fetchUser = useCallback(async () => { const { data: fetchedUser } = await fetchUser() return fetchedUser ?? null - } + }, [fetchUser]) const [isLoggingOut, setIsLoggingOut] = useState(false) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 08b1a8781..2c107db32 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -27,6 +27,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const regionIntentRef = useRef(regionIntent) // tracks the level name across initiate + refresh (e.g. 'peanut-additional-docs') const levelNameRef = useRef(undefined) + // guard: only fire onKycSuccess when the user initiated a kyc flow in this session. + // prevents stale websocket events or mount-time fetches from auto-closing the drawer. + const userInitiatedRef = useRef(false) useEffect(() => { regionIntentRef.current = regionIntent @@ -48,7 +51,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: prevStatusRef.current = liveKycStatus if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { - onKycSuccess?.() + if (userInitiatedRef.current) { + onKycSuccess?.() + } } else if ( liveKycStatus && liveKycStatus !== prevStatus && @@ -106,6 +111,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const handleInitiateKyc = useCallback( async (overrideIntent?: KYCRegionIntent, levelName?: string) => { + userInitiatedRef.current = true setIsLoading(true) setError(null) @@ -156,6 +162,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // called when sdk signals applicant submitted const handleSdkComplete = useCallback(() => { + userInitiatedRef.current = true setShowWrapper(false) setIsVerificationProgressModalOpen(true) }, [])