From 0e935d60e8bf7647d0f19807359f5f8c2a71c119 Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:58:11 +0000 Subject: [PATCH 01/23] =?UTF-8?q?content:=20update=20submodule=20=E2=80=94?= =?UTF-8?q?=20add=20delete-account=20help=20article?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /help/delete-account page for Google Play compliance. Users can learn how to request account deletion via support, what data is removed vs retained, and pre-deletion steps. --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 46b6b1ae4..1b53cf2ad 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 46b6b1ae40047c8674a34fff90b9371b6fdf3aa0 +Subproject commit 1b53cf2adccb6465cc6987970e4041eb33b6789b From 00a10c01b69078efe7ca291d5d679b36b05b1c2f Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:16:16 +0000 Subject: [PATCH 02/23] fix: redirect /help/:slug to /en/help/:slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wildcard redirect for help article URLs without locale prefix. Fixes /help/delete-account 404 — now redirects to /en/help/delete-account. --- redirects.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/redirects.json b/redirects.json index 078accdb5..632a92b59 100644 --- a/redirects.json +++ b/redirects.json @@ -34,6 +34,11 @@ "destination": "/en/help", "permanent": false }, + { + "source": "/help/:slug*", + "destination": "/en/help/:slug*", + "permanent": false + }, { "source": "/docs", "destination": "/en/help", @@ -115,4 +120,4 @@ "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d", "permanent": true } -] +] \ No newline at end of file From 5e031095892e557975bab80f3e0851a0c4f4ac4b Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:36:36 +0000 Subject: [PATCH 03/23] fix: redirect /help/:slug to /en/help/:slug (formatted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wildcard redirect for help article URLs without locale prefix. Fixes /help/delete-account 404 — now redirects to /en/help/delete-account. From 0026e78fe86e643b7b06ff00d62393c5c8eeeede Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:46:13 +0000 Subject: [PATCH 04/23] fix: redirect /help/:slug to /en/help/:slug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wildcard redirect for help article URLs without locale prefix. Fixes /help/delete-account 404 — now redirects to /en/help/delete-account. From 20dec5e33b6544306cfb5af2350c155c856cd17d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:27:59 +0530 Subject: [PATCH 05/23] fix: add missing newline at end of redirects.json Co-Authored-By: Claude Opus 4.6 (1M context) --- redirects.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redirects.json b/redirects.json index 632a92b59..4c39d2564 100644 --- a/redirects.json +++ b/redirects.json @@ -120,4 +120,4 @@ "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d", "permanent": true } -] \ No newline at end of file +] From d63c9045eb9eb9042e2e9ba49ab50cd4009d12f7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:39:22 +0530 Subject: [PATCH 06/23] feat: add provider rejection status hook and self-heal server action - useProviderRejectionStatus: derives per-provider fixable/blocked state from rails + kycVerifications - initiateSelfHealResubmission server action: calls POST /users/identity/resubmit - update IUserKycVerification type with PROVIDER_FIXABLE/PROVIDER_FINAL rejectTypes - update isTerminalRejection to handle PROVIDER_FINAL --- .../add-money/[country]/bank/page.tsx | 17 +- src/app/(mobile-ui)/qr-pay/page.tsx | 49 ++++++ src/app/(mobile-ui)/withdraw/manteca/page.tsx | 15 +- src/app/actions/sumsub.ts | 47 ++++++ .../AddMoney/components/MantecaAddMoney.tsx | 16 +- .../AddWithdraw/AddWithdrawCountriesList.tsx | 8 + .../Claim/Link/MantecaFlowManager.tsx | 15 +- src/components/Home/ActivationCTAs.tsx | 58 ++++++- src/components/Kyc/InitiateKycModal.tsx | 36 +++- src/components/Kyc/KycStatusDrawer.tsx | 24 +++ src/components/Kyc/KycStatusItem.tsx | 26 ++- src/components/Kyc/states/KycFailed.tsx | 2 +- .../views/RegionsVerification.view.tsx | 58 ++++++- src/constants/sumsub-reject-labels.consts.ts | 4 +- src/hooks/useMultiPhaseKycFlow.ts | 2 + src/hooks/useProviderRejectionStatus.ts | 155 ++++++++++++++++++ src/hooks/useQrKycGate.ts | 24 ++- src/hooks/useSumsubKycFlow.ts | 47 +++++- src/interfaces/interfaces.ts | 2 +- 19 files changed, 571 insertions(+), 34 deletions(-) create mode 100644 src/hooks/useProviderRejectionStatus.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 1ab42b4c4..4b8d8aff0 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -11,6 +11,7 @@ import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' import { useAuth } from '@/context/authContext' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -94,6 +95,7 @@ export default function OnrampBankPage() { const isUK = isUKCountry(selectedCountryPath) const { isUserKycApproved } = useKycStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() useEffect(() => { @@ -194,7 +196,7 @@ export default function OnrampBankPage() { const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - if (!isUserKycApproved) { + if (!isUserKycApproved || bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked') { setShowKycModal(true) return } @@ -395,10 +397,21 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD') + const hasRejection = bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc('STANDARD') + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + ? 'provider_rejection' + : 'default' + } + providerMessage={bridgeRejection.userMessage ?? undefined} /> diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index d642c4def..0b5d31363 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -1076,12 +1076,61 @@ export default function QRPayPage() { const needsKycVerification = kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION || kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS + const hasProviderRejection = + kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED // show loading while KYC state is being determined if (isLoadingKycState) { return } + // provider rejection: user is sumsub-approved but manteca rejected + if (hasProviderRejection) { + const isFixable = kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE + return ( +
+ + router.back()} + title={isFixable ? 'We need an updated document' : 'QR payments are not available'} + description={ + isFixable + ? 'We need an updated document to enable QR payments. Please upload a clearer photo of your ID.' + : 'QR payments are not available for your account. Contact support for help.' + } + icon={ + methodIcon ? ( + Payment method + ) : undefined + } + ctas={[ + isFixable + ? { + text: 'Upload document', + onClick: () => { + saveRedirectUrl() + router.push('/profile/identity-verification') + }, + variant: 'purple' as const, + shadowSize: '4' as const, + icon: 'upload', + } + : { + text: 'Contact support', + onClick: () => { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + }, + variant: 'stroke' as const, + }, + ]} + /> +
+ ) + } + // show KYC screens before any error screens - user needs to verify first if (needsKycVerification) { return ( diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 471a00a39..565afe57a 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -34,6 +34,7 @@ import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient } from '@tanstack/react-query' import { captureException } from '@sentry/nextjs' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -91,6 +92,7 @@ export default function MantecaWithdrawFlow() { const { setIsSupportModalOpen, openSupportWithMessage } = useModalsContext() const queryClient = useQueryClient() const { isUserMantecaKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() const { hasPendingTransactions } = usePendingTransactions() // inline sumsub kyc flow for manteca users who need LATAM verification @@ -530,10 +532,21 @@ export default function MantecaWithdrawFlow() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + const hasRejection = mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM') + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} /> => { + const jwtToken = (await getJWTCookie())?.value + + if (!jwtToken) { + return { error: 'Authentication required' } + } + + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity/resubmit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + body: JSON.stringify({ provider }), + }) + + const responseJson = await response.json() + + if (!response.ok) { + return { + error: responseJson.userMessage || responseJson.error || 'Failed to initiate document resubmission', + } + } + + return { data: responseJson } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index e12390235..7c34bfa32 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -11,6 +11,7 @@ import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' import { useQueryClient } from '@tanstack/react-query' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -64,6 +65,7 @@ const MantecaAddMoney: FC = () => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) const { isUserMantecaKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') const { user } = useAuth() @@ -221,10 +223,22 @@ const MantecaAddMoney: FC = () => { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + const hasRejection = + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM') + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} /> { const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() const [showKycStatusModal, setShowKycStatusModal] = useState(false) const countryPathParts = Array.isArray(params.country) ? params.country : [params.country] @@ -83,6 +85,12 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() + // block users with bridge provider rejection — they need to resubmit docs first + if (bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + return {} + } + // 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) { diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 0598ac4b1..1f53e5bf4 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -12,6 +12,7 @@ import MantecaReviewStep from './views/MantecaReviewStep' import { Button } from '@/components/0_Bruddle/Button' import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -28,6 +29,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount const router = useRouter() const [destinationAddress, setDestinationAddress] = useState('') const { isUserMantecaKycApproved } = useKycStatus() + const { manteca: mantecaRejection } = useProviderRejectionStatus() // inline sumsub kyc flow for manteca users who need LATAM verification // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -120,10 +122,21 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('LATAM') + const hasRejection = mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + if (hasRejection) { + await sumsubFlow.handleSelfHealResubmit('MANTECA') + } else { + await sumsubFlow.handleInitiateKyc('LATAM') + } setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} + variant={ + mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + ? 'provider_rejection' + : 'default' + } + providerMessage={mantecaRejection.userMessage ?? undefined} /> diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index f8fd156ba..08b609899 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -6,18 +6,24 @@ import { Icon, type IconName } from '@/components/Global/Icons/Icon' import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' interface ActivationCTAsProps { activationStep: ActivationStep } -const STEPS: Record< - Exclude, - { icon: IconName; title: string; description: string; ctaLabel: string; href: string } -> = { +interface StepConfig { + icon: IconName + title: string + description: string + ctaLabel: string + href: string +} + +const STEPS: Record, StepConfig> = { verify: { icon: 'globe-lock', title: 'Verify to get started', @@ -44,10 +50,13 @@ const STEPS: Record< /** * single activation CTA for non-activated users on the home screen. * shows only the current step the user needs to complete. + * when sumsub is approved but a provider rejected the user, overrides + * the deposit/outbound step with a "complete your setup" message. */ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen } = useModalsContext() + const { hasFixableRejection, hasBlockedRejection, primaryRejection } = useProviderRejectionStatus() const lastTrackedStep = useRef(null) useEffect(() => { @@ -59,9 +68,38 @@ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps) } }, [activationStep]) - if (activationStep === 'completed') return null + // provider rejection overrides the step copy when user is past the verify step + // (sumsub approved but provider rejected — deposit/outbound CTAs are useless) + const hasProviderRejection = activationStep !== 'verify' && (hasFixableRejection || hasBlockedRejection) + + const step: StepConfig | null = useMemo(() => { + if (activationStep === 'completed' && !hasProviderRejection) return null + + if (hasProviderRejection) { + if (hasFixableRejection) { + return { + icon: 'globe-lock', + title: 'Complete your setup', + description: + primaryRejection?.userMessage || 'We need an updated document before you can add money.', + ctaLabel: 'Upload document', + href: '/profile/identity-verification', + } + } + // blocked + return { + icon: 'globe-lock', + title: 'Verification issue', + description: 'Contact support for help with your verification.', + ctaLabel: 'Contact support', + href: '', // handled in onClick + } + } + + return STEPS[activationStep as Exclude] + }, [activationStep, hasProviderRejection, hasFixableRejection, primaryRejection]) - const step = STEPS[activationStep] + if (!step) return null return ( @@ -78,7 +116,11 @@ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps) shadowSize="4" className="mt-2 w-full" onClick={() => { - if (activationStep === 'outbound') { + if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + } else if (activationStep === 'outbound' && !hasProviderRejection) { setIsQRScannerOpen(true) } else { router.push(step.href) diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index 3d9a8ea55..fa99ce271 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -7,32 +7,52 @@ interface InitiateKycModalProps { onClose: () => void onVerify: () => void isLoading?: boolean + /** when set, shows provider-specific messaging instead of generic "verify your identity" */ + variant?: 'default' | 'provider_rejection' + providerMessage?: string } -// 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) => { +// confirmation modal shown before starting KYC or provider resubmission. +// for fresh KYC: "Verify your identity" — for provider rejections: "We need extra documents" +export const InitiateKycModal = ({ + visible, + onClose, + onVerify, + isLoading, + variant = 'default', + providerMessage, +}: InitiateKycModalProps) => { + const isProviderRejection = variant === 'provider_rejection' + return ( } + footer={ + isProviderRejection ? undefined : ( + + ) + } /> ) } diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 03b71e432..9c01cc0e4 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -4,6 +4,7 @@ import { KycFailed } from './states/KycFailed' import { KycNotStarted } from './states/KycNotStarted' import { KycProcessing } from './states/KycProcessing' import { KycRequiresDocuments } from './states/KycRequiresDocuments' +import { KycProviderRejection } from './states/KycProviderRejection' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' @@ -13,6 +14,7 @@ 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' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' interface KycStatusDrawerProps { isOpen: boolean @@ -101,6 +103,9 @@ export const KycStatusDrawer = ({ [closeAndStartKyc] ) + // provider rejection status + const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() + const renderContent = () => { // user initiated kyc but abandoned before submitting — close drawer visually // but keep component mounted so SumsubKycModals persists for the SDK flow @@ -108,6 +113,25 @@ export const KycStatusDrawer = ({ return } + // provider rejection: sumsub approved but bridge/manteca rejected + // show two-level status: identity verified + provider-specific rejection + if (statusCategory === 'completed' && hasAnyRejection) { + const rejection = + bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + ? bridgeRejection + : mantecaRejection + return ( + { + onKeepMounted?.(true) + onClose() + sumsubFlow.handleSelfHealResubmit(rejection.provider) + }} + /> + ) + } + // 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 80f9de1b4..b5986401b 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -17,6 +17,7 @@ import { isKycStatusNotStarted, isKycStatusActionRequired, } from '@/constants/kyc.consts' +import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' // kyc history entry type + type guard — used by HomeHistory and history page export interface KycHistoryEntry { @@ -80,6 +81,9 @@ export const KycStatusItem = ({ [user?.rails] ) + // provider rejection status (bridge/manteca) + const { hasFixableRejection, hasBlockedRejection } = useProviderRejectionStatus() + const isApproved = isKycStatusApproved(kycStatus) const isPending = isKycStatusPending(kycStatus) const isRejected = isKycStatusFailed(kycStatus) @@ -89,6 +93,9 @@ export const KycStatusItem = ({ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { + // provider rejection takes priority when sumsub is approved + if (isApproved && hasFixableRejection) return 'Action needed' + if (isApproved && hasBlockedRejection) return 'Verification issue' if (hasBridgeDocsNeeded) return 'Action needed' if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' @@ -96,7 +103,16 @@ export const KycStatusItem = ({ if (isApproved) return 'Verified' if (isRejected) return 'Failed' return 'Unknown' - }, [hasBridgeDocsNeeded, isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) + }, [ + hasBridgeDocsNeeded, + isInitiatedButNotStarted, + isActionRequired, + isPending, + isApproved, + isRejected, + hasFixableRejection, + hasBlockedRejection, + ]) // only hide for bridge's default "not_started" state. // if a verification record exists, the user has initiated KYC — show it. @@ -122,9 +138,13 @@ export const KycStatusItem = ({

{subtitle}

{ const router = useRouter() const { user } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() - const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus() + const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent, isSumsubApproved } = + useUnifiedKycStatus() + const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation const displayRegionRef = useRef(null) @@ -69,10 +73,19 @@ const RegionsVerification = () => { ) const clickedRegionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined - const modalVariant = selectedRegion + const baseModalVariant = selectedRegion ? getModalVariant(sumsubStatus, clickedRegionIntent, sumsubVerificationRegionIntent) : null + // override modal variant when sumsub is approved but a provider rejected the user + // determines which provider is relevant based on the clicked region + const providerRejectionForRegion = clickedRegionIntent === 'LATAM' ? mantecaRejection : bridgeRejection + const hasProviderRejectionForRegion = + !!selectedRegion && + isSumsubApproved && + (providerRejectionForRegion.state === 'fixable' || providerRejectionForRegion.state === 'blocked') + const modalVariant = hasProviderRejectionForRegion ? ('provider_rejection' as const) : baseModalVariant + const handleFinalKycSuccess = useCallback(() => { setSelectedRegion(null) setActiveRegionIntent(undefined) @@ -175,6 +188,47 @@ const RegionsVerification = () => { failureCount={sumsubFailureCount} /> + { + handleModalClose() + flow.handleSelfHealResubmit(providerRejectionForRegion.provider) + }, + variant: 'purple' as const, + shadowSize: '4' as const, + } + : { + text: 'Contact support', + onClick: () => { + if (typeof window !== 'undefined' && (window as any).$crisp) { + ;(window as any).$crisp.push(['do', 'chat:open']) + } + handleModalClose() + }, + variant: 'purple' as const, + shadowSize: '4' as const, + }, + ]} + /> + {flow.error &&

{flow.error}

} diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 7c4fc2850..9df35b914 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -322,11 +322,11 @@ export const isTerminalRejection = ({ failureCount, rejectLabels, }: { - rejectType?: 'RETRY' | 'FINAL' | null + rejectType?: 'RETRY' | 'FINAL' | 'PROVIDER_FIXABLE' | 'PROVIDER_FINAL' | null failureCount?: number rejectLabels?: string[] | null }): boolean => { - if (rejectType === 'FINAL') return true + if (rejectType === 'FINAL' || rejectType === 'PROVIDER_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/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 953ae58ae..63ea66f79 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -143,6 +143,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent accessToken, liveKycStatus, handleInitiateKyc: originalHandleInitiateKyc, + handleSelfHealResubmit, handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, @@ -321,6 +322,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent return { // initiation handleInitiateKyc, + handleSelfHealResubmit, isLoading, error, liveKycStatus, diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts new file mode 100644 index 000000000..30e667de6 --- /dev/null +++ b/src/hooks/useProviderRejectionStatus.ts @@ -0,0 +1,155 @@ +'use client' + +import { useAuth } from '@/context/authContext' +import { useMemo } from 'react' +import type { IUserRail, IUserKycVerification } from '@/interfaces/interfaces' + +export type ProviderRejectionState = 'happy' | 'processing' | 'fixable' | 'blocked' + +export interface ProviderRejectionInfo { + provider: 'BRIDGE' | 'MANTECA' + state: ProviderRejectionState + userMessage: string | null + rejectedRails: IUserRail[] + kycVerification: IUserKycVerification | null + selfHealAttempt: number + maxAttempts: number +} + +const MAX_SELF_HEAL_ATTEMPTS = 3 + +/** + * derives per-provider fixable/blocked/processing state from rails + kycVerifications. + * shared by ActivationCTAs and KycStatusItem (DRY — hugo's comment #11). + */ +export default function useProviderRejectionStatus() { + const { user } = useAuth() + + const rails = user?.rails ?? [] + const kycVerifications = user?.user?.kycVerifications ?? [] + + const getProviderState = useMemo(() => { + return (providerCode: 'BRIDGE' | 'MANTECA'): ProviderRejectionInfo => { + const providerRails = rails.filter((r) => r.rail.provider.code === providerCode) + const rejectedRails = providerRails.filter((r) => r.status === 'REJECTED') + const pendingRails = providerRails.filter((r) => r.status === 'PENDING') + const enabledRails = providerRails.filter((r) => r.status === 'ENABLED') + + // find the most recent kyc verification for this provider + const kycVerification = + kycVerifications + .filter((v) => v.provider === providerCode) + .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] ?? + null + + const metadata = (kycVerification?.metadata ?? {}) as Record + const selfHealAttempt = (metadata.selfHealAttempt as number) || 0 + + // no rails for this provider — not submitted + if (providerRails.length === 0) { + return { + provider: providerCode, + state: 'happy', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // all enabled — happy + if (enabledRails.length > 0 && rejectedRails.length === 0) { + return { + provider: providerCode, + state: 'happy', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // has rejected rails + if (rejectedRails.length > 0) { + const firstRejectedMetadata = (rejectedRails[0].metadata ?? {}) as Record + const isSelfHealable = firstRejectedMetadata.selfHealable === true + const rejectType = kycVerification?.rejectType + + // check if fixable: selfHealable flag on rail + rejectType + attempt limit + const isFixable = + isSelfHealable && rejectType !== 'PROVIDER_FINAL' && selfHealAttempt < MAX_SELF_HEAL_ATTEMPTS + + // extract user-facing message from rejection reasons or endorsement issues + let userMessage: string | null = null + const reasons = firstRejectedMetadata.rejectionReasons + const endorsementIssues = firstRejectedMetadata.endorsementIssues + if (Array.isArray(reasons) && reasons.length > 0) { + // bridge format: { reason: string, developer_reason: string } + // manteca format: { task: string, reason: string } + const first = reasons[0] + userMessage = first?.reason || first?.developer_reason || null + } else if (Array.isArray(endorsementIssues) && endorsementIssues.length > 0) { + // bridge endorsement issues: plain strings like 'government_id_verification_failed' + const issue = String(endorsementIssues[0]).replace(/_/g, ' ') + userMessage = `ID verification failed. Please upload a clearer photo.` + } + + return { + provider: providerCode, + state: isFixable ? 'fixable' : 'blocked', + userMessage, + rejectedRails, + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // has pending rails (submitted but not yet reviewed) + if (pendingRails.length > 0) { + return { + provider: providerCode, + state: 'processing', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + + // default: processing (REQUIRES_INFORMATION, REQUIRES_EXTRA_INFORMATION, etc.) + return { + provider: providerCode, + state: 'processing', + userMessage: null, + rejectedRails: [], + kycVerification, + selfHealAttempt, + maxAttempts: MAX_SELF_HEAL_ATTEMPTS, + } + } + }, [rails, kycVerifications]) + + const bridge = useMemo(() => getProviderState('BRIDGE'), [getProviderState]) + const manteca = useMemo(() => getProviderState('MANTECA'), [getProviderState]) + + // overall: has any fixable rejection across providers + const hasFixableRejection = bridge.state === 'fixable' || manteca.state === 'fixable' + const hasBlockedRejection = bridge.state === 'blocked' || manteca.state === 'blocked' + const hasAnyRejection = hasFixableRejection || hasBlockedRejection + + // the provider that needs attention first (fixable takes priority) + const primaryRejection = bridge.state === 'fixable' ? bridge : manteca.state === 'fixable' ? manteca : null + + return { + bridge, + manteca, + hasFixableRejection, + hasBlockedRejection, + hasAnyRejection, + primaryRejection, + } +} diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 747ef0e78..5ac4d9df4 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -10,6 +10,8 @@ export enum QrKycState { PROCEED_TO_PAY = 'proceed_to_pay', REQUIRES_IDENTITY_VERIFICATION = 'requires_identity_verification', IDENTITY_VERIFICATION_IN_PROGRESS = 'identity_verification_in_progress', + PROVIDER_REJECTION_FIXABLE = 'provider_rejection_fixable', + PROVIDER_REJECTION_BLOCKED = 'provider_rejection_blocked', } export interface QrKycGateResult { @@ -61,12 +63,28 @@ 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. + // sumsub approved users can proceed to qr pay, unless manteca rejected them const hasSumsubApproved = currentUser.kycVerifications?.some( (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status) ) if (hasSumsubApproved) { + // check if manteca has rejected rails (qr payments use manteca) + const rejectedMantecaRails = (user?.rails ?? []).filter( + (r) => r.rail.provider.code === 'MANTECA' && r.status === 'REJECTED' + ) + if (rejectedMantecaRails.length > 0) { + const railMeta = (rejectedMantecaRails[0].metadata ?? {}) as Record + const mantecaKyc = currentUser.kycVerifications?.find((v) => v.provider === 'MANTECA') + const kycMeta = (mantecaKyc?.metadata ?? {}) as Record + const isFixable = + railMeta.selfHealable === true && + mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && + ((kycMeta.selfHealAttempt as number) || 0) < 3 + setKycGateState( + isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED + ) + return + } setKycGateState(QrKycState.PROCEED_TO_PAY) return } @@ -121,6 +139,8 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): shouldBlockPay: kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION || kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS || + kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || + kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED || kycGateState === QrKycState.LOADING, } diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index d92709a2e..269bea6e8 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -2,7 +2,7 @@ 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 { initiateSumsubKyc, initiateSelfHealResubmission } from '@/app/actions/sumsub' import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' interface UseSumsubKycFlowOptions { @@ -36,6 +36,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // 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) + // tracks self-heal provider for token refresh — null when in regular KYC flow + const selfHealProviderRef = useRef<'BRIDGE' | 'MANTECA' | null>(null) useEffect(() => { regionIntentRef.current = regionIntent @@ -129,6 +131,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => { userInitiatedRef.current = true initiatingRef.current = true + selfHealProviderRef.current = null setIsLoading(true) setError(null) @@ -219,8 +222,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [onManualClose]) // token refresh function passed to the sdk for when the token expires. - // uses regionIntentRef + levelNameRef so refresh always matches the template used during initiation. + // uses self-heal provider ref when in self-heal mode, otherwise regular KYC endpoint. const refreshToken = useCallback(async (): Promise => { + if (selfHealProviderRef.current) { + const response = await initiateSelfHealResubmission(selfHealProviderRef.current) + if (response.error || !response.data?.token) { + throw new Error(response.error || 'Failed to refresh self-heal token') + } + setAccessToken(response.data.token) + return response.data.token + } + const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current, levelName: levelNameRef.current, @@ -247,6 +259,36 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError(null) }, []) + // initiate self-heal document resubmission: calls the resubmit API + // and opens the sumsub SDK with the action token + const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA') => { + setIsLoading(true) + setError(null) + userInitiatedRef.current = true + selfHealProviderRef.current = provider + + try { + const response = await initiateSelfHealResubmission(provider) + + if (response.error) { + setError(response.error) + return + } + + if (response.data?.token) { + setAccessToken(response.data.token) + setShowWrapper(true) + } else { + setError('Could not initiate document resubmission. Please try again.') + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) + } finally { + setIsLoading(false) + } + }, []) + return { isLoading, error, @@ -255,6 +297,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: liveKycStatus, rejectLabels, handleInitiateKyc, + handleSelfHealResubmit, handleSdkComplete, handleClose, refreshToken, diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index 6bc66a79a..cd5e95a73 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -254,7 +254,7 @@ export interface IUserKycVerification { providerRawStatus?: string | null sumsubApplicantId?: string | null rejectLabels?: string[] | null - rejectType?: 'RETRY' | 'FINAL' | null + rejectType?: 'RETRY' | 'FINAL' | 'PROVIDER_FIXABLE' | 'PROVIDER_FINAL' | null metadata?: { regionIntent?: string; [key: string]: unknown } | null createdAt: string updatedAt: string From 9bef94c37b796e0d1dedcac1a11301c9bc6d03f8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:39:42 +0530 Subject: [PATCH 07/23] feat: add KycProviderRejection component for self-heal drawer state - two-level status display: identity verified + provider-specific rejection - "Upload document" CTA triggers self-heal resubmission flow via parent callback - "Contact support" for permanently blocked rejections --- .../Kyc/states/KycProviderRejection.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/components/Kyc/states/KycProviderRejection.tsx diff --git a/src/components/Kyc/states/KycProviderRejection.tsx b/src/components/Kyc/states/KycProviderRejection.tsx new file mode 100644 index 000000000..716b35137 --- /dev/null +++ b/src/components/Kyc/states/KycProviderRejection.tsx @@ -0,0 +1,76 @@ +'use client' + +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' +import { Icon } from '@/components/Global/Icons/Icon' +import type { ProviderRejectionInfo } from '@/hooks/useProviderRejectionStatus' + +/** + * shown when a user is sumsub-approved but a provider (bridge/manteca) rejected their documents. + * displays two-level status: identity verified + provider-specific rejection with action. + * onStartResubmission is called when user clicks "Upload document" — the parent (KycStatusDrawer) + * handles the actual API call + SDK opening via sumsubFlow.handleSelfHealResubmit. + */ +export const KycProviderRejection = ({ + rejection, + onStartResubmission, +}: { + rejection: ProviderRejectionInfo + onStartResubmission?: () => void +}) => { + const providerLabel = rejection.provider === 'BRIDGE' ? 'Bank transfers' : 'QR payments' + const isFixable = rejection.state === 'fixable' + + return ( +
+ {/* identity verified status */} + + + {/* provider-specific status */} +
+
+
+ +
+
+

+ {providerLabel}: {isFixable ? 'action needed' : 'unavailable'} +

+

+ {rejection.userMessage || + (isFixable ? 'We need an updated document.' : 'Contact support for help.')} +

+
+
+ + {isFixable && rejection.selfHealAttempt > 0 && ( +

+ Attempt {rejection.selfHealAttempt} of {rejection.maxAttempts} +

+ )} +
+ + {isFixable ? ( + + ) : ( + + )} +
+ ) +} From de5f188595b28a7523215c70d0b0641c8cfd25d8 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:30:46 +0530 Subject: [PATCH 08/23] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20blocked=20user=20flow,=20error=20handling,=20ref=20?= =?UTF-8?q?cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - only call handleSelfHealResubmit for 'fixable' rejections, not 'blocked' - clear selfHealProviderRef on error to prevent wrong token refresh endpoint - await handleSelfHealResubmit in KycStatusDrawer + reset onKeepMounted on failure - primaryRejection falls back to blocked provider when no fixable exists - sort manteca KYC verifications by updatedAt in QR gate for latest state --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 2 +- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 2 +- src/components/AddWithdraw/AddWithdrawCountriesList.tsx | 7 +++++-- src/components/Claim/Link/MantecaFlowManager.tsx | 2 +- src/components/Kyc/KycStatusDrawer.tsx | 8 ++++++-- src/content | 2 +- src/hooks/useProviderRejectionStatus.ts | 2 +- src/hooks/useQrKycGate.ts | 4 +++- src/hooks/useSumsubKycFlow.ts | 2 ++ 9 files changed, 21 insertions(+), 10 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 4b8d8aff0..eb55efa2c 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -397,7 +397,7 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - const hasRejection = bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + const hasRejection = bridgeRejection.state === 'fixable' if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 565afe57a..d802deaa8 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -532,7 +532,7 @@ export default function MantecaWithdrawFlow() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - const hasRejection = mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + const hasRejection = mantecaRejection.state === 'fixable' if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 8dae82ceb..d4f46a123 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -85,11 +85,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() - // block users with bridge provider rejection — they need to resubmit docs first - if (bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked') { + // block users with bridge provider rejection + if (bridgeRejection.state === 'fixable') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') return {} } + if (bridgeRejection.state === 'blocked') { + return { error: 'Bank transfers are not available for your account. Please contact support.' } + } // 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 diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 1f53e5bf4..2b9e36c33 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -122,7 +122,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - const hasRejection = mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + const hasRejection = mantecaRejection.state === 'fixable' if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 9c01cc0e4..ba869b416 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -123,10 +123,14 @@ export const KycStatusDrawer = ({ return ( { + onStartResubmission={async () => { onKeepMounted?.(true) onClose() - sumsubFlow.handleSelfHealResubmit(rejection.provider) + try { + await sumsubFlow.handleSelfHealResubmit(rejection.provider) + } catch (e) { + onKeepMounted?.(false) + } }} /> ) diff --git a/src/content b/src/content index 1b53cf2ad..1019243e2 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 1b53cf2adccb6465cc6987970e4041eb33b6789b +Subproject commit 1019243e25d9eddfebc609f3fd3f04db8a25490f diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts index 30e667de6..9e4df5877 100644 --- a/src/hooks/useProviderRejectionStatus.ts +++ b/src/hooks/useProviderRejectionStatus.ts @@ -142,7 +142,7 @@ export default function useProviderRejectionStatus() { const hasAnyRejection = hasFixableRejection || hasBlockedRejection // the provider that needs attention first (fixable takes priority) - const primaryRejection = bridge.state === 'fixable' ? bridge : manteca.state === 'fixable' ? manteca : null + const primaryRejection = bridge.state === 'fixable' ? bridge : manteca.state === 'fixable' ? manteca : bridge.state === 'blocked' ? bridge : manteca.state === 'blocked' ? manteca : null return { bridge, diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 5ac4d9df4..b3d723023 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -74,7 +74,9 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): ) if (rejectedMantecaRails.length > 0) { const railMeta = (rejectedMantecaRails[0].metadata ?? {}) as Record - const mantecaKyc = currentUser.kycVerifications?.find((v) => v.provider === 'MANTECA') + const mantecaKyc = currentUser.kycVerifications + ?.filter((v) => v.provider === 'MANTECA') + .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] const kycMeta = (mantecaKyc?.metadata ?? {}) as Record const isFixable = railMeta.selfHealable === true && diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 269bea6e8..f106542b5 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -271,6 +271,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const response = await initiateSelfHealResubmission(provider) if (response.error) { + selfHealProviderRef.current = null setError(response.error) return } @@ -282,6 +283,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError('Could not initiate document resubmission. Please try again.') } } catch (e: unknown) { + selfHealProviderRef.current = null const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message) } finally { From 472693573c4026990409b0072623a6815bb62875 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:45:27 +0530 Subject: [PATCH 09/23] =?UTF-8?q?fix:=20address=20round=202=20review=20?= =?UTF-8?q?=E2=80=94=20blocked=20resubmit,=20ref=20cleanup,=20dead=20var,?= =?UTF-8?q?=20magic=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MantecaAddMoney: only self-heal for fixable (was still including blocked) - clear selfHealProviderRef when API returns success but no token - remove dead variable in useProviderRejectionStatus endorsement branch - replace magic number 3 with MAX_SELF_HEAL_ATTEMPTS in useQrKycGate --- src/components/AddMoney/components/MantecaAddMoney.tsx | 2 +- src/hooks/useProviderRejectionStatus.ts | 3 +-- src/hooks/useQrKycGate.ts | 4 +++- src/hooks/useSumsubKycFlow.ts | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index 7c34bfa32..323563f3c 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -224,7 +224,7 @@ const MantecaAddMoney: FC = () => { onClose={() => setShowKycModal(false)} onVerify={async () => { const hasRejection = - mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' + mantecaRejection.state === 'fixable' if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts index 9e4df5877..5720dab65 100644 --- a/src/hooks/useProviderRejectionStatus.ts +++ b/src/hooks/useProviderRejectionStatus.ts @@ -92,8 +92,7 @@ export default function useProviderRejectionStatus() { userMessage = first?.reason || first?.developer_reason || null } else if (Array.isArray(endorsementIssues) && endorsementIssues.length > 0) { // bridge endorsement issues: plain strings like 'government_id_verification_failed' - const issue = String(endorsementIssues[0]).replace(/_/g, ' ') - userMessage = `ID verification failed. Please upload a clearer photo.` + userMessage = `ID verification failed. Please upload a clearer photo.` } return { diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index b3d723023..607435876 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -5,6 +5,8 @@ import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts' +const MAX_SELF_HEAL_ATTEMPTS = 3 + export enum QrKycState { LOADING = 'loading', PROCEED_TO_PAY = 'proceed_to_pay', @@ -81,7 +83,7 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): const isFixable = railMeta.selfHealable === true && mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && - ((kycMeta.selfHealAttempt as number) || 0) < 3 + ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS setKycGateState( isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED ) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index f106542b5..2f50dc14a 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -280,6 +280,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setAccessToken(response.data.token) setShowWrapper(true) } else { + selfHealProviderRef.current = null setError('Could not initiate document resubmission. Please try again.') } } catch (e: unknown) { From 58d955f46f0db8acad7ed7a91d887b25bd1bd0d5 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:20:14 +0530 Subject: [PATCH 10/23] chore: format --- .../AddMoney/components/MantecaAddMoney.tsx | 3 +-- src/hooks/useProviderRejectionStatus.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index 323563f3c..7f909991a 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -223,8 +223,7 @@ const MantecaAddMoney: FC = () => { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - const hasRejection = - mantecaRejection.state === 'fixable' + const hasRejection = mantecaRejection.state === 'fixable' if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts index 5720dab65..014b94461 100644 --- a/src/hooks/useProviderRejectionStatus.ts +++ b/src/hooks/useProviderRejectionStatus.ts @@ -92,7 +92,7 @@ export default function useProviderRejectionStatus() { userMessage = first?.reason || first?.developer_reason || null } else if (Array.isArray(endorsementIssues) && endorsementIssues.length > 0) { // bridge endorsement issues: plain strings like 'government_id_verification_failed' - userMessage = `ID verification failed. Please upload a clearer photo.` + userMessage = `ID verification failed. Please upload a clearer photo.` } return { @@ -141,7 +141,16 @@ export default function useProviderRejectionStatus() { const hasAnyRejection = hasFixableRejection || hasBlockedRejection // the provider that needs attention first (fixable takes priority) - const primaryRejection = bridge.state === 'fixable' ? bridge : manteca.state === 'fixable' ? manteca : bridge.state === 'blocked' ? bridge : manteca.state === 'blocked' ? manteca : null + const primaryRejection = + bridge.state === 'fixable' + ? bridge + : manteca.state === 'fixable' + ? manteca + : bridge.state === 'blocked' + ? bridge + : manteca.state === 'blocked' + ? manteca + : null return { bridge, From 91d92101cd69598a50048df4a2556db43ed6c896 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:31:06 +0530 Subject: [PATCH 11/23] fix: release onKeepMounted when self-heal resubmit fails to open SDK --- src/components/Kyc/KycStatusDrawer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index ba869b416..9cf183328 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -126,9 +126,9 @@ export const KycStatusDrawer = ({ onStartResubmission={async () => { onKeepMounted?.(true) onClose() - try { - await sumsubFlow.handleSelfHealResubmit(rejection.provider) - } catch (e) { + await sumsubFlow.handleSelfHealResubmit(rejection.provider) + // release keep-mounted if SDK didn't open (error path) + if (!sumsubFlow.showWrapper) { onKeepMounted?.(false) } }} From 132404e74057800de0ba9d78eda4ce909410795e Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:53:24 +0530 Subject: [PATCH 12/23] fix: clear selfHealProviderRef on SDK completion (CTO review #14) --- src/hooks/useSumsubKycFlow.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 2f50dc14a..ff915c1a4 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -209,6 +209,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // called when sdk signals applicant submitted const handleSdkComplete = useCallback(() => { userInitiatedRef.current = true + selfHealProviderRef.current = null setShowWrapper(false) setIsActionFlow(false) setIsVerificationProgressModalOpen(true) From f9f0385710285f49700a1de3bbdb1909ae8468af Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:21:40 +0000 Subject: [PATCH 13/23] hotfix: disable cross-chain claims via underMaintenance config - Set disableSquidSend: true in underMaintenance.config.ts - Add guard in Initial.view.tsx to block x-chain claims when disabled - Add guard in Confirm.view.tsx to block x-chain claims when disabled - Shows clear error message to users instead of failing silently Refs: https://discord.com/channels/972435984954302464/1499037819379777696 --- src/components/Claim/Link/Initial.view.tsx | 4 ++++ src/components/Claim/Link/Onchain/Confirm.view.tsx | 4 ++++ src/config/underMaintenance.config.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 4a94b1123..f99bf3088 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -30,6 +30,7 @@ import { type IClaimScreenProps } from '../Claim.consts' import SendLinkActionList from '@/components/Claim/Link/SendLinkActionList' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import useClaimLink from '../useClaimLink' +import underMaintenanceConfig from '@/config/underMaintenance.config' import ActionModal from '@/components/Global/ActionModal' import { Slider } from '@/components/Slider' import { BankFlowManager } from './views/BankFlowManager.view' @@ -322,6 +323,9 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { // check if cross-chain claiming is needed if (isXChain) { + if (underMaintenanceConfig.disableSquidSend) { + throw new Error('Cross-chain claims are temporarily unavailable. Please claim on the same chain or try again later.') + } if (!selectedTokenData?.chainId || !selectedTokenData?.address) { throw new Error('Selected token data is required for cross-chain claims') } diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index f71f5d43f..bd2e8881e 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -21,6 +21,7 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import underMaintenanceConfig from '@/config/underMaintenance.config' export const ConfirmClaimLinkView = ({ onNext, @@ -97,6 +98,9 @@ export const ConfirmClaimLinkView = ({ try { let claimTxHash: string | undefined = '' if (selectedRoute) { + if (underMaintenanceConfig.disableSquidSend) { + throw new Error('Cross-chain claims are temporarily unavailable. Please claim on the same chain or try again later.') + } claimTxHash = await claimLinkXchain({ address: recipient ? recipient.address : (address ?? ''), link: claimLinkData.link, diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index 70e56cb11..ba1d36611 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -56,7 +56,7 @@ const underMaintenanceConfig: MaintenanceConfig = { enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments disableSquidWithdraw: false, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum) - disableSquidSend: false, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum) + disableSquidSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum) disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature } From 69bbf82a5c49104734b6ce47b7aeced0eb28f32b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Apr 2026 15:45:07 +0100 Subject: [PATCH 14/23] hotfix: also disable cross-chain withdraw + fix friendly error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flip disableSquidWithdraw too — picker already gates withdraw view, no extra code needed - replace throw -> ErrorHandler path with setErrorState directly so the maintenance copy actually reaches the user (ErrorHandler had no matching branch, was returning generic "contact support") - extract CROSS_CHAIN_DISABLED_MESSAGE constant so claim and confirm views share the same copy --- src/components/Claim/Link/Initial.view.tsx | 7 +++++-- src/components/Claim/Link/Onchain/Confirm.view.tsx | 7 +++++-- src/config/underMaintenance.config.ts | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index f99bf3088..e915f23d9 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -30,7 +30,7 @@ import { type IClaimScreenProps } from '../Claim.consts' import SendLinkActionList from '@/components/Claim/Link/SendLinkActionList' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' import useClaimLink from '../useClaimLink' -import underMaintenanceConfig from '@/config/underMaintenance.config' +import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' import ActionModal from '@/components/Global/ActionModal' import { Slider } from '@/components/Slider' import { BankFlowManager } from './views/BankFlowManager.view' @@ -324,7 +324,10 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { // check if cross-chain claiming is needed if (isXChain) { if (underMaintenanceConfig.disableSquidSend) { - throw new Error('Cross-chain claims are temporarily unavailable. Please claim on the same chain or try again later.') + // skip throwing through ErrorHandler — surface the friendly maintenance message directly + setErrorState({ showError: true, errorMessage: CROSS_CHAIN_DISABLED_MESSAGE }) + setLoadingState('Idle') + return } if (!selectedTokenData?.chainId || !selectedTokenData?.address) { throw new Error('Selected token data is required for cross-chain claims') diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index bd2e8881e..e0d57a125 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -21,7 +21,7 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import underMaintenanceConfig from '@/config/underMaintenance.config' +import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' export const ConfirmClaimLinkView = ({ onNext, @@ -99,7 +99,10 @@ export const ConfirmClaimLinkView = ({ let claimTxHash: string | undefined = '' if (selectedRoute) { if (underMaintenanceConfig.disableSquidSend) { - throw new Error('Cross-chain claims are temporarily unavailable. Please claim on the same chain or try again later.') + // safety net for stale routes — picker normally prevents reaching this view with a route + setErrorState({ showError: true, errorMessage: CROSS_CHAIN_DISABLED_MESSAGE }) + setLoadingState('Idle') + return } claimTxHash = await claimLinkXchain({ address: recipient ? recipient.address : (address ?? ''), diff --git a/src/config/underMaintenance.config.ts b/src/config/underMaintenance.config.ts index ba1d36611..e09b6cb7c 100644 --- a/src/config/underMaintenance.config.ts +++ b/src/config/underMaintenance.config.ts @@ -55,9 +55,13 @@ const underMaintenanceConfig: MaintenanceConfig = { enableFullMaintenance: false, // set to true to redirect all pages to /maintenance enableMaintenanceBanner: false, // set to true to show maintenance banner on all pages disabledPaymentProviders: [], // set to ['MANTECA'] to disable Manteca QR payments - disableSquidWithdraw: false, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum) + disableSquidWithdraw: true, // set to true to disable cross-chain withdrawals (only allows USDC on Arbitrum) disableSquidSend: true, // set to true to disable cross-chain sends (claim, request payments - only allows USDC on Arbitrum) disableCardPioneers: true, // set to false to enable the Card Pioneers waitlist feature } +// shared user-facing copy for cross-chain disabled paths — keep wording aligned with TokenSelector banner +export const CROSS_CHAIN_DISABLED_MESSAGE = + 'Cross-chain claims are temporarily unavailable. Try claiming to an external wallet on the same chain as the link, or try again later.' + export default underMaintenanceConfig From 5faf1dbf8c4ea8e431b6e7e80326825458d188e9 Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:57:48 +0000 Subject: [PATCH 15/23] fix: revert wildcard redirect + add delete-account help article --- redirects.json | 5 ----- src/content | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/redirects.json b/redirects.json index 4c39d2564..078accdb5 100644 --- a/redirects.json +++ b/redirects.json @@ -34,11 +34,6 @@ "destination": "/en/help", "permanent": false }, - { - "source": "/help/:slug*", - "destination": "/en/help/:slug*", - "permanent": false - }, { "source": "/docs", "destination": "/en/help", diff --git a/src/content b/src/content index 1019243e2..0b281d1bd 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 1019243e25d9eddfebc609f3fd3f04db8a25490f +Subproject commit 0b281d1bd5aa47ca82f04b9f98b5d73e8403ccc7 From aa870b89d827fcf917731d09539c9cfe4858aadf Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:47:38 +0530 Subject: [PATCH 16/23] fix: pass crossRegion to manteca KYC flows + surface region errors - pass crossRegion: true in all 3 handleInitiateKyc('LATAM') callsites (MantecaAddMoney, withdraw/manteca, MantecaFlowManager) so the backend creates the manteca applicant action for already-approved users - prefer userMessage from API error responses in initiateSumsubKyc - render sumsubFlow.error directly in JSX for region_not_supported errors --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 7 ++++--- src/app/actions/sumsub.ts | 8 +++++++- src/components/AddMoney/components/MantecaAddMoney.tsx | 4 ++-- src/components/Claim/Link/MantecaFlowManager.tsx | 5 ++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index d802deaa8..17a959090 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -101,6 +101,7 @@ export default function MantecaWithdrawFlow() { const sumsubFlow = useMultiPhaseKycFlow({}) const [showKycModal, setShowKycModal] = useState(false) const [isRedirectingToOnboarding, setIsRedirectingToOnboarding] = 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. @@ -536,7 +537,7 @@ export default function MantecaWithdrawFlow() { if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM') + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) } setShowKycModal(false) }} @@ -758,7 +759,7 @@ export default function MantecaWithdrawFlow() { : 'Review'} - {errorMessage && } + {(errorMessage || sumsubFlow.error) && } )} @@ -815,7 +816,7 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {errorMessage && } + {(errorMessage || sumsubFlow.error) && } )} diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index e79528064..2c730e151 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -39,7 +39,13 @@ export const initiateSumsubKyc = async (params?: { const responseJson = await response.json() if (!response.ok) { - return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' } + return { + error: + responseJson.userMessage || + responseJson.message || + responseJson.error || + 'Failed to initiate identity verification', + } } return { diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index 7f909991a..761bf3abf 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -227,7 +227,7 @@ const MantecaAddMoney: FC = () => { if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM') + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) } setShowKycModal(false) }} @@ -245,7 +245,7 @@ const MantecaAddMoney: FC = () => { setTokenAmount={handleUsdAmountChange} onSubmit={handleAmountSubmit} isLoading={isCreatingDeposit} - error={error} + error={error || sumsubFlow.error} currencyData={currencyData} setCurrencyAmount={handleLocalCurrencyAmountChange} setCurrentDenomination={handleDenominationChange} diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 2b9e36c33..0a1a2f944 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -126,7 +126,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM') + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) } setShowKycModal(false) }} @@ -139,6 +139,9 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount providerMessage={mantecaRejection.userMessage ?? undefined} /> + {sumsubFlow.error && ( +

{sumsubFlow.error}

+ )} ) } From 1648a9267c7fb3c8a8cd1a943bd34811588fc8ef Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:06:35 +0530 Subject: [PATCH 17/23] fix: use ErrorAlert in MantecaFlowManager for consistent error UI --- src/components/Claim/Link/MantecaFlowManager.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 0a1a2f944..0c715a1f4 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -10,6 +10,7 @@ import MantecaDetailsStep from './views/MantecaDetailsStep.view' import { MercadoPagoStep } from '@/types/manteca.types' import MantecaReviewStep from './views/MantecaReviewStep' import { Button } from '@/components/0_Bruddle/Button' +import ErrorAlert from '@/components/Global/ErrorAlert' import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' @@ -139,9 +140,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount providerMessage={mantecaRejection.userMessage ?? undefined} /> - {sumsubFlow.error && ( -

{sumsubFlow.error}

- )} + {sumsubFlow.error && } ) } From 82244d6455a041392a497d475751b023e53d8509 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 1 May 2026 11:37:57 +0530 Subject: [PATCH 18/23] fix: add country check to isCrossRegion LATAM path prevents MK user with crossRegion: true from bypassing eligibility gate --- src/app/actions/sumsub.ts | 1 - src/components/Claim/Link/MantecaFlowManager.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 2c730e151..aefc3796d 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -42,7 +42,6 @@ export const initiateSumsubKyc = async (params?: { return { error: responseJson.userMessage || - responseJson.message || responseJson.error || 'Failed to initiate identity verification', } diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index 0c715a1f4..e14b1abd0 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -118,6 +118,7 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount /> {renderStepDetails()} + {sumsubFlow.error && } = ({ claimLinkData, amount providerMessage={mantecaRejection.userMessage ?? undefined} /> - {sumsubFlow.error && } ) } From 6c94bf149edb2796aa95278f98ba474a0bffe1af Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 1 May 2026 12:03:51 +0530 Subject: [PATCH 19/23] fix: progress modal for action flows (manteca, self-heal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call handleSumsubApproved directly after SDK complete for action flows since the base KYC status is already APPROVED and won't transition. this makes the modal progress verifying → preparing → complete with real-time rail tracking instead of getting stuck at "verifying..." --- src/hooks/useMultiPhaseKycFlow.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 63ea66f79..64c6e0664 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -174,7 +174,12 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent posthog.capture(ANALYTICS_EVENTS.KYC_SUBMITTED, { region_intent: regionIntent }) isRealtimeFlowRef.current = true originalHandleSdkComplete() - }, [originalHandleSdkComplete, regionIntent]) + // for action flows (manteca, self-heal), the base status is already APPROVED + // and won't transition — directly start the preparing/tracking phase + if (isActionFlow) { + handleSumsubApproved() + } + }, [originalHandleSdkComplete, handleSumsubApproved, isActionFlow, regionIntent]) // wrap handleInitiateKyc to reset state for new attempts const handleInitiateKyc = useCallback( From be0f2c14b34b8acd6164f25964d13800c78549c1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 1 May 2026 15:15:21 +0530 Subject: [PATCH 20/23] feat: cross-region modal copy for already-verified users when a sumsub-approved user triggers the manteca KYC modal, show: "Your identity is already verified. To enable payments in this region, we need a valid ID from [Country]." instead of generic "verify your identity" --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 7 ++++-- .../AddMoney/components/MantecaAddMoney.tsx | 7 ++++-- .../Claim/Link/MantecaFlowManager.tsx | 7 ++++-- src/components/Kyc/InitiateKycModal.tsx | 25 +++++++++++++------ 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 17a959090..0d9a47e99 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -91,7 +91,7 @@ export default function MantecaWithdrawFlow() { const { user } = useAuth() const { setIsSupportModalOpen, openSupportWithMessage } = useModalsContext() const queryClient = useQueryClient() - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { manteca: mantecaRejection } = useProviderRejectionStatus() const { hasPendingTransactions } = usePendingTransactions() @@ -545,9 +545,12 @@ export default function MantecaWithdrawFlow() { variant={ mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' ? 'provider_rejection' - : 'default' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' } providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { manteca: mantecaRejection } = useProviderRejectionStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') const { user } = useAuth() @@ -235,9 +235,12 @@ const MantecaAddMoney: FC = () => { variant={ mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' ? 'provider_rejection' - : 'default' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' } providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> = ({ claimLinkData, amount const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS) const router = useRouter() const [destinationAddress, setDestinationAddress] = useState('') - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { manteca: mantecaRejection } = useProviderRejectionStatus() // inline sumsub kyc flow for manteca users who need LATAM verification @@ -136,9 +136,12 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount variant={ mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked' ? 'provider_rejection' - : 'default' + : isUserSumsubKycApproved + ? 'cross_region' + : 'default' } providerMessage={mantecaRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index fa99ce271..7e35837c7 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -8,12 +8,16 @@ interface InitiateKycModalProps { onVerify: () => void isLoading?: boolean /** when set, shows provider-specific messaging instead of generic "verify your identity" */ - variant?: 'default' | 'provider_rejection' + variant?: 'default' | 'provider_rejection' | 'cross_region' providerMessage?: string + /** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */ + regionName?: string } // confirmation modal shown before starting KYC or provider resubmission. -// for fresh KYC: "Verify your identity" — for provider rejections: "We need extra documents" +// for fresh KYC: "Verify your identity" +// for provider rejections: "We need extra documents" +// for cross-region: "Your identity is verified, we need a local ID" export const InitiateKycModal = ({ visible, onClose, @@ -21,19 +25,26 @@ export const InitiateKycModal = ({ isLoading, variant = 'default', providerMessage, + regionName, }: InitiateKycModalProps) => { const isProviderRejection = variant === 'provider_rejection' + const isCrossRegion = variant === 'cross_region' + + const getDescription = () => { + if (isProviderRejection) return providerMessage || 'Please upload a clearer photo of your ID to continue.' + if (isCrossRegion) { + const region = regionName ? ` from ${regionName}` : '' + return `Your identity is already verified. To enable payments in this region, we need a valid ID${region}.` + } + return 'To continue, you need to complete identity verification. This usually takes just a few minutes.' + } return ( Date: Fri, 1 May 2026 15:50:03 +0530 Subject: [PATCH 21/23] fix(ci): add --scope=squirrellabs to vercel preview deploy The preview deploy workflow was failing because the Vercel CLI defaulted to looking up the project under "squirrelcores-projects" scope, which the VERCEL_TOKEN doesn't have access to. Adding explicit --scope=squirrellabs to link, pull, and deploy commands resolves the authorization error. --- .github/workflows/preview.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 635054d4d..80001d63c 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -19,10 +19,10 @@ jobs: - name: Install Vercel CLI run: pnpm add --global vercel@latest - name: Link to Project - run: vercel link --yes --project=peanut-wallet --token=${{ secrets.VERCEL_TOKEN }} + run: vercel link --yes --project=peanut-wallet --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} - name: Pull Vercel Environment Information - run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + run: vercel pull --yes --environment=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} - name: Build Project Artifacts run: vercel build --target=preview --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel - run: vercel deploy --prebuilt --archive=tgz --target=preview --token=${{ secrets.VERCEL_TOKEN }} + run: vercel deploy --prebuilt --archive=tgz --target=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }} From 7fb60e5830b8746f3e2090df11e1f55ded5f6f92 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 1 May 2026 16:59:25 +0530 Subject: [PATCH 22/23] feat: JIT bridge enrollment for LATAM-first users in payment flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when a sumsub-approved user without bridge access tries to deposit or withdraw via bridge, show the cross_region modal explaining they need to set up payments for this region. on confirm, triggers bridge-direct enrollment (no SDK needed) → progress modal → ToS → ready. fixes the case where LATAM users see transfer failures when trying bridge flows because bridgeCustomerId is null. --- .../add-money/[country]/bank/page.tsx | 19 +++++++++++--- .../withdraw/[country]/bank/page.tsx | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 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 eb55efa2c..cbca208d9 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -94,7 +94,8 @@ export default function OnrampBankPage() { // uk-specific check const isUK = isUKCountry(selectedCountryPath) - const { isUserKycApproved } = useKycStatus() + const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = + useKycStatus() const { bridge: bridgeRejection } = useProviderRejectionStatus() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() @@ -193,10 +194,17 @@ export default function OnrampBankPage() { } }, [rawTokenAmount, validateAmount, setError]) + const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview + const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - if (!isUserKycApproved || bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked') { + if ( + needsBridgeEnrollment || + !isUserKycApproved || + bridgeRejection.state === 'fixable' || + bridgeRejection.state === 'blocked' + ) { setShowKycModal(true) return } @@ -401,7 +409,7 @@ export default function OnrampBankPage() { if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD') + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, needsBridgeEnrollment || undefined) } setShowKycModal(false) }} @@ -409,9 +417,12 @@ export default function OnrampBankPage() { variant={ bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' ? 'provider_rejection' - : 'default' + : needsBridgeEnrollment + ? 'cross_region' + : 'default' } providerMessage={bridgeRejection.userMessage ?? undefined} + regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 5aa46ddfb..809255d2f 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -28,6 +28,10 @@ import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import useKycStatus from '@/hooks/useKycStatus' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -61,6 +65,10 @@ export default function WithdrawBankPage() { const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const { hasPendingTransactions } = usePendingTransactions() const { isBridgeSupportedCountry } = useIdentityVerification() + const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus() + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) + const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId // validate country is supported for bank withdrawals useEffect(() => { @@ -155,6 +163,11 @@ export default function WithdrawBankPage() { } const handleCreateAndInitiateOfframp = async () => { + if (needsBridgeEnrollment) { + setShowKycModal(true) + return + } + if (guardWithTos()) return setIsLoading(true) @@ -428,6 +441,19 @@ export default function WithdrawBankPage() { }} onSkip={hideTos} /> + + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + variant="cross_region" + regionName={getCountryFromPath(country)?.title} + /> + ) } From c5c57f0fa0cd4859c88cf664bcf55b4cad66c167 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 1 May 2026 21:27:31 +0530 Subject: [PATCH 23/23] fix: JIT bridge enrollment modal + preventClose for Dialog conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add preventClose to InitiateKycModal (fixes headlessui Dialog focus conflict that caused modal to close on first attempt) - prioritize needsBridgeEnrollment over stale rejection state in bridge deposit variant logic - add JIT bridge enrollment in AddWithdrawCountriesList: when user submits bank details without bridge access, show cross_region modal first — enrollment happens on user confirm via "Start Verification" --- .../add-money/[country]/bank/page.tsx | 13 ++++++----- .../AddWithdraw/AddWithdrawCountriesList.tsx | 22 ++++++++++++++++++- src/components/Kyc/InitiateKycModal.tsx | 1 + 3 files changed, 29 insertions(+), 7 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 cbca208d9..ae32c5bf6 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -405,8 +405,9 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - const hasRejection = bridgeRejection.state === 'fixable' - if (hasRejection) { + // needsBridgeEnrollment takes priority: user has no bridge customer, + // so rejection state from a stale/deleted customer is irrelevant + if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc('STANDARD', undefined, needsBridgeEnrollment || undefined) @@ -415,10 +416,10 @@ export default function OnrampBankPage() { }} isLoading={sumsubFlow.isLoading} variant={ - bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' - ? 'provider_rejection' - : needsBridgeEnrollment - ? 'cross_region' + needsBridgeEnrollment + ? 'cross_region' + : bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + ? 'provider_rejection' : 'default' } providerMessage={bridgeRejection.userMessage ?? undefined} diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index d4f46a123..8c02b22f3 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -27,6 +27,7 @@ import { ActionListCard } from '@/components/ActionListCard' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -65,7 +66,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const formRef = useRef<{ handleSubmit: () => void }>(null) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview, isUserSumsubKycApproved, isUserBridgeKycApproved } = + useKycStatus() const { bridge: bridgeRejection } = useProviderRejectionStatus() const [showKycStatusModal, setShowKycStatusModal] = useState(false) @@ -94,6 +96,13 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { return { error: 'Bank transfers are not available for your account. Please contact support.' } } + // JIT bridge enrollment: user is sumsub-approved but no bridge customer yet + // show the KYC modal — enrollment happens when user clicks "Start Verification" + if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId) { + setIsKycModalOpen(true) + return {} + } + // 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) { @@ -276,6 +285,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> + setIsKycModalOpen(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + setIsKycModalOpen(false) + }} + isLoading={sumsubFlow.isLoading} + variant="cross_region" + regionName={currentCountry?.title} + /> ) diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index 7e35837c7..b7d9ca9f3 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -45,6 +45,7 @@ export const InitiateKycModal = ({ onClose={onClose} title={isProviderRejection ? 'We need extra documents' : 'Verify your identity'} description={getDescription()} + preventClose icon={'badge' as IconName} modalPanelClassName="max-w-full m-2" ctaClassName="grid grid-cols-1 gap-3"