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 f058b3361..c5cb43d2c 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -9,31 +9,30 @@ import { useOnrampFlow } from '@/context/OnrampFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { formatAmount } from '@/utils/general.utils'
import { countryData } from '@/components/AddMoney/consts'
-import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils'
-import { useWebSocket } from '@/hooks/useWebSocket'
import { useAuth } from '@/context/authContext'
+import useKycStatus from '@/hooks/useKycStatus'
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'
+import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
// Step type for URL state
-type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails'
+type BridgeBankStep = 'inputAmount' | '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', 'showDetails']),
amount: parseAsString,
},
{ history: 'push' }
@@ -54,20 +53,23 @@ export default function OnrampBankPage() {
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState(false)
+ const [showKycModal, setShowKycModal] = 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(() => {
@@ -87,19 +89,7 @@ export default function OnrampBankPage() {
// uk-specific check
const isUK = isUKCountry(selectedCountryPath)
- useWebSocket({
- username: user?.user.username ?? undefined,
- autoConnect: !!user?.user.username,
- onKycStatusUpdate: (newStatus) => {
- setLiveKycStatus(newStatus as BridgeKycStatus)
- },
- })
-
- useEffect(() => {
- if (user?.user.bridgeKycStatus) {
- setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus)
- }
- }, [user?.user.bridgeKycStatus])
+ const { isUserKycApproved } = useKycStatus()
useEffect(() => {
fetchUser()
@@ -150,30 +140,12 @@ export default function OnrampBankPage() {
currency: 'USD',
})
- // Determine initial step based on KYC status (only when URL has no step)
+ // Default to inputAmount step when no step in URL
useEffect(() => {
- // If URL already has a step, respect it (allows deep linking)
if (urlState.step) return
-
- // Wait for user to be fetched before determining initial step
if (user === null) return
-
- const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus
- const isUserKycVerified = currentKycStatus === 'approved'
-
- if (!isUserKycVerified) {
- setUrlState({ step: 'collectUserDetails' })
- } else {
- setUrlState({ step: 'inputAmount' })
- }
- }, [liveKycStatus, user, urlState.step, setUrlState])
-
- // Handle KYC completion
- useEffect(() => {
- if (urlState.step === 'kyc' && liveKycStatus === 'approved') {
- setUrlState({ step: 'inputAmount' })
- }
- }, [liveKycStatus, urlState.step, setUrlState])
+ setUrlState({ step: 'inputAmount' })
+ }, [user, urlState.step, setUrlState])
const validateAmount = useCallback(
(amountStr: string): boolean => {
@@ -215,9 +187,14 @@ export default function OnrampBankPage() {
}, [rawTokenAmount, validateAmount, setError])
const handleAmountContinue = () => {
- if (validateAmount(rawTokenAmount)) {
- setShowWarningModal(true)
+ if (!validateAmount(rawTokenAmount)) return
+
+ if (!isUserKycApproved) {
+ setShowKycModal(true)
+ return
}
+
+ setShowWarningModal(true)
}
const handleWarningConfirm = async () => {
@@ -261,39 +238,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,20 +246,6 @@ export default function OnrampBankPage() {
}
}
- const initialUserDetails: Partial = useMemo(
- () => ({
- fullName: user?.user.fullName ?? '',
- email: user?.user.email ?? '',
- }),
- [user?.user.fullName, user?.user.email]
- )
-
- useEffect(() => {
- if (urlState.step === 'kyc') {
- setIsKycModalOpen(true)
- }
- }, [urlState.step])
-
// Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation)
useEffect(() => {
if (urlState.step === 'showDetails' && !onrampData?.transferId) {
@@ -342,49 +272,6 @@ export default function OnrampBankPage() {
return
}
- if (urlState.step === 'collectUserDetails') {
- return (
-
-
-
-
Verify your details
-
- formRef.current?.handleSubmit()}
- loading={isUpdatingUser}
- variant="purple"
- shadowSize="4"
- className="w-full"
- disabled={!isUserDetailsFormValid || isUpdatingUser}
- >
- Continue
-
- {userUpdateError && }
-
-
- )
- }
-
- if (urlState.step === 'kyc') {
- return (
-
- router.push(`/add-money/${selectedCountry.path}`)}
- flow="add"
- />
-
- )
- }
-
if (urlState.step === 'showDetails') {
// Show loading while useEffect redirects if data is missing
if (!onrampData?.transferId) {
@@ -477,6 +364,18 @@ export default function OnrampBankPage() {
amount={rawTokenAmount}
currency={getCurrencySymbol(getCurrencyConfig(selectedCountry.id, 'onramp').currency)}
/>
+
+ setShowKycModal(false)}
+ onVerify={async () => {
+ await sumsubFlow.handleInitiateKyc('STANDARD')
+ setShowKycModal(false)
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
+
)
}
diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx
index 2ace526be..534e4aa1f 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'
@@ -140,6 +141,10 @@ const HistoryPage = () => {
console.log('KYC status updated via WebSocket:', newStatus)
await fetchUser()
},
+ onSumsubKycStatusUpdate: async (newStatus: string) => {
+ console.log('Sumsub KYC status updated via WebSocket:', newStatus)
+ await fetchUser()
+ },
})
const allEntries = useMemo(() => historyData?.pages.flatMap((page) => page.entries) ?? [], [historyData])
@@ -165,30 +170,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 +257,7 @@ const HistoryPage = () => {
bridgeKycStartedAt={
item.bridgeKycStatus ? user?.user.bridgeKycStartedAt : undefined
}
+ region={item.region}
/>
) : isBadgeHistoryItem(item) ? (
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index e4ed39a98..bdcca9036 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -10,7 +10,7 @@ import { UserHeader } from '@/components/UserHeader'
import { useAuth } from '@/context/authContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useUserStore } from '@/redux/hooks'
-import { formatExtendedNumber, getUserPreferences, updateUserPreferences, getRedirectUrl } from '@/utils/general.utils'
+import { formatExtendedNumber, getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
import { printableUsdc } from '@/utils/balance.utils'
import { useDisconnect } from '@reown/appkit/react'
import Link from 'next/link'
@@ -24,7 +24,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useClaimBankFlow } from '@/context/ClaimBankFlowContext'
-import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
+import { useDeviceType } from '@/hooks/useGetDeviceType'
import { useNotifications } from '@/hooks/useNotifications'
import useKycStatus from '@/hooks/useKycStatus'
import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo'
diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx
index 3a543e76f..9285f1396 100644
--- a/src/app/(mobile-ui)/points/invites/page.tsx
+++ b/src/app/(mobile-ui)/points/invites/page.tsx
@@ -86,7 +86,7 @@ const InvitesPage = () => {
{invites?.invitees?.map((invite: PointsInvite, i: number) => {
const username = invite.username
const fullName = invite.fullName
- const isVerified = invite.kycStatus === 'approved'
+ const isVerified = invite.kycVerified
const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 7690531a5..05baf8d35 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -238,7 +238,7 @@ const PointsPage = () => {
{invites.invitees?.slice(0, 5).map((invite: PointsInvite, i: number) => {
const username = invite.username
const fullName = invite.fullName
- const isVerified = invite.kycStatus === 'approved'
+ const isVerified = invite.kycVerified
const pointsEarned = invite.contributedPoints ?? 0
// respect user's showFullName preference for avatar and display name
const displayName = invite.showFullName && fullName ? fullName : username
diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx
deleted file mode 100644
index 8ffed617b..000000000
--- a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-'use client'
-import IdentityVerificationView from '@/components/Profile/views/IdentityVerification.view'
-
-export default function IdentityVerificationCountryPage() {
- return
-}
diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
deleted file mode 100644
index d1843f861..000000000
--- a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-'use client'
-import RegionsPage from '@/components/Profile/views/RegionsPage.view'
-import { useParams } from 'next/navigation'
-
-export default function IdentityVerificationRegionPage() {
- const params = useParams()
- const region = params.region as string
-
- return
-}
diff --git a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
index 29884066e..5f6049aa8 100644
--- a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
+++ b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx
@@ -1,59 +1,7 @@
'use client'
import PageContainer from '@/components/0_Bruddle/PageContainer'
-import ActionModal from '@/components/Global/ActionModal'
-import { useIdentityVerification } from '@/hooks/useIdentityVerification'
-import { useParams, useRouter } from 'next/navigation'
-import { useEffect, useState } from 'react'
export default function IdentityVerificationLayout({ children }: { children: React.ReactNode }) {
- const [isAlreadyVerifiedModalOpen, setIsAlreadyVerifiedModalOpen] = useState(false)
- const router = useRouter()
- const { isRegionAlreadyUnlocked, isVerifiedForCountry } = useIdentityVerification()
- const params = useParams()
- const regionParams = params.region as string
- const countryParams = params.country as string
-
- useEffect(() => {
- const isAlreadyVerified =
- (countryParams && isVerifiedForCountry(countryParams)) ||
- (regionParams && isRegionAlreadyUnlocked(regionParams))
-
- if (isAlreadyVerified) {
- setIsAlreadyVerifiedModalOpen(true)
- }
- }, [countryParams, regionParams, isVerifiedForCountry, isRegionAlreadyUnlocked])
-
- return (
-
- {children}
-
- {
- setIsAlreadyVerifiedModalOpen(false)
- router.push('/profile')
- }}
- title="You're already verified"
- description={
-
- Your identity has already been successfully verified for this region. You can continue to use
- features available in this region. No further action is needed.
-
- }
- icon="shield"
- ctas={[
- {
- text: 'Close',
- shadowSize: '4',
- className: 'md:py-2',
- onClick: () => {
- setIsAlreadyVerifiedModalOpen(false)
- router.push('/profile')
- },
- },
- ]}
- />
-
- )
+ return {children}
}
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 6b4a7805c..4282c07ca 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -2,7 +2,7 @@
import { useSearchParams, useRouter } from 'next/navigation'
import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react'
-import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/KycVerificationInProgressModal'
+import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation'
import Card from '@/components/Global/Card'
import { Button } from '@/components/0_Bruddle/Button'
import { Icon } from '@/components/Global/Icons/Icon'
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 8371f74f8..baac44283 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -17,22 +17,28 @@ import { loadingStateContext } from '@/context'
import { countryData } from '@/components/AddMoney/consts'
import Image from 'next/image'
import { formatAmount, formatNumberForDisplay } from '@/utils/general.utils'
-import { validateCbuCvuAlias, validatePixKey, normalizePixPhoneNumber, isPixPhoneNumber } from '@/utils/withdraw.utils'
+import {
+ validateCbuCvuAlias,
+ validatePixKey,
+ normalizePixPhoneNumber,
+ isPixPhoneNumber,
+ isPixEmvcoQr,
+} from '@/utils/withdraw.utils'
import ValidatedInput from '@/components/Global/ValidatedInput'
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 +74,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 +83,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 +117,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 +125,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()
@@ -164,6 +159,7 @@ export default function MantecaWithdrawFlow() {
}
break
case 'brazil':
+ value = isPixEmvcoQr(value.trim()) ? value.trim() : value.replace(/\s/g, '')
const pixResult = validatePixKey(value)
isValid = pixResult.valid
if (!pixResult.valid) {
@@ -200,14 +196,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 +240,7 @@ export default function MantecaWithdrawFlow() {
usdAmount,
currencyCode,
currencyAmount,
- isMantecaKycRequired,
+ isUserMantecaKycApproved,
isLockingPrice,
])
@@ -342,7 +332,6 @@ export default function MantecaWithdrawFlow() {
setSelectedBank(null)
setAccountType(null)
setErrorMessage(null)
- setIsKycModalOpen(false)
setIsDestinationAddressValid(false)
setIsDestinationAddressChanging(false)
setBalanceErrorMessage(null)
@@ -468,6 +457,16 @@ export default function MantecaWithdrawFlow() {
}
return (
+ setShowKycModal(false)}
+ onVerify={async () => {
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ setShowKycModal(false)
+ }}
+ isLoading={sumsubFlow.isLoading}
+ />
+
{
@@ -586,10 +585,15 @@ export default function MantecaWithdrawFlow() {
value={destinationAddress}
placeholder={countryConfig!.accountNumberLabel}
onUpdate={(update) => {
- // Auto-normalize PIX phone numbers for Brazil
+ // Auto-normalize PIX keys for Brazil: strip whitespace and normalize phone numbers
let normalizedValue = update.value
- if (countryPath === 'brazil' && isPixPhoneNumber(update.value)) {
- normalizedValue = normalizePixPhoneNumber(update.value)
+ if (countryPath === 'brazil') {
+ normalizedValue = isPixEmvcoQr(normalizedValue.trim())
+ ? normalizedValue.trim()
+ : normalizedValue.replace(/\s/g, '')
+ if (isPixPhoneNumber(normalizedValue)) {
+ normalizedValue = normalizePixPhoneNumber(normalizedValue)
+ }
}
setDestinationAddress(normalizedValue)
setIsDestinationAddressValid(update.isValid)
@@ -649,23 +653,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/app/actions/sumsub.ts b/src/app/actions/sumsub.ts
new file mode 100644
index 000000000..7222004c6
--- /dev/null
+++ b/src/app/actions/sumsub.ts
@@ -0,0 +1,54 @@
+'use server'
+
+import { type InitiateSumsubKycResponse, type KYCRegionIntent } from './types/sumsub.types'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { PEANUT_API_URL } from '@/constants/general.consts'
+import { getJWTCookie } from '@/utils/cookie-migration.utils'
+
+const API_KEY = process.env.PEANUT_API_KEY!
+
+// initiate kyc flow (using sumsub) and get websdk access token
+export const initiateSumsubKyc = async (params?: {
+ regionIntent?: KYCRegionIntent
+ levelName?: string
+}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+
+ if (!jwtToken) {
+ return { error: 'Authentication required' }
+ }
+
+ const body: Record = {
+ regionIntent: params?.regionIntent,
+ levelName: params?.levelName,
+ }
+
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ body: JSON.stringify(body),
+ })
+
+ const responseJson = await response.json()
+
+ if (!response.ok) {
+ return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' }
+ }
+
+ return {
+ data: {
+ token: responseJson.token,
+ applicantId: responseJson.applicantId,
+ status: responseJson.status,
+ },
+ }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ return { error: message }
+ }
+}
diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts
new file mode 100644
index 000000000..8565d2961
--- /dev/null
+++ b/src/app/actions/types/sumsub.types.ts
@@ -0,0 +1,9 @@
+export interface InitiateSumsubKycResponse {
+ token: string | null // null when user is already APPROVED
+ applicantId: string | null
+ status: SumsubKycStatus
+}
+
+export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED'
+
+export type KYCRegionIntent = 'STANDARD' | 'LATAM'
diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts
index e530ebf17..07e1498ef 100644
--- a/src/app/actions/users.ts
+++ b/src/app/actions/users.ts
@@ -160,3 +160,48 @@ export async function getContacts(params: {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}
+
+// fetch bridge ToS acceptance link for users with pending ToS
+export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ })
+ const responseJson = await response.json()
+ if (!response.ok) {
+ return { error: responseJson.error || 'Failed to fetch Bridge ToS link' }
+ }
+ return { data: responseJson }
+ } catch (e: unknown) {
+ return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
+ }
+}
+
+// confirm bridge ToS acceptance after user closes the ToS iframe
+export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => {
+ const jwtToken = (await getJWTCookie())?.value
+ try {
+ const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwtToken}`,
+ 'api-key': API_KEY,
+ },
+ body: JSON.stringify({}),
+ })
+ const responseJson = await response.json()
+ if (!response.ok) {
+ return { error: responseJson.error || 'Failed to confirm Bridge ToS' }
+ }
+ return { data: responseJson }
+ } catch (e: unknown) {
+ return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
+ }
+}
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..eab40ae05 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 () => {
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ setShowKycModal(false)
+ }}
+ 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..8ff2b9480 100644
--- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
+++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
@@ -10,23 +10,22 @@ import Image, { type StaticImageData } from 'next/image'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import EmptyState from '../Global/EmptyStates/EmptyState'
import { useAuth } from '@/context/authContext'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useMemo, useRef, useState } from 'react'
import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm'
-import { addBankAccount, updateUserById } from '@/app/actions/users'
-import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils'
+import { addBankAccount } from '@/app/actions/users'
import { type AddBankAccountPayload } from '@/app/actions/types/users.types'
-import { useWebSocket } from '@/hooks/useWebSocket'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { type Account } from '@/interfaces'
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,32 +47,26 @@ 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)
const formRef = useRef<{ handleSubmit: () => void }>(null)
- const [liveKycStatus, setLiveKycStatus] = useState(
- user?.user?.bridgeKycStatus as BridgeKycStatus
- )
const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false)
- const { isUserBridgeKycUnderReview } = useKycStatus()
+ const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus()
const [showKycStatusModal, setShowKycStatusModal] = useState(false)
- useWebSocket({
- username: user?.user.username ?? undefined,
- autoConnect: !!user?.user.username,
- onKycStatusUpdate: (newStatus) => {
- setLiveKycStatus(newStatus as BridgeKycStatus)
- },
- })
-
- useEffect(() => {
- if (user?.user.bridgeKycStatus) {
- setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus)
- }
- }, [user?.user.bridgeKycStatus])
-
const countryPathParts = Array.isArray(params.country) ? params.country : [params.country]
const isBankPage = countryPathParts[countryPathParts.length - 1] === 'bank'
const countrySlugFromUrl = isBankPage ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-')
@@ -86,15 +79,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
payload: AddBankAccountPayload,
rawData: IBankAccountDetails
): Promise<{ error?: string }> => {
- const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus
- const isUserKycVerified = currentKycStatus === 'approved'
-
- const hasEmailOnLoad = !!user?.user.email
+ // re-fetch user to ensure we have the latest KYC status
+ // (the multi-phase flow may have completed but websocket/state not yet propagated)
+ await fetchUser()
// scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly
- // note: we no longer check for fullName as account owner name is now always collected from the form
- if (isUserKycVerified && (hasEmailOnLoad || rawData.email)) {
- const currentAccountIds = new Set(user?.accounts.map((acc) => acc.id) ?? [])
+ // email and name are now collected by sumsub — no need to check them here
+ if (isUserKycApproved) {
+ const currentAccountIds = new Set((user?.accounts ?? []).map((acc) => acc.id))
const result = await addBankAccount(payload)
if (result.error) {
@@ -135,53 +127,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
}
// scenario (2): if the user hasn't completed kyc yet
- 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)
+ // name and email are now collected by sumsub sdk — no need to save them beforehand
+ if (!isUserKycApproved) {
+ 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 +265,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
initialData={{}}
error={null}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
}
@@ -431,11 +380,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
isKycApprovedModalOpen={showKycStatusModal}
onClose={() => setShowKycStatusModal(false)}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
}
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
index c94b48157..b0996cdb3 100644
--- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx
+++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
@@ -430,12 +430,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
})}
)}
- {flow !== 'claim' &&
- !hideEmailInput &&
- !user?.user?.email &&
- renderInput('email', 'E-mail', {
- required: 'Email is required',
- })}
{isMx
? renderInput('clabe', 'CLABE', {
diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx
index 318eba629..cb61325d7 100644
--- a/src/components/Claim/Claim.tsx
+++ b/src/components/Claim/Claim.tsx
@@ -22,6 +22,7 @@ import {
getTokenLogo,
getChainLogo,
} from '@/utils/general.utils'
+import { isUserKycVerified } from '@/constants/kyc.consts'
import * as Sentry from '@sentry/nextjs'
import { useQuery } from '@tanstack/react-query'
import type { Hash } from 'viem'
@@ -189,7 +190,7 @@ export const Claim = ({}) => {
peanutFeeDetails: {
amountDisplay: '$ 0.00',
},
- isVerified: claimLinkData.sender?.bridgeKycStatus === 'approved',
+ isVerified: isUserKycVerified(claimLinkData.sender),
haveSentMoneyToUser: claimLinkData.sender?.userId
? interactions[claimLinkData.sender?.userId] || false
: false,
@@ -396,7 +397,7 @@ export const Claim = ({}) => {
// redirect to bank flow if user is KYC approved and step is bank
useEffect(() => {
const stepFromURL = searchParams.get('step')
- if (user?.user.bridgeKycStatus === 'approved' && stepFromURL === 'bank') {
+ if (isUserKycVerified(user?.user) && stepFromURL === 'bank') {
setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList)
}
}, [user])
diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx
index b27e54a4d..0598ac4b1 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 () => {
+ await sumsubFlow.handleInitiateKyc('LATAM')
+ setShowKycModal(false)
+ }}
+ 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..d7da95828 100644
--- a/src/components/Claim/Link/views/BankFlowManager.view.tsx
+++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx
@@ -3,7 +3,7 @@
import { type IClaimScreenProps } from '../../Claim.consts'
import { DynamicBankAccountForm, type IBankAccountDetails } from '@/components/AddWithdraw/DynamicBankAccountForm'
import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext'
-import { useCallback, useContext, useState, useRef, useEffect } from 'react'
+import { useCallback, useContext, useState, useRef } from 'react'
import { loadingStateContext } from '@/context'
import { createBridgeExternalAccountForGuest } from '@/app/actions/external-accounts'
import { confirmOfframp, createOfframp, createOfframpForGuest } from '@/app/actions/offramp'
@@ -18,21 +18,20 @@ 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'
import { ConfirmBankClaimView } from './Confirm.bank-claim.view'
import { CountryListRouter } from '@/components/Common/CountryListRouter'
import NavHeader from '@/components/Global/NavHeader'
-import { useWebSocket } from '@/hooks/useWebSocket'
-import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils'
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,33 +75,28 @@ 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('')
const [error, setError] = useState(null)
const formRef = useRef<{ handleSubmit: () => void }>(null)
- const [liveKycStatus, setLiveKycStatus] = useState(
- user?.user?.bridgeKycStatus as BridgeKycStatus
- )
const [isProcessingKycSuccess, setIsProcessingKycSuccess] = useState(false)
const [offrampData, setOfframpData] = useState(null)
- // websocket for real-time KYC status updates
- useWebSocket({
- username: user?.user.username ?? undefined,
- autoConnect: !!user?.user.username,
- onKycStatusUpdate: (newStatus) => {
- setLiveKycStatus(newStatus as BridgeKycStatus)
- },
- })
-
- // effect to update live KYC status from user object
- useEffect(() => {
- if (user?.user.bridgeKycStatus) {
- setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus)
- }
- }, [user?.user.bridgeKycStatus])
-
/**
* @name handleConfirmClaim
* @description claims the link to the deposit address provided by the off-ramp api and confirms the transfer.
@@ -241,23 +235,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 +371,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 +459,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => {
initialData={{}}
error={error}
/>
- setIsKycModalOpen(false)}
- onKycSuccess={handleKycSuccess}
- />
+
)
case ClaimBankFlowStep.BankConfirmClaim:
diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx
index 8eaf7335d..1f37ab48a 100644
--- a/src/components/Global/Badges/StatusBadge.tsx
+++ b/src/components/Global/Badges/StatusBadge.tsx
@@ -30,8 +30,9 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm
return 'bg-secondary-4 text-yellow-6 border border-yellow-7'
case 'failed':
case 'cancelled':
- case 'refunded':
return 'bg-error-1 text-error border border-error-2'
+ case 'refunded':
+ return 'bg-secondary-4 text-yellow-6 border border-yellow-7'
case 'soon':
case 'custom':
return 'bg-primary-3 text-primary-4'
@@ -41,6 +42,10 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm
}
const getStatusText = () => {
+ // customText overrides the default label for any status type,
+ // allowing callers to use a specific status style with custom text
+ if (customText) return customText
+
switch (status) {
case 'completed':
return 'Completed'
@@ -59,7 +64,7 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm
case 'closed':
return 'Closed'
case 'custom':
- return customText
+ return 'Custom'
default:
return status
}
diff --git a/src/components/Global/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx
index faf8bafb9..5ec925560 100644
--- a/src/components/Global/Icons/Icon.tsx
+++ b/src/components/Global/Icons/Icon.tsx
@@ -66,6 +66,7 @@ import {
WarningRounded,
SpeedRounded,
InfoRounded,
+ UndoRounded,
} from '@mui/icons-material'
import { DocsIcon } from './docs'
import { PeanutSupportIcon } from './peanut-support'
@@ -148,6 +149,7 @@ export type IconName =
| 'split'
| 'globe-lock'
| 'bulb'
+ | 'undo'
| 'upload-cloud'
| 'alert-filled'
| 'paste'
@@ -288,6 +290,7 @@ const iconComponents: Record>> =
'txn-off': TxnOffIcon,
docs: DocsIcon,
bulb: BulbIcon,
+ undo: (props) => ,
'upload-cloud': (props) => ,
'invite-heart': InviteHeartIcon,
'alert-filled': (props) => ,
diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx
index 5285ec405..abec9c2ea 100644
--- a/src/components/Global/IframeWrapper/StartVerificationView.tsx
+++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx
@@ -33,10 +33,11 @@ const StartVerificationView = ({
Secure Verification. Limited Data Use.
- The verification is done by Persona, which only shares a yes/no with Peanut.
+ The verification is done using a trusted provider, which shares your verification status with
+ Peanut.
- Persona is trusted by millions and it operates under strict security and privacy standards.
+ It operates under industry-standard security and privacy practices.
Peanut never sees or stores your verification data.
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/Global/PostSignupActionManager/index.tsx b/src/components/Global/PostSignupActionManager/index.tsx
index 4c0821810..b58d6143f 100644
--- a/src/components/Global/PostSignupActionManager/index.tsx
+++ b/src/components/Global/PostSignupActionManager/index.tsx
@@ -7,6 +7,7 @@ import ActionModal from '../ActionModal'
import { POST_SIGNUP_ACTIONS } from './post-signup-action.consts'
import { type IconName } from '../Icons/Icon'
import { useAuth } from '@/context/authContext'
+import { isUserKycVerified } from '@/constants/kyc.consts'
export const PostSignupActionManager = ({
onActionModalVisibilityChange,
@@ -26,7 +27,7 @@ export const PostSignupActionManager = ({
const checkClaimModalAfterKYC = () => {
const redirectUrl = getRedirectUrl()
- if (user?.user.bridgeKycStatus === 'approved' && redirectUrl) {
+ if (isUserKycVerified(user?.user) && redirectUrl) {
const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(redirectUrl))
if (matchedAction) {
setActionConfig({
diff --git a/src/components/Global/StatusPill/index.tsx b/src/components/Global/StatusPill/index.tsx
index 269888fe7..4f7f45a40 100644
--- a/src/components/Global/StatusPill/index.tsx
+++ b/src/components/Global/StatusPill/index.tsx
@@ -13,7 +13,7 @@ const StatusPill = ({ status }: StatusPillProps) => {
completed: 'border-success-5 bg-success-2 text-success-4',
pending: 'border-yellow-8 bg-secondary-4 text-yellow-6',
cancelled: 'border-error-2 bg-error-1 text-error',
- refunded: 'border-error-2 bg-error-1 text-error',
+ refunded: 'border-yellow-8 bg-secondary-4 text-yellow-6',
failed: 'border-error-2 bg-error-1 text-error',
processing: 'border-yellow-8 bg-secondary-4 text-yellow-6',
soon: 'border-yellow-8 bg-secondary-4 text-yellow-6',
@@ -27,7 +27,7 @@ const StatusPill = ({ status }: StatusPillProps) => {
soon: 'pending',
pending: 'pending',
cancelled: 'cancel',
- refunded: 'cancel',
+ refunded: 'undo',
closed: 'success',
}
@@ -38,7 +38,7 @@ const StatusPill = ({ status }: StatusPillProps) => {
soon: 7,
pending: 8,
cancelled: 6,
- refunded: 6,
+ refunded: 8,
closed: 7,
}
diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx
index e04c59019..2bd5e3681 100644
--- a/src/components/Global/WalletNavigation/index.tsx
+++ b/src/components/Global/WalletNavigation/index.tsx
@@ -25,7 +25,7 @@ const desktopPaths: NavPathProps[] = [
{ name: 'Add', href: '/add-money', icon: 'arrow-down', size: 15 },
{ name: 'Withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 },
{ name: 'History', href: '/history', icon: 'history', size: 15 },
- { name: 'Docs', href: 'https://docs.peanut.me/', icon: 'docs', size: 14 },
+ { name: 'Docs', href: '/en/help', icon: 'docs', size: 14 },
{ name: 'Support', href: '/support', icon: 'peanut-support', size: 14 },
]
diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx
index b7867765d..d8a043bda 100644
--- a/src/components/Home/HomeCarouselCTA/index.tsx
+++ b/src/components/Home/HomeCarouselCTA/index.tsx
@@ -8,14 +8,15 @@ import { type IconName } from '@/components/Global/Icons/Icon'
import { useHomeCarouselCTAs, type CarouselCTA as CarouselCTAType } from '@/hooks/useHomeCarouselCTAs'
import { perksApi, type PendingPerk } from '@/services/perks'
import { useAuth } from '@/context/authContext'
+import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useWebSocket } from '@/hooks/useWebSocket'
import { extractInviteeName } from '@/utils/general.utils'
import PerkClaimModal from '../PerkClaimModal'
import underMaintenanceConfig from '@/config/underMaintenance.config'
const HomeCarouselCTA = () => {
- const { carouselCTAs, setCarouselCTAs } = useHomeCarouselCTAs()
- const { user } = useAuth()
+ const { carouselCTAs, setCarouselCTAs, showBridgeTos, setShowBridgeTos } = useHomeCarouselCTAs()
+ const { user, fetchUser } = useAuth()
const queryClient = useQueryClient()
// Perk claim modal state
@@ -89,6 +90,17 @@ const HomeCarouselCTA = () => {
setSelectedPerk(null)
}, [])
+ // bridge ToS handlers
+ const handleTosComplete = useCallback(async () => {
+ setShowBridgeTos(false)
+ setCarouselCTAs((prev) => prev.filter((c) => c.id !== 'bridge-tos'))
+ await fetchUser()
+ }, [setShowBridgeTos, setCarouselCTAs, fetchUser])
+
+ const handleTosSkip = useCallback(() => {
+ setShowBridgeTos(false)
+ }, [setShowBridgeTos])
+
// don't render carousel if there are no CTAs
if (!allCTAs.length) return null
@@ -130,6 +142,9 @@ const HomeCarouselCTA = () => {
onClaimed={handlePerkClaimed}
/>
)}
+
+ {/* Bridge ToS iframe */}
+
>
)
}
diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx
index e73e916c7..a1992a528 100644
--- a/src/components/Home/HomeHistory.tsx
+++ b/src/components/Home/HomeHistory.tsx
@@ -13,8 +13,8 @@ import { twMerge } from 'tailwind-merge'
import Card from '../Global/Card'
import { type CardPosition, getCardPosition } from '../Global/Card/card.utils'
import EmptyState from '../Global/EmptyStates/EmptyState'
-import { KycStatusItem } from '../Kyc/KycStatusItem'
-import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow'
+import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem'
+import { groupKycByRegion } from '@/utils/kyc-grouping.utils'
import { useWallet } from '@/hooks/wallet/useWallet'
import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem'
import { isBadgeHistoryItem } from '@/components/Badges/badge.types'
@@ -43,7 +43,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h
const { fetchBalance } = useWallet()
const { triggerHaptic } = useHaptic()
const { fetchUser } = useAuth()
-
const isViewingOwnHistory = useMemo(
() => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username),
[isLoggedIn, username, user?.user.username]
@@ -72,6 +71,13 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h
},
[fetchUser]
),
+ onSumsubKycStatusUpdate: useCallback(
+ async (newStatus: string) => {
+ console.log('Sumsub KYC status updated via WebSocket:', newStatus)
+ await fetchUser()
+ },
+ [fetchUser]
+ ),
})
// Combine fetched history with real-time updates
@@ -177,32 +183,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
@@ -271,38 +255,29 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h
Activity
{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/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx
index 1377b3a50..0b9fdff39 100644
--- a/src/components/Home/KycCompletedModal/index.tsx
+++ b/src/components/Home/KycCompletedModal/index.tsx
@@ -6,29 +6,27 @@ import InfoCard from '@/components/Global/InfoCard'
import { useAuth } from '@/context/authContext'
import { MantecaKycStatus } from '@/interfaces'
import { countryData, MantecaSupportedExchanges, type CountryData } from '@/components/AddMoney/consts'
-import useKycStatus from '@/hooks/useKycStatus'
+import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus'
import { useIdentityVerification } from '@/hooks/useIdentityVerification'
const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => {
const { user } = useAuth()
const [approvedCountryData, setApprovedCountryData] = useState
(null)
- const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus()
+ const { isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent } =
+ useUnifiedKycStatus()
const { getVerificationUnlockItems } = useIdentityVerification()
const kycApprovalType = useMemo(() => {
- if (isUserBridgeKycApproved && isUserMantecaKycApproved) {
- return 'all'
- }
-
- if (isUserBridgeKycApproved) {
+ if (isSumsubApproved) {
+ if (sumsubVerificationRegionIntent === 'LATAM') return 'manteca'
return 'bridge'
}
- if (isUserMantecaKycApproved) {
- return 'manteca'
- }
+ if (isBridgeApproved && isMantecaApproved) return 'all'
+ if (isBridgeApproved) return 'bridge'
+ if (isMantecaApproved) return 'manteca'
return 'none'
- }, [isUserBridgeKycApproved, isUserMantecaKycApproved])
+ }, [isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent])
const items = useMemo(() => {
return getVerificationUnlockItems(approvedCountryData?.title)
@@ -36,7 +34,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () =
useEffect(() => {
// If manteca KYC is approved, then we need to get the approved country
- if (isUserMantecaKycApproved) {
+ if (isMantecaApproved) {
const supportedCountries = Object.keys(MantecaSupportedExchanges)
let approvedCountry: string | undefined | null
@@ -58,7 +56,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () =
setApprovedCountryData(_approvedCountryData || null)
}
}
- }, [isUserMantecaKycApproved, user])
+ }, [isMantecaApproved, user])
return (
+ QR Payments in Argentina and Brazil
+
+)
+
+const BRIDGE_UNLOCK_ITEMS: Array = [
+
+ Europe SEPA transfers (+30 countries)
+
,
+
+ UK Faster payment transfers
+
,
+
+ United States ACH and Wire transfers
+
,
+
+ Mexico SPEI transfers
+
,
+ QR_PAYMENTS,
+]
+
+// unlock benefits shown per region
+export const REGION_UNLOCK_ITEMS: Record> = {
+ latam: [
+
+ Bank transfers to your own accounts in LATAM
+
,
+ QR_PAYMENTS,
+ ],
+
+ europe: BRIDGE_UNLOCK_ITEMS,
+
+ 'north-america': BRIDGE_UNLOCK_ITEMS,
+
+ 'rest-of-the-world': [QR_PAYMENTS],
+}
+
+const DEFAULT_UNLOCK_ITEMS = [Bank transfers and local payment methods
]
interface StartVerificationModalProps {
visible: boolean
onClose: () => void
onStartVerification: () => void
- selectedIdentityCountry: { id: string; title: string }
- selectedCountry: { id: string; title: string }
+ selectedRegion: Region | null
+ isLoading?: boolean
}
const StartVerificationModal = ({
visible,
onClose,
onStartVerification,
- selectedIdentityCountry,
- selectedCountry,
+ selectedRegion,
+ isLoading,
}: StartVerificationModalProps) => {
- const { getVerificationUnlockItems } = useIdentityVerification()
-
- const items = useMemo(() => {
- return getVerificationUnlockItems(selectedIdentityCountry.title)
- }, [getVerificationUnlockItems, selectedIdentityCountry.title])
-
- const isIdentityMantecaCountry = useMemo(
- () => Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, selectedIdentityCountry.id.toUpperCase()),
- [selectedIdentityCountry.id]
- )
-
- const isSelectedCountryMantecaCountry = useMemo(
- () => Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, selectedCountry.id.toUpperCase()),
- [selectedCountry]
- )
-
- const getDescription = () => {
- if (isSelectedCountryMantecaCountry && isIdentityMantecaCountry) {
- return (
-
- To send and receive money locally, you'll need to verify your identity with a
- government-issued ID from {selectedCountry.title}.
-
- )
- }
-
- if (isSelectedCountryMantecaCountry && !isIdentityMantecaCountry) {
- return `Without an ${selectedCountry.title} Issued ID, you can still pay in stores using QR codes but you won't be able to transfer money directly to bank accounts.`
- }
-
- return (
-
- To send money to and from bank accounts and local payment methods, verify your identity with a
- government-issued ID.
-
- )
- }
+ const unlockItems = selectedRegion
+ ? (REGION_UNLOCK_ITEMS[selectedRegion.path] ?? DEFAULT_UNLOCK_ITEMS)
+ : DEFAULT_UNLOCK_ITEMS
return (
+ To send and receive money in this region, verify your identity with a government-issued ID.
+
+ }
descriptionClassName="text-black"
icon="shield"
iconContainerClassName="bg-primary-1"
@@ -74,8 +83,9 @@ const StartVerificationModal = ({
{
shadowSize: '4',
icon: 'check-circle',
- text: 'Verify now',
+ text: isLoading ? 'Loading...' : 'Verify now',
onClick: onStartVerification,
+ disabled: isLoading,
},
]}
content={
@@ -86,11 +96,8 @@ const StartVerificationModal = ({
itemIcon="check"
itemIconSize={12}
itemIconClassName="text-secondary-7"
- items={items
- .filter((item) => item.type === (isIdentityMantecaCountry ? 'manteca' : 'bridge'))
- .map((item) => item.title)}
+ items={unlockItems}
/>
-
Peanut doesn't store any of your documents.
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index 789cecc5a..711d66430 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -28,6 +28,7 @@ const JoinWaitlistPage = () => {
const [inviteCode, setInviteCode] = useState(setupInviteCode)
const { requestPermission, afterPermissionAttempt, isPermissionGranted } = useNotifications()
+ const [notificationSkipped, setNotificationSkipped] = useState(false)
const { data, isLoading: isLoadingWaitlistPosition } = useQuery({
queryKey: ['waitlist-position'],
@@ -85,7 +86,7 @@ const JoinWaitlistPage = () => {
)}
>
- {!isPermissionGranted && (
+ {!isPermissionGranted && !notificationSkipped && (
Enable notifications
We'll send you an update as soon as you get access.
@@ -99,10 +100,13 @@ const JoinWaitlistPage = () => {
>
Yes, notify me
+
setNotificationSkipped(true)} className="text-sm underline">
+ Not now
+
)}
- {isPermissionGranted && (
+ {(isPermissionGranted || notificationSkipped) && (
You're still in Peanut jail
diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx
new file mode 100644
index 000000000..bba3ca60c
--- /dev/null
+++ b/src/components/Kyc/BridgeTosStep.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import { useState, useCallback, useEffect } from 'react'
+import ActionModal from '@/components/Global/ActionModal'
+import IframeWrapper from '@/components/Global/IframeWrapper'
+import { type IconName } from '@/components/Global/Icons/Icon'
+import { getBridgeTosLink } from '@/app/actions/users'
+import { useAuth } from '@/context/authContext'
+import { confirmBridgeTosAndAwaitRails } from '@/hooks/useMultiPhaseKycFlow'
+
+interface BridgeTosStepProps {
+ visible: boolean
+ onComplete: () => void
+ onSkip: () => void
+}
+
+// shown immediately after sumsub kyc approval when bridge rails need ToS acceptance.
+// displays a prompt, then opens the bridge ToS iframe.
+export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProps) => {
+ const { fetchUser } = useAuth()
+ const [showIframe, setShowIframe] = useState(false)
+ const [tosLink, setTosLink] = useState
(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // auto-fetch ToS link when step becomes visible so the iframe opens directly
+ // (skips the intermediate "Accept Terms" confirmation modal)
+ useEffect(() => {
+ if (visible) {
+ handleAcceptTerms()
+ } else {
+ setShowIframe(false)
+ setTosLink(null)
+ setError(null)
+ }
+ }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleAcceptTerms = useCallback(async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await getBridgeTosLink()
+
+ if (response.error || !response.data?.tosLink) {
+ // if we can't get the tos link (e.g. bridge customer not created yet),
+ // skip this step — the activity feed will show a reminder later
+ setError(response.error || 'Could not load terms. You can accept them later from your activity feed.')
+ return
+ }
+
+ setTosLink(response.data.tosLink)
+ setShowIframe(true)
+ } catch {
+ setError('Something went wrong. You can accept terms later from your activity feed.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ const handleIframeClose = useCallback(
+ async (source?: 'manual' | 'completed' | 'tos_accepted') => {
+ setShowIframe(false)
+
+ if (source === 'tos_accepted') {
+ await confirmBridgeTosAndAwaitRails(fetchUser)
+ onComplete()
+ } else {
+ onSkip()
+ }
+ },
+ [fetchUser, onComplete, onSkip]
+ )
+
+ if (!visible) return null
+
+ return (
+ <>
+ {/* only show modal on error — normal flow goes straight to iframe */}
+ {error && !showIframe && (
+
+ )}
+
+ {tosLink && }
+ >
+ )
+}
diff --git a/src/components/Kyc/CountryFlagAndName.tsx b/src/components/Kyc/CountryFlagAndName.tsx
index dbc72d5de..3398a38bf 100644
--- a/src/components/Kyc/CountryFlagAndName.tsx
+++ b/src/components/Kyc/CountryFlagAndName.tsx
@@ -16,6 +16,7 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA
icons={[
'https://flagcdn.com/w160/us.png',
'https://flagcdn.com/w160/eu.png',
+ 'https://flagcdn.com/w160/gb.png',
'https://flagcdn.com/w160/mx.png',
]}
iconSize={80}
@@ -31,7 +32,7 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA
loading="lazy"
/>
)}
- {isBridgeRegion ? 'US/EU/MX' : countryName}
+ {isBridgeRegion ? 'US/EU/UK/MX' : countryName}
)
}
diff --git a/src/components/Kyc/CountryRegionRow.tsx b/src/components/Kyc/CountryRegionRow.tsx
index 00519a908..aa247b36d 100644
--- a/src/components/Kyc/CountryRegionRow.tsx
+++ b/src/components/Kyc/CountryRegionRow.tsx
@@ -4,9 +4,10 @@ import { CountryFlagAndName } from './CountryFlagAndName'
interface CountryRegionRowProps {
countryCode?: string | null
isBridge?: boolean
+ hideBottomBorder?: boolean
}
-export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProps) => {
+export const CountryRegionRow = ({ countryCode, isBridge, hideBottomBorder }: CountryRegionRowProps) => {
if (!isBridge && !countryCode) {
return null
}
@@ -15,6 +16,7 @@ export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProp
}
+ hideBottomBorder={hideBottomBorder}
/>
)
}
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 d72f425cc..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 './KycVerificationInProgressModal'
-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 ? (
- setIsMantecaModalOpen(false)}
- >
- Not now
-
- ) : (
-
- )
- }
- 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/KYCStatusDrawerItem.tsx b/src/components/Kyc/KYCStatusDrawerItem.tsx
index 0d2cae261..f377c2ad0 100644
--- a/src/components/Kyc/KYCStatusDrawerItem.tsx
+++ b/src/components/Kyc/KYCStatusDrawerItem.tsx
@@ -2,13 +2,13 @@ import Card from '@/components/Global/Card'
import StatusBadge, { type StatusType } from '../Global/Badges/StatusBadge'
import { KYCStatusIcon } from './KYCStatusIcon'
-export const KYCStatusDrawerItem = ({ status }: { status: StatusType }) => {
+export const KYCStatusDrawerItem = ({ status, customText }: { status: StatusType; customText?: string }) => {
return (
Identity verification
-
+
)
diff --git a/src/components/Kyc/KycFailedContent.tsx b/src/components/Kyc/KycFailedContent.tsx
new file mode 100644
index 000000000..18151263f
--- /dev/null
+++ b/src/components/Kyc/KycFailedContent.tsx
@@ -0,0 +1,24 @@
+import { RejectLabelsList } from './RejectLabelsList'
+import InfoCard from '@/components/Global/InfoCard'
+
+interface KycFailedContentProps {
+ rejectLabels?: string[] | null
+ isTerminal: boolean
+}
+
+// shared rejection details — used by both KycFailed (drawer) and KycFailedModal.
+// renders reject labels (non-terminal) or terminal error info card.
+export const KycFailedContent = ({ rejectLabels, isTerminal }: KycFailedContentProps) => {
+ if (isTerminal) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/src/components/Kyc/KycFlow.tsx b/src/components/Kyc/KycFlow.tsx
index 88795f515..0ac66bd38 100644
--- a/src/components/Kyc/KycFlow.tsx
+++ b/src/components/Kyc/KycFlow.tsx
@@ -1,22 +1,22 @@
-import { Button, type ButtonProps } from '@/components/0_Bruddle/Button'
-import IframeWrapper from '@/components/Global/IframeWrapper'
-import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow'
+import { type ButtonProps } from '@/components/0_Bruddle/Button'
+import { SumsubKycFlow } from '@/components/Kyc/SumsubKycFlow'
+import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
-// this component is the main entry point for the kyc flow
-// it renders a button that, when clicked, initiates the process of fetching
-// tos/kyc links, showing them in an iframe, and then displaying a status modal
-export const KycFlow = (props: ButtonProps) => {
- const { isLoading, error, iframeOptions, handleInitiateKyc, handleIframeClose } = useBridgeKycFlow()
+interface KycFlowProps extends ButtonProps {
+ regionIntent?: KYCRegionIntent
+ onKycSuccess?: () => void
+ onManualClose?: () => void
+}
+// main entry point for the kyc flow.
+// renders SumsubKycFlow with an optional region intent for context-aware verification.
+export const KycFlow = ({ regionIntent, onKycSuccess, onManualClose, ...buttonProps }: KycFlowProps) => {
return (
- <>
-
- {isLoading ? 'Loading...' : (props.children ?? 'Start Verification')}
-
-
- {error && {error}
}
-
-
- >
+
)
}
diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx
index e8a80f9fd..03b71e432 100644
--- a/src/components/Kyc/KycStatusDrawer.tsx
+++ b/src/components/Kyc/KycStatusDrawer.tsx
@@ -1,41 +1,38 @@
+import { KycActionRequired } from './states/KycActionRequired'
import { KycCompleted } from './states/KycCompleted'
import { KycFailed } from './states/KycFailed'
+import { KycNotStarted } from './states/KycNotStarted'
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, MantecaKycStatus } from '@/interfaces'
+import { type IUserKycVerification } from '@/interfaces'
import { useUserStore } from '@/redux/hooks'
-import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow'
-import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow'
-import { type CountryData, countryData } from '@/components/AddMoney/consts'
-import IFrameWrapper from '@/components/Global/IframeWrapper'
-
-// a helper to categorize the kyc status from the user object
-const getKycStatusCategory = (status: BridgeKycStatus | MantecaKycStatus): 'processing' | 'completed' | 'failed' => {
- switch (status) {
- case 'approved':
- case MantecaKycStatus.ACTIVE:
- return 'completed'
- case 'rejected':
- case MantecaKycStatus.INACTIVE:
- return 'failed'
- case 'under_review':
- case 'incomplete':
- case MantecaKycStatus.ONBOARDING:
- default:
- return 'processing'
- }
-}
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts'
+import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
+import { useCallback } from 'react'
interface KycStatusDrawerProps {
isOpen: boolean
onClose: () => void
verification?: IUserKycVerification
bridgeKycStatus?: BridgeKycStatus
+ region?: 'STANDARD' | 'LATAM'
+ /** keep this component mounted even after drawer closes (so SumsubKycModals persists) */
+ onKeepMounted?: (keep: boolean) => void
}
// 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,
+ onKeepMounted,
+}: KycStatusDrawerProps) => {
const { user } = useUserStore()
const status = verification ? verification.status : bridgeKycStatus
@@ -43,39 +40,85 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
const countryCode = verification ? verification.mantecaGeo || verification.bridgeGeo : null
const isBridgeKyc = !verification && !!bridgeKycStatus
const provider = verification ? verification.provider : 'BRIDGE'
+ // derive region intent from sumsub verification metadata so token uses correct level
+ const sumsubRegionIntent = (
+ verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined
+ ) as KYCRegionIntent | undefined
- const {
- handleInitiateKyc: initiateBridgeKyc,
- iframeOptions: bridgeIframeOptions,
- handleIframeClose: handleBridgeIframeClose,
- isLoading: isBridgeLoading,
- } = useBridgeKycFlow({ onKycSuccess: onClose, onManualClose: onClose })
+ // close drawer and release the keep-mounted hold
+ const handleFlowDone = useCallback(() => {
+ onClose()
+ onKeepMounted?.(false)
+ }, [onClose, onKeepMounted])
- 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,
- onManualClose: onClose,
- country: country as CountryData,
+ const sumsubFlow = useMultiPhaseKycFlow({
+ onKycSuccess: handleFlowDone,
+ onManualClose: handleFlowDone,
+ // 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 onRetry = async () => {
- if (provider === 'MANTECA') {
- await openMantecaKyc(country as CountryData)
- } else {
- await initiateBridgeKyc()
- }
- }
+ // close drawer but keep mounted so SumsubKycModals persists, then start kyc
+ const closeAndStartKyc = useCallback(
+ async (regionIntent?: KYCRegionIntent, levelName?: string) => {
+ onKeepMounted?.(true)
+ onClose()
+ try {
+ await sumsubFlow.handleInitiateKyc(regionIntent, levelName)
+ } catch (e) {
+ onKeepMounted?.(false)
+ throw e
+ }
+ },
+ [onKeepMounted, onClose, sumsubFlow]
+ )
- const isLoadingKyc = isBridgeLoading || isMantecaLoading
+ // check if any bridge rail needs additional documents
+ const bridgeRailsNeedingDocs = (user?.rails ?? []).filter(
+ (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE'
+ )
+ const needsAdditionalDocs = bridgeRailsNeedingDocs.length > 0
+ // aggregate requirements across all rails and deduplicate
+ const additionalRequirements: string[] = needsAdditionalDocs
+ ? [
+ ...new Set(
+ bridgeRailsNeedingDocs.flatMap((r) => {
+ const reqs = r.metadata?.additionalRequirements
+ return Array.isArray(reqs) ? reqs : []
+ })
+ ),
+ ]
+ : []
+
+ // count sumsub rejections for failure lockout.
+ const sumsubFailureCount =
+ user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0
+
+ const handleSubmitAdditionalDocs = useCallback(
+ () => closeAndStartKyc(undefined, 'peanut-additional-docs'),
+ [closeAndStartKyc]
+ )
const renderContent = () => {
+ // user initiated kyc but abandoned before submitting — close drawer visually
+ // but keep component mounted so SumsubKycModals persists for the SDK flow
+ if (verification && isKycStatusNotStarted(status)) {
+ return
+ }
+
+ // bridge additional document requirement — but don't mask terminal kyc states
+ if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') {
+ return (
+
+ )
+ }
+
switch (statusCategory) {
case 'processing':
return (
@@ -90,18 +133,31 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
+ )
+ case 'action_required':
+ return (
+
)
case 'failed':
return (
)
default:
@@ -109,8 +165,9 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
}
}
- // don't render the drawer if the kyc status is unknown or not started
- if (status === 'not_started' || !status) {
+ // don't render the drawer if the kyc status is unknown or not started.
+ // if a verification record exists, the user has initiated KYC — show the drawer.
+ if (!verification && isKycStatusNotStarted(status)) {
return null
}
@@ -120,10 +177,12 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus
KYC Status
{renderContent()}
+ {sumsubFlow.error && provider === 'SUMSUB' && (
+ {sumsubFlow.error}
+ )}
-
-
+
>
)
}
diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx
index 1ca8536ed..80f9de1b4 100644
--- a/src/components/Kyc/KycStatusItem.tsx
+++ b/src/components/Kyc/KycStatusItem.tsx
@@ -10,6 +10,27 @@ import { twMerge } from 'tailwind-merge'
import { type IUserKycVerification } from '@/interfaces'
import StatusPill from '../Global/StatusPill'
import { KYCStatusIcon } from './KYCStatusIcon'
+import {
+ isKycStatusApproved,
+ isKycStatusPending,
+ isKycStatusFailed,
+ isKycStatusNotStarted,
+ 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 = ({
@@ -18,16 +39,21 @@ 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)
const [wsBridgeKycStatus, setWsBridgeKycStatus] = useState(undefined)
+ // keep drawer component mounted when SDK flow is active (so SumsubKycModals persists
+ // even after the drawer visually closes)
+ const [keepDrawerMounted, setKeepDrawerMounted] = useState(false)
const handleCloseDrawer = useCallback(() => {
setIsDrawerOpen(false)
@@ -45,23 +71,36 @@ export const KycStatusItem = ({
const finalBridgeKycStatus = wsBridgeKycStatus || bridgeKycStatus || user?.user?.bridgeKycStatus
const kycStatus = verification ? verification.status : finalBridgeKycStatus
- // Check if KYC is approved to show points earned
- const isApproved = kycStatus === 'approved' || kycStatus === 'ACTIVE'
+ // check if any bridge rail needs additional documents
+ const hasBridgeDocsNeeded = useMemo(
+ () =>
+ (user?.rails ?? []).some(
+ (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE'
+ ),
+ [user?.rails]
+ )
- const isPending = kycStatus === 'under_review' || kycStatus === 'incomplete' || kycStatus === 'ONBOARDING'
- const isRejected = kycStatus === 'rejected' || kycStatus === 'INACTIVE'
+ const isApproved = isKycStatusApproved(kycStatus)
+ const isPending = isKycStatusPending(kycStatus)
+ const isRejected = isKycStatusFailed(kycStatus)
+ const isActionRequired = isKycStatusActionRequired(kycStatus)
+ // if a verification record exists with NOT_STARTED, the user has initiated KYC
+ // (backend creates the record on initiation). only hide for bridge's default state.
+ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus)
const subtitle = useMemo(() => {
- if (isPending) {
- return 'Under review'
- }
- if (isApproved) {
- return 'Approved'
- }
- return 'Rejected'
- }, [isPending, isApproved, isRejected])
+ if (hasBridgeDocsNeeded) return 'Action needed'
+ if (isInitiatedButNotStarted) return 'Not completed'
+ if (isActionRequired) return 'Action needed'
+ if (isPending) return 'Processing'
+ if (isApproved) return 'Verified'
+ if (isRejected) return 'Failed'
+ return 'Unknown'
+ }, [hasBridgeDocsNeeded, isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected])
- if (!kycStatus || kycStatus === 'not_started') {
+ // only hide for bridge's default "not_started" state.
+ // if a verification record exists, the user has initiated KYC — show it.
+ if (!verification && !hasBridgeDocsNeeded && isKycStatusNotStarted(kycStatus)) {
return null
}
@@ -81,19 +120,31 @@ export const KycStatusItem = ({
Identity verification
-
+ {(isDrawerOpen || keepDrawerMounted) && (
+
+ )}
>
)
}
diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx
index 401aa0ba9..b314c53b9 100644
--- a/src/components/Kyc/KycVerificationInProgressModal.tsx
+++ b/src/components/Kyc/KycVerificationInProgressModal.tsx
@@ -1,16 +1,34 @@
import { useRouter } from 'next/navigation'
import ActionModal from '@/components/Global/ActionModal'
-import { Icon, type IconName } from '@/components/Global/Icons/Icon'
-import { twMerge } from 'tailwind-merge'
+import { type IconName } from '@/components/Global/Icons/Icon'
+import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation'
+import { type KycModalPhase } from '@/interfaces'
interface KycVerificationInProgressModalProps {
isOpen: boolean
onClose: () => void
+ phase?: KycModalPhase
+ onAcceptTerms?: () => void
+ onSkipTerms?: () => void
+ onContinue?: () => void
+ tosError?: string | null
+ isLoadingTos?: boolean
+ preparingTimedOut?: boolean
}
-// this modal is shown after the user submits their kyc information.
-// it waits for a final status from the websocket before disappearing.
-export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificationInProgressModalProps) => {
+// multi-phase modal shown during and after kyc verification.
+// phase transitions are controlled by the parent orchestrator (SumsubKycFlow).
+export const KycVerificationInProgressModal = ({
+ isOpen,
+ onClose,
+ phase = 'verifying',
+ onAcceptTerms,
+ onSkipTerms,
+ onContinue,
+ tosError,
+ isLoadingTos,
+ preparingTimedOut,
+}: KycVerificationInProgressModalProps) => {
const router = useRouter()
const handleGoHome = () => {
@@ -18,42 +36,114 @@ export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificat
router.push('/home')
}
- const descriptionWithInfo = (
-
- This usually takes less than a minute. You can stay here while we finish, or return to the home screen and
- we'll notify you when it's done.
-
- )
+ if (phase === 'verifying') {
+ return (
+
+ This usually takes less than a minute. You can stay here while we finish, or return to the home
+ screen and we'll notify you when it's done.
+
+ }
+ ctas={[
+ {
+ text: 'Go to Home',
+ onClick: handleGoHome,
+ variant: 'purple',
+ className: 'w-full',
+ shadowSize: '4',
+ },
+ ]}
+ preventClose
+ hideModalCloseButton
+ footer={ }
+ />
+ )
+ }
+
+ if (phase === 'preparing') {
+ return (
+
+ )
+ }
+
+ if (phase === 'bridge_tos') {
+ const description =
+ tosError || 'One more step: accept terms of service to enable bank transfers in the US, Europe, and Mexico.'
+ return (
+
+ )
+ }
+
+ // phase === 'complete'
return (
}
/>
)
}
-
-export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => {
- return (
-
-
- Peanut doesn't store any of your documents
-
- )
-}
diff --git a/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx
new file mode 100644
index 000000000..3922f0c00
--- /dev/null
+++ b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx
@@ -0,0 +1,11 @@
+import { Icon } from '@/components/Global/Icons/Icon'
+import { twMerge } from 'tailwind-merge'
+
+export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => {
+ return (
+
+
+ Peanut doesn't store any of your documents
+
+ )
+}
diff --git a/src/components/Kyc/RejectLabelsList.tsx b/src/components/Kyc/RejectLabelsList.tsx
new file mode 100644
index 000000000..69bfb63c5
--- /dev/null
+++ b/src/components/Kyc/RejectLabelsList.tsx
@@ -0,0 +1,32 @@
+import { useMemo } from 'react'
+import InfoCard from '@/components/Global/InfoCard'
+import { getRejectLabelInfo } from '@/constants/sumsub-reject-labels.consts'
+
+// renders sumsub reject labels as individual InfoCards, with a generic fallback
+// when no labels are provided. shared between drawer states and modals.
+export const RejectLabelsList = ({ rejectLabels }: { rejectLabels?: string[] | null }) => {
+ const labels = rejectLabels?.length ? rejectLabels : null
+
+ const reasons = useMemo(() => {
+ if (!labels) return null
+ return labels.map((label) => getRejectLabelInfo(label))
+ }, [labels])
+
+ if (!reasons) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {reasons.map((reason, i) => (
+
+ ))}
+
+ )
+}
diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx
new file mode 100644
index 000000000..2c1ab001c
--- /dev/null
+++ b/src/components/Kyc/SumsubKycFlow.tsx
@@ -0,0 +1,32 @@
+import { Button, type ButtonProps } from '@/components/0_Bruddle/Button'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
+
+interface SumsubKycFlowProps extends ButtonProps {
+ onKycSuccess?: () => void
+ onManualClose?: () => void
+ regionIntent?: KYCRegionIntent
+}
+
+/**
+ * entry point for the kyc flow.
+ * renders a button that initiates kyc, the sumsub sdk wrapper modal,
+ * and a multi-phase verification modal that handles:
+ * verifying → preparing → bridge_tos (if applicable) → complete
+ */
+export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => {
+ const flow = useMultiPhaseKycFlow({ onKycSuccess, onManualClose, regionIntent })
+
+ return (
+ <>
+ flow.handleInitiateKyc()} disabled={flow.isLoading} {...buttonProps}>
+ {flow.isLoading ? 'Loading...' : (buttonProps.children ?? 'Start Verification')}
+
+
+ {flow.error && {flow.error}
}
+
+
+ >
+ )
+}
diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx
new file mode 100644
index 000000000..25097b430
--- /dev/null
+++ b/src/components/Kyc/SumsubKycModals.tsx
@@ -0,0 +1,53 @@
+import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper'
+import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal'
+import IframeWrapper from '@/components/Global/IframeWrapper'
+import { type useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+
+interface SumsubKycModalsProps {
+ flow: ReturnType
+ autoStartSdk?: boolean
+}
+
+/**
+ * shared modal rendering for the multi-phase kyc flow.
+ * renders the sumsub SDK wrapper, the multi-phase verification modal,
+ * and the bridge ToS iframe.
+ *
+ * pair with useMultiPhaseKycFlow hook for the logic.
+ */
+export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => {
+ return (
+ <>
+
+
+
+
+ {flow.tosLink && (
+
+ )}
+ >
+ )
+}
diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx
new file mode 100644
index 000000000..5bbbafc3f
--- /dev/null
+++ b/src/components/Kyc/SumsubKycWrapper.tsx
@@ -0,0 +1,331 @@
+'use client'
+
+import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
+import Modal from '@/components/Global/Modal'
+import ActionModal from '@/components/Global/ActionModal'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
+import { Button, type ButtonVariant } from '@/components/0_Bruddle/Button'
+import { useModalsContext } from '@/context/ModalsContext'
+import StartVerificationView from '../Global/IframeWrapper/StartVerificationView'
+
+// todo: move to consts
+const SUMSUB_SDK_URL = 'https://static.sumsub.com/idensic/static/sns-websdk-builder.js'
+
+interface SumsubKycWrapperProps {
+ visible: boolean
+ accessToken: string | null
+ onClose: () => void
+ onComplete: () => void
+ onError?: (error: unknown) => void
+ onRefreshToken: () => Promise
+ /** skip StartVerificationView and launch SDK immediately (for re-submissions) */
+ autoStart?: boolean
+ /** multi-level workflow (e.g. LATAM) — don't close SDK on Level 1 submission */
+ isMultiLevel?: boolean
+}
+
+export const SumsubKycWrapper = ({
+ visible,
+ accessToken,
+ onClose,
+ onComplete,
+ onError,
+ onRefreshToken,
+ autoStart,
+ isMultiLevel,
+}: SumsubKycWrapperProps) => {
+ const [isVerificationStarted, setIsVerificationStarted] = useState(false)
+ const [sdkLoaded, setSdkLoaded] = useState(false)
+ const [sdkLoadError, setSdkLoadError] = useState(false)
+ const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
+ const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble')
+ const sdkContainerRef = useRef(null)
+ const sdkInstanceRef = useRef(null)
+ const { setIsSupportModalOpen } = useModalsContext()
+
+ // callback refs to avoid stale closures in sdk init effect
+ const onCompleteRef = useRef(onComplete)
+ const onErrorRef = useRef(onError)
+ const onRefreshTokenRef = useRef(onRefreshToken)
+ const isMultiLevelRef = useRef(isMultiLevel)
+
+ useEffect(() => {
+ onCompleteRef.current = onComplete
+ onErrorRef.current = onError
+ onRefreshTokenRef.current = onRefreshToken
+ isMultiLevelRef.current = isMultiLevel
+ }, [onComplete, onError, onRefreshToken, isMultiLevel])
+
+ // stable wrappers that read from refs
+ const stableOnComplete = useCallback(() => onCompleteRef.current(), [])
+ const stableOnError = useCallback((error: unknown) => onErrorRef.current?.(error), [])
+ const stableOnRefreshToken = useCallback(() => onRefreshTokenRef.current(), [])
+
+ // load sumsub websdk script
+ useEffect(() => {
+ const existingScript = document.getElementById('sumsub-websdk')
+ if (existingScript) {
+ setSdkLoaded(true)
+ return
+ }
+
+ const script = document.createElement('script')
+ script.id = 'sumsub-websdk'
+ script.src = SUMSUB_SDK_URL
+ script.async = true
+ script.onload = () => setSdkLoaded(true)
+ script.onerror = () => {
+ console.error('[sumsub] failed to load websdk script')
+ setSdkLoadError(true)
+ }
+ document.head.appendChild(script)
+ }, [])
+
+ // initialize sdk when verification starts and all deps are ready
+ useEffect(() => {
+ if (!isVerificationStarted || !accessToken || !sdkLoaded || !sdkContainerRef.current) return
+
+ // clean up previous instance
+ if (sdkInstanceRef.current) {
+ try {
+ sdkInstanceRef.current.destroy()
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+
+ try {
+ // track sdk init time so we can ignore stale onApplicantStatusChanged events
+ // that fire immediately when the applicant is already approved (e.g. additional-docs flow)
+ const sdkInitTime = Date.now()
+
+ const handleSubmitted = () => {
+ console.log('[sumsub] onApplicantSubmitted fired')
+ // for multi-level workflows (LATAM), the SDK transitions to Level 2
+ // internally. don't close the modal on Level 1 submission.
+ if (isMultiLevelRef.current) return
+ stableOnComplete()
+ }
+ // resubmission = user retried after rejection (ACTION_REQUIRED).
+ // always close SDK regardless of multi-level — the retry is a fresh submission.
+ const handleResubmitted = () => {
+ console.log('[sumsub] onApplicantResubmitted fired')
+ stableOnComplete()
+ }
+ const handleStatusChanged = (payload: {
+ reviewStatus?: string
+ reviewResult?: { reviewAnswer?: string }
+ }) => {
+ console.log('[sumsub] onApplicantStatusChanged fired', payload)
+ // ignore status events that fire within 3s of sdk init — these reflect
+ // pre-existing state (e.g. user already approved), not a new submission
+ if (Date.now() - sdkInitTime < 3000) {
+ console.log('[sumsub] ignoring early onApplicantStatusChanged (pre-existing state)')
+ return
+ }
+ // for multi-level workflows (LATAM), Level 1 fires completed+GREEN
+ // before Level 2 is shown. don't close the SDK.
+ if (isMultiLevelRef.current) return
+ // auto-close when sumsub shows success screen
+ if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') {
+ stableOnComplete()
+ }
+ }
+
+ const sdk = window.snsWebSdk
+ .init(accessToken, stableOnRefreshToken)
+ .withConf({ lang: 'en', theme: 'light' })
+ .withOptions({ addViewportTag: false, adaptIframeHeight: true })
+ .on('onApplicantSubmitted', handleSubmitted)
+ .on('onApplicantResubmitted', handleResubmitted)
+ .on('onApplicantStatusChanged', handleStatusChanged)
+ // also listen for idCheck-prefixed events (some sdk versions use these)
+ .on('idCheck.onApplicantSubmitted', handleSubmitted)
+ .on('idCheck.onApplicantResubmitted', handleResubmitted)
+ .on('idCheck.onApplicantStatusChanged', handleStatusChanged)
+ .on('onError', (error: unknown) => {
+ console.error('[sumsub] sdk error', error)
+ stableOnError(error)
+ })
+ .build()
+
+ sdk.launch(sdkContainerRef.current)
+ sdkInstanceRef.current = sdk
+ } catch (error) {
+ console.error('[sumsub] failed to initialize sdk', error)
+ stableOnError(error)
+ }
+
+ return () => {
+ if (sdkInstanceRef.current) {
+ try {
+ sdkInstanceRef.current.destroy()
+ } catch {
+ // ignore cleanup errors
+ }
+ sdkInstanceRef.current = null
+ }
+ }
+ }, [isVerificationStarted, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken])
+
+ // reset state when modal closes, auto-start on re-submission
+ useEffect(() => {
+ if (!visible) {
+ setIsVerificationStarted(false)
+ setSdkLoadError(false)
+ if (sdkInstanceRef.current) {
+ try {
+ sdkInstanceRef.current.destroy()
+ } catch {
+ // ignore cleanup errors
+ }
+ sdkInstanceRef.current = null
+ }
+ } else if (autoStart) {
+ // skip StartVerificationView on re-submission (user already consented)
+ setIsVerificationStarted(true)
+ }
+ }, [visible, autoStart])
+
+ const modalDetails = useMemo(() => {
+ if (modalVariant === 'trouble') {
+ return {
+ title: 'Having trouble verifying?',
+ description:
+ "If the verification isn't loading or working properly, please contact our support team for help.",
+ icon: 'question-mark' as IconName,
+ iconContainerClassName: 'bg-primary-1',
+ ctas: [
+ {
+ text: 'Chat with support',
+ onClick: () => setIsSupportModalOpen(true),
+ variant: 'purple' as ButtonVariant,
+ shadowSize: '4' as const,
+ },
+ ],
+ }
+ }
+
+ return autoStart
+ ? {
+ title: 'Are you sure you want to exit?',
+ description:
+ "You are about to exit verification, you can come back to finish this later. Your progress won't be lost.",
+ icon: 'alert' as IconName,
+ iconContainerClassName: 'bg-secondary-1',
+ ctas: [
+ {
+ text: 'Exit',
+ onClick: () => {
+ setIsHelpModalOpen(false)
+ onClose()
+ },
+ variant: 'purple' as ButtonVariant,
+ shadowSize: '4' as const,
+ },
+ {
+ text: 'Continue',
+ onClick: () => setIsHelpModalOpen(false),
+ variant: 'transparent' as ButtonVariant,
+ className: 'underline text-sm font-medium w-full h-fit mt-3',
+ },
+ ],
+ }
+ : {
+ title: 'Stop verification?',
+ description:
+ "If you exit now, your verification won't be completed and you'll need to start again later.",
+ icon: 'alert' as IconName,
+ iconContainerClassName: 'bg-secondary-1',
+ ctas: [
+ {
+ text: 'Stop verification',
+ onClick: () => {
+ setIsHelpModalOpen(false)
+ onClose()
+ },
+ variant: 'purple' as ButtonVariant,
+ shadowSize: '4' as const,
+ },
+ {
+ text: 'Continue verifying',
+ onClick: () => setIsHelpModalOpen(false),
+ variant: 'transparent' as ButtonVariant,
+ className: 'underline text-sm font-medium w-full h-fit mt-3',
+ },
+ ],
+ }
+ }, [autoStart, modalVariant, onClose, setIsSupportModalOpen])
+
+ return (
+ <>
+
+ {!isVerificationStarted ? (
+ setIsVerificationStarted(true)}
+ />
+ ) : sdkLoadError ? (
+
+
+
+ Failed to load verification. Please check your connection and try again.
+
+
+ Close
+
+
+ ) : (
+
+
+ {
+ setModalVariant('trouble')
+ setIsHelpModalOpen(true)
+ }}
+ className="flex items-center gap-1 p-1"
+ >
+
+
+ {
+ setModalVariant('stop-verification')
+ setIsHelpModalOpen(true)
+ }}
+ className="p-1"
+ >
+
+
+
+
+
+ )}
+
+ {/* rendered outside the outer Modal to avoid pointer-events-none blocking clicks */}
+ setIsHelpModalOpen(false)}
+ title={modalDetails.title}
+ description={modalDetails.description}
+ icon={modalDetails.icon}
+ iconContainerClassName={modalDetails.iconContainerClassName}
+ modalPanelClassName="max-w-full"
+ ctaClassName="grid grid-cols-1 gap-3"
+ contentContainerClassName="px-6 py-6"
+ modalClassName="!z-[10001]"
+ preventClose={true}
+ ctas={modalDetails.ctas}
+ />
+ >
+ )
+}
diff --git a/src/components/Kyc/modals/KycActionRequiredModal.tsx b/src/components/Kyc/modals/KycActionRequiredModal.tsx
new file mode 100644
index 000000000..707f5ead6
--- /dev/null
+++ b/src/components/Kyc/modals/KycActionRequiredModal.tsx
@@ -0,0 +1,44 @@
+import ActionModal from '@/components/Global/ActionModal'
+import { RejectLabelsList } from '../RejectLabelsList'
+
+interface KycActionRequiredModalProps {
+ visible: boolean
+ onClose: () => void
+ onResubmit: () => void
+ isLoading?: boolean
+ rejectLabels?: string[] | null
+}
+
+// shown when user clicks a locked region while their kyc needs resubmission (soft reject)
+export const KycActionRequiredModal = ({
+ visible,
+ onClose,
+ onResubmit,
+ isLoading,
+ rejectLabels,
+}: KycActionRequiredModalProps) => {
+ return (
+
+
+
+ }
+ ctas={[
+ {
+ text: isLoading ? 'Loading...' : 'Re-submit verification',
+ icon: 'retry',
+ onClick: onResubmit,
+ disabled: isLoading,
+ shadowSize: '4',
+ },
+ ]}
+ />
+ )
+}
diff --git a/src/components/Kyc/modals/KycFailedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx
new file mode 100644
index 000000000..ab3a1f12d
--- /dev/null
+++ b/src/components/Kyc/modals/KycFailedModal.tsx
@@ -0,0 +1,67 @@
+import { useMemo } from 'react'
+import ActionModal from '@/components/Global/ActionModal'
+import { KycFailedContent } from '../KycFailedContent'
+import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts'
+import { useModalsContext } from '@/context/ModalsContext'
+
+interface KycFailedModalProps {
+ visible: boolean
+ onClose: () => void
+ onRetry: () => void
+ isLoading?: boolean
+ rejectLabels?: string[] | null
+ rejectType?: 'RETRY' | 'FINAL' | null
+ failureCount?: number
+}
+
+// shown when user clicks a locked region while their kyc is rejected
+export const KycFailedModal = ({
+ visible,
+ onClose,
+ onRetry,
+ isLoading,
+ rejectLabels,
+ rejectType,
+ failureCount,
+}: KycFailedModalProps) => {
+ const { setIsSupportModalOpen } = useModalsContext()
+
+ const isTerminal = useMemo(
+ () => isTerminalRejection({ rejectType, failureCount, rejectLabels }),
+ [rejectType, failureCount, rejectLabels]
+ )
+
+ return (
+
+
+
+ }
+ ctas={[
+ isTerminal
+ ? {
+ text: 'Contact support',
+ onClick: () => {
+ onClose()
+ setIsSupportModalOpen(true)
+ },
+ shadowSize: '4',
+ }
+ : {
+ text: isLoading ? 'Loading...' : 'Retry verification',
+ icon: 'retry',
+ onClick: onRetry,
+ disabled: isLoading,
+ shadowSize: '4',
+ },
+ ]}
+ />
+ )
+}
diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx
new file mode 100644
index 000000000..f2e996c21
--- /dev/null
+++ b/src/components/Kyc/modals/KycProcessingModal.tsx
@@ -0,0 +1,27 @@
+import ActionModal from '@/components/Global/ActionModal'
+
+interface KycProcessingModalProps {
+ visible: boolean
+ onClose: () => void
+}
+
+// shown when user clicks a locked region while their kyc is pending/in review
+export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps) => {
+ return (
+
+ )
+}
diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx
new file mode 100644
index 000000000..418f28842
--- /dev/null
+++ b/src/components/Kyc/states/KycActionRequired.tsx
@@ -0,0 +1,34 @@
+import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem'
+import { RejectLabelsList } from '../RejectLabelsList'
+import { Button } from '@/components/0_Bruddle/Button'
+import type { IconName } from '@/components/Global/Icons/Icon'
+
+// this component shows the kyc status when sumsub requires additional action from the user.
+// displays specific rejection reasons when available (e.g. bad photo quality, expired doc).
+export const KycActionRequired = ({
+ onResume,
+ isLoading,
+ rejectLabels,
+}: {
+ onResume: () => void
+ isLoading?: boolean
+ rejectLabels?: string[] | null
+}) => {
+ return (
+
+
+
+
+
+
+ {isLoading ? 'Loading...' : 'Re-submit verification'}
+
+
+ )
+}
diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx
index 0c59259b9..0fd36b913 100644
--- a/src/components/Kyc/states/KycCompleted.tsx
+++ b/src/components/Kyc/states/KycCompleted.tsx
@@ -1,7 +1,9 @@
import Card from '@/components/Global/Card'
+import InfoCard from '@/components/Global/InfoCard'
import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { useMemo } from 'react'
+import { REGION_UNLOCK_ITEMS } from '@/components/IdentityVerification/StartVerificationModal'
import { formatDate } from '@/utils/general.utils'
import { CountryRegionRow } from '../CountryRegionRow'
import Image from 'next/image'
@@ -16,10 +18,12 @@ export const KycCompleted = ({
bridgeKycApprovedAt,
countryCode,
isBridge,
+ region,
}: {
bridgeKycApprovedAt?: string
countryCode?: string | null
isBridge?: boolean
+ region?: 'STANDARD' | 'LATAM'
}) => {
const verifiedOn = useMemo(() => {
if (!bridgeKycApprovedAt) return 'N/A'
@@ -31,10 +35,13 @@ export const KycCompleted = ({
}
}, [bridgeKycApprovedAt])
+ const regionPath = region === 'LATAM' ? 'latam' : 'europe'
+ const benefits = REGION_UNLOCK_ITEMS[regionPath] ?? []
+
return (
-
-
+
+
}
/>
-
+ {benefits.length > 0 && (
+
+
What you've unlocked:
+
+
+ )}
)
diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx
index 6c7a45fe6..493a4ef8b 100644
--- a/src/components/Kyc/states/KycFailed.tsx
+++ b/src/components/Kyc/states/KycFailed.tsx
@@ -1,76 +1,108 @@
import { Button } from '@/components/0_Bruddle/Button'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem'
+import { KycFailedContent } from '../KycFailedContent'
import Card from '@/components/Global/Card'
import { useMemo } from 'react'
import { formatDate } from '@/utils/general.utils'
import { CountryRegionRow } from '../CountryRegionRow'
+import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts'
+import { useModalsContext } from '@/context/ModalsContext'
// this component shows the kyc status when it's failed/rejected.
-// it displays the reason for the failure and provides a retry button.
+// for sumsub: maps reject labels to human-readable reasons, handles terminal vs retryable states.
+// for bridge: shows raw reason string as before.
export const KycFailed = ({
- reason,
+ rejectLabels,
+ bridgeReason,
+ isSumsub,
+ rejectType,
+ failureCount,
bridgeKycRejectedAt,
countryCode,
isBridge,
onRetry,
isLoading,
}: {
- reason: string | null
+ rejectLabels?: string[] | null
+ bridgeReason?: string | null
+ isSumsub?: boolean
+ rejectType?: 'RETRY' | 'FINAL' | null
+ failureCount?: number
bridgeKycRejectedAt?: string
countryCode?: string | null
isBridge?: boolean
onRetry: () => void
isLoading?: boolean
}) => {
+ const { setIsSupportModalOpen } = useModalsContext()
+
const rejectedOn = useMemo(() => {
if (!bridgeKycRejectedAt) return 'N/A'
try {
return formatDate(new Date(bridgeKycRejectedAt))
} catch (error) {
- console.error('Failed to parse bridgeKycRejectedAt date:', error)
+ console.error('failed to parse bridgeKycRejectedAt date:', error)
return 'N/A'
}
}, [bridgeKycRejectedAt])
- const formattedReason = useMemo(() => {
- const reasonText = reason || 'There was an issue. Contact Support.'
- // Split by actual newline characters (\n) or the escaped sequence (\\n)
- const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '')
-
- if (lines.length === 1) {
- return reasonText
- }
+ // only sumsub verifications can be terminal — bridge rejections always allow retry
+ const isTerminal = useMemo(
+ () => (isSumsub ? isTerminalRejection({ rejectType, failureCount, rejectLabels }) : false),
+ [isSumsub, rejectType, failureCount, rejectLabels]
+ )
- return (
-
- {lines.map((line, index) => (
- {line}
- ))}
-
- )
- }, [reason])
+ // determine which row is last in the card for border handling
+ const hasCountryRow = isBridge || !!countryCode
+ const hasReasonRow = !isSumsub
return (
-
-
-
-
-
+
+
+
+ {hasReasonRow && (
+
+ )}
-
- {isLoading ? 'Loading...' : 'Retry verification'}
-
+
+ {isSumsub && }
+
+ {isTerminal ? (
+
+ {/* TODO: auto-create crisp support ticket on terminal rejection */}
+ setIsSupportModalOpen(true)}
+ >
+ Contact support
+
+
+ ) : (
+
+ {isLoading ? 'Loading...' : 'Retry verification'}
+
+ )}
)
}
diff --git a/src/components/Kyc/states/KycNotStarted.tsx b/src/components/Kyc/states/KycNotStarted.tsx
new file mode 100644
index 000000000..cb649ade9
--- /dev/null
+++ b/src/components/Kyc/states/KycNotStarted.tsx
@@ -0,0 +1,23 @@
+import InfoCard from '@/components/Global/InfoCard'
+import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem'
+import { Button } from '@/components/0_Bruddle/Button'
+
+// shown when user initiated kyc but abandoned before submitting documents.
+// provides a cta to resume the verification flow.
+export const KycNotStarted = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => {
+ return (
+
+
+
+
+
+
+ {isLoading ? 'Loading...' : 'Continue verification'}
+
+
+ )
+}
diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx
new file mode 100644
index 000000000..c9a751615
--- /dev/null
+++ b/src/components/Kyc/states/KycRequiresDocuments.tsx
@@ -0,0 +1,49 @@
+import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem'
+import { Button } from '@/components/0_Bruddle/Button'
+import InfoCard from '@/components/Global/InfoCard'
+import { getRequirementLabel } from '@/constants/bridge-requirements.consts'
+
+// shows when a payment provider (bridge) needs additional documents from the user.
+// displays the specific requirements with human-readable descriptions.
+export const KycRequiresDocuments = ({
+ requirements,
+ onSubmitDocuments,
+ isLoading,
+}: {
+ requirements: string[]
+ onSubmitDocuments: () => void
+ isLoading?: boolean
+}) => {
+ return (
+
+
+
+
+
Our payment provider requires additional verification documents.
+ {requirements.length > 0 ? (
+ requirements.map((req) => {
+ const label = getRequirementLabel(req)
+ return (
+
+ )
+ })
+ ) : (
+
+ )}
+
+
+
+ {isLoading ? 'Loading...' : 'Submit documents'}
+
+
+ )
+}
diff --git a/src/components/Profile/components/IdentityVerificationCountryList.tsx b/src/components/Profile/components/IdentityVerificationCountryList.tsx
deleted file mode 100644
index 4146a8dad..000000000
--- a/src/components/Profile/components/IdentityVerificationCountryList.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-'use client'
-import { Icon } from '@/components/Global/Icons/Icon'
-import { SearchInput } from '@/components/SearchInput'
-import { getCountriesForRegion } from '@/utils/identityVerification'
-import { MantecaSupportedExchanges } from '@/components/AddMoney/consts'
-import StatusBadge from '@/components/Global/Badges/StatusBadge'
-import { Button } from '@/components/0_Bruddle/Button'
-import * as Accordion from '@radix-ui/react-accordion'
-import { useRouter } from 'next/navigation'
-import { useState } from 'react'
-import CountryListSection from './CountryListSection'
-import ActionModal from '@/components/Global/ActionModal'
-
-const IdentityVerificationCountryList = ({ region }: { region: string }) => {
- const [searchTerm, setSearchTerm] = useState('')
- const router = useRouter()
- const [isUnavailableModalOpen, setIsUnavailableModalOpen] = useState(false)
- const [selectedUnavailableCountry, setSelectedUnavailableCountry] = useState(null)
-
- const { supportedCountries, limitedAccessCountries, unsupportedCountries } = getCountriesForRegion(region)
-
- // Filter both arrays based on search term
- const filteredSupportedCountries = supportedCountries.filter((country) =>
- country.title.toLowerCase().includes(searchTerm.toLowerCase())
- )
-
- const filteredLimitedAccessCountries = limitedAccessCountries.filter((country) =>
- country.title.toLowerCase().includes(searchTerm.toLowerCase())
- )
-
- const filteredUnsupportedCountries = unsupportedCountries.filter((country) =>
- country.title.toLowerCase().includes(searchTerm.toLowerCase())
- )
-
- const isLatam = region === 'latam'
-
- return (
-
-
- setSearchTerm(e.target.value)}
- onClear={() => setSearchTerm('')}
- placeholder="Search by country name"
- />
-
-
-
- {
- if (isLatam) {
- router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`)
- } else {
- router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`)
- }
- }}
- rightContent={() => (isLatam ? undefined : )}
- defaultOpen
- />
-
- {
- // Check if country is in MantecaSupportedExchanges
- const countryCode = country.iso2?.toUpperCase()
- const isMantecaSupported =
- countryCode && Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, countryCode)
-
- if (isMantecaSupported && isLatam) {
- // Route to Manteca-specific KYC
- router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`)
- } else {
- // Route to Bridge KYC for all other countries
- router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`)
- }
- }}
- rightContent={() => (
-
- )}
- defaultOpen
- />
-
- {filteredUnsupportedCountries.length > 0 && (
- {
- setSelectedUnavailableCountry(country.title)
- setIsUnavailableModalOpen(true)
- }}
- rightContent={() => (
-
-
-
- )}
- defaultOpen
- />
- )}
-
-
-
{
- setSelectedUnavailableCountry(null)
- setIsUnavailableModalOpen(false)
- }}
- ctas={[
- {
- text: 'I Understand',
- shadowSize: '4',
- onClick: () => {
- setSelectedUnavailableCountry(null)
- setIsUnavailableModalOpen(false)
- },
- },
- ]}
- />
-
- )
-}
-
-export default IdentityVerificationCountryList
diff --git a/src/components/Profile/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx
index 8cc30b28a..e1bb97bec 100644
--- a/src/components/Profile/components/ProfileMenuItem.tsx
+++ b/src/components/Profile/components/ProfileMenuItem.tsx
@@ -6,7 +6,6 @@ import NavigationArrow from '@/components/Global/NavigationArrow'
import { Tooltip } from '@/components/Tooltip'
import Link from 'next/link'
import React from 'react'
-import { twMerge } from 'tailwind-merge'
interface ProfileMenuItemProps {
icon: IconName | React.ReactNode
@@ -21,6 +20,7 @@ interface ProfileMenuItemProps {
showTooltip?: boolean
toolTipText?: string
badge?: string
+ highlight?: boolean
}
const ProfileMenuItem: React.FC = ({
@@ -36,6 +36,7 @@ const ProfileMenuItem: React.FC = ({
showTooltip = false,
toolTipText,
badge,
+ highlight = false,
}) => {
const content = (
@@ -47,7 +48,14 @@ const ProfileMenuItem: React.FC
= ({
{icon}
)}
- {label}
+
+
{label}
+ {highlight && (
+
+ )}
+
{badge && }
{showTooltip && (
diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx
index ef6137a33..8fbeba0da 100644
--- a/src/components/Profile/components/PublicProfile.tsx
+++ b/src/components/Profile/components/PublicProfile.tsx
@@ -15,7 +15,7 @@ import { checkIfInternalNavigation } from '@/utils/general.utils'
import { useAuth } from '@/context/authContext'
import ShareButton from '@/components/Global/ShareButton'
import ActionModal from '@/components/Global/ActionModal'
-import { MantecaKycStatus } from '@/interfaces'
+import { isUserKycVerified } from '@/constants/kyc.consts'
import BadgesRow from '@/components/Badges/BadgesRow'
interface PublicProfileProps {
@@ -54,14 +54,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa
if (apiUser?.fullName) setFullName(apiUser.fullName)
// get the profile owner's showFullName preference
setShowFullName(apiUser?.showFullName ?? false)
- if (
- apiUser?.bridgeKycStatus === 'approved' ||
- apiUser?.kycVerifications?.some((v) => v.status === MantecaKycStatus.ACTIVE)
- ) {
- setIsKycVerified(true)
- } else {
- setIsKycVerified(false)
- }
+ setIsKycVerified(isUserKycVerified(apiUser))
// to check if the logged in user has sent money to the profile user,
// we check the amount that the profile user has received from the logged in user.
if (apiUser?.totalUsdReceivedFromCurrentUser) {
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 09086deed..554d1b02e 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -7,7 +7,6 @@ import NavHeader from '../Global/NavHeader'
import ProfileHeader from './components/ProfileHeader'
import ProfileMenuItem from './components/ProfileMenuItem'
import { useRouter } from 'next/navigation'
-import { checkIfInternalNavigation } from '@/utils/general.utils'
import { useState } from 'react'
import useKycStatus from '@/hooks/useKycStatus'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -35,20 +34,7 @@ export const Profile = () => {
return (
-
{
- // Check if the referrer is from the same domain (internal navigation)
- const isInternalReferrer = checkIfInternalNavigation()
-
- if (isInternalReferrer && window.history.length > 1) {
- router.back()
- } else {
- router.push('/home')
- }
- }}
- />
+ router.push('/home')} />
@@ -75,11 +61,13 @@ export const Profile = () => {
+
diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx
deleted file mode 100644
index 91c402a04..000000000
--- a/src/components/Profile/views/IdentityVerification.view.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-'use client'
-import { updateUserById } from '@/app/actions/users'
-import { Button } from '@/components/0_Bruddle/Button'
-import { countryData } from '@/components/AddMoney/consts'
-import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm'
-import { CountryList } from '@/components/Common/CountryList'
-import ErrorAlert from '@/components/Global/ErrorAlert'
-import IframeWrapper from '@/components/Global/IframeWrapper'
-import NavHeader from '@/components/Global/NavHeader'
-import {
- KycVerificationInProgressModal,
- PeanutDoesntStoreAnyPersonalInformation,
-} from '@/components/Kyc/KycVerificationInProgressModal'
-import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
-import { useAuth } from '@/context/authContext'
-import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow'
-import { useParams, useRouter } from 'next/navigation'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import useKycStatus from '@/hooks/useKycStatus'
-import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils'
-import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal'
-import { useIdentityVerification } from '@/hooks/useIdentityVerification'
-
-const IdentityVerificationView = () => {
- const router = useRouter()
- const formRef = useRef<{ handleSubmit: () => void }>(null)
- const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false)
- const [isUpdatingUser, setIsUpdatingUser] = useState(false)
- const [userUpdateError, setUserUpdateError] = useState
(null)
- const [showUserDetailsForm, setShowUserDetailsForm] = useState(false)
- const [isMantecaModalOpen, setIsMantecaModalOpen] = useState(false)
- const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null)
- const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null)
- const { isUserBridgeKycApproved } = useKycStatus()
- const { user, fetchUser } = useAuth()
- const [isStartVerificationModalOpen, setIsStartVerificationModalOpen] = useState(false)
- const params = useParams()
- const countryParam = params.country as string
- const { isMantecaSupportedCountry, isBridgeSupportedCountry } = useIdentityVerification()
-
- const handleRedirect = () => {
- const redirectUrl = getRedirectUrl()
- if (redirectUrl) {
- clearRedirectUrl()
- router.push(redirectUrl)
- } else {
- router.push('/profile')
- }
- }
-
- const handleBridgeKycSuccess = useCallback(async () => {
- await fetchUser()
- handleRedirect()
- }, [])
-
- const {
- iframeOptions,
- handleInitiateKyc,
- isVerificationProgressModalOpen,
- handleIframeClose,
- closeVerificationProgressModal,
- error: kycError,
- isLoading: isKycLoading,
- } = useBridgeKycFlow({
- onKycSuccess: handleBridgeKycSuccess,
- })
-
- const initialUserDetails: Partial = useMemo(
- () => ({
- fullName: user?.user.fullName ?? '',
- email: user?.user.email ?? '',
- }),
- [user]
- )
-
- const handleUserDetailsSubmit = useCallback(
- 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()
- await handleInitiateKyc()
- } catch (error: any) {
- setUserUpdateError(error.message)
- return { error: error.message }
- } finally {
- setIsUpdatingUser(false)
- }
- return {}
- },
- [user]
- )
-
- const handleBack = useCallback(() => {
- if (showUserDetailsForm) {
- setShowUserDetailsForm(false)
- } else {
- handleRedirect()
- }
- }, [showUserDetailsForm])
-
- // Bridge country object for all bridge supported countries
- const bridgeCountryObject = useMemo(
- () => ({ title: 'Bridge', id: 'bridge', type: 'bridge', description: '', path: 'bridge' }),
- []
- )
-
- // Memoized country lookup from URL param
- const selectedCountryParams = useMemo(() => {
- if (countryParam) {
- const country = countryData.find((country) => country.id.toUpperCase() === countryParam.toUpperCase())
- if (country) {
- return country
- } else {
- return bridgeCountryObject
- }
- }
- return null
- }, [countryParam, bridgeCountryObject])
-
- // Skip country selection if coming from a supported bridge country
- useEffect(() => {
- if (selectedCountryParams && (isBridgeSupportedCountry(countryParam) || countryParam === 'bridge')) {
- setUserClickedCountry({ title: selectedCountryParams.title, id: selectedCountryParams.id })
- setIsStartVerificationModalOpen(true)
- }
- }, [countryParam, isBridgeSupportedCountry, selectedCountryParams])
-
- useEffect(() => {
- return () => {
- setIsStartVerificationModalOpen(false)
- }
- }, [])
-
- return (
-
-
-
- {showUserDetailsForm ? (
-
-
Provide information to begin verification
-
-
-
-
formRef.current?.handleSubmit()}
- loading={isUpdatingUser || isKycLoading}
- variant="purple"
- shadowSize="4"
- className="mt-3 w-full"
- disabled={!isUserDetailsFormValid || isUpdatingUser || isKycLoading}
- icon="check-circle"
- >
- Verify now
-
-
-
-
- {(userUpdateError || kycError) &&
}
-
-
-
-
{
- closeVerificationProgressModal()
- handleRedirect()
- }}
- />
-
- ) : (
-
- {
- const { id, title } = country
- setUserClickedCountry({ id, title })
- setIsStartVerificationModalOpen(true)
- }}
- showLoadingState={false} // we don't want to show loading state when clicking a country, here because there is no async operation when clicking a country
- />
-
- )}
-
- {selectedCountry && (
-
- )}
-
- {userClickedCountry && selectedCountryParams && (
-
{
- // we dont show ID issuer country list for bridge countries
- if (
- isBridgeSupportedCountry(selectedCountryParams.id) ||
- selectedCountryParams.id === 'bridge'
- ) {
- handleRedirect()
- } else {
- setIsStartVerificationModalOpen(false)
- }
- }}
- onStartVerification={() => {
- setIsStartVerificationModalOpen(false)
- if (isMantecaSupportedCountry(userClickedCountry.id)) {
- setSelectedCountry(userClickedCountry)
- setIsMantecaModalOpen(true)
- } else {
- setShowUserDetailsForm(true)
- }
- }}
- selectedIdentityCountry={userClickedCountry}
- selectedCountry={selectedCountryParams}
- />
- )}
-
- )
-}
-
-export default IdentityVerificationView
diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx
index 442d47dc9..36d422051 100644
--- a/src/components/Profile/views/ProfileEdit.view.tsx
+++ b/src/components/Profile/views/ProfileEdit.view.tsx
@@ -14,7 +14,7 @@ import useKycStatus from '@/hooks/useKycStatus'
export const ProfileEditView = () => {
const router = useRouter()
const { user, fetchUser } = useAuth()
- const { isUserBridgeKycApproved } = useKycStatus()
+ const { isUserKycApproved } = useKycStatus()
const [isLoading, setIsLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
@@ -98,7 +98,7 @@ export const ProfileEditView = () => {
// refresh user data
await fetchUser()
- router.push('/profile')
+ router.replace('/profile')
} catch (error) {
console.error('Error updating profile:', error)
setErrorMessage('Something went wrong. Please try again or contact support.')
@@ -113,9 +113,9 @@ export const ProfileEditView = () => {
return (
-
router.push('/profile')} />
+ router.back()} />
-
+
{
value={formData.name}
onChange={(value) => handleChange('name', value)}
placeholder="Add your name"
- disabled={user?.user.bridgeKycStatus === 'approved'}
+ disabled={isUserKycApproved}
/>
{
value={formData.surname}
onChange={(value) => handleChange('surname', value)}
placeholder="Add your surname"
- disabled={user?.user.bridgeKycStatus === 'approved'}
+ disabled={isUserKycApproved}
/>
{
- const router = useRouter()
- const { lockedRegions } = useIdentityVerification()
-
- const hideVerifyButtonPaths = ['latam', 'rest-of-the-world']
-
- const region = lockedRegions.find((region) => region.path === path)
-
- if (!region) {
- return null
- }
-
- return (
-
-
- router.back()} />
-
-
-
- {!hideVerifyButtonPaths.includes(region.path) && (
-
- router.push(`/profile/identity-verification/${region.path}/bridge`)}
- variant="purple"
- shadowSize="4"
- className="max-w-[280px]"
- >
- Verify to unlock
-
-
- )}
-
- )
-}
-
-export default RegionsPage
diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx
index 9323f71be..fdd051eb5 100644
--- a/src/components/Profile/views/RegionsVerification.view.tsx
+++ b/src/components/Profile/views/RegionsVerification.view.tsx
@@ -5,13 +5,110 @@ import { getCardPosition } from '@/components/Global/Card/card.utils'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { Icon } from '@/components/Global/Icons/Icon'
import NavHeader from '@/components/Global/NavHeader'
-import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification'
+import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal'
+import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal'
+import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal'
+import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal'
+import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification'
+import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus'
+import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
+import { useAuth } from '@/context/authContext'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
+import { useState, useCallback, useRef, useMemo } from 'react'
+import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
+
+type ModalVariant = 'start' | 'processing' | 'action_required' | 'rejected'
+
+// determine which modal to show based on sumsub status and clicked region intent
+function getModalVariant(
+ sumsubStatus: string | null,
+ clickedRegionIntent: KYCRegionIntent | undefined,
+ existingRegionIntent: string | null
+): ModalVariant {
+ // no verification or not started → start fresh
+ if (!sumsubStatus || sumsubStatus === 'NOT_STARTED') return 'start'
+
+ // different region intent → allow new verification
+ if (existingRegionIntent && clickedRegionIntent && clickedRegionIntent !== existingRegionIntent) return 'start'
+
+ switch (sumsubStatus) {
+ case 'PENDING':
+ case 'IN_REVIEW':
+ return 'processing'
+ case 'ACTION_REQUIRED':
+ return 'action_required'
+ case 'REJECTED':
+ case 'FAILED':
+ return 'rejected'
+ default:
+ return 'start'
+ }
+}
const RegionsVerification = () => {
const router = useRouter()
+ const { user } = useAuth()
const { unlockedRegions, lockedRegions } = useIdentityVerification()
+ const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus()
+ const [selectedRegion, setSelectedRegion] = useState(null)
+ // keeps the region display stable during modal close animation
+ const displayRegionRef = useRef(null)
+ if (selectedRegion) displayRegionRef.current = selectedRegion
+ // 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)
+ // skip StartVerificationView when re-submitting (user already consented)
+ const [autoStartSdk, setAutoStartSdk] = useState(false)
+
+ const sumsubFailureCount = useMemo(
+ () =>
+ user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0,
+ [user]
+ )
+
+ const clickedRegionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined
+ const modalVariant = selectedRegion
+ ? getModalVariant(sumsubStatus, clickedRegionIntent, sumsubVerificationRegionIntent)
+ : null
+
+ const handleFinalKycSuccess = useCallback(() => {
+ setSelectedRegion(null)
+ setActiveRegionIntent(undefined)
+ setAutoStartSdk(false)
+ }, [])
+
+ const flow = useMultiPhaseKycFlow({
+ regionIntent: activeRegionIntent,
+ onKycSuccess: handleFinalKycSuccess,
+ onManualClose: () => {
+ setSelectedRegion(null)
+ setActiveRegionIntent(undefined)
+ setAutoStartSdk(false)
+ },
+ })
+
+ const handleRegionClick = useCallback((region: Region) => {
+ setSelectedRegion(region)
+ }, [])
+
+ const handleModalClose = useCallback(() => {
+ setSelectedRegion(null)
+ }, [])
+
+ const handleStartKyc = useCallback(async () => {
+ const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined
+ if (intent) setActiveRegionIntent(intent)
+ setSelectedRegion(null)
+ await flow.handleInitiateKyc(intent)
+ }, [flow.handleInitiateKyc, selectedRegion])
+
+ // re-submission: skip StartVerificationView since user already consented
+ const handleResubmitKyc = useCallback(async () => {
+ setAutoStartSdk(true)
+ await handleStartKyc()
+ }, [handleStartKyc])
return (
@@ -37,11 +134,47 @@ const RegionsVerification = () => {
-
Locked regions
-
Where do you want to send and receive money?
+ {lockedRegions.length > 0 && (
+ <>
+
Locked regions
+
Where do you want to send and receive money?
-
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ {flow.error && {flow.error}
}
+
+
)
}
@@ -51,9 +184,9 @@ export default RegionsVerification
interface RegionsListProps {
regions: Region[]
isLocked: boolean
+ onRegionClick?: (region: Region) => void
}
-const RegionsList = ({ regions, isLocked }: RegionsListProps) => {
- const router = useRouter()
+const RegionsList = ({ regions, isLocked, onRegionClick }: RegionsListProps) => {
return (
{regions.map((region, index) => (
@@ -71,8 +204,8 @@ const RegionsList = ({ regions, isLocked }: RegionsListProps) => {
position={getCardPosition(index, regions.length)}
title={region.name}
onClick={() => {
- if (isLocked) {
- router.push(`/profile/identity-verification/${region.path}`)
+ if (isLocked && onRegionClick) {
+ onRegionClick(region)
}
}}
isDisabled={!isLocked}
diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx
index c25bc440d..80cb02e01 100644
--- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx
+++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx
@@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useUserInteractions } from '@/hooks/useUserInteractions'
import { useUserByUsername } from '@/hooks/useUserByUsername'
+import { isUserKycVerified } from '@/constants/kyc.consts'
interface DirectRequestInitialViewProps {
username: string
@@ -216,7 +217,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) =
recipientType={'USERNAME'}
username={recipientUser?.username || username}
fullName={recipientUser?.fullName}
- isVerified={recipientUser?.bridgeKycStatus === 'approved'}
+ isVerified={isUserKycVerified(recipientUser)}
haveSentMoneyToUser={recipientUser?.userId ? interactions[recipientUser.userId] || false : false}
/>
diff --git a/src/components/Send/views/Contacts.view.tsx b/src/components/Send/views/Contacts.view.tsx
index 148b4e59b..73efbc09f 100644
--- a/src/components/Send/views/Contacts.view.tsx
+++ b/src/components/Send/views/Contacts.view.tsx
@@ -13,6 +13,7 @@ import PeanutLoading from '@/components/Global/PeanutLoading'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { Button } from '@/components/0_Bruddle/Button'
import { useDebounce } from '@/hooks/useDebounce'
+import { isUserKycVerified } from '@/constants/kyc.consts'
import { ContactsListSkeleton } from '@/components/Common/ContactsListSkeleton'
export default function ContactsView() {
@@ -138,7 +139,7 @@ export default function ContactsView() {
Your contacts
{contacts.map((contact, index) => {
- const isVerified = contact.bridgeKycStatus === 'approved'
+ const isVerified = isUserKycVerified(contact)
const displayName = contact.showFullName
? contact.fullName || contact.username
: contact.username
diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx
index 34243f177..d1496af07 100644
--- a/src/components/Setup/Views/InstallPWA.tsx
+++ b/src/components/Setup/Views/InstallPWA.tsx
@@ -209,12 +209,13 @@ const InstallPWA = ({
)
}
- // Scenario 4: Fallback (cannot initiate automatic install)
+ // Scenario 4: Fallback (manual install instructions)
return (
-
-
Could not initiate automatic installation.
-
Please try adding to Home Screen manually via your browser menu.
-
handleNext()} className="mt-4 w-full" shadowSize="4" variant="purple">
+
+
+ To install the app, please add it to your Home Screen from your browser menu.
+
+
handleNext()} className="w-full" shadowSize="4" variant="purple">
Continue
diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx
index aeb22bd3a..f6761b7f3 100644
--- a/src/components/TransactionDetails/TransactionCard.tsx
+++ b/src/components/TransactionDetails/TransactionCard.tsx
@@ -182,9 +182,13 @@ const TransactionCard: React.FC = ({
{/* display the action icon and type text */}
- {!isTestTransaction && getActionIcon(type, transaction.direction)}
+ {!isTestTransaction && getActionIcon(type, transaction.direction, status)}
- {isTestTransaction ? 'Setup' : isPerkReward ? 'Cashback' : getActionText(type)}
+ {isTestTransaction
+ ? 'Setup'
+ : isPerkReward
+ ? 'Cashback'
+ : getActionText(type, status)}
{status && }
@@ -232,7 +236,15 @@ const TransactionCard: React.FC
= ({
}
// helper functions
-function getActionIcon(type: TransactionType, direction: TransactionDirection): React.ReactNode {
+function getActionIcon(
+ type: TransactionType,
+ direction: TransactionDirection,
+ status?: StatusPillType
+): React.ReactNode {
+ if (status === 'refunded') {
+ return
+ }
+
let iconName: IconName | null = null
let iconSize = 7
@@ -275,7 +287,9 @@ function getActionIcon(type: TransactionType, direction: TransactionDirection):
return
}
-function getActionText(type: TransactionType): string {
+function getActionText(type: TransactionType, status?: StatusPillType): string {
+ if (status === 'refunded') return 'Refund'
+
let actionText: string = type
switch (type) {
case 'bank_withdraw':
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
index 92b95b4c3..d3fd2efd6 100644
--- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
+++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
@@ -161,7 +161,9 @@ export const TransactionDetailsReceipt = ({
!(
transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK &&
transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER
- )
+ ) &&
+ // hide token and network for refunded entries
+ transaction.status !== 'refunded'
),
txId: !!transaction.txHash,
// show cancelled row if status is cancelled, use cancelledDate or fallback to createdAt
@@ -1298,7 +1300,7 @@ export const TransactionDetailsReceipt = ({
)}
- {isQRPayment && (
+ {isQRPayment && transaction.status !== 'refunded' && (
{
router.push(`/request?amount=${transaction.amount}&merchant=${transaction.userName}`)
diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts
new file mode 100644
index 000000000..d50ae2868
--- /dev/null
+++ b/src/constants/bridge-requirements.consts.ts
@@ -0,0 +1,41 @@
+interface RequirementLabelInfo {
+ title: string
+ description: string
+}
+
+// map of bridge additional_requirements to user-friendly labels
+const BRIDGE_REQUIREMENT_LABELS: Record = {
+ proof_of_address: {
+ title: 'Proof of Address',
+ description:
+ 'Upload a utility bill, bank statement, or government letter showing your current address (dated within 3 months).',
+ },
+ additional_identity_document: {
+ title: 'Additional Identity Document',
+ description: 'Upload an additional government-issued ID document.',
+ },
+ proof_of_source_of_funds: {
+ title: 'Proof of Source of Funds',
+ description: 'Upload documentation showing the origin of your funds (e.g. pay stub, tax return).',
+ },
+ proof_of_tax_identification: {
+ title: 'Tax Identification',
+ description: 'Upload a document showing your tax identification number.',
+ },
+}
+
+const FALLBACK_LABEL: RequirementLabelInfo = {
+ title: 'Additional Document',
+ description: 'Please provide the requested document.',
+}
+
+/** get human-readable label for a bridge additional requirement */
+export function getRequirementLabel(requirement: string): RequirementLabelInfo {
+ return (
+ BRIDGE_REQUIREMENT_LABELS[requirement] ?? {
+ // auto-format unknown requirement codes as title case
+ title: requirement.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
+ description: FALLBACK_LABEL.description,
+ }
+ )
+}
diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts
new file mode 100644
index 000000000..d35557a54
--- /dev/null
+++ b/src/constants/kyc.consts.ts
@@ -0,0 +1,78 @@
+import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
+import { type MantecaKycStatus } from '@/interfaces'
+
+/**
+ * unified kyc status type across all providers.
+ * bridge uses lowercase strings, manteca uses its own enum, sumsub uses uppercase.
+ */
+export type KycVerificationStatus = MantecaKycStatus | SumsubKycStatus | string
+
+export type KycStatusCategory = 'completed' | 'processing' | 'failed' | 'action_required'
+
+// sets of status values by category — single source of truth
+const APPROVED_STATUSES: ReadonlySet = new Set(['approved', 'ACTIVE', 'APPROVED'])
+const FAILED_STATUSES: ReadonlySet = new Set(['rejected', 'INACTIVE', 'REJECTED'])
+const PENDING_STATUSES: ReadonlySet = new Set([
+ 'under_review',
+ 'incomplete',
+ 'ONBOARDING',
+ 'PENDING',
+ 'IN_REVIEW',
+])
+const ACTION_REQUIRED_STATUSES: ReadonlySet = new Set(['ACTION_REQUIRED'])
+const NOT_STARTED_STATUSES: ReadonlySet = new Set(['not_started', 'NOT_STARTED'])
+
+// sumsub-specific set for flow-level gating (e.g. useQrKycGate blocks payments).
+// ACTION_REQUIRED is intentionally included here — user hasn't completed verification
+// yet, so they should still be gated from features that require approved kyc.
+const SUMSUB_IN_PROGRESS_STATUSES: ReadonlySet = new Set(['PENDING', 'IN_REVIEW', 'ACTION_REQUIRED'])
+
+/** check if a kyc status represents an approved/completed state */
+export const isKycStatusApproved = (status: string | undefined | null): boolean =>
+ !!status && APPROVED_STATUSES.has(status)
+
+/**
+ * check if a user (from API data) has completed kyc with any provider.
+ * works with user objects from getUserById, contacts, senders, recipients, etc.
+ * for current user, prefer useUnifiedKycStatus hook instead.
+ */
+export function isUserKycVerified(
+ user:
+ | {
+ bridgeKycStatus?: string | null
+ kycVerifications?: Array<{ status: string }> | null
+ }
+ | null
+ | undefined
+): boolean {
+ if (!user) return false
+ if (user.bridgeKycStatus === 'approved') return true
+ return user.kycVerifications?.some((v) => isKycStatusApproved(v.status)) ?? false
+}
+
+/** check if a kyc status represents a failed/rejected state */
+export const isKycStatusFailed = (status: string | undefined | null): boolean => !!status && FAILED_STATUSES.has(status)
+
+/** check if a kyc status represents a pending/in-review state */
+export const isKycStatusPending = (status: string | undefined | null): boolean =>
+ !!status && PENDING_STATUSES.has(status)
+
+/** check if a kyc status represents an action-required state */
+export const isKycStatusActionRequired = (status: string | undefined | null): boolean =>
+ !!status && ACTION_REQUIRED_STATUSES.has(status)
+
+/** check if a kyc status means "not started" (should not render status ui) */
+export const isKycStatusNotStarted = (status: string | undefined | null): boolean =>
+ !status || NOT_STARTED_STATUSES.has(status)
+
+/** check if a sumsub status means verification is in progress */
+export const isSumsubStatusInProgress = (status: string | undefined | null): boolean =>
+ !!status && SUMSUB_IN_PROGRESS_STATUSES.has(status)
+
+/** categorize any provider's kyc status into a display category */
+export const getKycStatusCategory = (status: string): KycStatusCategory => {
+ if (APPROVED_STATUSES.has(status)) return 'completed'
+ if (FAILED_STATUSES.has(status)) return 'failed'
+ if (ACTION_REQUIRED_STATUSES.has(status)) return 'action_required'
+ return 'processing'
+}
diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts
new file mode 100644
index 000000000..7c4fc2850
--- /dev/null
+++ b/src/constants/sumsub-reject-labels.consts.ts
@@ -0,0 +1,333 @@
+interface RejectLabelInfo {
+ title: string
+ description: string
+}
+
+// map of sumsub reject labels to user-friendly descriptions.
+// source: https://docs.sumsub.com/reference/rejected
+// source: https://docs.sumsub.com/reference/resubmission-requested
+const REJECT_LABEL_MAP: Record = {
+ // --- data & address issues ---
+ PROBLEMATIC_APPLICANT_DATA: {
+ title: 'Data mismatch',
+ description:
+ 'Your provided information does not match our records. Please check your name, date of birth, and other details.',
+ },
+ WRONG_ADDRESS: {
+ title: 'Address mismatch',
+ description: 'The address you provided does not match your documents. Please correct it and try again.',
+ },
+ DB_DATA_MISMATCH: {
+ title: 'Data inconsistency',
+ description: 'Your information does not match the government database. Please verify your details are correct.',
+ },
+ DB_DATA_NOT_FOUND: {
+ title: 'Data not found',
+ description: 'Your data could not be found in the government database. Please double-check your details.',
+ },
+ REQUESTED_DATA_MISMATCH: {
+ title: 'Document details mismatch',
+ description: 'The document details do not match the information you provided.',
+ },
+ GPS_AS_POA_SKIPPED: {
+ title: 'Address details incomplete',
+ description: 'Insufficient address details were provided. Please provide your full address.',
+ },
+
+ // --- document quality & completeness ---
+ DOCUMENT_BAD_QUALITY: {
+ title: 'Low quality document',
+ description: 'The document image was blurry, dark, or hard to read. Please upload a clearer photo.',
+ },
+ LOW_QUALITY: {
+ title: 'Low quality document',
+ description: 'The document quality is too low to process. Please upload a clearer image.',
+ },
+ UNSATISFACTORY_PHOTOS: {
+ title: 'Unreadable photo',
+ description: 'The photo is not readable. Please upload a clearer image.',
+ },
+ DOCUMENT_DAMAGED: {
+ title: 'Damaged document',
+ description: 'The document appears damaged or worn. Please use a document in good condition.',
+ },
+ DOCUMENT_INCOMPLETE: {
+ title: 'Incomplete document',
+ description: 'Part of the document was cut off or missing. Make sure the full document is visible.',
+ },
+ INCOMPLETE_DOCUMENT: {
+ title: 'Incomplete document',
+ description: 'Some pages or sides of the document are missing. Please upload the complete document.',
+ },
+ DOCUMENT_PAGE_MISSING: {
+ title: 'Missing page',
+ description: 'A required page of the document is missing. Please upload all pages.',
+ },
+ DOCUMENT_MISSING: {
+ title: 'Missing document',
+ description: 'A required document was not provided. Please upload all requested documents.',
+ },
+ BLACK_AND_WHITE: {
+ title: 'Color document required',
+ description: 'The document was provided in black and white. Please upload a color image.',
+ },
+ SCREENSHOTS: {
+ title: 'Screenshots not accepted',
+ description: 'Screenshots are not accepted. Please upload a direct photo of the document.',
+ },
+ DIGITAL_DOCUMENT: {
+ title: 'Original document required',
+ description: 'A digital version was uploaded. Please upload a photo of the original document.',
+ },
+
+ // --- document validity ---
+ DOCUMENT_EXPIRED: {
+ title: 'Expired document',
+ description: 'The document has expired. Please use a valid, non-expired document.',
+ },
+ EXPIRATION_DATE: {
+ title: 'Expiration date issue',
+ description: 'The document is expired or expiring soon. Please use a document with a valid expiration date.',
+ },
+ ID_INVALID: {
+ title: 'Invalid ID',
+ description: 'The identity document is not valid. Please use a different document.',
+ },
+ UNSUPPORTED_DOCUMENT: {
+ title: 'Unsupported document type',
+ description: "This type of document is not accepted. Please use a passport, national ID, or driver's license.",
+ },
+ WRONG_DOCUMENT: {
+ title: 'Wrong document provided',
+ description: 'The uploaded document does not match what was requested. Please upload the correct document.',
+ },
+ NOT_DOCUMENT: {
+ title: 'Not a valid document',
+ description: 'The uploaded file is not a document. Please upload the correct file.',
+ },
+ DOCUMENT_TEMPLATE: {
+ title: 'Template document',
+ description: 'The provided document appears to be a template. Please upload your actual document.',
+ },
+ OUTDATED_DOCUMENT_VERSION: {
+ title: 'Outdated document',
+ description: 'This is not the most recent version of the document. Please provide the latest version.',
+ },
+ UNFILLED_ID: {
+ title: 'Unreadable document',
+ description: 'The document could not be read. Please upload a clearer image.',
+ },
+ UNSUITABLE_DOCUMENT: {
+ title: 'Unsuitable document',
+ description: 'The document does not meet the requirements. Please check the requirements and try again.',
+ },
+ INCOMPATIBLE_LANGUAGE: {
+ title: 'Unsupported language',
+ description: 'The document is in an unsupported language. Please provide a translated or alternative document.',
+ },
+ UNSUPPORTED_LANGUAGE: {
+ title: 'Unsupported language',
+ description: 'The document is in an unsupported language.',
+ },
+ BAD_PROOF_OF_IDENTITY: {
+ title: 'Identity document issue',
+ description: 'There was an issue with your identity document. Please upload a valid, clear copy.',
+ },
+ BAD_PROOF_OF_ADDRESS: {
+ title: 'Proof of address issue',
+ description:
+ 'There was an issue with your proof of address. Please upload a valid document with your full name and address.',
+ },
+ BAD_PROOF_OF_PAYMENT: {
+ title: 'Payment proof issue',
+ description: 'There was an issue verifying your payment information.',
+ },
+ ADDITIONAL_DOCUMENT_REQUIRED: {
+ title: 'Additional document needed',
+ description: 'An additional document is required. Please check the request and upload the needed document.',
+ },
+
+ // --- selfie & face matching ---
+ SELFIE_MISMATCH: {
+ title: 'Selfie does not match',
+ description: 'The selfie did not match the photo on your document. Please try again with a clear selfie.',
+ },
+ BAD_FACE_MATCHING: {
+ title: 'Face not clearly visible',
+ description: 'Your face was not clearly visible. Please take a well-lit selfie showing your full face.',
+ },
+ BAD_SELFIE: {
+ title: 'Selfie issue',
+ description: 'There was an issue with your selfie. Please take a clear, well-lit selfie.',
+ },
+ BAD_VIDEO_SELFIE: {
+ title: 'Video selfie issue',
+ description: 'The video selfie check could not be completed. Please try again.',
+ },
+ SELFIE_BAD_QUALITY: {
+ title: 'Low quality selfie',
+ description: 'The selfie was blurry or poorly lit. Please take a clear, well-lit selfie.',
+ },
+ SELFIE_SPOOFING: {
+ title: 'Selfie issue detected',
+ description: 'A live selfie is required. Do not use a photo of a photo or a screen.',
+ },
+ FRAUDULENT_LIVENESS: {
+ title: 'Liveness check failed',
+ description: 'The liveness check could not be completed. Please try again with a live selfie.',
+ },
+
+ // --- fraud & forgery (terminal) ---
+ DOCUMENT_FAKE: {
+ title: 'Document could not be verified',
+ description: 'We were unable to verify the authenticity of your document.',
+ },
+ FORGERY: {
+ title: 'Document could not be verified',
+ description: 'The document could not be verified. Please use an original, unaltered document.',
+ },
+ GRAPHIC_EDITOR: {
+ title: 'Edited document detected',
+ description: 'The document appears to have been digitally altered.',
+ },
+ GRAPHIC_EDITOR_USAGE: {
+ title: 'Edited document detected',
+ description: 'The document appears to have been digitally altered.',
+ },
+ FRAUDULENT_PATTERNS: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed due to a security concern.',
+ },
+ THIRD_PARTY_INVOLVED: {
+ title: 'Third party detected',
+ description: 'A third party was detected during verification. You must complete verification yourself.',
+ },
+ BLOCKLIST: {
+ title: 'Account restricted',
+ description: 'Your account has been restricted. Please contact support.',
+ },
+ SPAM: {
+ title: 'Too many attempts',
+ description: 'Too many files were uploaded. Please contact support.',
+ },
+
+ // --- regulatory & compliance (terminal) ---
+ AGE_REQUIREMENT_MISMATCH: {
+ title: 'Age requirement not met',
+ description: 'You must meet the minimum age requirement to use this service.',
+ },
+ AGE_BELOW_ACCEPTED_LIMIT: {
+ title: 'Age requirement not met',
+ description: 'You must be at least 18 years old to use this service.',
+ },
+ REGULATIONS_VIOLATIONS: {
+ title: 'Regulatory restriction',
+ description: 'Verification could not be completed due to regulatory requirements.',
+ },
+ WRONG_USER_REGION: {
+ title: 'Unsupported region',
+ description: 'Your country or region is not currently supported.',
+ },
+ DUPLICATE: {
+ title: 'Duplicate account',
+ description: 'An account with your details already exists.',
+ },
+ RESTRICTED_PERSON: {
+ title: 'Verification restricted',
+ description: 'Your verification could not be completed. Please contact support.',
+ },
+
+ // --- compromised persons (terminal) ---
+ ADVERSE_MEDIA: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed. Please contact support.',
+ },
+ CRIMINAL: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed. Please contact support.',
+ },
+ COMPROMISED_PERSONS: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed. Please contact support.',
+ },
+ PEP: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed due to compliance requirements.',
+ },
+ SANCTIONS: {
+ title: 'Verification failed',
+ description: 'Your verification could not be completed due to compliance requirements.',
+ },
+
+ // --- profile & consistency ---
+ INCONSISTENT_PROFILE: {
+ title: 'Inconsistent documents',
+ description: 'The documents provided appear to belong to different individuals.',
+ },
+
+ // --- availability ---
+ CHECK_UNAVAILABLE: {
+ title: 'Verification temporarily unavailable',
+ description: 'The verification database is currently unavailable. Please try again later.',
+ },
+
+ // --- provider submission errors (retryable) ---
+ DUPLICATE_EMAIL: {
+ title: 'Email already in use',
+ description:
+ 'The email you entered is already associated with another account. Please verify again with a different email.',
+ },
+}
+
+const FALLBACK_LABEL_INFO: RejectLabelInfo = {
+ title: 'Verification issue',
+ description: 'There was an issue with your verification. Please try again or contact support.',
+}
+
+// labels that indicate a permanent rejection — used as a frontend heuristic
+// until backend provides rejectType
+export const TERMINAL_REJECT_LABELS = new Set([
+ 'DOCUMENT_FAKE',
+ 'FORGERY',
+ 'GRAPHIC_EDITOR_USAGE',
+ 'GRAPHIC_EDITOR',
+ 'AGE_BELOW_ACCEPTED_LIMIT',
+ 'AGE_REQUIREMENT_MISMATCH',
+ 'FRAUDULENT_PATTERNS',
+ 'FRAUDULENT_LIVENESS',
+ 'BLOCKLIST',
+ 'ADVERSE_MEDIA',
+ 'CRIMINAL',
+ 'COMPROMISED_PERSONS',
+ 'PEP',
+ 'SANCTIONS',
+ 'DUPLICATE',
+])
+
+/** get human-readable info for a sumsub reject label, with a safe fallback */
+export const getRejectLabelInfo = (label: string): RejectLabelInfo => {
+ return REJECT_LABEL_MAP[label] ?? FALLBACK_LABEL_INFO
+}
+
+/** check if any of the reject labels indicate a terminal (permanent) rejection */
+export const hasTerminalRejectLabel = (labels: string[]): boolean => {
+ return labels.some((label) => TERMINAL_REJECT_LABELS.has(label))
+}
+
+const MAX_RETRY_COUNT = 2
+
+/** determine if a rejection is terminal (permanent, cannot be retried) */
+export const isTerminalRejection = ({
+ rejectType,
+ failureCount,
+ rejectLabels,
+}: {
+ rejectType?: 'RETRY' | 'FINAL' | null
+ failureCount?: number
+ rejectLabels?: string[] | null
+}): boolean => {
+ if (rejectType === 'FINAL') return true
+ if (failureCount && failureCount >= MAX_RETRY_COUNT) return true
+ if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true
+ return false
+}
diff --git a/src/content b/src/content
index 412a0c80d..ffc4bdd8a 160000
--- a/src/content
+++ b/src/content
@@ -1 +1 @@
-Subproject commit 412a0c80d75104debf456497361f3c902699f329
+Subproject commit ffc4bdd8ac3925b9f80d77bb5bb6e5f85a3d45b4
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/features/limits/hooks/useLimitsValidation.ts b/src/features/limits/hooks/useLimitsValidation.ts
index b2743c350..406dc8e2d 100644
--- a/src/features/limits/hooks/useLimitsValidation.ts
+++ b/src/features/limits/hooks/useLimitsValidation.ts
@@ -2,7 +2,6 @@
import { useMemo } from 'react'
import { useLimits } from '@/hooks/useLimits'
-import useKycStatus from '@/hooks/useKycStatus'
import type { MantecaLimit } from '@/interfaces'
import {
MAX_QR_PAYMENT_AMOUNT_FOREIGN,
@@ -42,20 +41,17 @@ interface UseLimitsValidationOptions {
}
/**
- * hook to validate amounts against user's transaction limits
- * automatically determines if user is local (manteca) or foreign (bridge) based on their kyc status
- * returns warning/blocking state based on remaining limits
+ * hook to validate amounts against user's transaction limits.
+ * uses the presence of API-returned limits (which are gated behind ENABLED rails on the backend)
*/
export function useLimitsValidation({ flowType, amount, currency: currencyInput }: UseLimitsValidationOptions) {
const { mantecaLimits, bridgeLimits, isLoading, hasMantecaLimits, hasBridgeLimits } = useLimits()
- const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus()
// normalize currency to valid LimitCurrency type
const currency = mapToLimitCurrency(currencyInput)
- // determine if user is "local" (has manteca kyc for latam operations)
- // this replaces the external isLocalUser parameter
- const isLocalUser = isUserMantecaKycApproved
+ // determine if user is "local" (has manteca limits = enabled manteca rails)
+ const isLocalUser = hasMantecaLimits
// parse amount to number - strip commas to handle "1,200" format
const numericAmount = useMemo(() => {
@@ -81,7 +77,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
// validate for manteca users (argentina/brazil)
const mantecaValidation = useMemo(() => {
- if (!isUserMantecaKycApproved || !relevantMantecaLimit) {
+ if (!hasMantecaLimits || !relevantMantecaLimit) {
return {
isBlocking: false,
isWarning: false,
@@ -155,12 +151,12 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
daysUntilReset: daysUntilMonthlyReset,
limitCurrency: currency,
}
- }, [isUserMantecaKycApproved, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType])
+ }, [hasMantecaLimits, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType])
// validate for bridge users (us/europe/mexico) - per transaction limits
// bridge limits are always in USD
const bridgeValidation = useMemo(() => {
- if (!isUserBridgeKycApproved || !bridgeLimits) {
+ if (!hasBridgeLimits || !bridgeLimits) {
return {
isBlocking: false,
isWarning: false,
@@ -213,7 +209,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
daysUntilReset: null,
limitCurrency: 'USD',
}
- }, [isUserBridgeKycApproved, bridgeLimits, flowType, numericAmount])
+ }, [hasBridgeLimits, bridgeLimits, flowType, numericAmount])
// qr payment validation for foreign users (non-manteca kyc)
// foreign qr limits are always in USD
@@ -258,8 +254,8 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
const validation = useMemo(() => {
// for qr payments
if (flowType === 'qr-payment') {
- // local users (manteca kyc) use manteca limits
- if (isLocalUser && isUserMantecaKycApproved) {
+ // local users (manteca limits) use manteca limits
+ if (isLocalUser && hasMantecaLimits) {
return mantecaValidation
}
// foreign users have fixed per-tx limit
@@ -268,14 +264,14 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
// for onramp/offramp - check which provider applies
// only use manteca if there's a relevant limit for the currency (prevents skipping bridge validation)
- if (isUserMantecaKycApproved && hasMantecaLimits && relevantMantecaLimit) {
+ if (hasMantecaLimits && relevantMantecaLimit) {
return mantecaValidation
}
- if (isUserBridgeKycApproved && hasBridgeLimits) {
+ if (hasBridgeLimits) {
return bridgeValidation
}
- // no kyc - no limits to validate
+ // no enabled rails - no limits to validate
return {
isBlocking: false,
isWarning: false,
@@ -288,8 +284,6 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
}, [
flowType,
isLocalUser,
- isUserMantecaKycApproved,
- isUserBridgeKycApproved,
hasMantecaLimits,
hasBridgeLimits,
relevantMantecaLimit,
@@ -305,7 +299,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput
currency,
// convenience getters
hasLimits: hasMantecaLimits || hasBridgeLimits,
- isMantecaUser: isUserMantecaKycApproved,
- isBridgeUser: isUserBridgeKycApproved,
+ isMantecaUser: hasMantecaLimits,
+ isBridgeUser: hasBridgeLimits,
}
}
diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx
index 8bbb6fc5e..8fa791ab0 100644
--- a/src/features/limits/views/BridgeLimitsView.tsx
+++ b/src/features/limits/views/BridgeLimitsView.tsx
@@ -4,7 +4,6 @@ import NavHeader from '@/components/Global/NavHeader'
import Card from '@/components/Global/Card'
import { Icon } from '@/components/Global/Icons/Icon'
import { useLimits } from '@/hooks/useLimits'
-import useKycStatus from '@/hooks/useKycStatus'
import { useRouter } from 'next/navigation'
import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts'
import Image from 'next/image'
@@ -25,8 +24,7 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState'
*/
const BridgeLimitsView = () => {
const router = useRouter()
- const { bridgeLimits, isLoading, error } = useLimits()
- const { isUserMantecaKycApproved } = useKycStatus()
+ const { bridgeLimits, isLoading, error, hasMantecaLimits } = useLimits()
// url state for source region (where user came from)
const [region] = useQueryState(
@@ -90,7 +88,7 @@ const BridgeLimitsView = () => {
)}
{/* qr payment limits accordion - for bridge users without manteca kyc */}
- {!isUserMantecaKycApproved && (
+ {!hasMantecaLimits && (
QR payment limits:
diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx
index 27c4ec23c..8eb45ec8b 100644
--- a/src/features/limits/views/LimitsPageView.tsx
+++ b/src/features/limits/views/LimitsPageView.tsx
@@ -6,6 +6,7 @@ import NavHeader from '@/components/Global/NavHeader'
import StatusBadge from '@/components/Global/Badges/StatusBadge'
import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification'
import useKycStatus from '@/hooks/useKycStatus'
+import { useLimits } from '@/hooks/useLimits'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useMemo } from 'react'
@@ -18,7 +19,8 @@ import { getProviderRoute } from '../utils'
const LimitsPageView = () => {
const router = useRouter()
const { unlockedRegions, lockedRegions } = useIdentityVerification()
- const { isUserKycApproved, isUserBridgeKycUnderReview, isUserMantecaKycApproved } = useKycStatus()
+ const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus()
+ const { hasMantecaLimits } = useLimits()
// check if user has any kyc at all
const hasAnyKyc = isUserKycApproved
@@ -63,7 +65,7 @@ const LimitsPageView = () => {
{/* unlocked regions */}
{unlockedRegions.length > 0 && (
-
+
)}
{/* locked regions - only render if there are actual locked regions */}
@@ -175,7 +177,7 @@ const LockedRegionsList = ({ regions, isBridgeKycPending }: LockedRegionsListPro
title={region.name}
onClick={() => {
if (!isPending) {
- router.push(`/profile/identity-verification/${region.path}`)
+ router.push('/profile/identity-verification')
}
}}
isDisabled={isPending}
diff --git a/src/features/payments/flows/direct-send/useDirectSendFlow.ts b/src/features/payments/flows/direct-send/useDirectSendFlow.ts
index 81cf02930..34002956d 100644
--- a/src/features/payments/flows/direct-send/useDirectSendFlow.ts
+++ b/src/features/payments/flows/direct-send/useDirectSendFlow.ts
@@ -51,7 +51,14 @@ export function useDirectSendFlow() {
const { user } = useAuth()
const { createCharge, isCreating: isCreatingCharge } = useChargeManager()
const { recordPayment, isRecording } = usePaymentRecorder()
- const { isConnected, address: walletAddress, sendMoney, formattedBalance, hasSufficientBalance } = useWallet()
+ const {
+ isConnected,
+ address: walletAddress,
+ sendMoney,
+ formattedBalance,
+ hasSufficientBalance,
+ isFetchingBalance,
+ } = useWallet()
const isLoggedIn = !!user?.user?.userId
@@ -84,9 +91,18 @@ export function useDirectSendFlow() {
}, [amount, hasSufficientBalance])
// check if should show insufficient balance error
+ // gate on !isFetchingBalance to avoid flash while balance is still loading
const isInsufficientBalance = useMemo(() => {
- return isLoggedIn && !!amount && !hasEnoughBalance && !isLoading && !isCreatingCharge && !isRecording
- }, [isLoggedIn, amount, hasEnoughBalance, isLoading, isCreatingCharge, isRecording])
+ return (
+ isLoggedIn &&
+ !!amount &&
+ !hasEnoughBalance &&
+ !isFetchingBalance &&
+ !isLoading &&
+ !isCreatingCharge &&
+ !isRecording
+ )
+ }, [isLoggedIn, amount, hasEnoughBalance, isFetchingBalance, isLoading, isCreatingCharge, isRecording])
// execute the payment (called from input view)
const executePayment = useCallback(async () => {
diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
index dc43176f8..82775c0b6 100644
--- a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
+++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
@@ -84,6 +84,7 @@ export function useSemanticRequestFlow() {
sendTransactions,
formattedBalance,
hasSufficientBalance,
+ isFetchingBalance,
} = useWallet()
// use token selector context for ui integration
@@ -174,11 +175,13 @@ export function useSemanticRequestFlow() {
}, [amount, hasSufficientBalance])
// check if should show insufficient balance error
+ // gate on !isFetchingBalance to avoid flash while balance is still loading
const isInsufficientBalance = useMemo(() => {
return (
isLoggedIn &&
!!amount &&
!hasEnoughBalance &&
+ !isFetchingBalance &&
!isLoading &&
!isCreatingCharge &&
!isFetchingCharge &&
@@ -189,6 +192,7 @@ export function useSemanticRequestFlow() {
isLoggedIn,
amount,
hasEnoughBalance,
+ isFetchingBalance,
isLoading,
isCreatingCharge,
isFetchingCharge,
diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx
index 644937bc8..62656a627 100644
--- a/src/features/payments/shared/components/SendWithPeanutCta.tsx
+++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx
@@ -44,10 +44,15 @@ export default function SendWithPeanutCta({
const { user, isFetchingUser } = useAuth()
const isLoggedIn = !!user?.user?.userId
+ // assume logged in while fetching to prevent "Join Peanut" flash
+ const showAsLoggedIn = isFetchingUser || isLoggedIn
const handleClick = (e: React.MouseEvent) => {
+ // don't act while auth is still resolving
+ if (isFetchingUser) return
+
// if auth is required and user is not logged in, redirect to login
- if (requiresAuth && !user?.user?.userId && !isFetchingUser) {
+ if (requiresAuth && !isLoggedIn) {
saveRedirectUrl()
router.push('/setup')
return
@@ -66,14 +71,14 @@ export default function SendWithPeanutCta({
}
const icon = useMemo((): IconName | undefined => {
- if (!isLoggedIn) {
+ if (!showAsLoggedIn) {
return undefined
}
if (insufficientBalance) {
return 'arrow-down'
}
return 'arrow-up-right'
- }, [isLoggedIn, insufficientBalance])
+ }, [showAsLoggedIn, insufficientBalance])
const peanutLogo = useMemo((): React.ReactNode => {
return (
@@ -94,7 +99,7 @@ export default function SendWithPeanutCta({
onClick={handleClick}
{...props}
>
- {!isLoggedIn ? (
+ {!showAsLoggedIn ? (
Join
{peanutLogo}
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/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts
new file mode 100644
index 000000000..46f8b86e5
--- /dev/null
+++ b/src/hooks/useBridgeTosStatus.ts
@@ -0,0 +1,17 @@
+import { useMemo } from 'react'
+import { useUserStore } from '@/redux/hooks'
+import { type IUserRail } from '@/interfaces'
+
+// derives bridge ToS status from the user's rails array
+export const useBridgeTosStatus = () => {
+ const { user } = useUserStore()
+
+ return useMemo(() => {
+ const rails: IUserRail[] = user?.rails ?? []
+ const bridgeRails = rails.filter((r) => r.rail.provider.code === 'BRIDGE')
+ const needsBridgeTos = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION')
+ const isBridgeFullyEnabled = bridgeRails.length > 0 && bridgeRails.every((r) => r.status === 'ENABLED')
+
+ return { needsBridgeTos, isBridgeFullyEnabled, bridgeRails }
+ }, [user?.rails])
+}
diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts
index f88360fad..37ac606ca 100644
--- a/src/hooks/useCreateOnramp.ts
+++ b/src/hooks/useCreateOnramp.ts
@@ -67,15 +67,19 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => {
})
if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`)
+ // parse error body from backend to get specific message
+ let errorMessage = 'Failed to create bank transfer. Please try again or contact support.'
+ setError(errorMessage)
+ throw new Error(errorMessage)
}
const onrampData = await response.json()
return onrampData
- } catch (error) {
- console.error('Error creating onramp:', error)
- setError('Failed to create bank transfer. Please try again or contact support.')
- throw error
+ } catch (err) {
+ console.error('Error creating onramp:', err)
+ // only set generic fallback if no specific error was already set by the !response.ok block
+ setError((prev) => prev ?? 'Failed to create bank transfer. Please try again or contact support.')
+ throw err
} finally {
setIsLoading(false)
}
diff --git a/src/hooks/useDetermineBankClaimType.ts b/src/hooks/useDetermineBankClaimType.ts
index 0d73234cb..69bef4914 100644
--- a/src/hooks/useDetermineBankClaimType.ts
+++ b/src/hooks/useDetermineBankClaimType.ts
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/authContext'
import { useClaimBankFlow } from '@/context/ClaimBankFlowContext'
import { useEffect, useState } from 'react'
import useKycStatus from './useKycStatus'
+import { isUserKycVerified } from '@/constants/kyc.consts'
export enum BankClaimType {
GuestBankClaim = 'guest-bank-claim',
@@ -23,12 +24,12 @@ export function useDetermineBankClaimType(senderUserId: string): {
const { user } = useAuth()
const [claimType, setClaimType] = useState(BankClaimType.ReceiverKycNeeded)
const { setSenderDetails } = useClaimBankFlow()
- const { isUserBridgeKycApproved } = useKycStatus()
+ const { isUserKycApproved } = useKycStatus()
useEffect(() => {
const determineBankClaimType = async () => {
// check if receiver (logged in user) exists and is KYC approved
- const receiverKycApproved = isUserBridgeKycApproved
+ const receiverKycApproved = isUserKycApproved
if (receiverKycApproved) {
// condition 1: Receiver is KYC approved → UserBankClaim
@@ -48,7 +49,7 @@ export function useDetermineBankClaimType(senderUserId: string): {
try {
const senderDetails = await getUserById(senderUserId)
- const senderKycApproved = senderDetails?.bridgeKycStatus === 'approved'
+ const senderKycApproved = isUserKycVerified(senderDetails)
if (senderKycApproved) {
// condition 3: Receiver not KYC approved BUT sender is → GuestBankClaim
diff --git a/src/hooks/useDetermineBankRequestType.ts b/src/hooks/useDetermineBankRequestType.ts
index 5f6583fee..4e84b763c 100644
--- a/src/hooks/useDetermineBankRequestType.ts
+++ b/src/hooks/useDetermineBankRequestType.ts
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/authContext'
import { useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext'
import { useEffect, useState } from 'react'
import useKycStatus from './useKycStatus'
+import { isUserKycVerified } from '@/constants/kyc.consts'
export enum BankRequestType {
GuestBankRequest = 'guest-bank-request',
@@ -23,11 +24,11 @@ export function useDetermineBankRequestType(requesterUserId: string): {
const { user } = useAuth()
const [requestType, setRequestType] = useState(BankRequestType.PayerKycNeeded)
const { setRequesterDetails } = useRequestFulfillmentFlow()
- const { isUserBridgeKycApproved } = useKycStatus()
+ const { isUserKycApproved } = useKycStatus()
useEffect(() => {
const determineBankRequestType = async () => {
- const payerKycApproved = isUserBridgeKycApproved
+ const payerKycApproved = isUserKycApproved
if (payerKycApproved) {
setRequestType(BankRequestType.UserBankRequest)
@@ -45,7 +46,7 @@ export function useDetermineBankRequestType(requesterUserId: string): {
try {
const requesterDetails = await getUserById(requesterUserId)
- const requesterKycApproved = requesterDetails?.bridgeKycStatus === 'approved'
+ const requesterKycApproved = isUserKycVerified(requesterDetails)
if (requesterKycApproved) {
setRequesterDetails(requesterDetails)
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index dc408c42c..a81020025 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -12,6 +12,7 @@ import { DeviceType, useDeviceType } from './useGetDeviceType'
import { usePWAStatus } from './usePWAStatus'
import { useGeoLocation } from './useGeoLocation'
import { useCardPioneerInfo } from './useCardPioneerInfo'
+import { useBridgeTosStatus } from './useBridgeTosStatus'
import { STAR_STRAIGHT_ICON } from '@/assets'
import underMaintenanceConfig from '@/config/underMaintenance.config'
@@ -50,6 +51,8 @@ export const useHomeCarouselCTAs = () => {
hasPurchased: hasCardPioneerPurchased,
isLoading: isCardPioneerLoading,
} = useCardPioneerInfo()
+ const { needsBridgeTos } = useBridgeTosStatus()
+ const [showBridgeTos, setShowBridgeTos] = useState(false)
const generateCarouselCTAs = useCallback(() => {
const _carouselCTAs: CarouselCTA[] = []
@@ -58,6 +61,18 @@ export const useHomeCarouselCTAs = () => {
const hasKycApproval = isUserKycApproved || isUserMantecaKycApproved
const isLatamUser = userCountryCode === 'AR' || userCountryCode === 'BR'
+ // Bridge ToS acceptance — must be first CTA when user has pending ToS
+ if (needsBridgeTos) {
+ _carouselCTAs.push({
+ id: 'bridge-tos',
+ title: 'Accept terms of service',
+ description: 'Required to enable bank transfers',
+ icon: 'alert',
+ iconContainerClassName: 'bg-yellow-1',
+ onClick: () => setShowBridgeTos(true),
+ })
+ }
+
// Card Pioneer CTA - show to all users who haven't purchased yet
// Eligibility check happens during the flow (geo screen)
// Only show when we know for sure they haven't purchased (not while loading)
@@ -215,6 +230,7 @@ export const useHomeCarouselCTAs = () => {
isCardPioneerEligible,
hasCardPioneerPurchased,
isCardPioneerLoading,
+ needsBridgeTos,
])
useEffect(() => {
@@ -226,5 +242,5 @@ export const useHomeCarouselCTAs = () => {
generateCarouselCTAs()
}, [user, generateCarouselCTAs, isPermissionGranted])
- return { carouselCTAs, setCarouselCTAs }
+ return { carouselCTAs, setCarouselCTAs, showBridgeTos, setShowBridgeTos }
}
diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx
index 228722ac6..e06a3bb41 100644
--- a/src/hooks/useIdentityVerification.tsx
+++ b/src/hooks/useIdentityVerification.tsx
@@ -1,10 +1,12 @@
import { EUROPE_GLOBE_ICON, LATAM_GLOBE_ICON, NORTH_AMERICA_GLOBE_ICON, REST_OF_WORLD_GLOBE_ICON } from '@/assets'
import type { StaticImageData } from 'next/image'
import useKycStatus from './useKycStatus'
+import useUnifiedKycStatus from './useUnifiedKycStatus'
import { useMemo, useCallback } from 'react'
import { useAuth } from '@/context/authContext'
import { MantecaKycStatus } from '@/interfaces'
import { BRIDGE_ALPHA3_TO_ALPHA2, MantecaSupportedExchanges, countryData } from '@/components/AddMoney/consts'
+import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
import React from 'react'
/** Represents a geographic region with its display information */
@@ -75,6 +77,14 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [
},
]
+// precompute bridge alpha2 values for O(1) lookup
+const BRIDGE_ALPHA2_SET = new Set(Object.values(BRIDGE_ALPHA3_TO_ALPHA2))
+
+/** maps a region path to the sumsub kyc template intent */
+export const getRegionIntent = (regionPath: string): KYCRegionIntent => {
+ return regionPath === 'latam' ? 'LATAM' : 'STANDARD'
+}
+
/**
* Hook for managing identity verification (KYC) status and region access.
*
@@ -96,7 +106,8 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [
*/
export const useIdentityVerification = () => {
const { user } = useAuth()
- const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus()
+ const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus()
+ const { sumsubVerificationRegionIntent } = useUnifiedKycStatus()
/**
* Check if a country is supported by Manteca (LATAM countries).
@@ -149,9 +160,37 @@ export const useIdentityVerification = () => {
const { lockedRegions, unlockedRegions } = useMemo(() => {
const isBridgeApproved = isUserBridgeKycApproved
const isMantecaApproved = isUserMantecaKycApproved
+ const isSumsubApproved = isUserSumsubKycApproved
+
+ // check if a provider's rails are in a functional state (not pending/failed)
+ const hasProviderAccess = (providerCode: string) => {
+ const providerRails = user?.rails?.filter((r) => r.rail.provider.code === providerCode) ?? []
+ if (providerRails.length === 0) return false
+ return providerRails.some(
+ (r) =>
+ r.status === 'ENABLED' ||
+ r.status === 'REQUIRES_INFORMATION' ||
+ r.status === 'REQUIRES_EXTRA_INFORMATION'
+ )
+ }
- // Helper to check if a region should be unlocked
+ // helper to check if a region should be unlocked
const isRegionUnlocked = (regionName: string) => {
+ // sumsub approval scoped by the regionIntent used during verification.
+ // 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions.
+ // rest of world is always unlocked with any sumsub approval (crypto features).
+ // provider-specific regions require the provider rails to be functional
+ // (not still PENDING from submission or FAILED).
+ if (isSumsubApproved) {
+ if (regionName === 'Rest of the world') return true
+ if (sumsubVerificationRegionIntent === 'LATAM') {
+ // LATAM is always unlocked for LATAM-intent sumsub users
+ // (QR payments work without manteca rails via superuser fallback)
+ if (MANTECA_SUPPORTED_REGIONS.includes(regionName)) return true
+ return false
+ }
+ return hasProviderAccess('BRIDGE') && BRIDGE_SUPPORTED_REGIONS.includes(regionName)
+ }
return (
(isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) ||
(isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName))
@@ -161,9 +200,9 @@ export const useIdentityVerification = () => {
const unlocked = SUPPORTED_REGIONS.filter((region) => isRegionUnlocked(region.name))
const locked = SUPPORTED_REGIONS.filter((region) => !isRegionUnlocked(region.name))
- // Bridge users get QR payment access in Argentina & Brazil
- // even without full Manteca KYC (which unlocks bank transfers too)
- if (isBridgeApproved && !isMantecaApproved) {
+ // bridge users get qr payment access in argentina & brazil
+ // even without full manteca kyc (which unlocks bank transfers too)
+ if (isBridgeApproved && !isMantecaApproved && !isSumsubApproved) {
unlocked.push(...MANTECA_QR_ONLY_REGIONS, ...BRIDGE_SUPPORTED_LATAM_COUNTRIES)
}
@@ -171,7 +210,13 @@ export const useIdentityVerification = () => {
lockedRegions: locked,
unlockedRegions: unlocked,
}
- }, [isUserBridgeKycApproved, isUserMantecaKycApproved])
+ }, [
+ isUserBridgeKycApproved,
+ isUserMantecaKycApproved,
+ isUserSumsubKycApproved,
+ sumsubVerificationRegionIntent,
+ user?.rails,
+ ])
/**
* Check if a region is already unlocked by comparing region paths.
@@ -251,12 +296,7 @@ export const useIdentityVerification = () => {
const isBridgeSupportedCountry = useCallback((code: string) => {
const upper = code.toUpperCase()
- return (
- upper === 'US' ||
- upper === 'MX' ||
- Object.keys(BRIDGE_ALPHA3_TO_ALPHA2).includes(upper) ||
- Object.values(BRIDGE_ALPHA3_TO_ALPHA2).includes(upper)
- )
+ return upper === 'US' || upper === 'MX' || upper in BRIDGE_ALPHA3_TO_ALPHA2 || BRIDGE_ALPHA2_SET.has(upper)
}, [])
return {
diff --git a/src/hooks/useKycStatus.tsx b/src/hooks/useKycStatus.tsx
index 0b6eb9c5f..36b2909f3 100644
--- a/src/hooks/useKycStatus.tsx
+++ b/src/hooks/useKycStatus.tsx
@@ -1,36 +1,20 @@
'use client'
-import { useAuth } from '@/context/authContext'
-import { MantecaKycStatus } from '@/interfaces'
-import { useMemo } from 'react'
+import useUnifiedKycStatus from './useUnifiedKycStatus'
/**
- * Used to get the user's KYC status for all providers - currently only bridge and manteca
- * NOTE: This hook can be extended to support more providers in the future based on requirements
- * @returns {object} An object with the user's KYC status for all providers and a combined status for all providers, if user is verified for any provider, return true
+ * thin wrapper around useUnifiedKycStatus for backward compatibility.
+ * existing consumers keep the same api shape.
*/
export default function useKycStatus() {
- const { user } = useAuth()
+ const { isBridgeApproved, isMantecaApproved, isSumsubApproved, isKycApproved, isBridgeUnderReview } =
+ useUnifiedKycStatus()
- const isUserBridgeKycApproved = useMemo(() => user?.user.bridgeKycStatus === 'approved', [user])
-
- const isUserMantecaKycApproved = useMemo(
- () =>
- user?.user.kycVerifications?.some((verification) => verification.status === MantecaKycStatus.ACTIVE) ??
- false,
- [user]
- )
-
- const isUserKycApproved = useMemo(
- () => isUserBridgeKycApproved || isUserMantecaKycApproved,
- [isUserBridgeKycApproved, isUserMantecaKycApproved]
- )
-
- const isUserBridgeKycUnderReview = useMemo(
- // Bridge kyc status is incomplete/under_review when user has started the kyc process
- () => user?.user.bridgeKycStatus === 'under_review' || user?.user.bridgeKycStatus === 'incomplete',
- [user]
- )
-
- return { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserKycApproved, isUserBridgeKycUnderReview }
+ return {
+ isUserBridgeKycApproved: isBridgeApproved,
+ isUserMantecaKycApproved: isMantecaApproved,
+ isUserSumsubKycApproved: isSumsubApproved,
+ isUserKycApproved: isKycApproved,
+ isUserBridgeKycUnderReview: isBridgeUnderReview,
+ }
}
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..d7d151299
--- /dev/null
+++ b/src/hooks/useMultiPhaseKycFlow.ts
@@ -0,0 +1,305 @@
+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
+
+/**
+ * confirms bridge ToS acceptance (with one retry) then polls fetchUser
+ * until bridge rails leave REQUIRES_INFORMATION. max 3 attempts × 2s.
+ */
+export async function confirmBridgeTosAndAwaitRails(fetchUser: () => Promise) {
+ const result = await confirmBridgeTos()
+ if (!result.data?.accepted) {
+ await new Promise((resolve) => setTimeout(resolve, 2000))
+ await confirmBridgeTos()
+ }
+
+ for (let i = 0; i < 3; i++) {
+ const updatedUser = await fetchUser()
+ const stillNeedsTos = (updatedUser?.rails ?? []).some(
+ (r: any) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION'
+ )
+ if (!stillNeedsTos) break
+ if (i < 2) await new Promise((resolve) => setTimeout(resolve, 2000))
+ }
+}
+
+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') {
+ // show loading state while confirming + polling
+ setModalPhase('preparing')
+ await confirmBridgeTosAndAwaitRails(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
+
+ const isMultiLevel = regionIntent === 'LATAM'
+
+ return {
+ // initiation
+ handleInitiateKyc,
+ isLoading,
+ error,
+ liveKycStatus,
+
+ // SDK wrapper
+ showWrapper,
+ accessToken,
+ handleSdkClose: handleClose,
+ handleSdkComplete,
+ refreshToken,
+ isMultiLevel,
+
+ // multi-phase modal
+ isModalOpen,
+ modalPhase,
+ handleModalClose,
+ handleAcceptTerms,
+ handleSkipTerms,
+ completeFlow,
+ tosError,
+ isLoadingTos,
+ preparingTimedOut,
+
+ // ToS iframe
+ tosLink,
+ showTosIframe,
+ handleTosIframeClose,
+ }
+}
diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts
index c025d3561..747ef0e78 100644
--- a/src/hooks/useQrKycGate.ts
+++ b/src/hooks/useQrKycGate.ts
@@ -3,7 +3,7 @@
import { useCallback, useState, useEffect, useRef } from 'react'
import { useAuth } from '@/context/authContext'
import { MantecaKycStatus } from '@/interfaces'
-import { getBridgeCustomerCountry } from '@/app/actions/bridge/get-customer'
+import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts'
export enum QrKycState {
LOADING = 'loading',
@@ -61,6 +61,16 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null):
return
}
+ // sumsub approved users (including foreign users) can proceed to qr pay.
+ // note: backend enforces per-rail access separately — frontend gate only checks identity verification.
+ const hasSumsubApproved = currentUser.kycVerifications?.some(
+ (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status)
+ )
+ if (hasSumsubApproved) {
+ setKycGateState(QrKycState.PROCEED_TO_PAY)
+ return
+ }
+
const mantecaKycs = currentUser.kycVerifications?.filter((v) => v.provider === 'MANTECA') ?? []
const hasAnyMantecaKyc = mantecaKycs.length > 0
@@ -73,18 +83,8 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null):
return
}
- if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) {
- try {
- const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId)
- // if (countryCode && countryCode.toUpperCase() === 'AR') {
- if (false) {
- } else {
- setKycGateState(QrKycState.PROCEED_TO_PAY)
- }
- } catch {
- // fail to require identity verification to avoid blocking pay due to rare outages
- setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION)
- }
+ if (currentUser.bridgeKycStatus === 'approved') {
+ setKycGateState(QrKycState.PROCEED_TO_PAY)
return
}
@@ -100,6 +100,15 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null):
return
}
+ // sumsub verification in progress
+ const hasSumsubInProgress = currentUser.kycVerifications?.some(
+ (v) => v.provider === 'SUMSUB' && isSumsubStatusInProgress(v.status)
+ )
+ if (hasSumsubInProgress) {
+ setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS)
+ return
+ }
+
setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION)
}, [user?.user, isFetchingUser, paymentProcessor, fetchUser])
diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts
new file mode 100644
index 000000000..10d3588ad
--- /dev/null
+++ b/src/hooks/useRailStatusTracking.ts
@@ -0,0 +1,170 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
+import { useUserStore } from '@/redux/hooks'
+import { useAuth } from '@/context/authContext'
+import { useWebSocket } from '@/hooks/useWebSocket'
+import { type IUserRail, type ProviderDisplayStatus, type ProviderStatus } from '@/interfaces'
+import { type RailStatusUpdate } from '@/services/websocket'
+
+interface RailStatusTrackingResult {
+ providers: ProviderStatus[]
+ allSettled: boolean
+ needsBridgeTos: boolean
+ needsAdditionalDocs: boolean
+ startTracking: () => void
+ stopTracking: () => void
+}
+
+const POLL_INTERVAL_MS = 4000
+
+// human-readable labels for provider groups
+const PROVIDER_LABELS: Record = {
+ BRIDGE: 'Bank transfers',
+ MANTECA: 'QR payments and bank transfers',
+}
+
+function deriveProviderDisplayName(providerCode: string, rails: IUserRail[]): string {
+ const base = PROVIDER_LABELS[providerCode] ?? providerCode
+ // add country context from rail methods
+ const countries = [...new Set(rails.map((r) => r.rail.method.country).filter(Boolean))]
+ if (countries.length > 0) {
+ return `${base} (${countries.join(', ')})`
+ }
+ return base
+}
+
+function deriveStatus(rail: IUserRail): ProviderDisplayStatus {
+ switch (rail.status) {
+ case 'ENABLED':
+ return 'enabled'
+ case 'REQUIRES_EXTRA_INFORMATION':
+ return 'requires_documents'
+ case 'REQUIRES_INFORMATION':
+ return 'requires_tos'
+ case 'FAILED':
+ case 'REJECTED':
+ return 'failed'
+ case 'PENDING':
+ default:
+ return 'setting_up'
+ }
+}
+
+// pick the "most advanced" status for a provider group
+function deriveGroupStatus(rails: IUserRail[]): ProviderDisplayStatus {
+ const statuses = rails.map(deriveStatus)
+ // priority: requires_documents > requires_tos > enabled > failed > setting_up
+ if (statuses.includes('requires_documents')) return 'requires_documents'
+ if (statuses.includes('requires_tos')) return 'requires_tos'
+ if (statuses.includes('enabled')) return 'enabled'
+ if (statuses.includes('failed')) return 'failed'
+ return 'setting_up'
+}
+
+export const useRailStatusTracking = (): RailStatusTrackingResult => {
+ const { user } = useUserStore()
+ const { fetchUser } = useAuth()
+ const [isTracking, setIsTracking] = useState(false)
+ const pollTimerRef = useRef(null)
+ const isMountedRef = useRef(true)
+
+ // listen for rail status WebSocket events
+ useWebSocket({
+ username: user?.user.username ?? undefined,
+ autoConnect: isTracking,
+ onRailStatusUpdate: useCallback(
+ (_data: RailStatusUpdate) => {
+ // refetch user to get updated rails from server
+ if (isTracking) {
+ fetchUser()
+ }
+ },
+ [isTracking, fetchUser]
+ ),
+ })
+
+ // derive provider statuses from current rails
+ const providers = useMemo((): ProviderStatus[] => {
+ const rails: IUserRail[] = user?.rails ?? []
+ if (rails.length === 0) return []
+
+ // group by provider
+ const byProvider = new Map()
+ for (const rail of rails) {
+ const code = rail.rail.provider.code
+ const list = byProvider.get(code) ?? []
+ list.push(rail)
+ byProvider.set(code, list)
+ }
+
+ return Array.from(byProvider.entries()).map(([code, providerRails]) => ({
+ providerCode: code,
+ displayName: deriveProviderDisplayName(code, providerRails),
+ status: deriveGroupStatus(providerRails),
+ rails: providerRails,
+ }))
+ }, [user?.rails])
+
+ const allSettled = useMemo(() => {
+ if (providers.length === 0) return false
+ return providers.every((p) => p.status !== 'setting_up')
+ }, [providers])
+
+ const needsBridgeTos = useMemo(() => {
+ return providers.some((p) => p.providerCode === 'BRIDGE' && p.status === 'requires_tos')
+ }, [providers])
+
+ const needsAdditionalDocs = useMemo(() => {
+ return providers.some((p) => p.status === 'requires_documents')
+ }, [providers])
+
+ // stop polling when all settled
+ useEffect(() => {
+ if (allSettled && isTracking) {
+ if (pollTimerRef.current) {
+ clearInterval(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ }
+ }, [allSettled, isTracking])
+
+ const startTracking = useCallback(() => {
+ setIsTracking(true)
+
+ // start polling as fallback
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
+ pollTimerRef.current = setInterval(() => {
+ if (isMountedRef.current) {
+ fetchUser()
+ }
+ }, POLL_INTERVAL_MS)
+ }, [fetchUser])
+
+ const stopTracking = useCallback(() => {
+ setIsTracking(false)
+ if (pollTimerRef.current) {
+ clearInterval(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ }, [])
+
+ // cleanup on unmount
+ useEffect(() => {
+ isMountedRef.current = true
+ return () => {
+ isMountedRef.current = false
+ if (pollTimerRef.current) {
+ clearInterval(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ }
+ }, [])
+
+ return {
+ providers,
+ allSettled,
+ needsBridgeTos,
+ needsAdditionalDocs,
+ startTracking,
+ stopTracking,
+ }
+}
diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts
new file mode 100644
index 000000000..47566ba0b
--- /dev/null
+++ b/src/hooks/useSumsubKycFlow.ts
@@ -0,0 +1,234 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useRouter } from 'next/navigation'
+import { useWebSocket } from '@/hooks/useWebSocket'
+import { useUserStore } from '@/redux/hooks'
+import { initiateSumsubKyc } from '@/app/actions/sumsub'
+import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
+
+interface UseSumsubKycFlowOptions {
+ onKycSuccess?: () => void
+ onManualClose?: () => void
+ regionIntent?: KYCRegionIntent
+}
+
+export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseSumsubKycFlowOptions = {}) => {
+ const { user } = useUserStore()
+ const router = useRouter()
+
+ const [accessToken, setAccessToken] = useState(null)
+ const [showWrapper, setShowWrapper] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false)
+ const [liveKycStatus, setLiveKycStatus] = useState(undefined)
+ const [rejectLabels, setRejectLabels] = useState(undefined)
+ const prevStatusRef = useRef(liveKycStatus)
+ const showWrapperRef = useRef(showWrapper)
+ showWrapperRef.current = showWrapper
+ // tracks the effective region intent across initiate + refresh so the correct template is always used
+ 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
+ }, [regionIntent])
+
+ // listen for sumsub kyc status updates via websocket
+ useWebSocket({
+ username: user?.user.username ?? undefined,
+ autoConnect: true,
+ onSumsubKycStatusUpdate: (newStatus, newRejectLabels) => {
+ setLiveKycStatus(newStatus as SumsubKycStatus)
+ if (newRejectLabels) setRejectLabels(newRejectLabels)
+ },
+ })
+
+ // react to status transitions
+ useEffect(() => {
+ const prevStatus = prevStatusRef.current
+ prevStatusRef.current = liveKycStatus
+
+ if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') {
+ // if SDK is still open (LATAM multi-level), close it now —
+ // applicantWorkflowCompleted has fired, all levels are done.
+ if (showWrapperRef.current) {
+ setShowWrapper(false)
+ setIsVerificationProgressModalOpen(true)
+ userInitiatedRef.current = true
+ }
+ if (userInitiatedRef.current) {
+ onKycSuccess?.()
+ }
+ } else if (
+ liveKycStatus &&
+ liveKycStatus !== prevStatus &&
+ liveKycStatus !== 'APPROVED' &&
+ liveKycStatus !== 'PENDING'
+ ) {
+ // close modal for any non-success terminal state (REJECTED, ACTION_REQUIRED, FAILED, etc.)
+ setIsVerificationProgressModalOpen(false)
+ }
+ }, [liveKycStatus, onKycSuccess])
+
+ // fetch current status to recover from missed websocket events.
+ // skip when regionIntent is undefined to avoid creating an applicant with the wrong template
+ // (e.g. RegionsVerification mounts with no region selected yet).
+ useEffect(() => {
+ if (!regionIntent) return
+
+ const fetchCurrentStatus = async () => {
+ try {
+ const response = await initiateSumsubKyc({ regionIntent })
+ if (response.data?.status) {
+ setLiveKycStatus(response.data.status)
+ }
+ } catch {
+ // silent failure - we just show the user an error when they try to initiate the kyc flow if the api call is failing
+ }
+ }
+
+ 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)
+
+ try {
+ const response = await initiateSumsubKyc({
+ regionIntent: overrideIntent ?? regionIntent,
+ levelName,
+ })
+
+ if (response.error) {
+ setError(response.error)
+ return
+ }
+
+ // sync status from api response, but skip when a token is returned
+ // alongside APPROVED — that means the SDK should open (e.g. additional-docs flow),
+ // not that kyc is finished. syncing APPROVED here would trigger the useEffect
+ // which fires onKycSuccess and closes everything before the SDK opens.
+ if (response.data?.status && !(response.data.status === 'APPROVED' && response.data.token)) {
+ setLiveKycStatus(response.data.status)
+ }
+
+ // update effective intent + level for token refresh
+ const effectiveIntent = overrideIntent ?? regionIntent
+ if (effectiveIntent) regionIntentRef.current = effectiveIntent
+ levelNameRef.current = levelName
+
+ // if already approved and no token returned, kyc is done.
+ // set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time.
+ // when a token IS returned (e.g. additional-docs flow), we still need to show the SDK.
+ if (response.data?.status === 'APPROVED' && !response.data?.token) {
+ prevStatusRef.current = 'APPROVED'
+ onKycSuccess?.()
+ return
+ }
+
+ if (response.data?.token) {
+ setAccessToken(response.data.token)
+ setShowWrapper(true)
+ } else {
+ setError('Could not initiate verification. Please try again.')
+ }
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : 'An unexpected error occurred'
+ setError(message)
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [regionIntent, onKycSuccess]
+ )
+
+ // called when sdk signals applicant submitted
+ const handleSdkComplete = useCallback(() => {
+ userInitiatedRef.current = true
+ setShowWrapper(false)
+ setIsVerificationProgressModalOpen(true)
+ }, [])
+
+ // called when user manually closes the sdk modal
+ const handleClose = useCallback(() => {
+ setShowWrapper(false)
+ onManualClose?.()
+ }, [onManualClose])
+
+ // token refresh function passed to the sdk for when the token expires.
+ // uses regionIntentRef + levelNameRef so refresh always matches the template used during initiation.
+ const refreshToken = useCallback(async (): Promise => {
+ const response = await initiateSumsubKyc({
+ regionIntent: regionIntentRef.current,
+ levelName: levelNameRef.current,
+ })
+
+ if (response.error || !response.data?.token) {
+ throw new Error(response.error || 'Failed to refresh token')
+ }
+
+ setAccessToken(response.data.token)
+ return response.data.token
+ }, [])
+
+ const closeVerificationProgressModal = useCallback(() => {
+ setIsVerificationProgressModalOpen(false)
+ }, [])
+
+ const closeVerificationModalAndGoHome = useCallback(() => {
+ setIsVerificationProgressModalOpen(false)
+ router.push('/home')
+ }, [router])
+
+ const resetError = useCallback(() => {
+ setError(null)
+ }, [])
+
+ return {
+ isLoading,
+ error,
+ showWrapper,
+ accessToken,
+ liveKycStatus,
+ rejectLabels,
+ handleInitiateKyc,
+ handleSdkComplete,
+ handleClose,
+ refreshToken,
+ isVerificationProgressModalOpen,
+ closeVerificationProgressModal,
+ closeVerificationModalAndGoHome,
+ resetError,
+ }
+}
diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts
new file mode 100644
index 000000000..bc5b410ae
--- /dev/null
+++ b/src/hooks/useUnifiedKycStatus.ts
@@ -0,0 +1,88 @@
+'use client'
+
+import { useAuth } from '@/context/authContext'
+import { MantecaKycStatus } from '@/interfaces'
+import { useMemo } from 'react'
+import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
+import { isSumsubStatusInProgress } from '@/constants/kyc.consts'
+
+/**
+ * single source of truth for kyc status across all providers (bridge, manteca, sumsub).
+ * all kyc status checks should go through this hook.
+ */
+export default function useUnifiedKycStatus() {
+ const { user } = useAuth()
+
+ const isBridgeApproved = useMemo(() => user?.user.bridgeKycStatus === 'approved', [user])
+
+ const isMantecaApproved = useMemo(
+ () =>
+ user?.user.kycVerifications?.some(
+ (v) => v.provider === 'MANTECA' && v.status === MantecaKycStatus.ACTIVE
+ ) ?? false,
+ [user]
+ )
+
+ // pick the most recently updated sumsub verification in case of retries
+ const sumsubVerification = useMemo(
+ () =>
+ user?.user.kycVerifications
+ ?.filter((v) => v.provider === 'SUMSUB')
+ .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] ?? null,
+ [user]
+ )
+
+ const isSumsubApproved = useMemo(() => sumsubVerification?.status === 'APPROVED', [sumsubVerification])
+
+ const sumsubStatus = useMemo(() => (sumsubVerification?.status as SumsubKycStatus) ?? null, [sumsubVerification])
+
+ const sumsubRejectLabels = useMemo(() => sumsubVerification?.rejectLabels ?? null, [sumsubVerification])
+
+ const sumsubRejectType = useMemo(
+ () => (sumsubVerification?.rejectType as 'RETRY' | 'FINAL' | null) ?? null,
+ [sumsubVerification]
+ )
+
+ // region intent used during the sumsub verification (stored in metadata by initiate-kyc)
+ const sumsubVerificationRegionIntent = useMemo(
+ () => (sumsubVerification?.metadata?.regionIntent as string) ?? null,
+ [sumsubVerification]
+ )
+
+ const isKycApproved = useMemo(
+ () => isBridgeApproved || isMantecaApproved || isSumsubApproved,
+ [isBridgeApproved, isMantecaApproved, isSumsubApproved]
+ )
+
+ const isBridgeUnderReview = useMemo(
+ () => user?.user.bridgeKycStatus === 'under_review' || user?.user.bridgeKycStatus === 'incomplete',
+ [user]
+ )
+
+ const isSumsubActionRequired = useMemo(() => sumsubStatus === 'ACTION_REQUIRED', [sumsubStatus])
+
+ const isSumsubInProgress = useMemo(() => isSumsubStatusInProgress(sumsubStatus), [sumsubStatus])
+
+ const isKycInProgress = useMemo(
+ () => isBridgeUnderReview || isSumsubInProgress,
+ [isBridgeUnderReview, isSumsubInProgress]
+ )
+
+ return {
+ // combined
+ isKycApproved,
+ isKycInProgress,
+ // bridge
+ isBridgeApproved,
+ isBridgeUnderReview,
+ // manteca
+ isMantecaApproved,
+ // sumsub
+ isSumsubApproved,
+ isSumsubActionRequired,
+ sumsubStatus,
+ sumsubRejectLabels,
+ sumsubRejectType,
+ sumsubVerificationRegionIntent,
+ }
+}
diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts
index 685adee39..94d435cdc 100644
--- a/src/hooks/useWebSocket.ts
+++ b/src/hooks/useWebSocket.ts
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react'
-import { PeanutWebSocket, getWebSocketInstance, type PendingPerk } from '@/services/websocket'
+import { PeanutWebSocket, getWebSocketInstance, type PendingPerk, type RailStatusUpdate } from '@/services/websocket'
import { type HistoryEntry } from './useTransactionHistory'
type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
@@ -10,8 +10,10 @@ interface UseWebSocketOptions {
onHistoryEntry?: (entry: HistoryEntry) => void
onKycStatusUpdate?: (status: string) => void
onMantecaKycStatusUpdate?: (status: string) => void
+ onSumsubKycStatusUpdate?: (status: string, rejectLabels?: string[]) => void
onTosUpdate?: (data: { accepted: boolean }) => void
onPendingPerk?: (perk: PendingPerk) => void
+ onRailStatusUpdate?: (data: RailStatusUpdate) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: Event) => void
@@ -24,8 +26,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onHistoryEntry,
onKycStatusUpdate,
onMantecaKycStatusUpdate,
+ onSumsubKycStatusUpdate,
onTosUpdate,
onPendingPerk,
+ onRailStatusUpdate,
onConnect,
onDisconnect,
onError,
@@ -39,8 +43,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onHistoryEntry,
onKycStatusUpdate,
onMantecaKycStatusUpdate,
+ onSumsubKycStatusUpdate,
onTosUpdate,
onPendingPerk,
+ onRailStatusUpdate,
onConnect,
onDisconnect,
onError,
@@ -52,8 +58,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onHistoryEntry,
onKycStatusUpdate,
onMantecaKycStatusUpdate,
+ onSumsubKycStatusUpdate,
onTosUpdate,
onPendingPerk,
+ onRailStatusUpdate,
onConnect,
onDisconnect,
onError,
@@ -62,8 +70,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
onHistoryEntry,
onKycStatusUpdate,
onMantecaKycStatusUpdate,
+ onSumsubKycStatusUpdate,
onTosUpdate,
onPendingPerk,
+ onRailStatusUpdate,
onConnect,
onDisconnect,
onError,
@@ -154,6 +164,14 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
}
}
+ const handleSumsubKycStatusUpdate = (data: { status: string; rejectLabels?: string[] }) => {
+ if (callbacksRef.current.onSumsubKycStatusUpdate) {
+ callbacksRef.current.onSumsubKycStatusUpdate(data.status, data.rejectLabels)
+ } else {
+ console.log(`[WebSocket] No onSumsubKycStatusUpdate callback registered for user: ${username}`)
+ }
+ }
+
const handleTosUpdate = (data: { status: string }) => {
if (callbacksRef.current.onTosUpdate) {
callbacksRef.current.onTosUpdate({ accepted: data.status === 'approved' })
@@ -168,6 +186,12 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
}
}
+ const handleRailStatusUpdate = (data: RailStatusUpdate) => {
+ if (callbacksRef.current.onRailStatusUpdate) {
+ callbacksRef.current.onRailStatusUpdate(data)
+ }
+ }
+
// Register event handlers
ws.on('connect', handleConnect)
ws.on('disconnect', handleDisconnect)
@@ -175,8 +199,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
ws.on('history_entry', handleHistoryEntry)
ws.on('kyc_status_update', handleKycStatusUpdate)
ws.on('manteca_kyc_status_update', handleMantecaKycStatusUpdate)
+ ws.on('sumsub_kyc_status_update', handleSumsubKycStatusUpdate)
ws.on('persona_tos_status_update', handleTosUpdate)
ws.on('pending_perk', handlePendingPerk)
+ ws.on('user_rail_status_changed', handleRailStatusUpdate)
// Auto-connect if enabled
if (autoConnect) {
@@ -191,8 +217,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
ws.off('history_entry', handleHistoryEntry)
ws.off('kyc_status_update', handleKycStatusUpdate)
ws.off('manteca_kyc_status_update', handleMantecaKycStatusUpdate)
+ ws.off('sumsub_kyc_status_update', handleSumsubKycStatusUpdate)
ws.off('persona_tos_status_update', handleTosUpdate)
ws.off('pending_perk', handlePendingPerk)
+ ws.off('user_rail_status_changed', handleRailStatusUpdate)
}
}, [autoConnect, connect, username])
diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts
index 79144b4cc..56965109c 100644
--- a/src/interfaces/interfaces.ts
+++ b/src/interfaces/interfaces.ts
@@ -1,7 +1,21 @@
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
+import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types'
export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username'
+// phases for the multi-phase kyc verification modal
+export type KycModalPhase = 'verifying' | 'preparing' | 'bridge_tos' | 'complete'
+
+// per-provider rail status for tracking after kyc approval
+export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'requires_documents' | 'enabled' | 'failed'
+
+export interface ProviderStatus {
+ providerCode: string
+ displayName: string
+ status: ProviderDisplayStatus
+ rails: IUserRail[]
+}
+
// Moved here from bridge-accounts.utils.ts to avoid circular dependency
export type BridgeKycStatus = 'not_started' | 'under_review' | 'approved' | 'rejected' | 'incomplete'
@@ -231,13 +245,17 @@ export enum MantecaKycStatus {
}
export interface IUserKycVerification {
- provider: 'MANTECA' | 'BRIDGE'
+ provider: 'MANTECA' | 'BRIDGE' | 'SUMSUB'
mantecaGeo?: string | null
bridgeGeo?: string | null
- status: MantecaKycStatus
+ status: MantecaKycStatus | SumsubKycStatus | string
approvedAt?: string | null
providerUserId?: string | null
providerRawStatus?: string | null
+ sumsubApplicantId?: string | null
+ rejectLabels?: string[] | null
+ rejectType?: 'RETRY' | 'FINAL' | null
+ metadata?: { regionIntent?: string; [key: string]: unknown } | null
createdAt: string
updatedAt: string
}
@@ -321,6 +339,26 @@ interface userInvites {
inviteeUsername: string
}
+export type UserRailStatus =
+ | 'PENDING'
+ | 'ENABLED'
+ | 'REQUIRES_INFORMATION'
+ | 'REQUIRES_EXTRA_INFORMATION'
+ | 'REJECTED'
+ | 'FAILED'
+
+export interface IUserRail {
+ id: string
+ railId: string
+ status: UserRailStatus
+ metadata?: { bridgeCustomerId?: string; additionalRequirements?: string[]; [key: string]: unknown } | null
+ rail: {
+ id: string
+ provider: { code: string; name: string }
+ method: { code: string; name: string; country: string; currency: string }
+ }
+}
+
export interface IUserProfile {
// OLD Points V1 fields removed - use pointsV2 in stats instead
// Points V2: Use stats.pointsV2.totalPoints, pointsV2.inviteCount, etc.
@@ -331,6 +369,7 @@ export interface IUserProfile {
totalPoints: number // Kept for backward compatibility - same as pointsV2.totalPoints
hasPwaInstalled: boolean
user: User
+ rails: IUserRail[]
invitesSent: userInvites[]
showEarlyUserModal: boolean
invitedBy: string | null // Username of the person who invited this user
diff --git a/src/services/websocket.ts b/src/services/websocket.ts
index b49418771..8c64ec8e0 100644
--- a/src/services/websocket.ts
+++ b/src/services/websocket.ts
@@ -1,7 +1,12 @@
import { type HistoryEntry } from '@/hooks/useTransactionHistory'
import { type PendingPerk } from '@/services/perks'
export type { PendingPerk }
-import { jsonStringify } from '@/utils/general.utils'
+
+export interface RailStatusUpdate {
+ railId: string
+ status: string
+ provider?: string
+}
export type WebSocketMessage = {
type:
@@ -10,9 +15,11 @@ export type WebSocketMessage = {
| 'history_entry'
| 'kyc_status_update'
| 'manteca_kyc_status_update'
+ | 'sumsub_kyc_status_update'
| 'persona_tos_status_update'
| 'pending_perk'
- data?: HistoryEntry | PendingPerk
+ | 'user_rail_status_changed'
+ data?: HistoryEntry | PendingPerk | RailStatusUpdate
}
export class PeanutWebSocket {
@@ -126,6 +133,12 @@ export class PeanutWebSocket {
}
break
+ case 'sumsub_kyc_status_update':
+ if (message.data && 'status' in (message.data as object)) {
+ this.emit('sumsub_kyc_status_update', message.data)
+ }
+ break
+
case 'persona_tos_status_update':
if (message.data && 'status' in (message.data as object)) {
this.emit('persona_tos_status_update', message.data)
@@ -138,6 +151,12 @@ export class PeanutWebSocket {
}
break
+ case 'user_rail_status_changed':
+ if (message.data && 'railId' in (message.data as object)) {
+ this.emit('user_rail_status_changed', message.data)
+ }
+ break
+
default:
// Handle other message types if needed
this.emit(message.type, message.data)
diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts
new file mode 100644
index 000000000..4d6b7c5c7
--- /dev/null
+++ b/src/types/sumsub-websdk.d.ts
@@ -0,0 +1,27 @@
+// type declarations for sumsub websdk loaded via CDN script
+// https://static.sumsub.com/idensic/static/sns-websdk-builder.js
+
+declare global {
+ interface SnsWebSdkInstance {
+ launch(container: HTMLElement): void
+ destroy(): void
+ }
+
+ interface SnsWebSdkBuilderChain {
+ withConf(conf: { lang?: string; theme?: string }): SnsWebSdkBuilderChain
+ withOptions(opts: { addViewportTag?: boolean; adaptIframeHeight?: boolean }): SnsWebSdkBuilderChain
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- sumsub sdk event handlers have varying untyped signatures
+ on(event: string, handler: (...args: any[]) => void): SnsWebSdkBuilderChain
+ build(): SnsWebSdkInstance
+ }
+
+ interface SnsWebSdkBuilder {
+ init(token: string, refreshCallback: () => Promise): SnsWebSdkBuilderChain
+ }
+
+ interface Window {
+ snsWebSdk: SnsWebSdkBuilder
+ }
+}
+
+export {}
diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts
index b9f10137a..b979a910c 100644
--- a/src/utils/__tests__/withdraw.utils.test.ts
+++ b/src/utils/__tests__/withdraw.utils.test.ts
@@ -276,5 +276,38 @@ describe('Withdraw Utilities', () => {
}
})
})
+
+ describe('Pasted values with whitespace (stripped before validation)', () => {
+ // The withdraw page strips all whitespace from non-QR PIX keys before validation.
+ // EMVCo QR codes only get trimmed (internal whitespace is preserved).
+ const normalizePixInput = (value: string) =>
+ isPixEmvcoQr(value.trim()) ? value.trim() : value.replace(/\s/g, '')
+
+ it.each([
+ { raw: ' +5511999999999 ', desc: 'phone with leading/trailing spaces' },
+ { raw: '+55 11 999999999', desc: 'phone with internal spaces' },
+ { raw: ' 5511999999999 ', desc: 'phone without + with spaces' },
+ { raw: '123 456 789 01', desc: 'CPF with spaces' },
+ { raw: ' 12345678901234 ', desc: 'CNPJ with spaces' },
+ { raw: ' user@example.com ', desc: 'email with spaces' },
+ { raw: ' 123e4567-e89b-12d3-a456-426614174000 ', desc: 'UUID with spaces' },
+ ])('should accept $desc after whitespace stripping', ({ raw }) => {
+ const cleaned = normalizePixInput(raw)
+ const result = validatePixKey(cleaned)
+ expect(result.valid).toBe(true)
+ })
+
+ it('should preserve internal whitespace in EMVCo QR codes', () => {
+ const qrWithSpaces =
+ '00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D'
+ const padded = ` ${qrWithSpaces} `
+ const cleaned = normalizePixInput(padded)
+ // Internal whitespace in "Fulano de Tal" must survive
+ expect(cleaned).toBe(qrWithSpaces)
+ expect(cleaned).toContain('Fulano de Tal')
+ const result = validatePixKey(cleaned)
+ expect(result.valid).toBe(true)
+ })
+ })
})
})
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index 87ea6ce20..ebe0241ab 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -19,6 +19,7 @@ import { type ChargeEntry } from '@/services/services.types'
import { toWebAuthnKey } from '@zerodev/passkey-validator'
import { USER_OPERATION_REVERT_REASON_TOPIC } from '@/constants/zerodev.consts'
import { CHAIN_LOGOS, type ChainName } from '@/constants/rhino.consts'
+import { isUserKycVerified } from '@/constants/kyc.consts'
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
@@ -984,7 +985,7 @@ export const getContributorsFromCharge = (charges: ChargeEntry[]) => {
amount: charge.tokenAmount,
username,
fulfillmentPayment: charge.fulfillmentPayment,
- isUserVerified: successfulPayment?.payerAccount?.user?.bridgeKycStatus === 'approved',
+ isUserVerified: isUserKycVerified(successfulPayment?.payerAccount?.user),
isPeanutUser,
}
})
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
+}