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 3e773c247..58bcf7b69 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -9,9 +9,8 @@ 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, useState } from 'react' @@ -56,7 +55,6 @@ export default function OnrampBankPage() { const [showWarningModal, setShowWarningModal] = useState(false) const [showKycModal, setShowKycModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) - const [liveKycStatus, setLiveKycStatus] = useState(undefined) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() const { balance } = useWallet() @@ -91,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() @@ -203,10 +189,7 @@ export default function OnrampBankPage() { const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus - const isUserKycVerified = currentKycStatus === 'approved' - - if (!isUserKycVerified) { + if (!isUserKycApproved) { setShowKycModal(true) return } diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 2caf96b09..534e4aa1f 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -141,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]) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 0472cdffa..8ff2b9480 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -10,12 +10,10 @@ 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 } from '@/app/actions/users' -import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' -import { useWebSocket } from '@/hooks/useWebSocket' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { type Account } from '@/interfaces' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' @@ -64,28 +62,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { 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('-') @@ -100,14 +81,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { ): Promise<{ error?: string }> => { // re-fetch user to ensure we have the latest KYC status // (the multi-phase flow may have completed but websocket/state not yet propagated) - const freshUser = await fetchUser() - const currentKycStatus = freshUser?.user?.bridgeKycStatus || liveKycStatus || user?.user.bridgeKycStatus - const isUserKycVerified = currentKycStatus === 'approved' + await fetchUser() // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly // email and name are now collected by sumsub — no need to check them here - if (isUserKycVerified) { - const currentAccountIds = new Set((freshUser?.accounts ?? user?.accounts ?? []).map((acc) => acc.id)) + if (isUserKycApproved) { + const currentAccountIds = new Set((user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) if (result.error) { @@ -149,7 +128,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet // name and email are now collected by sumsub sdk — no need to save them beforehand - if (!isUserKycVerified) { + if (!isUserKycApproved) { await sumsubFlow.handleInitiateKyc('STANDARD') } diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 6d601ac8e..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' @@ -25,8 +25,6 @@ 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' @@ -96,28 +94,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { 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. diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 0cd6d7027..a1992a528 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -71,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 diff --git a/src/components/IdentityVerification/StartVerificationModal.tsx b/src/components/IdentityVerification/StartVerificationModal.tsx index 045760bf8..41e4a105b 100644 --- a/src/components/IdentityVerification/StartVerificationModal.tsx +++ b/src/components/IdentityVerification/StartVerificationModal.tsx @@ -6,40 +6,42 @@ import { Icon } from '../Global/Icons/Icon' import { type Region } from '@/hooks/useIdentityVerification' import React from 'react' +const QR_PAYMENTS = ( +

+ 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 const REGION_UNLOCK_ITEMS: Record> = { latam: [

Bank transfers to your own accounts in LATAM

, -

- QR Payments in Argentina and Brazil -

, - ], - europe: [ -

- Europe SEPA transfers (+30 countries) -

, -

- QR Payments in Argentina and Brazil -

, - ], - 'north-america': [ -

- United States ACH and Wire transfers -

, -

- Mexico SPEI transfers -

, -

- QR Payments in Argentina and Brazil -

, - ], - 'rest-of-the-world': [ -

- QR Payments in Argentina and Brazil -

, + 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

] diff --git a/src/components/Kyc/CountryFlagAndName.tsx b/src/components/Kyc/CountryFlagAndName.tsx index fec797e3c..3398a38bf 100644 --- a/src/components/Kyc/CountryFlagAndName.tsx +++ b/src/components/Kyc/CountryFlagAndName.tsx @@ -16,8 +16,8 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA icons={[ 'https://flagcdn.com/w160/us.png', 'https://flagcdn.com/w160/eu.png', - 'https://flagcdn.com/w160/mx.png', 'https://flagcdn.com/w160/gb.png', + 'https://flagcdn.com/w160/mx.png', ]} iconSize={80} imageClassName="h-5 w-5 min-h-5 min-w-5 rounded-full object-cover object-center shadow-sm" @@ -32,7 +32,7 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA loading="lazy" /> )} - {isBridgeRegion ? 'US/EU/MX/UK' : countryName} + {isBridgeRegion ? 'US/EU/UK/MX' : countryName} ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 45e04b036..93f2db865 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -71,6 +71,15 @@ export const KycStatusItem = ({ const finalBridgeKycStatus = wsBridgeKycStatus || bridgeKycStatus || user?.user?.bridgeKycStatus const kycStatus = verification ? verification.status : finalBridgeKycStatus + // 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 isApproved = isKycStatusApproved(kycStatus) const isPending = isKycStatusPending(kycStatus) const isRejected = isKycStatusFailed(kycStatus) @@ -80,13 +89,14 @@ export const KycStatusItem = ({ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { + if (hasBridgeDocsNeeded) return 'Action needed' if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' if (isPending) return 'Processing' - if (isApproved) return 'Completed' + if (isApproved) return 'Verified' if (isRejected) return 'Failed' return 'Unknown' - }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) + }, [hasBridgeDocsNeeded, isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) const title = useMemo(() => { if (region === 'LATAM') return 'LATAM verification' @@ -95,7 +105,7 @@ export const KycStatusItem = ({ // only hide for bridge's default "not_started" state. // if a verification record exists, the user has initiated KYC — show it. - if (!verification && isKycStatusNotStarted(kycStatus)) { + if (!verification && !hasBridgeDocsNeeded && isKycStatusNotStarted(kycStatus)) { return null } @@ -117,7 +127,7 @@ export const KycStatusItem = ({

{subtitle}

{ console.log('[sumsub] onApplicantSubmitted fired') stableOnComplete() @@ -103,6 +107,12 @@ export const SumsubKycWrapper = ({ 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 + } // auto-close when sumsub shows success screen if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') { stableOnComplete() diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx index c904bd12d..f2e996c21 100644 --- a/src/components/Kyc/modals/KycProcessingModal.tsx +++ b/src/components/Kyc/modals/KycProcessingModal.tsx @@ -12,7 +12,7 @@ export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps visible={visible} onClose={onClose} icon="clock" - iconContainerClassName="bg-purple-3" + iconContainerClassName="bg-yellow-1" title="Verification in progress" description="We're reviewing your identity. This usually takes less than a minute." ctas={[ diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..268ad9bfb 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -33,7 +33,7 @@ export const KycCompleted = ({ return (
- + diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx index 16407bcea..c9a751615 100644 --- a/src/components/Kyc/states/KycRequiresDocuments.tsx +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -1,5 +1,6 @@ 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. @@ -15,25 +16,28 @@ export const KycRequiresDocuments = ({ }) => { return (
- +
-

Your payment provider requires additional verification documents.

+

Our payment provider requires additional verification documents.

{requirements.length > 0 ? ( requirements.map((req) => { const label = getRequirementLabel(req) return ( -
-

{label.title}

-

{label.description}

-
+ ) }) ) : ( -
-

Additional Document

-

Please provide the requested document.

-
+ )}
diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 948ce2f3f..7c4fc2850 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -270,6 +270,13 @@ const REJECT_LABEL_MAP: Record = { 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 = { 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/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 2c107db32..4dc80f996 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -126,8 +126,11 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: return } - // sync status from api response - if (response.data?.status) { + // 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) } @@ -136,9 +139,10 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (effectiveIntent) regionIntentRef.current = effectiveIntent levelNameRef.current = levelName - // if already approved, no token is returned. + // if already approved and no token returned, kyc is done. // set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time. - if (response.data?.status === 'APPROVED') { + // 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