diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 3c35a330c..a63caaf6f 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -182,6 +182,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } if (gate.kind === 'accept-tos') { guardWithTos() + } else if (gate.kind === 'provide-email') { + // A rail that flipped to email-blocked between form-open and submit + // is self-serve — open the email sheet, NOT the contact-support KYC + // modal (mirrors checkBridgeGate; the whole point of provide-email). + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index 0a1737561..f49360439 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -160,6 +160,10 @@ jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBan jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null })) jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null })) +jest.mock('@/components/Kyc/ProvideEmailStep', () => ({ + __esModule: true, + default: (props: any) => (props.visible ?
: null), +})) jest.mock('@/components/Kyc/InitiateKycModal', () => ({ InitiateKycModal: (props: any) => (props.visible ?
: null), })) @@ -235,6 +239,22 @@ describe('AddWithdrawCountriesList — bank gate', () => { expect(mockPush).not.toHaveBeenCalled() expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() }) + + // provide-email is a self-serve gate (one email unblocks the rail) — it must + // open the email sheet, NEVER the contact-support KYC modal. Both the click + // gate (checkBridgeGate) and the form-submit gate (handleFormSubmit) must + // route it there; a missing branch on the submit path turned self-serve + // recovery into a support ticket (2026-07 review finding). + it('an email-blocked gate opens the provide-email sheet, not the contact-support KYC modal', () => { + setCapabilities('provide-email', [{ status: 'blocked', channel: 'bank', country: 'US' }]) + + render() + fireEvent.click(screen.getByTestId('method-bank')) + + expect(screen.getByTestId('provide-email-sheet')).toBeInTheDocument() + expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() + expect(mockPush).not.toHaveBeenCalled() + }) }) /** diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 380ff2225..d34f2dd0e 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -107,7 +107,8 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa return { hasFixableRejection: !!fixableRail, hasBlockedRejection: !!blocked, - primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, + // Same precedence the copy/onClick use: email-blocked → fixable → terminal. + primaryRejectionMessage: (emailBlocked ?? fixableRail ?? blocked)?.reason?.userMessage ?? null, blockedRail: blocked, isEmailBlocked: !!emailBlocked, } @@ -139,17 +140,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (activationStep === 'verify' && isIdentityProcessing && !isIdentityActionRequired) return null if (hasProviderRejection) { - if (hasFixableRejection) { - return { - icon: 'globe-lock', - iconBg: 'bg-primary-1', - title: 'Complete your setup', - description: primaryRejectionMessage || 'We need an updated document before you can add money.', - ctaLabel: 'Upload document', - href: '/profile/identity-verification', - } - } - // blocked on a missing email — self-serve, not a support ticket + // Email-blocked (status=blocked) outranks a fixable RFI (status=requires-info) + // — the canonical `deriveGate` order, and the order this card's onClick + // already follows (isEmailBlocked first). Ranking fixable above email here + // made the copy say "Upload document" while the button opened the email + // sheet, and hid the document-upload path entirely when both coexisted. if (isEmailBlocked) { return { icon: 'globe-lock', @@ -161,6 +156,16 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa href: '', // handled in onClick } } + if (hasFixableRejection) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Complete your setup', + description: primaryRejectionMessage || 'We need an updated document before you can add money.', + ctaLabel: 'Upload document', + href: '/profile/identity-verification', + } + } // blocked return { icon: 'globe-lock',