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..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,26 +14,25 @@ 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'
-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'
+type BridgeBankStep = 'inputAmount' | 'kyc' | 'showDetails'
export default function OnrampBankPage() {
const router = useRouter()
@@ -43,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' }
@@ -55,19 +54,22 @@ 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()
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({
+ onKycSuccess: () => {
+ setUrlState({ step: 'inputAmount' })
+ },
+ })
+
const selectedCountryPath = params.country as string
const selectedCountry = useMemo(() => {
@@ -162,7 +164,7 @@ export default function OnrampBankPage() {
const isUserKycVerified = currentKycStatus === 'approved'
if (!isUserKycVerified) {
- setUrlState({ step: 'collectUserDetails' })
+ setUrlState({ step: 'kyc' })
} else {
setUrlState({ step: 'inputAmount' })
}
@@ -261,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}`)
@@ -302,19 +271,11 @@ 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') {
- setIsKycModalOpen(true)
+ 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(() => {
@@ -342,45 +303,11 @@ export default function OnrampBankPage() {
return
}
- if (urlState.step === 'collectUserDetails') {
- return (
-
-
-
-
Verify your details
-
-
- {userUpdateError && }
-
-
- )
- }
-
if (urlState.step === 'kyc') {
return (
- router.push(`/add-money/${selectedCountry.path}`)}
- flow="add"
- />
+
+
)
}
diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx
index 2ace526be..2caf96b09 100644
--- a/src/app/(mobile-ui)/history/page.tsx
+++ b/src/app/(mobile-ui)/history/page.tsx
@@ -12,7 +12,8 @@ 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 { 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/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 8371f74f8..712d8b0bc 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -23,16 +23,16 @@ 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 { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
import { PointsAction } from '@/services/services.types'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
@@ -68,7 +68,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 +77,17 @@ 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
+ // 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.
@@ -106,9 +111,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 +119,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 +189,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) {
+ setShowKycModal(true)
return
}
@@ -250,7 +233,7 @@ export default function MantecaWithdrawFlow() {
usdAmount,
currencyCode,
currencyAmount,
- isMantecaKycRequired,
+ isUserMantecaKycApproved,
isLockingPrice,
])
@@ -342,7 +325,6 @@ export default function MantecaWithdrawFlow() {
setSelectedBank(null)
setAccountType(null)
setErrorMessage(null)
- setIsKycModalOpen(false)
setIsDestinationAddressValid(false)
setIsDestinationAddressChanging(false)
setBalanceErrorMessage(null)
@@ -468,6 +450,16 @@ export default function MantecaWithdrawFlow() {
}
return (
+ setShowKycModal(false)}
+ onVerify={async () => {
+ setShowKycModal(false)
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
{
@@ -649,23 +641,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/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 (
-
- )
- }
-)
-
-UserDetailsForm.displayName = 'UserDetailsForm'
diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx
index f7e902f18..c5f88bf6b 100644
--- a/src/components/AddMoney/components/MantecaAddMoney.tsx
+++ b/src/components/AddMoney/components/MantecaAddMoney.tsx
@@ -2,18 +2,18 @@
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 { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
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 +28,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 +56,20 @@ 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
+ // 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
@@ -76,18 +79,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 +109,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 +140,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) {
+ setShowKycModal(true)
return
}
@@ -191,14 +169,14 @@ const MantecaAddMoney: FC = () => {
} finally {
setIsCreatingDeposit(false)
}
- }, [currentDenomination, selectedCountry, displayedAmount, isMantecaKycRequired, isCreatingDeposit, setUrlState])
-
- // handle verification modal opening
- useEffect(() => {
- if (isMantecaKycRequired) {
- setIsKycModalOpen(true)
- }
- }, [isMantecaKycRequired])
+ }, [
+ currentDenomination,
+ selectedCountry,
+ displayedAmount,
+ isUserMantecaKycApproved,
+ isCreatingDeposit,
+ setUrlState,
+ ])
// Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation)
useEffect(() => {
@@ -212,6 +190,16 @@ const MantecaAddMoney: FC = () => {
if (step === 'inputAmount') {
return (
<>
+ setShowKycModal(false)}
+ onVerify={async () => {
+ setShowKycModal(false)
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
{
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..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'
@@ -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,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
const { setSelectedBankAccount, amountToWithdraw, setSelectedMethod, setAmountToWithdraw } = useWithdrawFlow()
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({
+ 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)
@@ -135,53 +147,14 @@ 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)
- }
- }
- }
- }
-
- setIsKycModalOpen(true)
+ await sumsubFlow.handleInitiateKyc('STANDARD')
}
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 +285,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
initialData={{}}
error={null}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
}
@@ -431,11 +400,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..e014c639b 100644
--- a/src/components/Claim/Link/MantecaFlowManager.tsx
+++ b/src/components/Claim/Link/MantecaFlowManager.tsx
@@ -12,9 +12,9 @@ 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'
+import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
interface MantecaFlowManagerProps {
claimLinkData: ClaimLinkData
@@ -27,33 +27,23 @@ 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
+ // 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
- const handleKycCancel = () => {
- setIsKYCModalOpen(false)
- onPrev()
- }
-
+ // show confirmation modal if user hasn't completed manteca verification
useEffect(() => {
if (!isUserMantecaKycApproved) {
- setIsKYCModalOpen(true)
+ setShowKycModal(true)
}
}, [isUserMantecaKycApproved])
@@ -125,23 +115,17 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount
/>
{renderStepDetails()}
-
- {isKYCModalOpen && (
- {
- // close the modal and let the user continue with amount input
- setIsKYCModalOpen(false)
- fetchUser()
- }}
- selectedCountry={selectedCountry || argentinaCountryData}
- />
- )}
+ 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 2e6519249..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'
@@ -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,20 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
const { claimLink } = useClaimLink()
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({
+ 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('')
@@ -241,23 +256,9 @@ 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()
- }
- }
-
- setIsKycModalOpen(true)
+ await sumsubFlow.handleInitiateKyc('STANDARD')
return {}
}
@@ -391,19 +392,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 +480,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
initialData={{}}
error={error}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
case ClaimBankFlowStep.BankConfirmClaim:
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/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx
index c89f606b1..9a5a67c57 100644
--- a/src/components/Home/HomeHistory.tsx
+++ b/src/components/Home/HomeHistory.tsx
@@ -13,9 +13,9 @@ 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 { groupKycByRegion } from '@/utils/kyc-grouping.utils'
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'
@@ -180,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
@@ -275,38 +253,29 @@ 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)) && (
-
- {user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started' && (
-
- )}
- {user?.user.kycVerifications?.map((verification) => (
-
- ))}
-
- )}
-
- {isViewingOwnHistory &&
- !user?.user.bridgeKycStatus &&
- (!user?.user.kycVerifications || user?.user.kycVerifications.length === 0) && (
-
- )}
+ user?.user &&
+ (() => {
+ const regionEntries = groupKycByRegion(user.user)
+ return regionEntries.length > 0 ? (
+
+ {regionEntries.map((entry) => (
+
+ ))}
+
+ ) : (
+
+ )
+ })()}
{!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/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx
index ac23576ab..12152dbb3 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,41 +95,34 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp
return (
<>
- {!showIframe && (
+ {/* only show modal on error — normal flow goes straight to iframe */}
+ {error && !showIframe && (
)}
- {tosLink && }
+ {tosLink && }
>
)
}
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/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 (
+ }
+ />
+ )
+}
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..25f2249a9 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'
@@ -22,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
@@ -38,52 +34,20 @@ 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,
+ // 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,
})
- 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 +66,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 +80,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
)
}
@@ -139,13 +100,18 @@ 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':
return (
)
@@ -161,7 +127,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
countryCode={countryCode ?? undefined}
isBridge={isBridgeKyc}
onRetry={onRetry}
- isLoading={isLoadingKyc}
+ isLoading={sumsubFlow.isLoading}
/>
)
default:
@@ -181,22 +147,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..359e53a1f 100644
--- a/src/components/Kyc/KycStatusItem.tsx
+++ b/src/components/Kyc/KycStatusItem.tsx
@@ -18,6 +18,20 @@ 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
+ region?: 'STANDARD' | 'LATAM'
+}
+
+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',
@@ -25,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)
@@ -69,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)) {
@@ -88,7 +109,7 @@ export const KycStatusItem = ({
-
Identity verification
+
{title}
{subtitle}
)}
>
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/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 (
<>
-
)
}
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/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/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..0e722345a
--- /dev/null
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -0,0 +1,292 @@
+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')
+ }
+ }
+
+ // optimistically complete — don't wait for rail status WebSocket
+ await fetchUser()
+ completeFlow()
+ }
+ // if manual close, stay on bridge_tos phase (user can try again)
+ },
+ [fetchUser, completeFlow]
+ )
+
+ // 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..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 &&
@@ -80,8 +85,33 @@ 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) => {
+ userInitiatedRef.current = true
setIsLoading(true)
setError(null)
@@ -132,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)
}, [])
diff --git a/src/utils/kyc-grouping.utils.ts b/src/utils/kyc-grouping.utils.ts
new file mode 100644
index 000000000..7da3aedcd
--- /dev/null
+++ b/src/utils/kyc-grouping.utils.ts
@@ -0,0 +1,70 @@
+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
+}