From 2a66155537dfa37d925f24871bc90125237f5270 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:36:14 +0530 Subject: [PATCH 01/93] feat: sumsub sdk types and declrations --- src/app/(mobile-ui)/home/page.tsx | 4 ++-- src/app/actions/types/sumsub.types.ts | 7 +++++++ src/types/sumsub-websdk.d.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/app/actions/types/sumsub.types.ts create mode 100644 src/types/sumsub-websdk.d.ts diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index f3a542d54..68f03a0e7 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 HomeCarouselCTA from '@/components/Home/HomeCarouselCTA' diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts new file mode 100644 index 000000000..5e9c6f867 --- /dev/null +++ b/src/app/actions/types/sumsub.types.ts @@ -0,0 +1,7 @@ +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' diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts new file mode 100644 index 000000000..58400646f --- /dev/null +++ b/src/types/sumsub-websdk.d.ts @@ -0,0 +1,26 @@ +// 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 + on(event: string, handler: (...args: any[]) => void): SnsWebSdkBuilderChain + build(): SnsWebSdkInstance + } + + interface SnsWebSdkBuilder { + init(token: string, refreshCallback: () => Promise): SnsWebSdkBuilderChain + } + + interface Window { + snsWebSdk: SnsWebSdkBuilder + } +} + +export {} From b7a41410227c85ac3726b4e0cddb60b93ee6665c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:36:56 +0530 Subject: [PATCH 02/93] feat: initiateSumsubKyc server action --- src/app/actions/sumsub.ts | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/app/actions/sumsub.ts diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts new file mode 100644 index 000000000..b3401567c --- /dev/null +++ b/src/app/actions/sumsub.ts @@ -0,0 +1,43 @@ +'use server' + +import { type InitiateSumsubKycResponse } 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?: string +}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + + 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(params || {}), + }) + + 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: any) { + return { error: e.message || 'An unexpected error occurred' } + } +} From 0dd89d37355529ec77b7a6d00876fe542cca318b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:47:13 +0530 Subject: [PATCH 03/93] feat: useSumsubKycFlow hook setup using initiateSumsubKyc server action --- src/hooks/useSumsubKycFlow.ts | 155 ++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/hooks/useSumsubKycFlow.ts diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts new file mode 100644 index 000000000..96f4ef54e --- /dev/null +++ b/src/hooks/useSumsubKycFlow.ts @@ -0,0 +1,155 @@ +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 SumsubKycStatus } from '@/app/actions/types/sumsub.types' + +interface UseSumsubKycFlowOptions { + onKycSuccess?: () => void + onManualClose?: () => void + regionIntent?: string +} + +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) + + // 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') { + setIsVerificationProgressModalOpen(false) + onKycSuccess?.() + } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { + setIsVerificationProgressModalOpen(false) + } + }, [liveKycStatus, onKycSuccess]) + + // fetch current status on mount to recover from missed websocket events + useEffect(() => { + 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() + }, []) + + const handleInitiateKyc = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const response = await initiateSumsubKyc({ regionIntent }) + + if (response.error) { + setError(response.error) + return + } + + // sync status from api response + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + + // if already approved, no token is returned + if (response.data?.status === 'APPROVED') { + onKycSuccess?.() + return + } + + if (response.data?.token) { + setAccessToken(response.data.token) + setShowWrapper(true) + } else { + setError('Could not initate verification. Please try again.') + } + } catch (e: any) { + setError(e.message || 'An unexpected error occurred') + } finally { + setIsLoading(false) + } + }, [regionIntent, onKycSuccess]) + + // called when sdk signals applicant submitted + const handleSdkComplete = useCallback(() => { + 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 + const refreshToken = useCallback(async (): Promise => { + const response = await initiateSumsubKyc({ regionIntent }) + + if (response.error || !response.data?.token) { + throw new Error(response.error || 'Failed to refresh token') + } + + setAccessToken(response.data.token) + return response.data.token + }, [regionIntent]) + + 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, + } +} From 2c2bf1464080dad5dbf0871149991f3e125bb930 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:52:39 +0530 Subject: [PATCH 04/93] feat: handle websocket event receiving for sumsub kyc --- src/hooks/useWebSocket.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 68f006e7f..b1d551d12 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -10,6 +10,7 @@ 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 onConnect?: () => void onDisconnect?: () => void @@ -23,6 +24,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, @@ -37,6 +39,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, @@ -49,12 +52,22 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError, } - }, [onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, onTosUpdate, onConnect, onDisconnect, onError]) + }, [ + onHistoryEntry, + onKycStatusUpdate, + onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, + onTosUpdate, + onConnect, + onDisconnect, + onError, + ]) // Connect to WebSocket const connect = useCallback(() => { @@ -141,6 +154,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' }) @@ -156,6 +177,7 @@ 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) // Auto-connect if enabled @@ -171,6 +193,7 @@ 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) } }, [autoConnect, connect, username]) From 27ea649f1f76bb2eb9538f3236377b18ccdbe27d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:28:25 +0530 Subject: [PATCH 05/93] feat: SumsubKycWrapper component to handle sumsub web sdk intialization and kyc flow states --- src/components/Kyc/SumsubKycWrapper.tsx | 258 ++++++++++++++++++++++++ src/hooks/useSumsubKycFlow.ts | 5 +- src/types/sumsub-websdk.d.ts | 1 + 3 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 src/components/Kyc/SumsubKycWrapper.tsx diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx new file mode 100644 index 000000000..d6eea6e1b --- /dev/null +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -0,0 +1,258 @@ +'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 +} + +export const SumsubKycWrapper = ({ + visible, + accessToken, + onClose, + onComplete, + onError, + onRefreshToken, +}: 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) + + useEffect(() => { + onCompleteRef.current = onComplete + onErrorRef.current = onError + onRefreshTokenRef.current = onRefreshToken + }, [onComplete, onError, onRefreshToken]) + + // 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 { + const sdk = window.snsWebSdk + .init(accessToken, stableOnRefreshToken) + .withConf({ lang: 'en', theme: 'light' }) + .withOptions({ addViewportTag: false, adaptIframeHeight: true }) + .on('onApplicantSubmitted', () => stableOnComplete()) + .on('onApplicantResubmitted', () => stableOnComplete()) + .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 + useEffect(() => { + if (!visible) { + setIsVerificationStarted(false) + setSdkLoadError(false) + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + sdkInstanceRef.current = null + } + } + }, [visible]) + + 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', + icon: 'peanut-support' as IconName, + onClick: () => setIsSupportModalOpen(true), + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + ], + } + } + + return { + 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', + }, + ], + } + }, [modalVariant, onClose, setIsSupportModalOpen]) + + return ( + { }} // todo: implement close modal logic that also stops the sdk and resets state + classWrap="h-full w-full !max-w-none sm:!max-w-[600px] border-none sm:m-auto m-0" + classOverlay={`bg-black bg-opacity-50 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + video={false} + className={`z-[100] !p-0 md:!p-6 ${isHelpModalOpen ? 'pointer-events-none' : ''}`} + classButtonClose="hidden" + preventClose={true} + hideOverlay={false} + > + {!isVerificationStarted ? ( + // start verification view (provider-agnostic, not reusing StartVerificationView which references "Persona") + { }} // todo: handle kyc cancellation by user + onStartVerification={() => setIsVerificationStarted(true)} + /> + ) : sdkLoadError ? ( + // script failed to load — show user-facing error +
+ +

+ Failed to load verification. Please check your connection and try again. +

+ +
+ ) : ( + // SDK container + controls +
+
+
+
+ + +
+
+
+ )} + setIsHelpModalOpen(false)} + title={modalDetails.title} + description={modalDetails.description} + icon={modalDetails.icon} + iconContainerClassName={modalDetails.iconContainerClassName} + modalPanelClassName="max-w-full pointer-events-auto" + ctaClassName="grid grid-cols-1 gap-3" + contentContainerClassName="px-6 py-6" + modalClassName="!z-[10001] pointer-events-auto" + preventClose={true} + ctas={modalDetails.ctas} + /> + + ) +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 96f4ef54e..d726b224b 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -92,8 +92,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } else { setError('Could not initate verification. Please try again.') } - } catch (e: any) { - setError(e.message || 'An unexpected error occurred') + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) } finally { setIsLoading(false) } diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts index 58400646f..4d6b7c5c7 100644 --- a/src/types/sumsub-websdk.d.ts +++ b/src/types/sumsub-websdk.d.ts @@ -10,6 +10,7 @@ declare global { 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 } From 1fa243f33e4560c66f4865b2fb4861ecacc400e5 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:30:12 +0530 Subject: [PATCH 06/93] feat: SumsubKycFlow entry point for sumsub kyc wrapper and pending states --- src/app/actions/sumsub.ts | 5 +-- src/components/Kyc/SumsubKycFlow.tsx | 51 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/components/Kyc/SumsubKycFlow.tsx diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index b3401567c..9ef6af6b5 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -37,7 +37,8 @@ export const initiateSumsubKyc = async (params?: { status: responseJson.status, }, } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } } } diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx new file mode 100644 index 000000000..fb5bda64e --- /dev/null +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -0,0 +1,51 @@ +import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' + +interface SumsubKycFlowProps extends ButtonProps { + onKycSuccess?: () => void + onManualClose?: () => void +} + +/** + * entry point for the kyc flow + * renders a button that initiates kyc, the sumsub sdk wrapper modal, and a verification-in-progress modal + */ +export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: SumsubKycFlowProps) => { + const { + isLoading, + error, + showWrapper, + accessToken, + handleInitiateKyc, + handleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ onKycSuccess, onManualClose }) + + return ( + <> + + + {error &&

{error}

} + + + + + + ) +} From 61bd07ec1d4aaab0ca5e54a01ffb6861db1a2511 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:37:55 +0530 Subject: [PATCH 07/93] fix: update verification view ux copy + add region intent type --- src/app/actions/sumsub.ts | 9 ++++----- src/app/actions/types/sumsub.types.ts | 2 ++ .../Global/IframeWrapper/StartVerificationView.tsx | 6 ++---- src/components/Kyc/SumsubKycFlow.tsx | 2 +- src/components/Kyc/SumsubKycWrapper.tsx | 10 +++++----- src/hooks/useSumsubKycFlow.ts | 4 ++-- 6 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 9ef6af6b5..f113d14ca 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -1,6 +1,6 @@ 'use server' -import { type InitiateSumsubKycResponse } from './types/sumsub.types' +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' @@ -9,7 +9,7 @@ const API_KEY = process.env.PEANUT_API_KEY! // initiate kyc flow (using sumsub) and get websdk access token export const initiateSumsubKyc = async (params?: { - regionIntent?: string + regionIntent?: KYCRegionIntent }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value @@ -37,8 +37,7 @@ export const initiateSumsubKyc = async (params?: { status: responseJson.status, }, } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : 'An unexpected error occurred' - return { error: message } + } catch (e: any) { + return { error: e.message || 'An unexpected error occurred' } } } diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts index 5e9c6f867..3922f4235 100644 --- a/src/app/actions/types/sumsub.types.ts +++ b/src/app/actions/types/sumsub.types.ts @@ -5,3 +5,5 @@ export interface InitiateSumsubKycResponse { } export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED' + +export type KYCRegionIntent = 'STANDARD' | 'LATAM' \ No newline at end of file diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx index 5285ec405..fde280ad9 100644 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx @@ -33,11 +33,9 @@ const StartVerificationView = ({

Secure Verification. Limited Data Use.

- The verification is done by Persona, which only shares a yes/no with Peanut. -

-

- Persona is trusted by millions and it operates under strict security and privacy standards. + The verification is done using a trusted provider, which only shares a yes/no with Peanut.

+

It operates under strict security and privacy standards.

Peanut never sees or stores your verification data.

- - {error &&

{error}

} +interface KycFlowProps extends ButtonProps { + regionIntent?: KYCRegionIntent +} - - - ) +// main entry point for the kyc flow. +// renders SumsubKycFlow with an optional region intent for context-aware verification. +export const KycFlow = ({ regionIntent, ...buttonProps }: KycFlowProps) => { + return } diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index df332f7fc..faf067bb2 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -2,17 +2,19 @@ import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +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 verification-in-progress modal */ -export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: SumsubKycFlowProps) => { +export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { const { isLoading, error, @@ -24,7 +26,7 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, ...buttonProps }: S refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess, onManualClose }) // todo: pass region intent param + } = useSumsubKycFlow({ onKycSuccess, onManualClose, regionIntent }) return ( <> diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 228722ac6..9d2b5c340 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -5,6 +5,7 @@ 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 +76,11 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ }, ] +/** 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 +102,7 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ */ export const useIdentityVerification = () => { const { user } = useAuth() - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() /** * Check if a country is supported by Manteca (LATAM countries). @@ -149,9 +155,13 @@ export const useIdentityVerification = () => { const { lockedRegions, unlockedRegions } = useMemo(() => { const isBridgeApproved = isUserBridgeKycApproved const isMantecaApproved = isUserMantecaKycApproved + const isSumsubApproved = isUserSumsubKycApproved - // Helper to check if a region should be unlocked + // helper to check if a region should be unlocked const isRegionUnlocked = (regionName: string) => { + // sumsub approval unlocks all regions (one verification per user). + // backend enforces per-rail access separately — frontend only gates on identity verification. + if (isSumsubApproved) return true return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || (isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName)) @@ -161,8 +171,8 @@ 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) + // bridge users get qr payment access in argentina & brazil + // even without full manteca kyc (which unlocks bank transfers too) if (isBridgeApproved && !isMantecaApproved) { unlocked.push(...MANTECA_QR_ONLY_REGIONS, ...BRIDGE_SUPPORTED_LATAM_COUNTRIES) } @@ -171,7 +181,7 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) /** * Check if a region is already unlocked by comparing region paths. From 3188926d73f5f57ce8edf002f1a0a45be5da7338 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:16:25 +0530 Subject: [PATCH 13/93] feat: unified kyc status drawer --- src/components/Kyc/KycStatusDrawer.tsx | 61 ++++++++++++++++---------- src/components/Kyc/KycStatusItem.tsx | 41 +++++++++-------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index e8a80f9fd..7744e9d4c 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -3,29 +3,16 @@ import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' 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 { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' 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 { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' interface KycStatusDrawerProps { isOpen: boolean @@ -65,15 +52,29 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus country: country as CountryData, }) + const { + handleInitiateKyc: initiateSumsub, + showWrapper: showSumsubWrapper, + accessToken: sumsubAccessToken, + handleSdkComplete: handleSumsubComplete, + handleClose: handleSumsubClose, + refreshToken: sumsubRefreshToken, + isLoading: isSumsubLoading, + isVerificationProgressModalOpen: isSumsubProgressModalOpen, + closeVerificationProgressModal: closeSumsubProgressModal, + } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + const onRetry = async () => { - if (provider === 'MANTECA') { + if (provider === 'SUMSUB') { + await initiateSumsub() + } else if (provider === 'MANTECA') { await openMantecaKyc(country as CountryData) } else { await initiateBridgeKyc() } } - const isLoadingKyc = isBridgeLoading || isMantecaLoading + const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading const renderContent = () => { switch (statusCategory) { @@ -93,10 +94,15 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isBridge={isBridgeKyc} /> ) - case 'failed': + case 'failed': { + // for sumsub, use reject labels as the reason + const reason = + provider === 'SUMSUB' + ? (verification?.rejectLabels?.join(', ') ?? '') + : (user?.user?.bridgeKycRejectionReasonString ?? '') return ( ) + } default: return null } } // don't render the drawer if the kyc status is unknown or not started - if (status === 'not_started' || !status) { + if (isKycStatusNotStarted(status)) { return null } @@ -124,6 +131,14 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus + + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 1ca8536ed..57094b13d 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -10,6 +10,12 @@ import { twMerge } from 'tailwind-merge' import { type IUserKycVerification } from '@/interfaces' import StatusPill from '../Global/StatusPill' import { KYCStatusIcon } from './KYCStatusIcon' +import { + isKycStatusApproved, + isKycStatusPending, + isKycStatusFailed, + isKycStatusNotStarted, +} from '@/constants/kyc.consts' // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ @@ -45,23 +51,18 @@ 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' - - 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 subtitle = useMemo(() => { - if (isPending) { - return 'Under review' - } - if (isApproved) { - return 'Approved' - } - return 'Rejected' + if (isPending) return 'Under review' + if (isApproved) return 'Approved' + if (isRejected) return 'Rejected' + return 'Unknown' }, [isPending, isApproved, isRejected]) - if (!kycStatus || kycStatus === 'not_started') { + if (isKycStatusNotStarted(kycStatus)) { return null } @@ -88,12 +89,14 @@ export const KycStatusItem = ({
- + {isDrawerOpen && ( + + )} ) } From 4fb61d471dcc14ad746ea02fd9027b13535148c2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:17:26 +0530 Subject: [PATCH 14/93] feat: sumsub kyc gate for qr payments --- src/hooks/useQrKycGate.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) 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]) From 6d390ad466633bb1d4fcc56d97a78f123b614747 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:25:45 +0530 Subject: [PATCH 15/93] feat: update views for sumsub flow --- .../Home/KycCompletedModal/index.tsx | 9 +- .../views/RegionsVerification.view.tsx | 93 +++++++++++++++++-- src/features/limits/views/LimitsPageView.tsx | 2 +- 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 1377b3a50..80cd086bc 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -13,14 +13,17 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const { user } = useAuth() const [approvedCountryData, setApprovedCountryData] = useState(null) - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { + // sumsub covers all regions, treat as 'all' + if (isUserSumsubKycApproved) { + return 'all' + } if (isUserBridgeKycApproved && isUserMantecaKycApproved) { return 'all' } - if (isUserBridgeKycApproved) { return 'bridge' } @@ -28,7 +31,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = return 'manteca' } return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 9323f71be..b67a321fd 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -5,13 +5,51 @@ 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 ActionModal from '@/components/Global/ActionModal' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import Image from 'next/image' import { useRouter } from 'next/navigation' +import { useState, useCallback } from 'react' const RegionsVerification = () => { const router = useRouter() const { unlockedRegions, lockedRegions } = useIdentityVerification() + const [selectedRegion, setSelectedRegion] = useState(null) + + const regionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined + + const { + isLoading, + error, + showWrapper, + accessToken, + handleInitiateKyc, + handleSdkComplete, + handleClose: handleSumsubClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ + regionIntent, + onKycSuccess: () => setSelectedRegion(null), + onManualClose: () => setSelectedRegion(null), + }) + + const handleRegionClick = useCallback((region: Region) => { + setSelectedRegion(region) + }, []) + + const handleModalClose = useCallback(() => { + setSelectedRegion(null) + }, []) + + const handleStartKyc = useCallback(async () => { + setSelectedRegion(null) + await handleInitiateKyc() + }, [handleInitiateKyc]) return (
@@ -37,11 +75,50 @@ 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?

- + + + )}
+ + + + {error &&

{error}

} + + + + ) } @@ -51,9 +128,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 +148,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/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 27c4ec23c..4ea063318 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -175,7 +175,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} From ec0d1e98cfa5cd5ba104bcddfd50ca68284d07a4 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:26:48 +0530 Subject: [PATCH 16/93] chore: remove dead code --- .../[region]/[country]/page.tsx | 6 - .../identity-verification/[region]/page.tsx | 10 - .../profile/identity-verification/layout.tsx | 54 +--- .../IdentityVerificationCountryList.tsx | 154 ----------- .../views/IdentityVerification.view.tsx | 243 ------------------ .../Profile/views/RegionsPage.view.tsx | 44 ---- 6 files changed, 1 insertion(+), 510 deletions(-) delete mode 100644 src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx delete mode 100644 src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx delete mode 100644 src/components/Profile/components/IdentityVerificationCountryList.tsx delete mode 100644 src/components/Profile/views/IdentityVerification.view.tsx delete mode 100644 src/components/Profile/views/RegionsPage.view.tsx 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/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/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

- - - - - - - - {(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/RegionsPage.view.tsx b/src/components/Profile/views/RegionsPage.view.tsx deleted file mode 100644 index af40c49c5..000000000 --- a/src/components/Profile/views/RegionsPage.view.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import NavHeader from '@/components/Global/NavHeader' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import { useRouter } from 'next/navigation' -import IdentityVerificationCountryList from '../components/IdentityVerificationCountryList' -import { Button } from '@/components/0_Bruddle/Button' - -const RegionsPage = ({ path }: { path: string }) => { - 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) && ( -
- -
- )} -
- ) -} - -export default RegionsPage From d0b2764455cd1f79f518b1c44acf884bb2d8a54c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:29:57 +0530 Subject: [PATCH 17/93] chore: format --- src/hooks/useWebSocket.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index d8d4db439..e6234c509 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -63,7 +63,6 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onError, } }, [ - onHistoryEntry, onKycStatusUpdate, From e9715afcaeea9d868619909f59ad5fb61e921eb0 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:30:08 +0530 Subject: [PATCH 18/93] fix: address cr review comments --- src/app/actions/sumsub.ts | 4 ++++ .../IframeWrapper/StartVerificationView.tsx | 7 ++++-- src/components/Kyc/KycFlow.tsx | 13 +++++++++-- .../views/RegionsVerification.view.tsx | 23 ++++++++++++++----- src/constants/kyc.consts.ts | 4 ++-- src/hooks/useIdentityVerification.tsx | 2 +- src/hooks/useUnifiedKycStatus.ts | 6 ++++- src/hooks/useWebSocket.ts | 7 ------ 8 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a96e38003..185449496 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -14,6 +14,10 @@ export const initiateSumsubKyc = async (params?: { }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value + if (!jwtToken) { + return { error: 'Authentication required' } + } + const body: Record = { regionIntent: params?.regionIntent, } diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx index fde280ad9..abec9c2ea 100644 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx @@ -33,9 +33,12 @@ const StartVerificationView = ({

Secure Verification. Limited Data Use.

- The verification is done using a trusted provider, which only shares a yes/no with Peanut. + The verification is done using a trusted provider, which shares your verification status with + Peanut. +

+

+ It operates under industry-standard security and privacy practices.

-

It operates under strict security and privacy standards.

Peanut never sees or stores your verification data.

diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index ef3f8179d..484e9ccfa 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -23,6 +23,12 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const [liveKycStatus, setLiveKycStatus] = useState(undefined) const [rejectLabels, setRejectLabels] = useState(undefined) const prevStatusRef = useRef(liveKycStatus) + // tracks the effective region intent across initiate + refresh so the correct template is always used + const regionIntentRef = useRef(regionIntent) + + useEffect(() => { + regionIntentRef.current = regionIntent + }, [regionIntent]) // listen for sumsub kyc status updates via websocket useWebSocket({ @@ -85,8 +91,14 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setLiveKycStatus(response.data.status) } - // if already approved, no token is returned + // update effective intent for token refresh + const effectiveIntent = overrideIntent ?? regionIntent + if (effectiveIntent) regionIntentRef.current = effectiveIntent + + // if already approved, no token is returned. + // set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time. if (response.data?.status === 'APPROVED') { + prevStatusRef.current = 'APPROVED' onKycSuccess?.() return } @@ -119,9 +131,10 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: onManualClose?.() }, [onManualClose]) - // token refresh function passed to the sdk for when the token expires + // token refresh function passed to the sdk for when the token expires. + // uses regionIntentRef so refresh always matches the template used during initiation. const refreshToken = useCallback(async (): Promise => { - const response = await initiateSumsubKyc({ regionIntent }) + const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current }) if (response.error || !response.data?.token) { throw new Error(response.error || 'Failed to refresh token') @@ -129,7 +142,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setAccessToken(response.data.token) return response.data.token - }, [regionIntent]) + }, []) const closeVerificationProgressModal = useCallback(() => { setIsVerificationProgressModalOpen(false) From 9fbb5d84f8d328f83ed01f11a81ba33eed20bde7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:19:13 +0530 Subject: [PATCH 23/93] feat: add UserRail types, rejectType and metadata to kyc verification interface --- src/interfaces/interfaces.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 0d955d4a3..bb84671bc 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -240,6 +240,8 @@ export interface IUserKycVerification { 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 +323,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; [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 +353,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 From 60e9dc9f47eb546d1e7272daffe64fb0819405ef Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:19:57 +0530 Subject: [PATCH 24/93] feat: add action_required status category and human-readable sumsub reject label mappings --- src/constants/kyc.consts.ts | 13 ++- src/constants/sumsub-reject-labels.consts.ts | 83 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/constants/sumsub-reject-labels.consts.ts diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts index 4c633403e..f8fc5d507 100644 --- a/src/constants/kyc.consts.ts +++ b/src/constants/kyc.consts.ts @@ -7,7 +7,7 @@ import { type MantecaKycStatus } from '@/interfaces' */ export type KycVerificationStatus = MantecaKycStatus | SumsubKycStatus | string -export type KycStatusCategory = 'completed' | 'processing' | 'failed' +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']) @@ -18,11 +18,13 @@ const PENDING_STATUSES: ReadonlySet = new Set([ 'ONBOARDING', 'PENDING', 'IN_REVIEW', - 'ACTION_REQUIRED', ]) +const ACTION_REQUIRED_STATUSES: ReadonlySet = new Set(['ACTION_REQUIRED']) const NOT_STARTED_STATUSES: ReadonlySet = new Set(['not_started', 'NOT_STARTED']) -// sumsub-specific sets for checks that only care about sumsub +// 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 */ @@ -36,6 +38,10 @@ export const isKycStatusFailed = (status: string | undefined | null): boolean => 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) @@ -48,5 +54,6 @@ export const isSumsubStatusInProgress = (status: string | undefined | null): boo 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..64197c111 --- /dev/null +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -0,0 +1,83 @@ +interface RejectLabelInfo { + title: string + description: string +} + +// map of sumsub reject labels to user-friendly descriptions +const REJECT_LABEL_MAP: Record = { + DOCUMENT_BAD_QUALITY: { + title: 'Low quality document', + description: 'The document image was blurry, dark, or hard to read. Please upload a clearer photo.', + }, + 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.', + }, + DOCUMENT_MISSING: { + title: 'Missing document', + description: 'A required document was not provided. Please upload all requested documents.', + }, + DOCUMENT_EXPIRED: { + title: 'Expired document', + description: 'The document has expired. Please use a valid, non-expired document.', + }, + 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.', + }, + 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.', + }, + DOCUMENT_FAKE: { + title: 'Document could not be verified', + description: 'We were unable to verify the authenticity of your document.', + }, + GRAPHIC_EDITOR_USAGE: { + title: 'Edited document detected', + description: 'The document appears to have been digitally altered.', + }, + AGE_BELOW_ACCEPTED_LIMIT: { + title: 'Age requirement not met', + description: 'You must be at least 18 years old to use this service.', + }, + 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.', + }, + REGULATIONS_VIOLATIONS: { + title: 'Regulatory restriction', + description: 'Verification could not be completed due to regulatory requirements.', + }, +} + +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', 'GRAPHIC_EDITOR_USAGE', 'AGE_BELOW_ACCEPTED_LIMIT']) + +/** 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)) +} From fe5f07d6e3a090275da6caff1da05d1aa3041632 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:20:48 +0530 Subject: [PATCH 25/93] feat: scope region unlocking by verification regionIntent, expose action_required status --- src/hooks/useIdentityVerification.tsx | 17 +++++++++++++---- src/hooks/useUnifiedKycStatus.ts | 10 ++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 5a1c5a3d4..e3f396532 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -1,6 +1,7 @@ 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' @@ -103,6 +104,7 @@ export const getRegionIntent = (regionPath: string): KYCRegionIntent => { export const useIdentityVerification = () => { const { user } = useAuth() const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { sumsubVerificationRegionIntent } = useUnifiedKycStatus() /** * Check if a country is supported by Manteca (LATAM countries). @@ -159,9 +161,16 @@ export const useIdentityVerification = () => { // helper to check if a region should be unlocked const isRegionUnlocked = (regionName: string) => { - // sumsub approval unlocks all regions (one verification per user). - // backend enforces per-rail access separately — frontend only gates on identity verification. - if (isSumsubApproved) return true + // sumsub approval scoped by the regionIntent used during verification. + // 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions + rest of world. + // no intent (or rest-of-world) → unlocks rest of world only. + if (isSumsubApproved) { + if (sumsubVerificationRegionIntent === 'LATAM') { + return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } + // STANDARD intent covers bridge regions + rest of world + return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || (isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName)) @@ -181,7 +190,7 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) /** * Check if a region is already unlocked by comparing region paths. diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts index 122227928..4b3bc9ff2 100644 --- a/src/hooks/useUnifiedKycStatus.ts +++ b/src/hooks/useUnifiedKycStatus.ts @@ -38,6 +38,12 @@ export default function useUnifiedKycStatus() { const sumsubRejectLabels = useMemo(() => sumsubVerification?.rejectLabels ?? 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] @@ -48,6 +54,8 @@ export default function useUnifiedKycStatus() { [user] ) + const isSumsubActionRequired = useMemo(() => sumsubStatus === 'ACTION_REQUIRED', [sumsubStatus]) + const isSumsubInProgress = useMemo(() => isSumsubStatusInProgress(sumsubStatus), [sumsubStatus]) const isKycInProgress = useMemo( @@ -66,7 +74,9 @@ export default function useUnifiedKycStatus() { isMantecaApproved, // sumsub isSumsubApproved, + isSumsubActionRequired, sumsubStatus, sumsubRejectLabels, + sumsubVerificationRegionIntent, } } From ea64d17f69e8bc0055ce5022f0f2e9ea57509c47 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:21:11 +0530 Subject: [PATCH 26/93] feat: handle sumsub_kyc_status_update websocket messages --- src/services/websocket.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index b49418771..497df472e 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,7 +1,6 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' export type { PendingPerk } -import { jsonStringify } from '@/utils/general.utils' export type WebSocketMessage = { type: @@ -10,6 +9,7 @@ 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 @@ -126,6 +126,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) From 7ed541a4f0ba64deb5f97b95cba9ef51dc73a789 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:23:03 +0530 Subject: [PATCH 27/93] feat: add bridge tos acceptance flow: tos step component, reminder card, server actions --- src/components/Global/Badges/StatusBadge.tsx | 6 +- src/components/Kyc/KYCStatusDrawerItem.tsx | 4 +- src/components/Kyc/KycStatusDrawer.tsx | 29 +++-- src/components/Kyc/KycStatusItem.tsx | 23 +++- .../Kyc/states/KycActionRequired.tsx | 27 +++++ src/components/Kyc/states/KycFailed.tsx | 106 +++++++++++++----- 6 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 src/components/Kyc/states/KycActionRequired.tsx diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 8eaf7335d..0aebee174 100644 --- a/src/components/Global/Badges/StatusBadge.tsx +++ b/src/components/Global/Badges/StatusBadge.tsx @@ -41,6 +41,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 +63,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/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/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 7744e9d4c..e0fe1bea9 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,3 +1,4 @@ +import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' @@ -76,6 +77,13 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading + // count sumsub rejections for failure lockout. + // counts total REJECTED entries — accurate if backend creates a new row per attempt. + // if backend updates in-place (single row), this will be 0 or 1 and the lockout + // won't trigger from count alone (terminal labels and rejectType still work). + const sumsubFailureCount = + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + const renderContent = () => { switch (statusCategory) { case 'processing': @@ -94,15 +102,16 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isBridge={isBridgeKyc} /> ) - case 'failed': { - // for sumsub, use reject labels as the reason - const reason = - provider === 'SUMSUB' - ? (verification?.rejectLabels?.join(', ') ?? '') - : (user?.user?.bridgeKycRejectionReasonString ?? '') + case 'action_required': + return + case 'failed': return ( ) - } default: return null } } - // don't render the drawer if the kyc status is unknown or not started - if (isKycStatusNotStarted(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 } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 57094b13d..7eae92316 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -15,6 +15,7 @@ import { isKycStatusPending, isKycStatusFailed, isKycStatusNotStarted, + isKycStatusActionRequired, } from '@/constants/kyc.consts' // this component shows the current kyc status and opens a drawer with more details on click @@ -54,15 +55,23 @@ export const KycStatusItem = ({ 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 (isInitiatedButNotStarted) return 'In progress' + if (isActionRequired) return 'Action needed' if (isPending) return 'Under review' if (isApproved) return 'Approved' if (isRejected) return 'Rejected' return 'Unknown' - }, [isPending, isApproved, isRejected]) + }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) - if (isKycStatusNotStarted(kycStatus)) { + // 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)) { return null } @@ -82,7 +91,15 @@ export const KycStatusItem = ({

Identity verification

{subtitle}

- +
diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx new file mode 100644 index 000000000..508dba7ee --- /dev/null +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -0,0 +1,27 @@ +import { Button } from '@/components/0_Bruddle/Button' +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import InfoCard from '@/components/Global/InfoCard' + +// this component shows the kyc status when sumsub requires additional action from the user. +export const KycActionRequired = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => { + return ( +
+ + + +
+ ) +} diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 6c7a45fe6..8365d6608 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -2,46 +2,79 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import Card from '@/components/Global/Card' +import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' +import { getRejectLabelInfo, hasTerminalRejectLabel } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +const MAX_RETRY_COUNT = 2 // 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() !== '') + // determine if this is a terminal (permanent) rejection + const isTerminal = useMemo(() => { + if (rejectType === 'FINAL') return true + if (failureCount && failureCount >= MAX_RETRY_COUNT) return true + if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true + return false + }, [rejectType, failureCount, rejectLabels]) - if (lines.length === 1) { - return reasonText - } + // map sumsub labels to human-readable items for InfoCard + const reasonItems = useMemo(() => { + if (!isSumsub || !rejectLabels?.length) return null + return rejectLabels.map((label) => { + const info = getRejectLabelInfo(label) + return ( + + {info.title}: {info.description} + + ) + }) + }, [isSumsub, rejectLabels]) + // formatted bridge reason (legacy display) + const formattedBridgeReason = useMemo(() => { + const reasonText = bridgeReason || 'There was an issue. Contact Support.' + const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') + if (lines.length === 1) return reasonText return (
    {lines.map((line, index) => ( @@ -49,28 +82,51 @@ export const KycFailed = ({ ))}
) - }, [reason]) + }, [bridgeReason]) return (
+ - - - + {!isSumsub && } - + + {isSumsub && reasonItems && ( + + )} + + {isTerminal ? ( +
+ + {/* TODO: auto-create crisp support ticket on terminal rejection */} + +
+ ) : ( + + )}
) } From 235724f0381eac34ff1bda29f4e507b9f1104a58 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:24:54 +0530 Subject: [PATCH 28/93] feat: handle bridge tos acceptance --- src/app/actions/users.ts | 44 ++++++ src/components/Home/HomeHistory.tsx | 7 + src/components/Kyc/BridgeTosReminder.tsx | 51 +++++++ src/components/Kyc/BridgeTosStep.tsx | 132 ++++++++++++++++++ src/components/Kyc/SumsubKycFlow.tsx | 43 +++++- src/components/Kyc/states/KycCompleted.tsx | 5 + .../views/RegionsVerification.view.tsx | 32 ++++- src/hooks/useBridgeTosStatus.ts | 17 +++ 8 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 src/components/Kyc/BridgeTosReminder.tsx create mode 100644 src/components/Kyc/BridgeTosStep.tsx create mode 100644 src/hooks/useBridgeTosStatus.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index e530ebf17..5929fed25 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -160,3 +160,47 @@ 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, + }, + }) + 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/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index e73e916c7..c89f606b1 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -14,7 +14,9 @@ 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 { BridgeTosReminder } from '../Kyc/BridgeTosReminder' import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -43,6 +45,7 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h const { fetchBalance } = useWallet() const { triggerHaptic } = useHaptic() const { fetchUser } = useAuth() + const { needsBridgeTos } = useBridgeTosStatus() const isViewingOwnHistory = useMemo( () => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username), @@ -270,6 +273,7 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (

Activity

+ {isViewingOwnHistory && needsBridgeTos && } {isViewingOwnHistory && ((user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started') || (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( @@ -317,6 +321,9 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (
+ {/* bridge ToS reminder for users who haven't accepted yet */} + {isViewingOwnHistory && needsBridgeTos && } + {/* link to the full history page */} {pendingRequests.length > 0 && ( <> diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx new file mode 100644 index 000000000..e2965a62e --- /dev/null +++ b/src/components/Kyc/BridgeTosReminder.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useState, useCallback } from 'react' +import Card from '@/components/Global/Card' +import { Icon } from '@/components/Global/Icons/Icon' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useAuth } from '@/context/authContext' +import { type CardPosition } from '@/components/Global/Card/card.utils' + +interface BridgeTosReminderProps { + position?: CardPosition +} + +// shown in the activity feed when user has bridge rails needing ToS acceptance. +// clicking opens the bridge ToS flow. +export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProps) => { + const { fetchUser } = useAuth() + const [showTosStep, setShowTosStep] = useState(false) + + const handleClick = useCallback(() => { + setShowTosStep(true) + }, []) + + const handleComplete = useCallback(async () => { + setShowTosStep(false) + await fetchUser() + }, [fetchUser]) + + const handleSkip = useCallback(() => { + setShowTosStep(false) + }, []) + + return ( + <> + +
+
+ +
+
+

Accept terms of service

+

Required to enable bank transfers

+
+ +
+
+ + + + ) +} diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx new file mode 100644 index 000000000..ac23576ab --- /dev/null +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -0,0 +1,132 @@ +'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, confirmBridgeTos } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' + +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) + + // reset state when visibility changes + useEffect(() => { + if (!visible) { + setShowIframe(false) + setTosLink(null) + setError(null) + } + }, [visible]) + + 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') { + // confirm with backend that bridge actually accepted the ToS + const result = await confirmBridgeTos() + + if (result.data?.accepted) { + await fetchUser() + onComplete() + return + } + + // bridge hasn't registered acceptance yet — poll once after a short delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + const retry = await confirmBridgeTos() + + if (retry.data?.accepted) { + await fetchUser() + onComplete() + } else { + // will be caught by poller/webhook eventually + await fetchUser() + onComplete() + } + } else { + // user closed without accepting — skip, activity feed will remind them + onSkip() + } + }, + [fetchUser, onComplete, onSkip] + ) + + if (!visible) return null + + return ( + <> + {!showIframe && ( + + )} + + {tosLink && } + + ) +} diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index bfd7ac340..adf5a1528 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -1,7 +1,11 @@ +import { useState, useCallback } from 'react' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' +import { useAuth } from '@/context/authContext' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface SumsubKycFlowProps extends ButtonProps { @@ -11,10 +15,31 @@ interface SumsubKycFlowProps extends ButtonProps { } /** - * entry point for the kyc flow - * renders a button that initiates kyc, the sumsub sdk wrapper modal, and a verification-in-progress modal + * entry point for the kyc flow. + * renders a button that initiates kyc, the sumsub sdk wrapper modal, + * a verification-in-progress modal, and a bridge ToS step after sumsub approval. */ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { + const { fetchUser } = useAuth() + const [showBridgeTos, setShowBridgeTos] = useState(false) + const { needsBridgeTos } = useBridgeTosStatus() + + // intercept onKycSuccess to check for bridge ToS + const handleKycApproved = useCallback(async () => { + // refetch user to get latest rails (submitToProviders may have just run) + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setShowBridgeTos(true) + } else { + onKycSuccess?.() + } + }, [fetchUser, onKycSuccess]) + const { isLoading, error, @@ -26,7 +51,17 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess, onManualClose, regionIntent }) + } = useSumsubKycFlow({ onKycSuccess: handleKycApproved, onManualClose, regionIntent }) + + const handleTosComplete = useCallback(() => { + setShowBridgeTos(false) + onKycSuccess?.() + }, [onKycSuccess]) + + const handleTosSkip = useCallback(() => { + setShowBridgeTos(false) + onKycSuccess?.() + }, [onKycSuccess]) return ( <> @@ -48,6 +83,8 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu isOpen={isVerificationProgressModalOpen} onClose={closeVerificationProgressModal} /> + + ) } diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..a28420426 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -1,6 +1,8 @@ import Card from '@/components/Global/Card' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { BridgeTosReminder } from '../BridgeTosReminder' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -21,6 +23,8 @@ export const KycCompleted = ({ countryCode?: string | null isBridge?: boolean }) => { + const { needsBridgeTos } = useBridgeTosStatus() + const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' try { @@ -34,6 +38,7 @@ export const KycCompleted = ({ return (
+ {needsBridgeTos && } diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 7b52c041d..aa5707259 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -8,8 +8,10 @@ import NavHeader from '@/components/Global/NavHeader' import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useState, useCallback, useRef } from 'react' @@ -17,6 +19,7 @@ import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' const RegionsVerification = () => { const router = useRouter() + const { fetchUser } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation @@ -25,6 +28,28 @@ const RegionsVerification = () => { // persist region intent for the duration of the kyc session so token refresh // and status checks use the correct template after the confirmation modal closes const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) + const [showBridgeTos, setShowBridgeTos] = useState(false) + + const handleFinalKycSuccess = useCallback(() => { + setSelectedRegion(null) + setActiveRegionIntent(undefined) + setShowBridgeTos(false) + }, []) + + // intercept sumsub approval to check for bridge ToS + const handleKycApproved = useCallback(async () => { + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setShowBridgeTos(true) + } else { + handleFinalKycSuccess() + } + }, [fetchUser, handleFinalKycSuccess]) const { isLoading, @@ -39,10 +64,7 @@ const RegionsVerification = () => { closeVerificationProgressModal, } = useSumsubKycFlow({ regionIntent: activeRegionIntent, - onKycSuccess: () => { - setSelectedRegion(null) - setActiveRegionIntent(undefined) - }, + onKycSuccess: handleKycApproved, onManualClose: () => { setSelectedRegion(null) setActiveRegionIntent(undefined) @@ -120,6 +142,8 @@ const RegionsVerification = () => { isOpen={isVerificationProgressModalOpen} onClose={closeVerificationProgressModal} /> + +
) } 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]) +} From 9e996b095b355764a4894c7dbe9749cc03650996 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:30:57 +0530 Subject: [PATCH 29/93] fix: listen to additional sumsub sdk methods --- src/components/Kyc/SumsubKycWrapper.tsx | 37 +++++++++++++++++-------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 3486f7b52..46fa7cb67 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -87,21 +87,36 @@ export const SumsubKycWrapper = ({ } try { + const handleSubmitted = () => { + console.log('[sumsub] onApplicantSubmitted fired') + stableOnComplete() + } + const handleResubmitted = () => { + console.log('[sumsub] onApplicantResubmitted fired') + stableOnComplete() + } + const handleStatusChanged = (payload: { + reviewStatus?: string + reviewResult?: { reviewAnswer?: string } + }) => { + console.log('[sumsub] onApplicantStatusChanged fired', payload) + // 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', () => stableOnComplete()) - .on('onApplicantResubmitted', () => stableOnComplete()) - .on( - 'onApplicantStatusChanged', - (payload: { reviewStatus?: string; reviewResult?: { reviewAnswer?: string } }) => { - // auto-close when sumsub shows success screen - if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') { - stableOnComplete() - } - } - ) + .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) From 250cea8d8d26269129e428eb2ff3a2db4994f604 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:32:52 +0530 Subject: [PATCH 30/93] feat: listen to user rail websocket events --- src/hooks/useWebSocket.ts | 15 ++++++++++++++- src/services/websocket.ts | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 13b0704fe..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' @@ -13,6 +13,7 @@ interface UseWebSocketOptions { 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 @@ -28,6 +29,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -44,6 +46,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -58,6 +61,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -69,6 +73,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -181,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) @@ -191,6 +202,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { 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) { @@ -208,6 +220,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { 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/services/websocket.ts b/src/services/websocket.ts index 497df472e..8c64ec8e0 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -2,6 +2,12 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' export type { PendingPerk } +export interface RailStatusUpdate { + railId: string + status: string + provider?: string +} + export type WebSocketMessage = { type: | 'ping' @@ -12,7 +18,8 @@ export type WebSocketMessage = { | 'sumsub_kyc_status_update' | 'persona_tos_status_update' | 'pending_perk' - data?: HistoryEntry | PendingPerk + | 'user_rail_status_changed' + data?: HistoryEntry | PendingPerk | RailStatusUpdate } export class PeanutWebSocket { @@ -144,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) From 40d222f5a4d4c93ab98e4e1cc841daa92e0e07af Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:35:23 +0530 Subject: [PATCH 31/93] fix: move PeanutDoesntStoreAnyPersonalInformation out to a separate component --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- src/components/Kyc/InitiateMantecaKYCModal.tsx | 2 +- .../Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx | 11 +++++++++++ src/hooks/useSumsubKycFlow.ts | 1 - 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9fadf1fbb..92137bdc3 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/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx index d72f425cc..6f547e6f3 100644 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx @@ -6,7 +6,7 @@ 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 { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' import { useEffect } from 'react' interface Props { 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/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 484e9ccfa..6256d1307 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -46,7 +46,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: prevStatusRef.current = liveKycStatus if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { - setIsVerificationProgressModalOpen(false) onKycSuccess?.() } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { setIsVerificationProgressModalOpen(false) From 2f892cbbeb45626a09a2279fa63f1fa0bcf2df90 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:36:37 +0530 Subject: [PATCH 32/93] feat: add useRailStatusTracking hook --- src/hooks/useRailStatusTracking.ts | 162 +++++++++++++++++++++++++++++ src/interfaces/interfaces.ts | 13 +++ 2 files changed, 175 insertions(+) create mode 100644 src/hooks/useRailStatusTracking.ts diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts new file mode 100644 index 000000000..2be342ca0 --- /dev/null +++ b/src/hooks/useRailStatusTracking.ts @@ -0,0 +1,162 @@ +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 + 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', +} + +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_INFORMATION': + case 'REQUIRES_EXTRA_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_tos > enabled > failed > setting_up + 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]) + + // 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, + startTracking, + stopTracking, + } +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index bb84671bc..6b4b0868d 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -3,6 +3,19 @@ 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' | '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' From f58ad8dddcf416313ece23f3073bce367b113613 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:37:47 +0530 Subject: [PATCH 33/93] fix: update SumsubKycFlow and KycVerificationInProgressModal to handle multi phase kyc verification tackling bridge tos acceptance --- .../Kyc/KycVerificationInProgressModal.tsx | 158 ++++++++--- src/components/Kyc/SumsubKycFlow.tsx | 246 ++++++++++++++++-- 2 files changed, 348 insertions(+), 56 deletions(-) diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 401aa0ba9..c7f23d04c 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,124 @@ 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/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index adf5a1528..412bfde37 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -1,12 +1,16 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' -import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import IframeWrapper from '@/components/Global/IframeWrapper' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' -import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' +import { useRailStatusTracking } from '@/hooks/useRailStatusTracking' import { useAuth } from '@/context/authContext' +import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { type KycModalPhase } from '@/interfaces' + +const PREPARING_TIMEOUT_MS = 30000 interface SumsubKycFlowProps extends ButtonProps { onKycSuccess?: () => void @@ -17,51 +21,232 @@ interface SumsubKycFlowProps extends ButtonProps { /** * entry point for the kyc flow. * renders a button that initiates kyc, the sumsub sdk wrapper modal, - * a verification-in-progress modal, and a bridge ToS step after sumsub approval. + * and a multi-phase verification modal that handles: + * verifying → preparing → bridge_tos (if applicable) → complete */ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { const { fetchUser } = useAuth() - const [showBridgeTos, setShowBridgeTos] = useState(false) - const { needsBridgeTos } = useBridgeTosStatus() - // intercept onKycSuccess to check for bridge ToS - const handleKycApproved = useCallback(async () => { - // refetch user to get latest rails (submitToProviders may have just run) + // multi-phase modal state + const [modalPhase, setModalPhase] = useState('verifying') + const [forceShowModal, setForceShowModal] = useState(false) + const [preparingTimedOut, setPreparingTimedOut] = useState(false) + const preparingTimerRef = useRef(null) + const isRealtimeFlowRef = useRef(false) + + // bridge ToS state + const [tosLink, setTosLink] = useState(null) + const [showTosIframe, setShowTosIframe] = useState(false) + const [tosError, setTosError] = useState(null) + const [isLoadingTos, setIsLoadingTos] = useState(false) + + // ref for closeVerificationProgressModal (avoids circular dep with completeFlow) + const closeVerificationModalRef = useRef<() => void>(() => {}) + + // rail tracking + const { allSettled, needsBridgeTos, startTracking, stopTracking } = useRailStatusTracking() + + const clearPreparingTimer = useCallback(() => { + if (preparingTimerRef.current) { + clearTimeout(preparingTimerRef.current) + preparingTimerRef.current = null + } + }, []) + + // complete the flow — close everything, call original onKycSuccess + const completeFlow = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + setModalPhase('verifying') + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + clearPreparingTimer() + stopTracking() + closeVerificationModalRef.current() + onKycSuccess?.() + }, [onKycSuccess, clearPreparingTimer, stopTracking]) + + // called by useSumsubKycFlow when sumsub status transitions to APPROVED + const handleSumsubApproved = useCallback(async () => { + // for real-time flow, optimistically show "Identity verified!" while we check rails + if (isRealtimeFlowRef.current) { + setModalPhase('preparing') + setForceShowModal(true) + } + const updatedUser = await fetchUser() const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' ) if (bridgeNeedsTos) { - setShowBridgeTos(true) - } else { - onKycSuccess?.() + 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 } - }, [fetchUser, onKycSuccess]) + + // all settled — done + completeFlow() + }, [fetchUser, startTracking, clearPreparingTimer, completeFlow]) const { isLoading, error, showWrapper, accessToken, - handleInitiateKyc, - handleSdkComplete, + handleInitiateKyc: originalHandleInitiateKyc, + handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, - } = useSumsubKycFlow({ onKycSuccess: handleKycApproved, onManualClose, regionIntent }) + } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) - const handleTosComplete = useCallback(() => { - setShowBridgeTos(false) - onKycSuccess?.() - }, [onKycSuccess]) + // keep ref in sync + useEffect(() => { + closeVerificationModalRef.current = closeVerificationProgressModal + }, [closeVerificationProgressModal]) - const handleTosSkip = useCallback(() => { - setShowBridgeTos(false) - onKycSuccess?.() - }, [onKycSuccess]) + // wrap handleSdkComplete to track real-time flow + const handleSdkComplete = useCallback(() => { + isRealtimeFlowRef.current = true + originalHandleSdkComplete() + }, [originalHandleSdkComplete]) + + // wrap handleInitiateKyc to reset state for new attempts + const handleInitiateKyc = useCallback( + async (overrideIntent?: KYCRegionIntent) => { + setModalPhase('verifying') + setForceShowModal(false) + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + isRealtimeFlowRef.current = false + clearPreparingTimer() + + await originalHandleInitiateKyc(overrideIntent) + }, + [originalHandleInitiateKyc, clearPreparingTimer] + ) + + // 30s timeout for preparing phase + useEffect(() => { + if (modalPhase === 'preparing' && !preparingTimedOut) { + clearPreparingTimer() + preparingTimerRef.current = setTimeout(() => { + setPreparingTimedOut(true) + }, PREPARING_TIMEOUT_MS) + } else { + clearPreparingTimer() + } + }, [modalPhase, preparingTimedOut, clearPreparingTimer]) + + // phase transitions driven by rail tracking + useEffect(() => { + if (modalPhase === 'preparing') { + if (needsBridgeTos) { + setModalPhase('bridge_tos') + clearPreparingTimer() + } else if (allSettled) { + setModalPhase('complete') + clearPreparingTimer() + stopTracking() + } + } else if (modalPhase === 'bridge_tos') { + // after ToS accepted, rails transition to ENABLED + if (allSettled && !needsBridgeTos) { + setModalPhase('complete') + stopTracking() + } + } + }, [modalPhase, needsBridgeTos, allSettled, clearPreparingTimer, stopTracking]) + + // handle "Accept Terms" click in bridge_tos phase + const handleAcceptTerms = useCallback(async () => { + setIsLoadingTos(true) + setTosError(null) + + try { + const response = await getBridgeTosLink() + + if (response.error || !response.data?.tosLink) { + setTosError( + response.error || 'Could not load terms. You can accept them later from your activity feed.' + ) + return + } + + setTosLink(response.data.tosLink) + setShowTosIframe(true) + } catch { + setTosError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoadingTos(false) + } + }, []) + + // handle ToS iframe close + const handleTosIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + setShowTosIframe(false) + + if (source === 'tos_accepted') { + // confirm with backend + const result = await confirmBridgeTos() + + if (!result.data?.accepted) { + // retry after short delay + await new Promise((resolve) => setTimeout(resolve, 2000)) + await confirmBridgeTos() + } + + // refetch user — the phase-transition effect will handle moving to 'complete' + await fetchUser() + } + // if manual close, stay on bridge_tos phase (user can try again or skip) + }, + [fetchUser] + ) + + // handle "Skip for now" in bridge_tos phase + const handleSkipTerms = useCallback(() => { + completeFlow() + }, [completeFlow]) + + // handle modal close (Go to Home, etc.) + const handleModalClose = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + clearPreparingTimer() + stopTracking() + closeVerificationProgressModal() + }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal]) + + // cleanup on unmount + useEffect(() => { + return () => { + clearPreparingTimer() + stopTracking() + } + }, [clearPreparingTimer, stopTracking]) + + const isModalOpen = isVerificationProgressModalOpen || forceShowModal return ( <> @@ -80,11 +265,18 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu /> - + {tosLink && } ) } From 8c64be9fec75fe38c25ae7340c182660102950a7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:41:34 +0530 Subject: [PATCH 34/93] feat: add isTerminalRejection utility and sumsubRejectType to unified kyc status --- src/constants/sumsub-reject-labels.consts.ts | 18 ++++++++++++++++++ src/hooks/useUnifiedKycStatus.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 64197c111..ab641bd76 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -81,3 +81,21 @@ export const getRejectLabelInfo = (label: string): RejectLabelInfo => { 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/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts index 4b3bc9ff2..bc5b410ae 100644 --- a/src/hooks/useUnifiedKycStatus.ts +++ b/src/hooks/useUnifiedKycStatus.ts @@ -38,6 +38,11 @@ export default function useUnifiedKycStatus() { 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, @@ -77,6 +82,7 @@ export default function useUnifiedKycStatus() { isSumsubActionRequired, sumsubStatus, sumsubRejectLabels, + sumsubRejectType, sumsubVerificationRegionIntent, } } From 302df7b6bdc4566f99724be42a9457a9bb8df0df Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:00 +0530 Subject: [PATCH 35/93] refactor: extract shared rejectlabelslist component and simplify drawer states --- src/components/Kyc/RejectLabelsList.tsx | 32 ++++++++++++++++++ .../Kyc/states/KycActionRequired.tsx | 31 ++++++++++------- src/components/Kyc/states/KycFailed.tsx | 33 ++++--------------- 3 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 src/components/Kyc/RejectLabelsList.tsx 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/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx index 508dba7ee..418f28842 100644 --- a/src/components/Kyc/states/KycActionRequired.tsx +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -1,26 +1,33 @@ -import { Button } from '@/components/0_Bruddle/Button' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' -import InfoCard from '@/components/Global/InfoCard' +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. -export const KycActionRequired = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => { +// 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 ( -
+
- + + +
) diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 8365d6608..3088a6b06 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,16 +1,15 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { RejectLabelsList } from '../RejectLabelsList' import Card from '@/components/Global/Card' import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' -import { getRejectLabelInfo, hasTerminalRejectLabel } from '@/constants/sumsub-reject-labels.consts' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' import { useModalsContext } from '@/context/ModalsContext' -const MAX_RETRY_COUNT = 2 - // this component shows the kyc status when it's failed/rejected. // for sumsub: maps reject labels to human-readable reasons, handles terminal vs retryable states. // for bridge: shows raw reason string as before. @@ -49,26 +48,10 @@ export const KycFailed = ({ } }, [bridgeKycRejectedAt]) - // determine if this is a terminal (permanent) rejection - const isTerminal = useMemo(() => { - if (rejectType === 'FINAL') return true - if (failureCount && failureCount >= MAX_RETRY_COUNT) return true - if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true - return false - }, [rejectType, failureCount, rejectLabels]) - - // map sumsub labels to human-readable items for InfoCard - const reasonItems = useMemo(() => { - if (!isSumsub || !rejectLabels?.length) return null - return rejectLabels.map((label) => { - const info = getRejectLabelInfo(label) - return ( - - {info.title}: {info.description} - - ) - }) - }, [isSumsub, rejectLabels]) + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) // formatted bridge reason (legacy display) const formattedBridgeReason = useMemo(() => { @@ -94,9 +77,7 @@ export const KycFailed = ({ {!isSumsub && } - {isSumsub && reasonItems && ( - - )} + {isSumsub && } {isTerminal ? (
From 8e5ab8bdc4b460a4ee5bfbf88ea4b4241d15ae93 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:14 +0530 Subject: [PATCH 36/93] fix: handle kyc status transitions for non-success terminal states --- src/components/Kyc/SumsubKycFlow.tsx | 9 +++++++++ src/hooks/useSumsubKycFlow.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index 412bfde37..9e0cc345b 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -109,6 +109,7 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu error, showWrapper, accessToken, + liveKycStatus, handleInitiateKyc: originalHandleInitiateKyc, handleSdkComplete: originalHandleSdkComplete, handleClose, @@ -122,6 +123,14 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu 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 diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 6256d1307..d52cddc7b 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -47,7 +47,13 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { onKycSuccess?.() - } else if (prevStatus !== 'REJECTED' && liveKycStatus === 'REJECTED') { + } 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]) From 2bc70ba9c7e200c54860dce831f5304b7a0b7073 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:42:32 +0530 Subject: [PATCH 37/93] feat: add status-aware modals for regions verification page --- .../Kyc/modals/KycActionRequiredModal.tsx | 44 ++++++++++ .../Kyc/modals/KycProcessingModal.tsx | 27 +++++++ .../Kyc/modals/KycRejectedModal.tsx | 79 ++++++++++++++++++ .../views/RegionsVerification.view.tsx | 81 ++++++++++++++++++- 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/modals/KycActionRequiredModal.tsx create mode 100644 src/components/Kyc/modals/KycProcessingModal.tsx create mode 100644 src/components/Kyc/modals/KycRejectedModal.tsx 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/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx new file mode 100644 index 000000000..c904bd12d --- /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/modals/KycRejectedModal.tsx b/src/components/Kyc/modals/KycRejectedModal.tsx new file mode 100644 index 000000000..9d2aece39 --- /dev/null +++ b/src/components/Kyc/modals/KycRejectedModal.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import InfoCard from '@/components/Global/InfoCard' +import { RejectLabelsList } from '../RejectLabelsList' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +interface KycRejectedModalProps { + 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 KycRejectedModal = ({ + visible, + onClose, + onRetry, + isLoading, + rejectLabels, + rejectType, + failureCount, +}: KycRejectedModalProps) => { + const { setIsSupportModalOpen } = useModalsContext() + + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) + + return ( + + + {isTerminal && ( + + )} +
+ } + 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/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index aa5707259..1a48238eb 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -8,19 +8,52 @@ import NavHeader from '@/components/Global/NavHeader' import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' +import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' +import { KycRejectedModal } from '@/components/Kyc/modals/KycRejectedModal' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { useState, useCallback, useRef } from 'react' +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 { fetchUser } = useAuth() + const { user, fetchUser } = 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) @@ -29,11 +62,25 @@ const RegionsVerification = () => { // and status checks use the correct template after the confirmation modal closes const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) const [showBridgeTos, setShowBridgeTos] = useState(false) + // skip StartVerificationView when re-submitting (user already consented) + const [autoStartSdk, setAutoStartSdk] = useState(false) + + 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) setShowBridgeTos(false) + setAutoStartSdk(false) }, []) // intercept sumsub approval to check for bridge ToS @@ -68,6 +115,7 @@ const RegionsVerification = () => { onManualClose: () => { setSelectedRegion(null) setActiveRegionIntent(undefined) + setAutoStartSdk(false) }, }) @@ -86,6 +134,12 @@ const RegionsVerification = () => { await handleInitiateKyc(intent) }, [handleInitiateKyc, selectedRegion]) + // re-submission: skip StartVerificationView since user already consented + const handleResubmitKyc = useCallback(async () => { + setAutoStartSdk(true) + await handleStartKyc() + }, [handleStartKyc]) + return (
{
+ + + + + + {error &&

{error}

} { onClose={handleSumsubClose} onComplete={handleSdkComplete} onRefreshToken={refreshToken} + autoStart={autoStartSdk} /> Date: Fri, 20 Feb 2026 21:43:02 +0530 Subject: [PATCH 38/93] fix: re-submit verification flow and stop verification button in kyc modal --- src/components/Kyc/KycStatusDrawer.tsx | 20 +++- src/components/Kyc/SumsubKycWrapper.tsx | 127 +++++++++++++----------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index e0fe1bea9..762328478 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -14,6 +14,7 @@ import IFrameWrapper from '@/components/Global/IframeWrapper' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface KycStatusDrawerProps { isOpen: boolean @@ -31,6 +32,10 @@ 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, @@ -63,7 +68,8 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isLoading: isSumsubLoading, isVerificationProgressModalOpen: isSumsubProgressModalOpen, closeVerificationProgressModal: closeSumsubProgressModal, - } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + error: sumsubError, + } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose, regionIntent: sumsubRegionIntent }) const onRetry = async () => { if (provider === 'SUMSUB') { @@ -103,7 +109,13 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus /> ) case 'action_required': - return + return ( + + ) case 'failed': return ( KYC Status {renderContent()} + {sumsubError && provider === 'SUMSUB' && ( +

{sumsubError}

+ )} @@ -146,6 +161,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus onClose={handleSumsubClose} onComplete={handleSumsubComplete} onRefreshToken={sumsubRefreshToken} + autoStart /> diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 46fa7cb67..8fcf0bc57 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -18,6 +18,8 @@ interface SumsubKycWrapperProps { onComplete: () => void onError?: (error: unknown) => void onRefreshToken: () => Promise + /** skip StartVerificationView and launch SDK immediately (for re-submissions) */ + autoStart?: boolean } export const SumsubKycWrapper = ({ @@ -27,6 +29,7 @@ export const SumsubKycWrapper = ({ onComplete, onError, onRefreshToken, + autoStart, }: SumsubKycWrapperProps) => { const [isVerificationStarted, setIsVerificationStarted] = useState(false) const [sdkLoaded, setSdkLoaded] = useState(false) @@ -142,7 +145,7 @@ export const SumsubKycWrapper = ({ } }, [isVerificationStarted, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) - // reset state when modal closes + // reset state when modal closes, auto-start on re-submission useEffect(() => { if (!visible) { setIsVerificationStarted(false) @@ -155,8 +158,11 @@ export const SumsubKycWrapper = ({ } sdkInstanceRef.current = null } + } else if (autoStart) { + // skip StartVerificationView on re-submission (user already consented) + setIsVerificationStarted(true) } - }, [visible]) + }, [visible, autoStart]) const modalDetails = useMemo(() => { if (modalVariant === 'trouble') { @@ -204,62 +210,65 @@ export const SumsubKycWrapper = ({ }, [modalVariant, onClose, setIsSupportModalOpen]) return ( - - {!isVerificationStarted ? ( - // start verification view (provider-agnostic, not reusing StartVerificationView which references "Persona") - setIsVerificationStarted(true)} /> - ) : sdkLoadError ? ( - // script failed to load — show user-facing error -
- -

- Failed to load verification. Please check your connection and try again. -

- -
- ) : ( - // SDK container + controls -
-
-
-
- - + <> + + {!isVerificationStarted ? ( + setIsVerificationStarted(true)} + /> + ) : sdkLoadError ? ( +
+ +

+ Failed to load verification. Please check your connection and try again. +

+ +
+ ) : ( +
+
+
+
+ + +
-
- )} + )} +
+ {/* rendered outside the outer Modal to avoid pointer-events-none blocking clicks */} setIsHelpModalOpen(false)} @@ -267,13 +276,13 @@ export const SumsubKycWrapper = ({ description={modalDetails.description} icon={modalDetails.icon} iconContainerClassName={modalDetails.iconContainerClassName} - modalPanelClassName="max-w-full pointer-events-auto" + modalPanelClassName="max-w-full" ctaClassName="grid grid-cols-1 gap-3" contentContainerClassName="px-6 py-6" - modalClassName="!z-[10001] pointer-events-auto" + modalClassName="!z-[10001]" preventClose={true} ctas={modalDetails.ctas} /> - + ) } From bebaeeff8c5491c8114d58f8d65b3ebd896d712c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:31:45 +0530 Subject: [PATCH 39/93] fix: gate terminal rejection logic by provider and log tos retry failure --- src/components/Kyc/SumsubKycFlow.tsx | 7 +++++-- src/components/Kyc/states/KycFailed.tsx | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx index 9e0cc345b..e5d01ea85 100644 --- a/src/components/Kyc/SumsubKycFlow.tsx +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -220,9 +220,12 @@ export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...bu const result = await confirmBridgeTos() if (!result.data?.accepted) { - // retry after short delay + // bridge may not have registered acceptance yet — retry after short delay await new Promise((resolve) => setTimeout(resolve, 2000)) - await confirmBridgeTos() + const retryResult = await confirmBridgeTos() + if (!retryResult.data?.accepted) { + console.warn('[SumsubKycFlow] bridge ToS confirmation failed after retry') + } } // refetch user — the phase-transition effect will handle moving to 'complete' diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 3088a6b06..991ff9a3f 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -48,9 +48,10 @@ export const KycFailed = ({ } }, [bridgeKycRejectedAt]) + // only sumsub verifications can be terminal — bridge rejections always allow retry const isTerminal = useMemo( - () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), - [rejectType, failureCount, rejectLabels] + () => (isSumsub ? isTerminalRejection({ rejectType, failureCount, rejectLabels }) : false), + [isSumsub, rejectType, failureCount, rejectLabels] ) // formatted bridge reason (legacy display) From 05029c0f54475cdb3b149af0e0c0ec3cc755d037 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:31:50 +0530 Subject: [PATCH 40/93] docs: update kyc 2.0 testing guide with all implemented test cases --- docs/KYC-2.0-TESTING.md | 248 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/KYC-2.0-TESTING.md diff --git a/docs/KYC-2.0-TESTING.md b/docs/KYC-2.0-TESTING.md new file mode 100644 index 000000000..8b147e9a9 --- /dev/null +++ b/docs/KYC-2.0-TESTING.md @@ -0,0 +1,248 @@ +# KYC 2.0 — Testing Guide + +## Prerequisites + +- Backend (`peanut-api-ts`) running locally on `feat/kyc2.0` +- Frontend (`peanut-ui`) running locally on `feat/kyc2.0-error-retry-ui` +- Sumsub sandbox dashboard access +- A test user account + +## How to simulate statuses + +| Method | How | +|--------|-----| +| **Sumsub sandbox** | Use [test documents](https://docs.sumsub.com/docs/verification-document-templates) to trigger real review results | +| **DB manipulation** | Directly update `user_kyc_verifications` — see SQL snippets per test | +| **WebSocket** | Send manual `sumsub_kyc_status_update` message with `{ status, rejectLabels }` | +| **Sumsub dashboard** | Manually approve/reject applicant — fires real webhook | + +--- + +## Test cases + +### 1. Happy path — standard regions (Europe / North America) + +- [ ] 1a. Open regions & verification → select Europe or North America → `regionIntent: 'STANDARD'` sent to backend +- [ ] 1b. StartVerificationModal appears with region info → click "Start verification" +- [ ] 1c. SumsubKycWrapper opens → StartVerificationView (privacy consent) shown → click "Start" +- [ ] 1d. Complete Sumsub SDK with valid test document → SDK fires `onApplicantSubmitted` +- [ ] 1e. Modal transitions to "Verification in progress" phase (verifying) +- [ ] 1f. Sumsub approves (webhook GREEN) → modal transitions to "preparing" phase +- [ ] 1g. Provider submission completes → Bridge rail → REQUIRES_INFORMATION → modal transitions to "bridge_tos" phase +- [ ] 1h. Accept Bridge ToS in iframe → rails transition to ENABLED → modal shows "complete" phase +- [ ] 1i. Activity feed shows "Verified" green badge, region unlocked + +### 2. Happy path — LATAM + +- [ ] 2a. Open regions & verification → select LATAM (e.g. Argentina) → `regionIntent: 'LATAM'` sent +- [ ] 2b. Complete SDK with questionnaire (tax ID, PEP, FATCA fields appear for LATAM template) +- [ ] 2c. Sumsub approves → modal transitions: verifying → preparing → complete (no bridge_tos phase) +- [ ] 2d. Manteca rail → ENABLED, region unlocked +- [ ] 2e. Backend logs: `submitToProviders` sends Manteca payload with questionnaire data + +### 3. Happy path — foreign user (rest of world) + +- [ ] 3a. Open regions & verification → select "Rest of world" (e.g. India) → `regionIntent: 'STANDARD'` +- [ ] 3b. Complete SDK, get approved → same flow +- [ ] 3c. QR payment access: `useQrKycGate` returns `PROCEED_TO_PAY` (provides tax ID per-payment for Manteca super user) +- [ ] 3d. Bank transfer access: should NOT have Bridge/Manteca rails enabled (no provider submission for foreign users) + +### 4. Multi-phase completion modal + +- [ ] 4a. Phase 1 (verifying): clock icon + "We're verifying your identity" + preventClose +- [ ] 4b. Phase 2 (preparing): "Identity verified!" + "Preparing your account..." spinner + preventClose +- [ ] 4c. Phase 2 timeout: after 30s, shows "This is taking longer than expected" + escape button +- [ ] 4d. Phase 3 (bridge_tos): ToS prompt + Bridge iframe (only for standard regions with Bridge rails) +- [ ] 4e. Phase 4 (complete): "All set!" + "Continue" button +- [ ] 4f. LATAM flow: skips bridge_tos phase entirely (verifying → preparing → complete) +- [ ] 4g. Close during preparing: activity drawer shows per-provider status as fallback + +### 5. Bridge ToS acceptance + +- [ ] 5a. After sumsub APPROVED + Bridge submission → Bridge rail = REQUIRES_INFORMATION +- [ ] 5b. BridgeTosStep shown inline in multi-phase modal with ToS iframe +- [ ] 5c. Accept ToS → backend confirms via Bridge API → rails transition to ENABLED +- [ ] 5d. Skip ToS (close modal) → BridgeTosReminder appears in activity feed +- [ ] 5e. Click BridgeTosReminder → reopens BridgeTosStep modal → accept → rails ENABLED + +### 6. ACTION_REQUIRED state — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'ACTION_REQUIRED' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 6a. Activity feed (`KycStatusItem`) shows "Action needed" subtitle with amber status pill +- [ ] 6b. Open drawer → shows "Action needed" badge, warning InfoCard, reject labels listed +- [ ] 6c. Click "Continue verification" → opens Sumsub SDK with `autoStart` (skips StartVerificationView) +- [ ] 6d. `useQrKycGate` returns `IDENTITY_VERIFICATION_IN_PROGRESS` — user blocked from QR payments + +### 7. ACTION_REQUIRED state — regions page + +DB: same as test 6 + +- [ ] 7a. Click a locked region → KycActionRequiredModal appears (not StartVerificationModal) +- [ ] 7b. Modal shows "Action needed" title, reject labels, "Re-submit verification" button +- [ ] 7c. Click "Re-submit verification" → SumsubKycWrapper opens with `autoStart` (skips consent screen) +- [ ] 7d. User re-submits in SDK → flow continues normally + +### 8. REJECTED — retryable (RETRY) — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'RETRY', reject_labels = '["DOCUMENT_BAD_QUALITY", "SELFIE_MISMATCH"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 8a. Activity feed shows "Rejected" subtitle with red status pill +- [ ] 8b. Open drawer → human-readable reasons: "Poor document quality..." and "Selfie doesn't match..." +- [ ] 8c. "Retry verification" button visible (not terminal) +- [ ] 8d. Click retry → opens Sumsub SDK with `autoStart` and correct `regionIntent` + +### 9. REJECTED — retryable (RETRY) — regions page + +DB: same as test 8 + +- [ ] 9a. Click a locked region → KycRejectedModal appears with amber styling +- [ ] 9b. Title: "Verification unsuccessful", shows reject labels, "Retry verification" button +- [ ] 9c. Click retry → SumsubKycWrapper opens with `autoStart` +- [ ] 9d. User re-submits → flow continues normally + +### 10. REJECTED — terminal (FINAL) — activity drawer + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'FINAL', reject_labels = '["REGULATIONS_VIOLATIONS"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 10a. Open drawer → `isTerminal = true` +- [ ] 10b. Shows "Your verification cannot be retried" InfoCard with lock icon, no retry button +- [ ] 10c. "Contact support" button opens Crisp support modal + +### 11. REJECTED — terminal (FINAL) — regions page + +DB: same as test 10 + +- [ ] 11a. Click a locked region → KycRejectedModal appears with red styling +- [ ] 11b. Title: "Verification failed", lock icon, "Your verification cannot be retried" +- [ ] 11c. "Contact support" button (no retry button) + +### 12. REJECTED — terminal via labels (no rejectType needed) + +DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = NULL, reject_labels = '["DOCUMENT_FAKE"]' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 12a. Both drawer and regions page modal → terminal state triggered by `hasTerminalRejectLabel` +- [ ] 12b. Same locked UI — support contact only, no retry + +### 13. REJECTED — terminal via failure count (2+ rejections) + +DB: insert 2+ rows with `provider = 'SUMSUB'` and `status = 'REJECTED'` for the same user. + +- [ ] 13a. Both drawer and regions page modal → `failureCount >= 2` triggers terminal +- [ ] 13b. Same locked UI — support contact only + +### 14. PENDING / IN_REVIEW — regions page + +DB: `UPDATE user_kyc_verifications SET status = 'PENDING' WHERE user_id = '' AND provider = 'SUMSUB';` + +- [ ] 14a. Click a locked region → KycProcessingModal appears +- [ ] 14b. Shows "Verification in progress" title, "We're reviewing your identity" +- [ ] 14c. Close button works, no retry/resubmit CTA + +### 15. Status-aware modal routing on regions page + +This tests the `getModalVariant()` logic: + +- [ ] 15a. No verification → StartVerificationModal (start fresh) +- [ ] 15b. Status NOT_STARTED → StartVerificationModal +- [ ] 15c. Status PENDING/IN_REVIEW → KycProcessingModal +- [ ] 15d. Status ACTION_REQUIRED → KycActionRequiredModal +- [ ] 15e. Status REJECTED (retryable) → KycRejectedModal with retry +- [ ] 15f. Status REJECTED (terminal) → KycRejectedModal without retry +- [ ] 15g. Different regionIntent than existing → StartVerificationModal (new verification) + +### 16. Stop verification flow + +- [ ] 16a. During SDK verification, click "Stop verification process" button at bottom +- [ ] 16b. Confirmation modal appears: "Stop verification?" with description +- [ ] 16c. Click "Stop verification" → SDK destroyed, modal closes +- [ ] 16d. Click "Continue verifying" → confirmation modal closes, SDK still active +- [ ] 16e. "Having trouble?" button → help modal → "Chat with support" opens Crisp + +### 17. WebSocket real-time updates + +- [ ] 17a. Start KYC, complete SDK → "Verification in progress" modal (verifying phase) +- [ ] 17b. Sumsub dashboard approve → WebSocket fires `sumsub_kyc_status_update` with APPROVED +- [ ] 17c. Modal transitions to preparing phase (not closed immediately) +- [ ] 17d. `user_rail_status_changed` WebSocket fires as rails update +- [ ] 17e. All rails settled → modal transitions to complete phase +- [ ] 17f. REJECTED webhook → progress modal closes, user data refreshed for drawer + +### 18. Resume abandoned flow + +- [ ] 18a. Start KYC, begin SDK, stop verification → SDK destroyed +- [ ] 18b. Re-open KYC later → same applicant reused, SDK picks up where user left off + +### 19. SDK error / script load failure + +- [ ] 19a. Block `static.sumsub.com` in devtools network tab, open KYC, start verification +- [ ] 19b. Error UI: red alert icon, "Failed to load verification..." + Close button +- [ ] 19c. "Having trouble?" button (when SDK loads but hangs) → help modal → "Chat with support" + +### 20. Bridge KYC status (regression check) + +- [ ] 20a. User with `bridgeKycStatus: 'approved'` → activity feed "Approved" green badge, drawer shows completed +- [ ] 20b. User with `bridgeKycStatus: 'under_review'` → "In progress", drawer shows processing +- [ ] 20c. User with `bridgeKycStatus: 'rejected'` → drawer shows raw bridge reason (not mapped labels) + +### 21. QR payment gating (`useQrKycGate`) + +- [ ] 21a. No KYC at all → `REQUIRES_IDENTITY_VERIFICATION` — blocked +- [ ] 21b. Sumsub APPROVED → `PROCEED_TO_PAY` +- [ ] 21c. Manteca ACTIVE → `PROCEED_TO_PAY` +- [ ] 21d. Bridge approved → `PROCEED_TO_PAY` +- [ ] 21e. Sumsub PENDING / IN_REVIEW → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked +- [ ] 21f. Sumsub ACTION_REQUIRED → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked +- [ ] 21g. `paymentProcessor === 'SIMPLEFI'` → `PROCEED_TO_PAY` regardless + +### 22. Provider submission (backend-only — check via logs/DB) + +- [ ] 22a. Sumsub approved + PENDING Bridge rail → Bridge payload built, UserRail → `REQUIRES_INFORMATION`, UserRailEvent created +- [ ] 22b. Sumsub approved + PENDING Manteca rail → Manteca payload built, questionnaire data extracted, image uploaded, UserRail → `ENABLED` +- [ ] 22c. Bridge accepts, Manteca API down → Bridge `REQUIRES_INFORMATION`, Manteca stays `PENDING`, Sentry logged +- [ ] 22d. Both providers fail → both rails stay `PENDING`, errors logged +- [ ] 22e. Duplicate webhook → status unchanged → early exit, no duplicate submission +- [ ] 22f. `applicantCreated` webhook → `sumsubApplicantId` linked via `externalUserId` in DB +- [ ] 22g. WebSocket `user_rail_status_changed` fires for each rail status transition + +### 23. Rail status tracking (frontend) + +- [ ] 23a. After APPROVED, `useRailStatusTracking` starts polling (4s interval) +- [ ] 23b. WebSocket `user_rail_status_changed` → immediate status update without waiting for poll +- [ ] 23c. Bridge rail REQUIRES_INFORMATION → provider status = `requires_tos` +- [ ] 23d. Manteca rail ENABLED → provider status = `enabled` +- [ ] 23e. All rails settled → tracking stops automatically +- [ ] 23f. Rail FAILED → provider status = `failed` + +### 24. Re-submit from activity drawer (KycStatusDrawer) + +- [ ] 24a. ACTION_REQUIRED in drawer → click "Continue verification" → Sumsub SDK opens with `autoStart` +- [ ] 24b. Correct `regionIntent` derived from verification metadata (STANDARD vs LATAM) +- [ ] 24c. REJECTED (retryable) in drawer → click "Retry verification" → Sumsub SDK opens with `autoStart` +- [ ] 24d. Error from `useSumsubKycFlow` displayed in drawer below content +- [ ] 24e. REJECTED (terminal) in drawer → no retry button, "Contact support" only + +--- + +## Known blockers + +| Blocker | Impact | Workaround | +|---------|--------|------------| +| No self-healing submission retry | If `submitToProviders()` fails, rails stay PENDING forever | Manually re-trigger via DB or restart. Poller only polls sumsub status, not re-triggers submission. | +| `moderationComment` not in events | Can't show reviewer's human note | Label mapping covers common cases. | +| Bridge ToS not e2e tested | Full flow (create customer → ToS prompt → accept → ENABLED) untested with real Bridge customer | Test with sandbox Bridge account. | +| No ProviderStatusList component | After APPROVED, activity drawer doesn't show per-provider rail status breakdown | Check DB for rail statuses. | + +--- + +## Issues found during testing + +> Add any bugs, weird behavior, or unexpected results here. Include the test case number, what you expected, and what actually happened. + +| # | Test case | Description | Severity | Fixed? | +|---|-----------|-------------|----------|--------| +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | | | From 3c62aef50b99002c2f41ff176405b339b72ecbd9 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:33:38 +0530 Subject: [PATCH 41/93] fix: remove kyc testing guide --- docs/KYC-2.0-TESTING.md | 248 ---------------------------------------- 1 file changed, 248 deletions(-) delete mode 100644 docs/KYC-2.0-TESTING.md diff --git a/docs/KYC-2.0-TESTING.md b/docs/KYC-2.0-TESTING.md deleted file mode 100644 index 8b147e9a9..000000000 --- a/docs/KYC-2.0-TESTING.md +++ /dev/null @@ -1,248 +0,0 @@ -# KYC 2.0 — Testing Guide - -## Prerequisites - -- Backend (`peanut-api-ts`) running locally on `feat/kyc2.0` -- Frontend (`peanut-ui`) running locally on `feat/kyc2.0-error-retry-ui` -- Sumsub sandbox dashboard access -- A test user account - -## How to simulate statuses - -| Method | How | -|--------|-----| -| **Sumsub sandbox** | Use [test documents](https://docs.sumsub.com/docs/verification-document-templates) to trigger real review results | -| **DB manipulation** | Directly update `user_kyc_verifications` — see SQL snippets per test | -| **WebSocket** | Send manual `sumsub_kyc_status_update` message with `{ status, rejectLabels }` | -| **Sumsub dashboard** | Manually approve/reject applicant — fires real webhook | - ---- - -## Test cases - -### 1. Happy path — standard regions (Europe / North America) - -- [ ] 1a. Open regions & verification → select Europe or North America → `regionIntent: 'STANDARD'` sent to backend -- [ ] 1b. StartVerificationModal appears with region info → click "Start verification" -- [ ] 1c. SumsubKycWrapper opens → StartVerificationView (privacy consent) shown → click "Start" -- [ ] 1d. Complete Sumsub SDK with valid test document → SDK fires `onApplicantSubmitted` -- [ ] 1e. Modal transitions to "Verification in progress" phase (verifying) -- [ ] 1f. Sumsub approves (webhook GREEN) → modal transitions to "preparing" phase -- [ ] 1g. Provider submission completes → Bridge rail → REQUIRES_INFORMATION → modal transitions to "bridge_tos" phase -- [ ] 1h. Accept Bridge ToS in iframe → rails transition to ENABLED → modal shows "complete" phase -- [ ] 1i. Activity feed shows "Verified" green badge, region unlocked - -### 2. Happy path — LATAM - -- [ ] 2a. Open regions & verification → select LATAM (e.g. Argentina) → `regionIntent: 'LATAM'` sent -- [ ] 2b. Complete SDK with questionnaire (tax ID, PEP, FATCA fields appear for LATAM template) -- [ ] 2c. Sumsub approves → modal transitions: verifying → preparing → complete (no bridge_tos phase) -- [ ] 2d. Manteca rail → ENABLED, region unlocked -- [ ] 2e. Backend logs: `submitToProviders` sends Manteca payload with questionnaire data - -### 3. Happy path — foreign user (rest of world) - -- [ ] 3a. Open regions & verification → select "Rest of world" (e.g. India) → `regionIntent: 'STANDARD'` -- [ ] 3b. Complete SDK, get approved → same flow -- [ ] 3c. QR payment access: `useQrKycGate` returns `PROCEED_TO_PAY` (provides tax ID per-payment for Manteca super user) -- [ ] 3d. Bank transfer access: should NOT have Bridge/Manteca rails enabled (no provider submission for foreign users) - -### 4. Multi-phase completion modal - -- [ ] 4a. Phase 1 (verifying): clock icon + "We're verifying your identity" + preventClose -- [ ] 4b. Phase 2 (preparing): "Identity verified!" + "Preparing your account..." spinner + preventClose -- [ ] 4c. Phase 2 timeout: after 30s, shows "This is taking longer than expected" + escape button -- [ ] 4d. Phase 3 (bridge_tos): ToS prompt + Bridge iframe (only for standard regions with Bridge rails) -- [ ] 4e. Phase 4 (complete): "All set!" + "Continue" button -- [ ] 4f. LATAM flow: skips bridge_tos phase entirely (verifying → preparing → complete) -- [ ] 4g. Close during preparing: activity drawer shows per-provider status as fallback - -### 5. Bridge ToS acceptance - -- [ ] 5a. After sumsub APPROVED + Bridge submission → Bridge rail = REQUIRES_INFORMATION -- [ ] 5b. BridgeTosStep shown inline in multi-phase modal with ToS iframe -- [ ] 5c. Accept ToS → backend confirms via Bridge API → rails transition to ENABLED -- [ ] 5d. Skip ToS (close modal) → BridgeTosReminder appears in activity feed -- [ ] 5e. Click BridgeTosReminder → reopens BridgeTosStep modal → accept → rails ENABLED - -### 6. ACTION_REQUIRED state — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'ACTION_REQUIRED' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 6a. Activity feed (`KycStatusItem`) shows "Action needed" subtitle with amber status pill -- [ ] 6b. Open drawer → shows "Action needed" badge, warning InfoCard, reject labels listed -- [ ] 6c. Click "Continue verification" → opens Sumsub SDK with `autoStart` (skips StartVerificationView) -- [ ] 6d. `useQrKycGate` returns `IDENTITY_VERIFICATION_IN_PROGRESS` — user blocked from QR payments - -### 7. ACTION_REQUIRED state — regions page - -DB: same as test 6 - -- [ ] 7a. Click a locked region → KycActionRequiredModal appears (not StartVerificationModal) -- [ ] 7b. Modal shows "Action needed" title, reject labels, "Re-submit verification" button -- [ ] 7c. Click "Re-submit verification" → SumsubKycWrapper opens with `autoStart` (skips consent screen) -- [ ] 7d. User re-submits in SDK → flow continues normally - -### 8. REJECTED — retryable (RETRY) — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'RETRY', reject_labels = '["DOCUMENT_BAD_QUALITY", "SELFIE_MISMATCH"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 8a. Activity feed shows "Rejected" subtitle with red status pill -- [ ] 8b. Open drawer → human-readable reasons: "Poor document quality..." and "Selfie doesn't match..." -- [ ] 8c. "Retry verification" button visible (not terminal) -- [ ] 8d. Click retry → opens Sumsub SDK with `autoStart` and correct `regionIntent` - -### 9. REJECTED — retryable (RETRY) — regions page - -DB: same as test 8 - -- [ ] 9a. Click a locked region → KycRejectedModal appears with amber styling -- [ ] 9b. Title: "Verification unsuccessful", shows reject labels, "Retry verification" button -- [ ] 9c. Click retry → SumsubKycWrapper opens with `autoStart` -- [ ] 9d. User re-submits → flow continues normally - -### 10. REJECTED — terminal (FINAL) — activity drawer - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = 'FINAL', reject_labels = '["REGULATIONS_VIOLATIONS"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 10a. Open drawer → `isTerminal = true` -- [ ] 10b. Shows "Your verification cannot be retried" InfoCard with lock icon, no retry button -- [ ] 10c. "Contact support" button opens Crisp support modal - -### 11. REJECTED — terminal (FINAL) — regions page - -DB: same as test 10 - -- [ ] 11a. Click a locked region → KycRejectedModal appears with red styling -- [ ] 11b. Title: "Verification failed", lock icon, "Your verification cannot be retried" -- [ ] 11c. "Contact support" button (no retry button) - -### 12. REJECTED — terminal via labels (no rejectType needed) - -DB: `UPDATE user_kyc_verifications SET status = 'REJECTED', reject_type = NULL, reject_labels = '["DOCUMENT_FAKE"]' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 12a. Both drawer and regions page modal → terminal state triggered by `hasTerminalRejectLabel` -- [ ] 12b. Same locked UI — support contact only, no retry - -### 13. REJECTED — terminal via failure count (2+ rejections) - -DB: insert 2+ rows with `provider = 'SUMSUB'` and `status = 'REJECTED'` for the same user. - -- [ ] 13a. Both drawer and regions page modal → `failureCount >= 2` triggers terminal -- [ ] 13b. Same locked UI — support contact only - -### 14. PENDING / IN_REVIEW — regions page - -DB: `UPDATE user_kyc_verifications SET status = 'PENDING' WHERE user_id = '' AND provider = 'SUMSUB';` - -- [ ] 14a. Click a locked region → KycProcessingModal appears -- [ ] 14b. Shows "Verification in progress" title, "We're reviewing your identity" -- [ ] 14c. Close button works, no retry/resubmit CTA - -### 15. Status-aware modal routing on regions page - -This tests the `getModalVariant()` logic: - -- [ ] 15a. No verification → StartVerificationModal (start fresh) -- [ ] 15b. Status NOT_STARTED → StartVerificationModal -- [ ] 15c. Status PENDING/IN_REVIEW → KycProcessingModal -- [ ] 15d. Status ACTION_REQUIRED → KycActionRequiredModal -- [ ] 15e. Status REJECTED (retryable) → KycRejectedModal with retry -- [ ] 15f. Status REJECTED (terminal) → KycRejectedModal without retry -- [ ] 15g. Different regionIntent than existing → StartVerificationModal (new verification) - -### 16. Stop verification flow - -- [ ] 16a. During SDK verification, click "Stop verification process" button at bottom -- [ ] 16b. Confirmation modal appears: "Stop verification?" with description -- [ ] 16c. Click "Stop verification" → SDK destroyed, modal closes -- [ ] 16d. Click "Continue verifying" → confirmation modal closes, SDK still active -- [ ] 16e. "Having trouble?" button → help modal → "Chat with support" opens Crisp - -### 17. WebSocket real-time updates - -- [ ] 17a. Start KYC, complete SDK → "Verification in progress" modal (verifying phase) -- [ ] 17b. Sumsub dashboard approve → WebSocket fires `sumsub_kyc_status_update` with APPROVED -- [ ] 17c. Modal transitions to preparing phase (not closed immediately) -- [ ] 17d. `user_rail_status_changed` WebSocket fires as rails update -- [ ] 17e. All rails settled → modal transitions to complete phase -- [ ] 17f. REJECTED webhook → progress modal closes, user data refreshed for drawer - -### 18. Resume abandoned flow - -- [ ] 18a. Start KYC, begin SDK, stop verification → SDK destroyed -- [ ] 18b. Re-open KYC later → same applicant reused, SDK picks up where user left off - -### 19. SDK error / script load failure - -- [ ] 19a. Block `static.sumsub.com` in devtools network tab, open KYC, start verification -- [ ] 19b. Error UI: red alert icon, "Failed to load verification..." + Close button -- [ ] 19c. "Having trouble?" button (when SDK loads but hangs) → help modal → "Chat with support" - -### 20. Bridge KYC status (regression check) - -- [ ] 20a. User with `bridgeKycStatus: 'approved'` → activity feed "Approved" green badge, drawer shows completed -- [ ] 20b. User with `bridgeKycStatus: 'under_review'` → "In progress", drawer shows processing -- [ ] 20c. User with `bridgeKycStatus: 'rejected'` → drawer shows raw bridge reason (not mapped labels) - -### 21. QR payment gating (`useQrKycGate`) - -- [ ] 21a. No KYC at all → `REQUIRES_IDENTITY_VERIFICATION` — blocked -- [ ] 21b. Sumsub APPROVED → `PROCEED_TO_PAY` -- [ ] 21c. Manteca ACTIVE → `PROCEED_TO_PAY` -- [ ] 21d. Bridge approved → `PROCEED_TO_PAY` -- [ ] 21e. Sumsub PENDING / IN_REVIEW → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked -- [ ] 21f. Sumsub ACTION_REQUIRED → `IDENTITY_VERIFICATION_IN_PROGRESS` — blocked -- [ ] 21g. `paymentProcessor === 'SIMPLEFI'` → `PROCEED_TO_PAY` regardless - -### 22. Provider submission (backend-only — check via logs/DB) - -- [ ] 22a. Sumsub approved + PENDING Bridge rail → Bridge payload built, UserRail → `REQUIRES_INFORMATION`, UserRailEvent created -- [ ] 22b. Sumsub approved + PENDING Manteca rail → Manteca payload built, questionnaire data extracted, image uploaded, UserRail → `ENABLED` -- [ ] 22c. Bridge accepts, Manteca API down → Bridge `REQUIRES_INFORMATION`, Manteca stays `PENDING`, Sentry logged -- [ ] 22d. Both providers fail → both rails stay `PENDING`, errors logged -- [ ] 22e. Duplicate webhook → status unchanged → early exit, no duplicate submission -- [ ] 22f. `applicantCreated` webhook → `sumsubApplicantId` linked via `externalUserId` in DB -- [ ] 22g. WebSocket `user_rail_status_changed` fires for each rail status transition - -### 23. Rail status tracking (frontend) - -- [ ] 23a. After APPROVED, `useRailStatusTracking` starts polling (4s interval) -- [ ] 23b. WebSocket `user_rail_status_changed` → immediate status update without waiting for poll -- [ ] 23c. Bridge rail REQUIRES_INFORMATION → provider status = `requires_tos` -- [ ] 23d. Manteca rail ENABLED → provider status = `enabled` -- [ ] 23e. All rails settled → tracking stops automatically -- [ ] 23f. Rail FAILED → provider status = `failed` - -### 24. Re-submit from activity drawer (KycStatusDrawer) - -- [ ] 24a. ACTION_REQUIRED in drawer → click "Continue verification" → Sumsub SDK opens with `autoStart` -- [ ] 24b. Correct `regionIntent` derived from verification metadata (STANDARD vs LATAM) -- [ ] 24c. REJECTED (retryable) in drawer → click "Retry verification" → Sumsub SDK opens with `autoStart` -- [ ] 24d. Error from `useSumsubKycFlow` displayed in drawer below content -- [ ] 24e. REJECTED (terminal) in drawer → no retry button, "Contact support" only - ---- - -## Known blockers - -| Blocker | Impact | Workaround | -|---------|--------|------------| -| No self-healing submission retry | If `submitToProviders()` fails, rails stay PENDING forever | Manually re-trigger via DB or restart. Poller only polls sumsub status, not re-triggers submission. | -| `moderationComment` not in events | Can't show reviewer's human note | Label mapping covers common cases. | -| Bridge ToS not e2e tested | Full flow (create customer → ToS prompt → accept → ENABLED) untested with real Bridge customer | Test with sandbox Bridge account. | -| No ProviderStatusList component | After APPROVED, activity drawer doesn't show per-provider rail status breakdown | Check DB for rail statuses. | - ---- - -## Issues found during testing - -> Add any bugs, weird behavior, or unexpected results here. Include the test case number, what you expected, and what actually happened. - -| # | Test case | Description | Severity | Fixed? | -|---|-----------|-------------|----------|--------| -| | | | | | -| | | | | | -| | | | | | -| | | | | | -| | | | | | From 4cda4bbece0d1e28479a27f9b0cfc027b7a706cb Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:38:12 +0530 Subject: [PATCH 42/93] feat: bridge additional document collection UI adds requires_documents display status to rail tracking so bridge rails needing extra documents are distinguished from those needing ToS acceptance. new KycRequiresDocuments component shows human-readable requirement descriptions and a submit button that opens the sumsub SDK with the peanut-additional-docs level. wires into KycStatusDrawer to show the additional docs UI when bridge rails have REQUIRES_EXTRA_INFORMATION status, and extends initiateSumsubKyc to accept a levelName parameter. --- src/app/actions/sumsub.ts | 2 + src/components/Kyc/KycStatusDrawer.tsx | 26 ++++++++++ .../Kyc/states/KycRequiresDocuments.tsx | 47 +++++++++++++++++++ src/constants/bridge-requirements.consts.ts | 41 ++++++++++++++++ src/hooks/useRailStatusTracking.ts | 12 ++++- src/hooks/useSumsubKycFlow.ts | 7 ++- src/interfaces/interfaces.ts | 2 +- 7 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 src/components/Kyc/states/KycRequiresDocuments.tsx create mode 100644 src/constants/bridge-requirements.consts.ts diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 39629d9a1..7222004c6 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -10,6 +10,7 @@ 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 @@ -19,6 +20,7 @@ export const initiateSumsubKyc = async (params?: { const body: Record = { regionIntent: params?.regionIntent, + levelName: params?.levelName, } try { diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 762328478..1811689ec 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -2,6 +2,7 @@ import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' +import { KycRequiresDocuments } from './states/KycRequiresDocuments' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { type IUserKycVerification } from '@/interfaces' @@ -83,6 +84,16 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading + // check if any bridge rail needs additional documents + const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( + (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' + ) + const additionalRequirements: string[] = + bridgeRailsNeedingDocs.length > 0 + ? ((bridgeRailsNeedingDocs[0].metadata?.additionalRequirements as string[]) ?? []) + : [] + const needsAdditionalDocs = additionalRequirements.length > 0 + // count sumsub rejections for failure lockout. // counts total REJECTED entries — accurate if backend creates a new row per attempt. // if backend updates in-place (single row), this will be 0 or 1 and the lockout @@ -90,7 +101,22 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const sumsubFailureCount = user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + const handleSubmitAdditionalDocs = async () => { + await initiateSumsub(undefined, 'peanut-additional-docs') + } + const renderContent = () => { + // bridge additional document requirement takes priority over verification status + if (needsAdditionalDocs) { + return ( + + ) + } + switch (statusCategory) { case 'processing': return ( diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx new file mode 100644 index 000000000..9b6a247ef --- /dev/null +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -0,0 +1,47 @@ +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' +import { getRequirementLabel } from '@/constants/bridge-requirements.consts' +import type { IconName } from '@/components/Global/Icons/Icon' + +// 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 ( +
+ + +
+

+ Your payment provider requires additional verification documents. +

+ {requirements.map((req) => { + const label = getRequirementLabel(req) + return ( +
+

{label.title}

+

{label.description}

+
+ ) + })} +
+ + +
+ ) +} diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts new file mode 100644 index 000000000..6e7cb5bef --- /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/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts index 2be342ca0..bc3dbf8e1 100644 --- a/src/hooks/useRailStatusTracking.ts +++ b/src/hooks/useRailStatusTracking.ts @@ -9,6 +9,7 @@ interface RailStatusTrackingResult { providers: ProviderStatus[] allSettled: boolean needsBridgeTos: boolean + needsAdditionalDocs: boolean startTracking: () => void stopTracking: () => void } @@ -35,8 +36,9 @@ function deriveStatus(rail: IUserRail): ProviderDisplayStatus { switch (rail.status) { case 'ENABLED': return 'enabled' - case 'REQUIRES_INFORMATION': case 'REQUIRES_EXTRA_INFORMATION': + return 'requires_documents' + case 'REQUIRES_INFORMATION': return 'requires_tos' case 'FAILED': case 'REJECTED': @@ -50,7 +52,8 @@ function deriveStatus(rail: IUserRail): ProviderDisplayStatus { // pick the "most advanced" status for a provider group function deriveGroupStatus(rails: IUserRail[]): ProviderDisplayStatus { const statuses = rails.map(deriveStatus) - // priority: requires_tos > enabled > failed > setting_up + // 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' @@ -110,6 +113,10 @@ export const useRailStatusTracking = (): RailStatusTrackingResult => { 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) { @@ -156,6 +163,7 @@ export const useRailStatusTracking = (): RailStatusTrackingResult => { providers, allSettled, needsBridgeTos, + needsAdditionalDocs, startTracking, stopTracking, } diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index d52cddc7b..f6d5a248f 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -79,12 +79,15 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [regionIntent]) const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent) => { + async (overrideIntent?: KYCRegionIntent, levelName?: string) => { setIsLoading(true) setError(null) try { - const response = await initiateSumsubKyc({ regionIntent: overrideIntent ?? regionIntent }) + const response = await initiateSumsubKyc({ + regionIntent: overrideIntent ?? regionIntent, + levelName, + }) if (response.error) { setError(response.error) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 6b4b0868d..b201ce45a 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -7,7 +7,7 @@ export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username' export type KycModalPhase = 'verifying' | 'preparing' | 'bridge_tos' | 'complete' // per-provider rail status for tracking after kyc approval -export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'enabled' | 'failed' +export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'requires_documents' | 'enabled' | 'failed' export interface ProviderStatus { providerCode: string From 91d7b1f922f6fbbcc36995cac7833fbb899ec2dc Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:01:06 +0530 Subject: [PATCH 43/93] chore: format --- src/components/Kyc/states/KycRequiresDocuments.tsx | 6 ++---- src/constants/bridge-requirements.consts.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx index 9b6a247ef..671e26f5f 100644 --- a/src/components/Kyc/states/KycRequiresDocuments.tsx +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -19,15 +19,13 @@ export const KycRequiresDocuments = ({
-

- Your payment provider requires additional verification documents. -

+

Your payment provider requires additional verification documents.

{requirements.map((req) => { const label = getRequirementLabel(req) return (

{label.title}

-

{label.description}

+

{label.description}

) })} diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts index 6e7cb5bef..d50ae2868 100644 --- a/src/constants/bridge-requirements.consts.ts +++ b/src/constants/bridge-requirements.consts.ts @@ -31,11 +31,11 @@ const FALLBACK_LABEL: RequirementLabelInfo = { /** 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, - } + 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, + } + ) } From bcb698ce6d18ee3cd7371116c9a263f3a6118914 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:28:28 +0530 Subject: [PATCH 44/93] fix: address code review findings - fix icon name: document -> docs, remove unsafe IconName cast - preserve levelName across token refresh via levelNameRef - add explicit additionalRequirements type to IUserRail.metadata - fix needsAdditionalDocs: derive from rail status, not empty requirements - add fallback UI when requirements array is empty --- src/components/Kyc/KycStatusDrawer.tsx | 9 +++--- .../Kyc/states/KycRequiresDocuments.tsx | 28 +++++++++++-------- src/hooks/useSumsubKycFlow.ts | 12 ++++++-- src/interfaces/interfaces.ts | 2 +- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 1811689ec..2faef4a66 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -88,11 +88,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' ) - const additionalRequirements: string[] = - bridgeRailsNeedingDocs.length > 0 - ? ((bridgeRailsNeedingDocs[0].metadata?.additionalRequirements as string[]) ?? []) - : [] - const needsAdditionalDocs = additionalRequirements.length > 0 + const needsAdditionalDocs = bridgeRailsNeedingDocs.length > 0 + const additionalRequirements: string[] = needsAdditionalDocs + ? (bridgeRailsNeedingDocs[0].metadata?.additionalRequirements ?? []) + : [] // count sumsub rejections for failure lockout. // counts total REJECTED entries — accurate if backend creates a new row per attempt. diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx index 671e26f5f..4cccf90be 100644 --- a/src/components/Kyc/states/KycRequiresDocuments.tsx +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -1,7 +1,6 @@ import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { Button } from '@/components/0_Bruddle/Button' import { getRequirementLabel } from '@/constants/bridge-requirements.consts' -import type { IconName } from '@/components/Global/Icons/Icon' // shows when a payment provider (bridge) needs additional documents from the user. // displays the specific requirements with human-readable descriptions. @@ -20,19 +19,26 @@ export const KycRequiresDocuments = ({

Your payment provider requires additional verification documents.

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

{label.title}

-

{label.description}

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

{label.title}

+

{label.description}

+
+ ) + }) + ) : ( +
+

Additional Document

+

Please provide the requested document.

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

- ) : ( -

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

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

{sumsubError}

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

{sumsubFlow.error}

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

{error}

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

{flow.error}

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

{error}

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

{flow.error}

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

Verify your details

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

Activity

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

Identity verification

+

{title}

{subtitle}

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

Activity

{isViewingOwnHistory && needsBridgeTos && } - {isViewingOwnHistory && user?.user && (() => { - const regionEntries = groupKycByRegion(user.user) - return regionEntries.length > 0 ? ( -
- {regionEntries.map((entry) => ( - - ))} -
- ) : ( - - ) - })()} + {isViewingOwnHistory && + user?.user && + (() => { + const regionEntries = groupKycByRegion(user.user) + return regionEntries.length > 0 ? ( +
+ {regionEntries.map((entry) => ( + + ))} +
+ ) : ( + + ) + })()} {!isViewingOwnHistory && ( Date: Wed, 25 Feb 2026 15:16:01 +0530 Subject: [PATCH 56/93] fix: qa bugs --- src/components/Global/IframeWrapper/index.tsx | 5 +++-- src/components/Kyc/BridgeTosStep.tsx | 2 +- src/components/Kyc/KycStatusDrawer.tsx | 5 +++++ .../Kyc/KycVerificationInProgressModal.tsx | 12 +--------- src/components/Kyc/SumsubKycModals.tsx | 7 +++++- src/components/Kyc/states/KycCompleted.tsx | 22 +++++++++++++++++++ src/hooks/useMultiPhaseKycFlow.ts | 7 +++--- 7 files changed, 42 insertions(+), 18 deletions(-) diff --git a/src/components/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index f41e820d3..e33a74c9b 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -12,14 +12,15 @@ export type IFrameWrapperProps = { visible: boolean onClose: (source?: 'manual' | 'completed' | 'tos_accepted') => void closeConfirmMessage?: string + skipStartView?: boolean } -const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWrapperProps) => { +const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartView }: IFrameWrapperProps) => { const enableConfirmationPrompt = closeConfirmMessage !== undefined const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') const [copied, setCopied] = useState(false) - const [isVerificationStarted, setIsVerificationStarted] = useState(false) + const [isVerificationStarted, setIsVerificationStarted] = useState(skipStartView ?? false) const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 4f9590deb..12152dbb3 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -122,7 +122,7 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp /> )} - {tosLink && } + {tosLink && } ) } diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 2ef606b7a..5b8b7e808 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -97,6 +97,11 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus bridgeKycApprovedAt={verification?.approvedAt ?? user?.user?.bridgeKycApprovedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc} + rails={user?.rails?.filter((r) => { + if (region === 'STANDARD') return r.rail.provider.code === 'BRIDGE' + if (region === 'LATAM') return r.rail.provider.code === 'MANTECA' + return true + })} /> ) case 'action_required': diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index c7f23d04c..40095eeea 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -113,22 +113,12 @@ export const KycVerificationInProgressModal = ({ ctas={[ { text: tosError ? 'Continue' : 'Accept Terms', - onClick: tosError ? (onSkipTerms ?? onClose) : (onAcceptTerms ?? onClose), + onClick: tosError ? onClose : (onAcceptTerms ?? onClose), disabled: isLoadingTos, variant: 'purple', className: 'w-full', shadowSize: '4', }, - ...(!tosError - ? [ - { - text: 'Skip for now', - onClick: onSkipTerms ?? onClose, - variant: 'transparent' as const, - className: 'underline text-sm font-medium w-full h-fit mt-3', - }, - ] - : []), ]} preventClose hideModalCloseButton diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx index b2c72fa6d..a38672b94 100644 --- a/src/components/Kyc/SumsubKycModals.tsx +++ b/src/components/Kyc/SumsubKycModals.tsx @@ -40,7 +40,12 @@ export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => /> {flow.tosLink && ( - + )} ) diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..29edc4db1 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -6,6 +6,7 @@ import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' +import { type IUserRail } from '@/interfaces' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -16,10 +17,12 @@ export const KycCompleted = ({ bridgeKycApprovedAt, countryCode, isBridge, + rails, }: { bridgeKycApprovedAt?: string countryCode?: string | null isBridge?: boolean + rails?: IUserRail[] }) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -31,6 +34,8 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) + const enabledRails = useMemo(() => (rails ?? []).filter((r) => r.status === 'ENABLED'), [rails]) + return (
@@ -48,6 +53,23 @@ export const KycCompleted = ({ /> + {enabledRails.length > 0 && ( + + + {enabledRails.map((r) => ( + + {r.rail.method.name} ({r.rail.method.currency}) + + ))} +
+ } + hideBottomBorder + /> + + )}
) } diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 50f588322..0e722345a 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -226,12 +226,13 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent } } - // refetch user — the phase-transition effect will handle moving to 'complete' + // optimistically complete — don't wait for rail status WebSocket await fetchUser() + completeFlow() } - // if manual close, stay on bridge_tos phase (user can try again or skip) + // if manual close, stay on bridge_tos phase (user can try again) }, - [fetchUser] + [fetchUser, completeFlow] ) // handle "Skip for now" in bridge_tos phase From 0cba1de9194da2893a4ed764e270a051096d8382 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:12:05 +0530 Subject: [PATCH 57/93] fix: auto close drawer bug + activity drawer ui --- src/components/Kyc/KycStatusDrawer.tsx | 5 ++- src/components/Kyc/states/KycCompleted.tsx | 36 ++++++++++++++-------- src/context/authContext.tsx | 4 +-- src/hooks/useSumsubKycFlow.ts | 9 +++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 5b8b7e808..25f2249a9 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -37,7 +37,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: onClose, onManualClose: onClose, - regionIntent: sumsubRegionIntent, + // don't pass regionIntent for completed kyc — prevents the mount effect + // in useSumsubKycFlow from calling initiateSumsubKyc(), which triggers + // the undefined->APPROVED transition that auto-closes the drawer + regionIntent: statusCategory === 'completed' ? undefined : sumsubRegionIntent, }) // all kyc retries now go through sumsub diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 29edc4db1..f1fc3a79d 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -7,6 +7,7 @@ import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' import { type IUserRail } from '@/interfaces' +import { getCurrencyFlagUrl } from '@/constants/countryCurrencyMapping' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -55,19 +56,28 @@ export const KycCompleted = ({
{enabledRails.length > 0 && ( - - {enabledRails.map((r) => ( - - {r.rail.method.name} ({r.rail.method.currency}) - - ))} -
- } - hideBottomBorder - /> + {enabledRails.map((r, index) => ( + + {getCurrencyFlagUrl(r.rail.method.currency) && ( + {`${r.rail.method.currency} + )} + {r.rail.method.name} +
+ } + value={r.rail.method.currency} + hideBottomBorder={index === enabledRails.length - 1} + /> + ))} )}
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 69d7ad7c6..9b92b434d 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -78,10 +78,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } }, [user]) - const legacy_fetchUser = async () => { + const legacy_fetchUser = useCallback(async () => { const { data: fetchedUser } = await fetchUser() return fetchedUser ?? null - } + }, [fetchUser]) const [isLoggingOut, setIsLoggingOut] = useState(false) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 08b1a8781..2c107db32 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -27,6 +27,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const regionIntentRef = useRef(regionIntent) // tracks the level name across initiate + refresh (e.g. 'peanut-additional-docs') const levelNameRef = useRef(undefined) + // guard: only fire onKycSuccess when the user initiated a kyc flow in this session. + // prevents stale websocket events or mount-time fetches from auto-closing the drawer. + const userInitiatedRef = useRef(false) useEffect(() => { regionIntentRef.current = regionIntent @@ -48,7 +51,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: prevStatusRef.current = liveKycStatus if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { - onKycSuccess?.() + if (userInitiatedRef.current) { + onKycSuccess?.() + } } else if ( liveKycStatus && liveKycStatus !== prevStatus && @@ -106,6 +111,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const handleInitiateKyc = useCallback( async (overrideIntent?: KYCRegionIntent, levelName?: string) => { + userInitiatedRef.current = true setIsLoading(true) setError(null) @@ -156,6 +162,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // called when sdk signals applicant submitted const handleSdkComplete = useCallback(() => { + userInitiatedRef.current = true setShowWrapper(false) setIsVerificationProgressModalOpen(true) }, []) From e0f4d206833057ffa78362993a9664f4ef7c773f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:39:19 +0530 Subject: [PATCH 58/93] fix: bride tos stuck issue --- src/components/Kyc/BridgeTosStep.tsx | 27 +++--------------- src/components/Kyc/KycStatusDrawer.tsx | 2 +- src/hooks/useMultiPhaseKycFlow.ts | 38 ++++++++++++++++---------- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 12152dbb3..bba3ca60c 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -4,8 +4,9 @@ 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, confirmBridgeTos } from '@/app/actions/users' +import { getBridgeTosLink } from '@/app/actions/users' import { useAuth } from '@/context/authContext' +import { confirmBridgeTosAndAwaitRails } from '@/hooks/useMultiPhaseKycFlow' interface BridgeTosStepProps { visible: boolean @@ -62,29 +63,9 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp setShowIframe(false) if (source === 'tos_accepted') { - // confirm with backend that bridge actually accepted the ToS - const result = await confirmBridgeTos() - - if (result.data?.accepted) { - await fetchUser() - onComplete() - return - } - - // bridge hasn't registered acceptance yet — poll once after a short delay - await new Promise((resolve) => setTimeout(resolve, 2000)) - const retry = await confirmBridgeTos() - - if (retry.data?.accepted) { - await fetchUser() - onComplete() - } else { - // will be caught by poller/webhook eventually - await fetchUser() - onComplete() - } + await confirmBridgeTosAndAwaitRails(fetchUser) + onComplete() } else { - // user closed without accepting — skip, activity feed will remind them onSkip() } }, diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 25f2249a9..cd05f577e 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -99,7 +99,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus { if (region === 'STANDARD') return r.rail.provider.code === 'BRIDGE' if (region === 'LATAM') return r.rail.provider.code === 'MANTECA' diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 0e722345a..f6064be1c 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -8,6 +8,27 @@ 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 @@ -214,20 +235,9 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent setShowTosIframe(false) if (source === 'tos_accepted') { - // confirm with backend - const result = await confirmBridgeTos() - - if (!result.data?.accepted) { - // bridge may not have registered acceptance yet — retry after short delay - await new Promise((resolve) => setTimeout(resolve, 2000)) - const retryResult = await confirmBridgeTos() - if (!retryResult.data?.accepted) { - console.warn('[useMultiPhaseKycFlow] bridge ToS confirmation failed after retry') - } - } - - // optimistically complete — don't wait for rail status WebSocket - await fetchUser() + // show loading state while confirming + polling + setModalPhase('preparing') + await confirmBridgeTosAndAwaitRails(fetchUser) completeFlow() } // if manual close, stay on bridge_tos phase (user can try again) From 58ab99c40d2b7f4fde04026e50c3632b18c2e147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 26 Feb 2026 14:26:58 -0300 Subject: [PATCH 59/93] Fix PIX key validation failing when pasted with whitespace Strip all whitespace from PIX keys at the input level in the withdraw flow so spaces from copy-paste don't cause Manteca API errors. --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 10 +++++++--- src/utils/__tests__/withdraw.utils.test.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 712d8b0bc..68e21fc30 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -153,6 +153,7 @@ export default function MantecaWithdrawFlow() { } break case 'brazil': + value = value.replace(/\s/g, '') const pixResult = validatePixKey(value) isValid = pixResult.valid if (!pixResult.valid) { @@ -578,10 +579,13 @@ 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 = normalizedValue.replace(/\s/g, '') + if (isPixPhoneNumber(normalizedValue)) { + normalizedValue = normalizePixPhoneNumber(normalizedValue) + } } setDestinationAddress(normalizedValue) setIsDestinationAddressValid(update.isValid) diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index b9f10137a..147cbb7a4 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -276,5 +276,25 @@ describe('Withdraw Utilities', () => { } }) }) + + describe('Pasted values with whitespace (stripped before validation)', () => { + // The withdraw page strips all whitespace from PIX keys before validation. + // These tests verify that after stripping, the values validate correctly. + const stripWhitespace = (value: string) => 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 = stripWhitespace(raw) + const result = validatePixKey(cleaned) + expect(result.valid).toBe(true) + }) + }) }) }) From dc44e04c94ca5984a525691c96c4c2cba27a7b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 26 Feb 2026 14:39:05 -0300 Subject: [PATCH 60/93] Preserve internal whitespace in EMVCo QR PIX keys Full whitespace stripping was corrupting EMVCo QR payloads that contain valid spaces (e.g. merchant names). Now QR codes are only trimmed at the ends while all other PIX key types still get full whitespace removal. --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 14 ++++++++++--- src/utils/__tests__/withdraw.utils.test.ts | 21 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 68e21fc30..3b54a8a35 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -17,7 +17,13 @@ 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' @@ -153,7 +159,7 @@ export default function MantecaWithdrawFlow() { } break case 'brazil': - value = value.replace(/\s/g, '') + value = isPixEmvcoQr(value.trim()) ? value.trim() : value.replace(/\s/g, '') const pixResult = validatePixKey(value) isValid = pixResult.valid if (!pixResult.valid) { @@ -582,7 +588,9 @@ export default function MantecaWithdrawFlow() { // Auto-normalize PIX keys for Brazil: strip whitespace and normalize phone numbers let normalizedValue = update.value if (countryPath === 'brazil') { - normalizedValue = normalizedValue.replace(/\s/g, '') + normalizedValue = isPixEmvcoQr(normalizedValue.trim()) + ? normalizedValue.trim() + : normalizedValue.replace(/\s/g, '') if (isPixPhoneNumber(normalizedValue)) { normalizedValue = normalizePixPhoneNumber(normalizedValue) } diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index 147cbb7a4..b979a910c 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -278,9 +278,10 @@ describe('Withdraw Utilities', () => { }) describe('Pasted values with whitespace (stripped before validation)', () => { - // The withdraw page strips all whitespace from PIX keys before validation. - // These tests verify that after stripping, the values validate correctly. - const stripWhitespace = (value: string) => value.replace(/\s/g, '') + // 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' }, @@ -291,7 +292,19 @@ describe('Withdraw Utilities', () => { { 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 = stripWhitespace(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) }) From 54d0aa42d26da80e6b1f76e71b175a76ad9cc24f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:52:22 +0530 Subject: [PATCH 61/93] fix: remove email from add bank account flow --- src/app/actions/users.ts | 1 + .../AddWithdraw/AddWithdrawCountriesList.tsx | 11 ++++++----- src/components/AddWithdraw/DynamicBankAccountForm.tsx | 6 ------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index 5929fed25..07e1498ef 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -194,6 +194,7 @@ export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean } Authorization: `Bearer ${jwtToken}`, 'api-key': API_KEY, }, + body: JSON.stringify({}), }) const responseJson = await response.json() if (!response.ok) { diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 639b02fd2..8d0dc6df8 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -98,14 +98,15 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { payload: AddBankAccountPayload, rawData: IBankAccountDetails ): Promise<{ error?: string }> => { - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus + // 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' - const hasEmailOnLoad = !!user?.user.email - // 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)) { + // email and name are now collected by sumsub — no need to check them here + if (isUserKycVerified) { const currentAccountIds = new Set(user?.accounts.map((acc) => acc.id) ?? []) const result = await addBankAccount(payload) 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', { From 35e37b1803b04d21b7937cb837333aebcfde69f6 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:15:58 +0530 Subject: [PATCH 62/93] feat: handle pending bridge tos using home carousel card --- src/components/Home/HomeCarouselCTA/index.tsx | 19 ++++++- src/components/Home/HomeHistory.tsx | 8 --- src/components/Kyc/BridgeTosReminder.tsx | 55 ------------------- src/hooks/useHomeCarouselCTAs.tsx | 18 +++++- 4 files changed, 34 insertions(+), 66 deletions(-) delete mode 100644 src/components/Kyc/BridgeTosReminder.tsx 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 9a5a67c57..0cd6d7027 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -15,8 +15,6 @@ import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem' import { groupKycByRegion } from '@/utils/kyc-grouping.utils' -import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' -import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -45,8 +43,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h const { fetchBalance } = useWallet() const { triggerHaptic } = useHaptic() const { fetchUser } = useAuth() - const { needsBridgeTos } = useBridgeTosStatus() - const isViewingOwnHistory = useMemo( () => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username), [isLoggedIn, username, user?.user.username] @@ -251,7 +247,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (

Activity

- {isViewingOwnHistory && needsBridgeTos && } {isViewingOwnHistory && user?.user && (() => { @@ -290,9 +285,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (
- {/* bridge ToS reminder for users who haven't accepted yet */} - {isViewingOwnHistory && needsBridgeTos && } - {/* link to the full history page */} {pendingRequests.length > 0 && ( <> diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx deleted file mode 100644 index 7db489a4b..000000000 --- a/src/components/Kyc/BridgeTosReminder.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import { useState, useCallback } from 'react' -import Card from '@/components/Global/Card' -import { Icon } from '@/components/Global/Icons/Icon' -import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' -import { useAuth } from '@/context/authContext' -import { type CardPosition } from '@/components/Global/Card/card.utils' - -interface BridgeTosReminderProps { - position?: CardPosition -} - -// shown in the activity feed when user has bridge rails needing ToS acceptance. -// clicking opens the bridge ToS flow. -export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProps) => { - const { fetchUser } = useAuth() - const [showTosStep, setShowTosStep] = useState(false) - const [tosJustAccepted, setTosJustAccepted] = useState(false) - - const handleClick = useCallback(() => { - setShowTosStep(true) - }, []) - - const handleComplete = useCallback(async () => { - setShowTosStep(false) - setTosJustAccepted(true) // optimistically hide — backend rail transition is async - await fetchUser() - }, [fetchUser]) - - const handleSkip = useCallback(() => { - setShowTosStep(false) - }, []) - - if (tosJustAccepted) return null - - return ( - <> - -
-
- -
-
-

Accept terms of service

-

Required to enable bank transfers

-
- -
-
- - - - ) -} diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index 8613eccf9..6b0fc48bf 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 } } From 1eb165849dfa55ac2baffa855750005ec82ea932 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:48:28 +0530 Subject: [PATCH 63/93] fix: handle retry if user abandons kyc --- src/components/Kyc/KycStatusDrawer.tsx | 6 ++++++ src/components/Kyc/KycStatusItem.tsx | 2 +- src/components/Kyc/states/KycNotStarted.tsx | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/components/Kyc/states/KycNotStarted.tsx diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index cd05f577e..30effed2d 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,6 +1,7 @@ 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' @@ -74,6 +75,11 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus } const renderContent = () => { + // user initiated kyc but abandoned before submitting — show resume cta + if (verification && isKycStatusNotStarted(status)) { + return + } + // bridge additional document requirement — but don't mask terminal kyc states if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') { return ( diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 359e53a1f..1f553a0e5 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -77,7 +77,7 @@ export const KycStatusItem = ({ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { - if (isInitiatedButNotStarted) return 'In progress' + if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' if (isPending) return 'Under review' if (isApproved) return 'Approved' diff --git a/src/components/Kyc/states/KycNotStarted.tsx b/src/components/Kyc/states/KycNotStarted.tsx new file mode 100644 index 000000000..a4dd9a38b --- /dev/null +++ b/src/components/Kyc/states/KycNotStarted.tsx @@ -0,0 +1,21 @@ +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 ( +
+ + +

+ Your verification isn't complete yet. Continue where you left off to enable bank transfers and QR + payments. +

+ + +
+ ) +} From b26a33c538d0cb85f31c3228f43380e29c5e988f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:57:05 +0530 Subject: [PATCH 64/93] fix: remove duplicate rails entry in kyc drawer --- src/components/Kyc/KycStatusDrawer.tsx | 5 ---- src/components/Kyc/states/KycCompleted.tsx | 32 ---------------------- 2 files changed, 37 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 30effed2d..bb1ad12b2 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -106,11 +106,6 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus bridgeKycApprovedAt={verification?.approvedAt ?? user?.user?.bridgeKycApprovedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc || region === 'STANDARD'} - rails={user?.rails?.filter((r) => { - if (region === 'STANDARD') return r.rail.provider.code === 'BRIDGE' - if (region === 'LATAM') return r.rail.provider.code === 'MANTECA' - return true - })} /> ) case 'action_required': diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index f1fc3a79d..0c59259b9 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -6,8 +6,6 @@ import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' -import { type IUserRail } from '@/interfaces' -import { getCurrencyFlagUrl } from '@/constants/countryCurrencyMapping' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -18,12 +16,10 @@ export const KycCompleted = ({ bridgeKycApprovedAt, countryCode, isBridge, - rails, }: { bridgeKycApprovedAt?: string countryCode?: string | null isBridge?: boolean - rails?: IUserRail[] }) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -35,8 +31,6 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) - const enabledRails = useMemo(() => (rails ?? []).filter((r) => r.status === 'ENABLED'), [rails]) - return (
@@ -54,32 +48,6 @@ export const KycCompleted = ({ /> - {enabledRails.length > 0 && ( - - {enabledRails.map((r, index) => ( - - {getCurrencyFlagUrl(r.rail.method.currency) && ( - {`${r.rail.method.currency} - )} - {r.rail.method.name} -
- } - value={r.rail.method.currency} - hideBottomBorder={index === enabledRails.length - 1} - /> - ))} - - )}
) } From aca3fb0cd8a7bb7f385daea4482cd6af96226850 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:44:27 +0530 Subject: [PATCH 65/93] fix: stop auto initiate kyc in deposit flow --- .../add-money/[country]/bank/page.tsx | 69 ++++++++----------- 1 file changed, 29 insertions(+), 40 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index eda417834..6c043e783 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -30,9 +30,10 @@ 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' | 'showDetails' +type BridgeBankStep = 'inputAmount' | 'showDetails' export default function OnrampBankPage() { const router = useRouter() @@ -42,7 +43,7 @@ export default function OnrampBankPage() { // Example: /add-money/mexico/bank?step=inputAmount&amount=500 const [urlState, setUrlState] = useQueryStates( { - step: parseAsStringEnum(['inputAmount', 'kyc', 'showDetails']), + step: parseAsStringEnum(['inputAmount', 'showDetails']), amount: parseAsString, }, { history: 'push' } @@ -53,6 +54,7 @@ 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 [liveKycStatus, setLiveKycStatus] = useState(undefined) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() @@ -152,30 +154,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: 'kyc' }) - } 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 => { @@ -217,9 +201,17 @@ export default function OnrampBankPage() { }, [rawTokenAmount, validateAmount, setError]) const handleAmountContinue = () => { - if (validateAmount(rawTokenAmount)) { - setShowWarningModal(true) + if (!validateAmount(rawTokenAmount)) return + + const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus + const isUserKycVerified = currentKycStatus === 'approved' + + if (!isUserKycVerified) { + setShowKycModal(true) + return } + + setShowWarningModal(true) } const handleWarningConfirm = async () => { @@ -271,12 +263,6 @@ export default function OnrampBankPage() { } } - useEffect(() => { - if (urlState.step === 'kyc') { - sumsubFlow.handleInitiateKyc('STANDARD') - } - }, [urlState.step]) // eslint-disable-line react-hooks/exhaustive-deps - // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) useEffect(() => { if (urlState.step === 'showDetails' && !onrampData?.transferId) { @@ -303,15 +289,6 @@ export default function OnrampBankPage() { return } - if (urlState.step === 'kyc') { - return ( -
- - -
- ) - } - if (urlState.step === 'showDetails') { // Show loading while useEffect redirects if data is missing if (!onrampData?.transferId) { @@ -408,6 +385,18 @@ export default function OnrampBankPage() { amount={rawTokenAmount} currency={getCurrencySymbol(getCurrencyConfig(selectedCountry.id, 'onramp').currency)} /> + + setShowKycModal(false)} + onVerify={async () => { + setShowKycModal(false) + await sumsubFlow.handleInitiateKyc('STANDARD') + }} + isLoading={sumsubFlow.isLoading} + /> + +
) } From 72d75f0d4bb01f828c8a21b21bfb189a50659009 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:44:52 +0530 Subject: [PATCH 66/93] fix: sumsub sdk unmounting issue --- src/components/Kyc/KycStatusDrawer.tsx | 35 ++++++++++++++++++++++---- src/components/Kyc/KycStatusItem.tsx | 6 ++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index bb1ad12b2..5bb0144d9 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -12,6 +12,7 @@ import { useUserStore } from '@/redux/hooks' 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 @@ -19,10 +20,19 @@ interface KycStatusDrawerProps { 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, region }: KycStatusDrawerProps) => { +export const KycStatusDrawer = ({ + isOpen, + onClose, + verification, + bridgeKycStatus, + region, + onKeepMounted, +}: KycStatusDrawerProps) => { const { user } = useUserStore() const status = verification ? verification.status : bridgeKycStatus @@ -35,9 +45,15 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined ) as KYCRegionIntent | undefined + // close drawer and release the keep-mounted hold + const handleFlowDone = useCallback(() => { + onClose() + onKeepMounted?.(false) + }, [onClose, onKeepMounted]) + const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: onClose, - onManualClose: onClose, + 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 @@ -75,9 +91,18 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus } const renderContent = () => { - // user initiated kyc but abandoned before submitting — show resume cta + // 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 + return ( + { + onKeepMounted?.(true) + onClose() + sumsubFlow.handleInitiateKyc() + }} + /> + ) } // bridge additional document requirement — but don't mask terminal kyc states diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 1f553a0e5..fb8086e97 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -51,6 +51,9 @@ export const KycStatusItem = ({ 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) @@ -127,13 +130,14 @@ export const KycStatusItem = ({
- {isDrawerOpen && ( + {(isDrawerOpen || keepDrawerMounted) && ( )} From 17db99e2815432cd708f7d1bfd3857e724fda56c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:44:42 +0530 Subject: [PATCH 67/93] fix: resolve cr comment --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 2 +- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 2 +- src/components/AddMoney/components/MantecaAddMoney.tsx | 2 +- src/components/AddWithdraw/AddWithdrawCountriesList.tsx | 2 +- src/components/Claim/Link/MantecaFlowManager.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 6c043e783..3e773c247 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -390,8 +390,8 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('STANDARD') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 712d8b0bc..e2c564e4a 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -454,8 +454,8 @@ export default function MantecaWithdrawFlow() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index c5f88bf6b..eab40ae05 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -194,8 +194,8 @@ const MantecaAddMoney: FC = () => { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 8d0dc6df8..0472cdffa 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -107,7 +107,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // 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(user?.accounts.map((acc) => acc.id) ?? []) + const currentAccountIds = new Set((freshUser?.accounts ?? user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) if (result.error) { diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index e014c639b..0598ac4b1 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -120,8 +120,8 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> From 87b06d9dd1ed6ba20ecf8b7d21b3282e0417f0fb Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:55:49 +0530 Subject: [PATCH 68/93] fix: replace legacy bridgeKycStatus checks with unified isUserKycVerified Added isUserKycVerified utility in kyc.consts.ts that checks all KYC providers (Bridge, Manteca, Sumsub) instead of only bridge status. Replaced direct bridgeKycStatus === 'approved' checks across 12 files to support KYC 2.0 where users may be verified through any provider. --- src/app/(mobile-ui)/points/invites/page.tsx | 2 +- src/app/(mobile-ui)/points/page.tsx | 2 +- src/components/Claim/Claim.tsx | 5 +++-- .../Global/PostSignupActionManager/index.tsx | 3 ++- .../Profile/components/PublicProfile.tsx | 11 ++--------- .../Profile/views/ProfileEdit.view.tsx | 8 ++++---- .../views/Initial.direct.request.view.tsx | 3 ++- src/components/Send/views/Contacts.view.tsx | 3 ++- src/constants/kyc.consts.ts | 19 +++++++++++++++++++ src/hooks/useDetermineBankClaimType.ts | 7 ++++--- src/hooks/useDetermineBankRequestType.ts | 7 ++++--- src/utils/general.utils.ts | 3 ++- 12 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx index ee1a36597..f7665b0c3 100644 --- a/src/app/(mobile-ui)/points/invites/page.tsx +++ b/src/app/(mobile-ui)/points/invites/page.tsx @@ -75,7 +75,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 = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER) // 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 dd1c38605..d9cc569dc 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -217,7 +217,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 = Math.floor(invite.totalPoints * TRANSITIVITY_MULTIPLIER) // respect user's showFullName preference for avatar and display name const displayName = invite.showFullName && fullName ? fullName : username 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/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/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/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 442d47dc9..b7fac0170 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('') @@ -115,7 +115,7 @@ export const ProfileEditView = () => {
router.push('/profile')} /> - +
{ 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} /> 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/constants/kyc.consts.ts b/src/constants/kyc.consts.ts index f8fc5d507..d35557a54 100644 --- a/src/constants/kyc.consts.ts +++ b/src/constants/kyc.consts.ts @@ -31,6 +31,25 @@ const SUMSUB_IN_PROGRESS_STATUSES: ReadonlySet = new Set(['PENDING', 'IN 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) 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/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, } }) From 6055b42945cfa861b0f8187515c1311671e5002f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:55:59 +0530 Subject: [PATCH 69/93] fix: use rails-based flags for limits display Replaced legacy isUserMantecaKycApproved with hasMantecaLimits from useLimits hook in BridgeLimitsView and LimitsPageView. Limits now derive from the rails-based /users/limits endpoint instead of direct KYC status checks. --- .../limits/hooks/useLimitsValidation.ts | 36 ++++++++----------- .../limits/views/BridgeLimitsView.tsx | 6 ++-- src/features/limits/views/LimitsPageView.tsx | 6 ++-- 3 files changed, 21 insertions(+), 27 deletions(-) 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 4ea063318..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 */} From 48cbe3193fbd8d8f63f944a365657a158fb49c68 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:56:11 +0530 Subject: [PATCH 70/93] fix: check provider rail status before showing regions as unlocked Regions now require provider rails to be in a functional state (ENABLED/REQUIRES_INFORMATION/REQUIRES_EXTRA_INFORMATION) before showing as unlocked. Prevents showing EU/US/MX as unlocked when Bridge submission failed. Also optimized isBridgeSupportedCountry to use O(1) lookups instead of allocating arrays per call. --- src/hooks/useIdentityVerification.tsx | 42 +++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index e3f396532..548555b0c 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -77,6 +77,9 @@ 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' @@ -159,17 +162,31 @@ export const useIdentityVerification = () => { 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 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. - // no intent (or rest-of-world) → unlocks rest of world only. + // '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') { - return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + return hasProviderAccess('MANTECA') && MANTECA_SUPPORTED_REGIONS.includes(regionName) } - // STANDARD intent covers bridge regions + rest of world - return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + return hasProviderAccess('BRIDGE') && BRIDGE_SUPPORTED_REGIONS.includes(regionName) } return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || @@ -190,7 +207,13 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) + }, [ + isUserBridgeKycApproved, + isUserMantecaKycApproved, + isUserSumsubKycApproved, + sumsubVerificationRegionIntent, + user?.rails, + ]) /** * Check if a region is already unlocked by comparing region paths. @@ -270,12 +293,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 { From 215d37eb24364c9426647d13c06d16444ff443b1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:56:26 +0530 Subject: [PATCH 71/93] fix: kyc drawer SDK unresponsiveness, status labels, and DRY refactor - Fixed SDK unresponsiveness by closing drawer before opening Sumsub SDK (drawer z-50 was blocking SDK Dialog.Panel at z-10) - Extracted closeAndStartKyc helper to deduplicate 3 close+start patterns - Aligned status labels between activity list and drawer (Processing, Completed, Failed) - Fixed rejected state: hide RejectLabelsList in terminal rejection, fixed bottom border when Rejected On is the only card row - Extracted KycFailedContent shared component for drawer and modal - Renamed KycRejectedModal to KycFailedModal for naming consistency --- src/components/Kyc/CountryRegionRow.tsx | 4 +- src/components/Kyc/KycFailedContent.tsx | 24 ++++++++++ src/components/Kyc/KycStatusDrawer.tsx | 32 ++++++------- src/components/Kyc/KycStatusItem.tsx | 6 +-- ...ycRejectedModal.tsx => KycFailedModal.tsx} | 30 ++++-------- src/components/Kyc/states/KycFailed.tsx | 46 ++++++++----------- .../views/RegionsVerification.view.tsx | 4 +- 7 files changed, 77 insertions(+), 69 deletions(-) create mode 100644 src/components/Kyc/KycFailedContent.tsx rename src/components/Kyc/modals/{KycRejectedModal.tsx => KycFailedModal.tsx} (64%) 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/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/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 5bb0144d9..d8ca56646 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -60,10 +60,15 @@ export const KycStatusDrawer = ({ regionIntent: statusCategory === 'completed' ? undefined : sumsubRegionIntent, }) - // all kyc retries now go through sumsub - const onRetry = async () => { - await sumsubFlow.handleInitiateKyc() - } + // close drawer but keep mounted so SumsubKycModals persists, then start kyc + const closeAndStartKyc = useCallback( + async (regionIntent?: KYCRegionIntent, levelName?: string) => { + onKeepMounted?.(true) + onClose() + await sumsubFlow.handleInitiateKyc(regionIntent, levelName) + }, + [onKeepMounted, onClose, sumsubFlow] + ) // check if any bridge rail needs additional documents const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( @@ -86,22 +91,17 @@ export const KycStatusDrawer = ({ const sumsubFailureCount = user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 - const handleSubmitAdditionalDocs = async () => { - await sumsubFlow.handleInitiateKyc(undefined, 'peanut-additional-docs') - } + 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 ( - { - onKeepMounted?.(true) - onClose() - sumsubFlow.handleInitiateKyc() - }} - /> + ) } @@ -136,7 +136,7 @@ export const KycStatusDrawer = ({ case 'action_required': return ( @@ -152,7 +152,7 @@ export const KycStatusDrawer = ({ bridgeKycRejectedAt={verification?.updatedAt ?? user?.user?.bridgeKycRejectedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc} - onRetry={onRetry} + onRetry={closeAndStartKyc} isLoading={sumsubFlow.isLoading} /> ) diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index fb8086e97..45e04b036 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -82,9 +82,9 @@ export const KycStatusItem = ({ const subtitle = useMemo(() => { if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' - if (isPending) return 'Under review' - if (isApproved) return 'Approved' - if (isRejected) return 'Rejected' + if (isPending) return 'Processing' + if (isApproved) return 'Completed' + if (isRejected) return 'Failed' return 'Unknown' }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) diff --git a/src/components/Kyc/modals/KycRejectedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx similarity index 64% rename from src/components/Kyc/modals/KycRejectedModal.tsx rename to src/components/Kyc/modals/KycFailedModal.tsx index 9d2aece39..ab3a1f12d 100644 --- a/src/components/Kyc/modals/KycRejectedModal.tsx +++ b/src/components/Kyc/modals/KycFailedModal.tsx @@ -1,11 +1,10 @@ import { useMemo } from 'react' import ActionModal from '@/components/Global/ActionModal' -import InfoCard from '@/components/Global/InfoCard' -import { RejectLabelsList } from '../RejectLabelsList' +import { KycFailedContent } from '../KycFailedContent' import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' import { useModalsContext } from '@/context/ModalsContext' -interface KycRejectedModalProps { +interface KycFailedModalProps { visible: boolean onClose: () => void onRetry: () => void @@ -16,7 +15,7 @@ interface KycRejectedModalProps { } // shown when user clicks a locked region while their kyc is rejected -export const KycRejectedModal = ({ +export const KycFailedModal = ({ visible, onClose, onRetry, @@ -24,7 +23,7 @@ export const KycRejectedModal = ({ rejectLabels, rejectType, failureCount, -}: KycRejectedModalProps) => { +}: KycFailedModalProps) => { const { setIsSupportModalOpen } = useModalsContext() const isTerminal = useMemo( @@ -36,24 +35,13 @@ export const KycRejectedModal = ({ - - {isTerminal && ( - - )} +
+
} ctas={[ diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 991ff9a3f..493a4ef8b 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,9 +1,8 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' -import { RejectLabelsList } from '../RejectLabelsList' +import { KycFailedContent } from '../KycFailedContent' import Card from '@/components/Global/Card' -import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -54,39 +53,34 @@ export const KycFailed = ({ [isSumsub, rejectType, failureCount, rejectLabels] ) - // formatted bridge reason (legacy display) - const formattedBridgeReason = useMemo(() => { - const reasonText = bridgeReason || 'There was an issue. Contact Support.' - const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') - if (lines.length === 1) return reasonText - return ( -
    - {lines.map((line, index) => ( -
  • {line}
  • - ))} -
- ) - }, [bridgeReason]) + // determine which row is last in the card for border handling + const hasCountryRow = isBridge || !!countryCode + const hasReasonRow = !isSumsub return (
- - - - {!isSumsub && } + + + + {hasReasonRow && ( + + )} - {isSumsub && } + {isSumsub && } {isTerminal ? ( -
- +
{/* TODO: auto-create crisp support ticket on terminal rejection */}
) } From 6c9eec7632355ca211d3e6397b947e83396f05f2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:52:02 +0530 Subject: [PATCH 77/93] fix: surface bridge docs requirement and allow additional-docs SDK flow KycStatusItem: check for REQUIRES_EXTRA_INFORMATION bridge rails and show "Action needed" with pending pill instead of "Verified". useSumsubKycFlow: don't short-circuit on APPROVED when a token is returned (additional-docs flow needs the SDK to open). Don't sync APPROVED status when token present to prevent useEffect from firing onKycSuccess prematurely. SumsubKycWrapper: ignore onApplicantStatusChanged events within 3s of SDK init to prevent pre-existing APPROVED status from auto-closing. --- src/components/Kyc/KycStatusItem.tsx | 21 +++++++++++++++++---- src/components/Kyc/SumsubKycWrapper.tsx | 10 ++++++++++ src/hooks/useSumsubKycFlow.ts | 12 ++++++++---- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 45e04b036..320de4e37 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,10 @@ 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/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 From f3eb36dbbbffa342689a86e358a936bc1c97ede7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:56:46 +0530 Subject: [PATCH 78/93] fix: improve KYC UI states, copy, and requirement labels - update verification-in-progress modal title and description - use InfoCard for additional docs requirements display - add DUPLICATE_EMAIL reject label for bridge submission errors - fix country flag and region label ordering (US/EU/UK/MX) - update fallback requirement description copy --- src/components/Kyc/CountryFlagAndName.tsx | 4 ++-- .../Kyc/KycVerificationInProgressModal.tsx | 4 ++-- .../Kyc/modals/KycProcessingModal.tsx | 2 +- src/components/Kyc/states/KycCompleted.tsx | 2 +- .../Kyc/states/KycRequiresDocuments.tsx | 24 +++++++++++-------- src/constants/bridge-requirements.consts.ts | 2 +- src/constants/sumsub-reject-labels.consts.ts | 7 ++++++ 7 files changed, 28 insertions(+), 17 deletions(-) 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/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 40095eeea..38c0385aa 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -73,11 +73,11 @@ export const KycVerificationInProgressModal = ({ onClose={onClose} isLoadingIcon iconContainerClassName="bg-yellow-1 text-black" - title="Identity verified!" + title={'Verfication in progress'} description={ preparingTimedOut ? "This is taking longer than expected. You can continue and we'll notify you when it's ready." - : 'Preparing your account...' + : 'Submitting your information and preparing your account. This usually takes less than a minute.' } ctas={ preparingTimedOut 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/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts index d50ae2868..d2adebd9d 100644 --- a/src/constants/bridge-requirements.consts.ts +++ b/src/constants/bridge-requirements.consts.ts @@ -26,7 +26,7 @@ const BRIDGE_REQUIREMENT_LABELS: Record = { const FALLBACK_LABEL: RequirementLabelInfo = { title: 'Additional Document', - description: 'Please provide the requested document.', + description: 'Please resubmit your identity document.', } /** get human-readable label for a bridge additional requirement */ 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 = { From f79bbe235971e343892f033b8b5afcfdda694fb8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:57:16 +0530 Subject: [PATCH 79/93] refactor: replace inline KYC websocket with useKycStatus hook remove duplicated bridgeKycStatus websocket listeners and manual status tracking in favor of the centralized useKycStatus hook. --- .../add-money/[country]/bank/page.tsx | 23 ++------------ .../AddWithdraw/AddWithdrawCountriesList.tsx | 31 +++---------------- .../Claim/Link/views/BankFlowManager.view.tsx | 23 +------------- 3 files changed, 9 insertions(+), 68 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 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/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 0472cdffa..403f9cb39 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,13 +81,11 @@ 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) { + if (isUserKycApproved) { const currentAccountIds = new Set((freshUser?.accounts ?? user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) @@ -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. From 7dc41e8310170ec839993d7d43c84cc421ec888b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:57:48 +0530 Subject: [PATCH 80/93] fix: refresh user on sumsub KYC websocket status updates --- src/app/(mobile-ui)/history/page.tsx | 4 ++++ src/components/Home/HomeHistory.tsx | 7 +++++++ 2 files changed, 11 insertions(+) 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/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 From 44343234b056d3f252d146c43af7ef4256ed0f32 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:57:55 +0530 Subject: [PATCH 81/93] fix: DRY verification modal unlock items and improve onramp error handling - deduplicate BRIDGE_UNLOCK_ITEMS across regions - preserve specific error messages from backend in useCreateOnramp --- .../StartVerificationModal.tsx | 56 ++++++++++--------- src/hooks/useCreateOnramp.ts | 14 +++-- 2 files changed, 38 insertions(+), 32 deletions(-) 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/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) } From 5c1ee0adbf0df9663b59fdb33d100c033c6c22e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:58:20 +0530 Subject: [PATCH 82/93] chore: format --- src/components/Kyc/KycStatusItem.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 320de4e37..93f2db865 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -127,10 +127,7 @@ export const KycStatusItem = ({

{subtitle}

Date: Thu, 5 Mar 2026 13:32:02 +0530 Subject: [PATCH 83/93] =?UTF-8?q?fix:=20address=20CR=20feedback=20?= =?UTF-8?q?=E2=80=94=20freshUser=20ref,=20typo,=20fallback=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove stale freshUser reference in AddWithdrawCountriesList (bug) - fix "Verfication" typo in KycVerificationInProgressModal - revert fallback requirement description to generic copy --- src/components/AddWithdraw/AddWithdrawCountriesList.tsx | 2 +- src/components/Kyc/KycVerificationInProgressModal.tsx | 2 +- src/constants/bridge-requirements.consts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 403f9cb39..8ff2b9480 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -86,7 +86,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // 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 (isUserKycApproved) { - const currentAccountIds = new Set((freshUser?.accounts ?? user?.accounts ?? []).map((acc) => acc.id)) + const currentAccountIds = new Set((user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) if (result.error) { diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 38c0385aa..b314c53b9 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -73,7 +73,7 @@ export const KycVerificationInProgressModal = ({ onClose={onClose} isLoadingIcon iconContainerClassName="bg-yellow-1 text-black" - title={'Verfication in progress'} + title="Verification in progress" description={ preparingTimedOut ? "This is taking longer than expected. You can continue and we'll notify you when it's ready." diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts index d2adebd9d..d50ae2868 100644 --- a/src/constants/bridge-requirements.consts.ts +++ b/src/constants/bridge-requirements.consts.ts @@ -26,7 +26,7 @@ const BRIDGE_REQUIREMENT_LABELS: Record = { const FALLBACK_LABEL: RequirementLabelInfo = { title: 'Additional Document', - description: 'Please resubmit your identity document.', + description: 'Please provide the requested document.', } /** get human-readable label for a bridge additional requirement */ From 6cf5d5b77f0bbbce4ee06c5fe39b08f935de3106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 6 Mar 2026 12:39:15 -0300 Subject: [PATCH 84/93] fix: setup flow bugs - install copy, notification stuck state, PWA loop 1. Install screen: Replace tech-speak fallback with user-friendly copy 2. Waitlist notification: Add 'Not now' skip so users without invite codes don't get stuck on 'Enable Notifications' forever 3. PWA install loop: Remove target=_blank /setup link that causes redirect loop between browser and PWA; show instruction to open from Home Screen with fallback 'Continue here instead' button --- src/components/Invites/JoinWaitlistPage.tsx | 11 ++++++-- src/components/Setup/Views/InstallPWA.tsx | 30 ++++++++++----------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 789cecc5a..32286c1a2 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,16 @@ const JoinWaitlistPage = () => { > Yes, notify me +
)} - {isPermissionGranted && ( + {(isPermissionGranted || notificationSkipped) && (

You're still in Peanut jail

diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx index 34243f177..825d058b9 100644 --- a/src/components/Setup/Views/InstallPWA.tsx +++ b/src/components/Setup/Views/InstallPWA.tsx @@ -162,23 +162,20 @@ const InstallPWA = ({ return null } - // for other browsers, try to open the pwa in a new tab + // for other browsers, prompt user to open the installed PWA from home screen + // (opening /setup in a new tab causes a redirect loop between browser and PWA) return (
+

+ Peanut has been installed! Open it from your Home Screen to continue. +

) @@ -209,12 +206,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.

-
From 5413e9dbe33381a2dc8627f943ff6b9cd3fd2028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 7 Mar 2026 01:24:31 -0300 Subject: [PATCH 85/93] revert: keep automatic PWA open behavior --- src/components/Setup/Views/InstallPWA.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx index 825d058b9..d1496af07 100644 --- a/src/components/Setup/Views/InstallPWA.tsx +++ b/src/components/Setup/Views/InstallPWA.tsx @@ -162,20 +162,23 @@ const InstallPWA = ({ return null } - // for other browsers, prompt user to open the installed PWA from home screen - // (opening /setup in a new tab causes a redirect loop between browser and PWA) + // for other browsers, try to open the pwa in a new tab return (
-

- Peanut has been installed! Open it from your Home Screen to continue. -

) From 5a472c2a580bb1bf2087d917004f640eb75bb3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 7 Mar 2026 01:28:56 -0300 Subject: [PATCH 86/93] fix: formatting --- src/components/Invites/JoinWaitlistPage.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 32286c1a2..711d66430 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -100,10 +100,7 @@ const JoinWaitlistPage = () => { > Yes, notify me -
From d2596b1e426f299f92d36527b58062ead1c6b0e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:49:07 +0530 Subject: [PATCH 87/93] fix: kyc ui bugs --- src/components/Kyc/SumsubKycWrapper.tsx | 122 ++++++++++-------- src/components/Profile/index.tsx | 16 +-- .../Profile/views/ProfileEdit.view.tsx | 4 +- src/content | 2 +- 4 files changed, 75 insertions(+), 69 deletions(-) diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index c5c17f92b..cd1a1056e 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -185,7 +185,6 @@ export const SumsubKycWrapper = ({ ctas: [ { text: 'Chat with support', - icon: 'peanut-support' as IconName, onClick: () => setIsSupportModalOpen(true), variant: 'purple' as ButtonVariant, shadowSize: '4' as const, @@ -194,30 +193,56 @@ export const SumsubKycWrapper = ({ } } - return { - 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', - }, - ], - } - }, [modalVariant, onClose, setIsSupportModalOpen]) + 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 ( <> @@ -248,33 +273,28 @@ export const SumsubKycWrapper = ({
) : ( -
-
-
-
- - -
+
+
+ +
+
)} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 09086deed..e9b7f8427 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')} />
diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index b7fac0170..36d422051 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -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,7 +113,7 @@ export const ProfileEditView = () => { return (
- router.push('/profile')} /> + router.back()} /> diff --git a/src/content b/src/content index 065575532..ffc4bdd8a 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 065575532e31ed9cb4c83357009fec78f0ed415c +Subproject commit ffc4bdd8ac3925b9f80d77bb5bb6e5f85a3d45b4 From 6f12e8b8571e99e8166818b0d5c37ea4911f50b3 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:58:49 +0530 Subject: [PATCH 88/93] fix: docs link --- src/components/Global/WalletNavigation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }, ] From 0ac8384a35a4c4e55b623b09056e3612bea18b15 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:53:41 +0530 Subject: [PATCH 89/93] fix: sumsub multi level transition bug --- src/components/Kyc/SumsubKycModals.tsx | 1 + src/components/Kyc/SumsubKycWrapper.tsx | 15 ++++++++++++++- .../Profile/components/ProfileMenuItem.tsx | 12 ++++++++++-- src/components/Profile/index.tsx | 2 ++ src/hooks/useMultiPhaseKycFlow.ts | 3 +++ src/hooks/useSumsubKycFlow.ts | 9 +++++++++ 6 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx index a38672b94..25097b430 100644 --- a/src/components/Kyc/SumsubKycModals.tsx +++ b/src/components/Kyc/SumsubKycModals.tsx @@ -25,6 +25,7 @@ export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => onComplete={flow.handleSdkComplete} onRefreshToken={flow.refreshToken} autoStart={autoStartSdk} + isMultiLevel={flow.isMultiLevel} /> 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 = ({ @@ -30,6 +32,7 @@ export const SumsubKycWrapper = ({ onError, onRefreshToken, autoStart, + isMultiLevel, }: SumsubKycWrapperProps) => { const [isVerificationStarted, setIsVerificationStarted] = useState(false) const [sdkLoaded, setSdkLoaded] = useState(false) @@ -44,12 +47,14 @@ export const SumsubKycWrapper = ({ 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 - }, [onComplete, onError, onRefreshToken]) + isMultiLevelRef.current = isMultiLevel + }, [onComplete, onError, onRefreshToken, isMultiLevel]) // stable wrappers that read from refs const stableOnComplete = useCallback(() => onCompleteRef.current(), []) @@ -96,8 +101,13 @@ export const SumsubKycWrapper = ({ 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() @@ -113,6 +123,9 @@ export const SumsubKycWrapper = ({ 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() 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} +
+ + {highlight && ( +
+
+
+ )} +
{badge && } {showTooltip && ( diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index e9b7f8427..554d1b02e 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -61,11 +61,13 @@ export const Profile = () => {
+ diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index f6064be1c..d7d151299 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -269,6 +269,8 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent const isModalOpen = isVerificationProgressModalOpen || forceShowModal + const isMultiLevel = regionIntent === 'LATAM' + return { // initiation handleInitiateKyc, @@ -282,6 +284,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent handleSdkClose: handleClose, handleSdkComplete, refreshToken, + isMultiLevel, // multi-phase modal isModalOpen, diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 4dc80f996..47566ba0b 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -23,6 +23,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: 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') @@ -51,6 +53,13 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: 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?.() } From 68f3d67d03336c0febc91fc0a64c8e96154fbe08 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:20:32 +0530 Subject: [PATCH 90/93] fix: show kyc benefits --- .../Home/KycCompletedModal/index.tsx | 18 ++++++-------- .../StartVerificationModal.tsx | 2 +- src/components/Kyc/KycStatusDrawer.tsx | 1 + src/components/Kyc/KycStatusItem.tsx | 7 +----- src/components/Kyc/states/KycCompleted.tsx | 24 +++++++++++++++++-- src/hooks/useIdentityVerification.tsx | 5 +++- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 80cd086bc..98aa40625 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -7,6 +7,7 @@ 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 }) => { @@ -14,24 +15,19 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const [approvedCountryData, setApprovedCountryData] = useState(null) const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { sumsubVerificationRegionIntent } = useUnifiedKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { - // sumsub covers all regions, treat as 'all' if (isUserSumsubKycApproved) { - return 'all' - } - if (isUserBridgeKycApproved && isUserMantecaKycApproved) { - return 'all' - } - if (isUserBridgeKycApproved) { + if (sumsubVerificationRegionIntent === 'LATAM') return 'manteca' return 'bridge' } - if (isUserMantecaKycApproved) { - return 'manteca' - } + if (isUserBridgeKycApproved && isUserMantecaKycApproved) return 'all' + if (isUserBridgeKycApproved) return 'bridge' + if (isUserMantecaKycApproved) return 'manteca' return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) diff --git a/src/components/IdentityVerification/StartVerificationModal.tsx b/src/components/IdentityVerification/StartVerificationModal.tsx index 41e4a105b..edd1b50fc 100644 --- a/src/components/IdentityVerification/StartVerificationModal.tsx +++ b/src/components/IdentityVerification/StartVerificationModal.tsx @@ -29,7 +29,7 @@ const BRIDGE_UNLOCK_ITEMS: Array = [ ] // unlock benefits shown per region -const REGION_UNLOCK_ITEMS: Record> = { +export const REGION_UNLOCK_ITEMS: Record> = { latam: [

Bank transfers to your own accounts in LATAM diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 3c6c9acf6..03b71e432 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -134,6 +134,7 @@ export const KycStatusDrawer = ({ bridgeKycApprovedAt={verification?.approvedAt ?? user?.user?.bridgeKycApprovedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc || region === 'STANDARD'} + region={region} /> ) case 'action_required': diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 93f2db865..80f9de1b4 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -98,11 +98,6 @@ export const KycStatusItem = ({ return 'Unknown' }, [hasBridgeDocsNeeded, isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) - const title = useMemo(() => { - if (region === 'LATAM') return 'LATAM verification' - return 'Identity verification' - }, [region]) - // only hide for bridge's default "not_started" state. // if a verification record exists, the user has initiated KYC — show it. if (!verification && !hasBridgeDocsNeeded && isKycStatusNotStarted(kycStatus)) { @@ -122,7 +117,7 @@ export const KycStatusItem = ({

-

{title}

+

Identity verification

{subtitle}

{ const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -31,10 +35,15 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) + const benefits = useMemo(() => { + const regionPath = region === 'LATAM' ? 'latam' : 'europe' + return REGION_UNLOCK_ITEMS[regionPath] ?? [] + }, [region]) + return (
- + } /> - + {benefits.length > 0 && ( +
+

What you've unlocked:

+ +
+ )}
) diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 548555b0c..e06a3bb41 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -184,7 +184,10 @@ export const useIdentityVerification = () => { if (isSumsubApproved) { if (regionName === 'Rest of the world') return true if (sumsubVerificationRegionIntent === 'LATAM') { - return hasProviderAccess('MANTECA') && MANTECA_SUPPORTED_REGIONS.includes(regionName) + // 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) } From 1026381f48ca26695a23768ae8434aad0cdd856c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:39:37 +0530 Subject: [PATCH 91/93] fix: review --- .../Home/KycCompletedModal/index.tsx | 19 +++++++++---------- src/components/Kyc/states/KycCompleted.tsx | 8 +++----- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 98aa40625..0b9fdff39 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -6,7 +6,6 @@ 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' @@ -14,20 +13,20 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const { user } = useAuth() const [approvedCountryData, setApprovedCountryData] = useState(null) - const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() - const { sumsubVerificationRegionIntent } = useUnifiedKycStatus() + const { isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent } = + useUnifiedKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { - if (isUserSumsubKycApproved) { + if (isSumsubApproved) { if (sumsubVerificationRegionIntent === 'LATAM') return 'manteca' return 'bridge' } - if (isUserBridgeKycApproved && isUserMantecaKycApproved) return 'all' - if (isUserBridgeKycApproved) return 'bridge' - if (isUserMantecaKycApproved) return 'manteca' + if (isBridgeApproved && isMantecaApproved) return 'all' + if (isBridgeApproved) return 'bridge' + if (isMantecaApproved) return 'manteca' return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) + }, [isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) @@ -35,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 @@ -57,7 +56,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = setApprovedCountryData(_approvedCountryData || null) } } - }, [isUserMantecaKycApproved, user]) + }, [isMantecaApproved, user]) return ( { - const regionPath = region === 'LATAM' ? 'latam' : 'europe' - return REGION_UNLOCK_ITEMS[regionPath] ?? [] - }, [region]) + const regionPath = region === 'LATAM' ? 'latam' : 'europe' + const benefits = REGION_UNLOCK_ITEMS[regionPath] ?? [] return (
From 6ddd050399353572ee4eafe870f9200ab6493673 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:45:09 +0530 Subject: [PATCH 92/93] fix: request cta flashing state --- .../flows/direct-send/useDirectSendFlow.ts | 22 ++++++++++++++++--- .../useSemanticRequestFlow.ts | 4 ++++ .../shared/components/SendWithPeanutCta.tsx | 13 +++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) 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} From 31c1d235591d86be1d7757e60397b39ab8c1f4c6 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:07:40 +0530 Subject: [PATCH 93/93] fix: update manteca refund activity ui --- src/components/Global/Badges/StatusBadge.tsx | 3 ++- src/components/Global/Icons/Icon.tsx | 3 +++ src/components/Global/StatusPill/index.tsx | 6 ++--- .../TransactionDetails/TransactionCard.tsx | 22 +++++++++++++++---- .../TransactionDetailsReceipt.tsx | 6 +++-- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 0aebee174..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' 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/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/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 03544ebc3..e35ef7a72 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -160,7 +160,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 @@ -1291,7 +1293,7 @@ export const TransactionDetailsReceipt = ({
)} - {isQRPayment && ( + {isQRPayment && transaction.status !== 'refunded' && (