From f2385e6638aee401b98417058dbc57c405998d0b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 18:05:24 -0700 Subject: [PATCH 1/2] feat(kyc): provide-email sheet for the no-email verification dead-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to peanut-api-ts#1119. Users whose email was never captured used to dead-end on 'message support' (their only fix was a ticket); the BE now emits a provide-email NextAction for those rails, and this renders it: an email form (ActionModal, BridgeTosStep shell) wired into the two places the block surfaces — the home activation CTA and the bank add/withdraw gate. Saving posts update-user (first-time email set is allowed while KYC-locked) and the BE flips the rails to PENDING and resubmits automatically. --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 9 ++ src/components/Home/ActivationCTAs.tsx | 18 +++- src/components/Kyc/ProvideEmailStep.tsx | 97 +++++++++++++++++++ src/types/capabilities.ts | 2 +- src/utils/capability-gate.test.ts | 41 ++++++++ src/utils/capability-gate.ts | 15 +++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/ProvideEmailStep.tsx diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c00fc73de..3c35a330c 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -34,6 +34,7 @@ import { railJurisdictionForBank } from '@/utils/bridge.utils' import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' import { useModalsContext } from '@/context/ModalsContext' import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config' @@ -126,6 +127,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const bankCountry = useMemo(() => railJurisdictionForBank(currentCountry?.id), [currentCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() + const [showProvideEmail, setShowProvideEmail] = useState(false) const { setIsSupportModalOpen } = useModalsContext() // stores the callback to replay after tos acceptance in the list view @@ -149,6 +151,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (gate.kind === 'accept-tos') { pendingAfterTosRef.current = onAfterTos ?? null guardWithTos() + } else if (gate.kind === 'provide-email') { + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } @@ -379,6 +383,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { onSkip={hideTos} reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> + setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 6e6f7db8c..683f5c2a7 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -7,13 +7,14 @@ import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useAuth } from '@/context/authContext' import { buildContactSupportMessage } from '@/utils/contact-support.utils' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' interface ActivationCTAsProps { activationStep: ActivationStep @@ -103,6 +104,12 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa } }, [rails, channelOf]) + // Self-serve email recovery: the BE tags email-less submission failures + // with reason code 'email_required' + a provide-email action — the fix is + // an email form, not a support ticket. + const isEmailBlocked = blockedRail?.reason?.code === 'email_required' + const [showProvideEmail, setShowProvideEmail] = useState(false) + const lastTrackedStep = useRef(null) useEffect(() => { if (activationStep !== 'completed' && activationStep !== lastTrackedStep.current) { @@ -193,7 +200,9 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa shadowSize="4" className="mt-2 w-full" onClick={() => { - if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { + if (isEmailBlocked) { + setShowProvideEmail(true) + } else if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { // REQUIRES_SUPPORT class (or any blocked rail) — pre-fill Crisp // with the failure context so support can dispatch without // re-investigating the user's state. @@ -219,6 +228,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa )} + setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) } diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx new file mode 100644 index 000000000..b8c7b8163 --- /dev/null +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import ProfileEditField from '@/components/Profile/components/ProfileEditField' +import { updateUserById } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' +import { isValidEmail } from '@/utils/format.utils' +import type { IconName } from '@/components/Global/Icons/Icon' + +interface ProvideEmailStepProps { + visible: boolean + onComplete: () => void + onSkip: () => void +} + +/** + * Self-serve recovery for the no-email KYC dead-end: the BE emits a + * `provide-email` NextAction when provider submission failed because no email + * was ever captured. Saving an email here flips the blocked rails back to + * PENDING server-side and re-runs submission automatically — no support + * ticket needed. (Shell mirrors BridgeTosStep.) + */ +export default function ProvideEmailStep({ visible, onComplete, onSkip }: ProvideEmailStepProps) { + const { user, fetchUser } = useAuth() + const [email, setEmail] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (visible) setError(null) + }, [visible]) + + const handleSave = useCallback(async () => { + const trimmed = email.trim() + if (!isValidEmail(trimmed)) { + setError('Please enter a valid email address.') + return + } + setIsSaving(true) + setError(null) + try { + const response = await updateUserById({ userId: user?.user?.userId, email: trimmed }) + if (response.error) { + setError(response.error) + return + } + // The BE resubmits the blocked rails on email set; refetch so the + // provide-email gate clears (rails go PENDING → the UI shows the + // normal in-progress state instead of this modal). + await fetchUser() + onComplete() + } catch { + setError('Something went wrong saving your email. Please try again.') + } finally { + setIsSaving(false) + } + }, [email, user?.user?.userId, fetchUser, onComplete]) + + return ( + + + {error &&

{error}

} + + } + /> + ) +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 5471d7a30..e16693627 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -100,7 +100,7 @@ export interface RailCapability { * document (used for the country-not-supported CTA * on Manteca-only rails; user has a self-fix path). */ -export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' +export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' | 'provide-email' export interface NextAction { key: string // stable id, referenced by RailCapability.blockingActions diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 72f86ac69..d0a01f369 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -578,3 +578,44 @@ describe('deriveGate — advisory pre-empt (future-dated requirement on a ready expect(gate.kind).toBe('fixable-rejection') }) }) + +describe('deriveGate — provide-email self-serve for email-blocked rails', () => { + const provideEmailAction: NextAction = { + key: 'provide-email', + kind: 'provide-email', + purpose: 'submission-failed-no-email', + } + + test('blocked rail carrying provide-email action → provide-email gate (self-fix path)', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['provide-email'], + reason: { + code: 'email_required', + userMessage: 'We need an email address to finish setting up this account.', + }, + }) + + const gate = deriveGate(state([rail], [provideEmailAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('provide-email') + if (gate.kind === 'provide-email') { + expect(gate.userMessage).toMatch(/email address/) + expect(gate.reason?.code).toBe('email_required') + } + expect(getGateUserMessage(gate)).toMatch(/email address/) + }) + + test('blocked rail without provide-email action still dead-ends on blocked-rejection', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['contact-support'], + reason: { code: 'submission_failed', userMessage: 'We hit a snag.' }, + }) + const supportAction: NextAction = { key: 'contact-support', kind: 'contact-support', purpose: 'kyc-support' } + + const gate = deriveGate(state([rail], [supportAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('blocked-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index dbc183fe2..5de2f6f8f 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -68,6 +68,12 @@ export type GateState = * CTA opens a fresh Sumsub WebSDK after POST /users/identity/restart. */ | { kind: 'restart-identity'; userMessage: string | null; reason?: CapabilityReason } + /** + * Blocked rail whose pipeline failure is self-serviceable: no email was + * ever captured, so provider submission couldn't run. CTA opens an email + * form; on save the BE flips the rails back to PENDING and resubmits. + */ + | { kind: 'provide-email'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'needs-identity' } | { kind: 'needs-enrollment' } @@ -202,6 +208,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // the only path is contact-support. const blocked = candidates.find((rail) => rail.status === 'blocked') if (blocked) { + const hasProvideEmail = railActions(blocked, actionByKey).some((action) => action.kind === 'provide-email') + if (hasProvideEmail) { + return { + kind: 'provide-email', + userMessage: blocked.reason?.userMessage ?? null, + reason: blocked.reason, + } + } const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') if (hasRestart) { return { @@ -303,6 +317,7 @@ export function getGateUserMessage(gate: GateState): string | undefined { gate.kind === 'fixable-rejection' || gate.kind === 'blocked-rejection' || gate.kind === 'restart-identity' || + gate.kind === 'provide-email' || gate.kind === 'accept-tos' || gate.kind === 'waiting-on-provider' ) { From 35700e9132d9ac7b4c80dd4f360a3fa8e0d6710b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 18:19:15 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(kyc):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20modal-variant=20floor,=20single=20detection=20contract,=20sh?= =?UTF-8?q?adowing,=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getKycModalVariant maps provide-email to 'blocked' so unwired gate consumers keep the contact-support floor instead of a bogus re-verify CTA; the gate and ActivationCTAs both detect via the provide-email action kind (one contract, no reason-code divergence) and prefer an email-blocked rail over an earlier terminal one; the home CTA copy now says 'Add your email / Add email' instead of promising support and opening a form; the sheet resets on reopen and guards a not-yet-loaded userId. --- src/components/Home/ActivationCTAs.tsx | 56 ++++++++++++++++--------- src/components/Kyc/ProvideEmailStep.tsx | 12 +++++- src/utils/capability-gate.ts | 27 ++++++++---- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 683f5c2a7..380ff2225 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -77,7 +77,7 @@ const STEPS: Record, StepConfig> = { export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() - const { rails, channelOf } = useCapabilities() + const { rails, channelOf, nextActionsForRail } = useCapabilities() const { user } = useAuth() // Suppress the "Unlock payments" verify CTA while identity is mid-flight // (Sumsub processing / action_required). The user already took the verify @@ -89,25 +89,30 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // qr-only channels — never through card. Top-level status (not per-op // refinement): Manteca's pool tier reads `enabled` at the rail level even when // deposit/withdraw individually need an upgrade — that's not a rejection. - const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail } = useMemo(() => { - const rejectableRails = rails.filter((rail) => { - const channel = channelOf(rail) - return channel === 'bank' || channel === 'qr-only' - }) - const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') - const blocked = rejectableRails.find((rail) => rail.status === 'blocked') - return { - hasFixableRejection: !!fixableRail, - hasBlockedRejection: !!blocked, - primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, - blockedRail: blocked, - } - }, [rails, channelOf]) + const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail, isEmailBlocked } = + useMemo(() => { + const rejectableRails = rails.filter((rail) => { + const channel = channelOf(rail) + return channel === 'bank' || channel === 'qr-only' + }) + const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') + // Email-blocked rails carry a self-serve provide-email action (same + // contract the capability gate reads) — prefer one over an earlier + // blocked rail with a terminal reason, since one email fixes them all. + const emailBlocked = rejectableRails.find( + (rail) => + rail.status === 'blocked' && nextActionsForRail(rail.id).some((a) => a.kind === 'provide-email') + ) + const blocked = emailBlocked ?? rejectableRails.find((rail) => rail.status === 'blocked') + return { + hasFixableRejection: !!fixableRail, + hasBlockedRejection: !!blocked, + primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, + blockedRail: blocked, + isEmailBlocked: !!emailBlocked, + } + }, [rails, channelOf, nextActionsForRail]) - // Self-serve email recovery: the BE tags email-less submission failures - // with reason code 'email_required' + a provide-email action — the fix is - // an email form, not a support ticket. - const isEmailBlocked = blockedRail?.reason?.code === 'email_required' const [showProvideEmail, setShowProvideEmail] = useState(false) const lastTrackedStep = useRef(null) @@ -144,6 +149,18 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa href: '/profile/identity-verification', } } + // blocked on a missing email — self-serve, not a support ticket + if (isEmailBlocked) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Add your email', + description: + primaryRejectionMessage || 'We need an email address to finish setting up your account.', + ctaLabel: 'Add email', + href: '', // handled in onClick + } + } // blocked return { icon: 'globe-lock', @@ -160,6 +177,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa activationStep, hasProviderRejection, hasFixableRejection, + isEmailBlocked, primaryRejectionMessage, isIdentityProcessing, isIdentityActionRequired, diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx index b8c7b8163..9668e5b8f 100644 --- a/src/components/Kyc/ProvideEmailStep.tsx +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -28,7 +28,10 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid const [error, setError] = useState(null) useEffect(() => { - if (visible) setError(null) + if (visible) { + setEmail('') + setError(null) + } }, [visible]) const handleSave = useCallback(async () => { @@ -37,10 +40,15 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid setError('Please enter a valid email address.') return } + const userId = user?.user?.userId + if (!userId) { + setError('Still loading your account — please try again in a moment.') + return + } setIsSaving(true) setError(null) try { - const response = await updateUserById({ userId: user?.user?.userId, email: trimmed }) + const response = await updateUserById({ userId, email: trimmed }) if (response.error) { setError(response.error) return diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 5de2f6f8f..9237d702b 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -206,16 +206,23 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. + // provide-email is a USER-level fix (one email unblocks every email-blocked + // rail), so any blocked rail carrying it wins over an earlier blocked rail + // with a terminal reason — .find() order must not shadow the self-serve path. + const emailBlocked = candidates.find( + (rail) => + rail.status === 'blocked' && + railActions(rail, actionByKey).some((action) => action.kind === 'provide-email') + ) + if (emailBlocked) { + return { + kind: 'provide-email', + userMessage: emailBlocked.reason?.userMessage ?? null, + reason: emailBlocked.reason, + } + } const blocked = candidates.find((rail) => rail.status === 'blocked') if (blocked) { - const hasProvideEmail = railActions(blocked, actionByKey).some((action) => action.kind === 'provide-email') - if (hasProvideEmail) { - return { - kind: 'provide-email', - userMessage: blocked.reason?.userMessage ?? null, - reason: blocked.reason, - } - } const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') if (hasRestart) { return { @@ -301,6 +308,10 @@ export function getKycModalVariant( kind: GateState['kind'] ): 'blocked' | 'provider_rejection' | 'cross_region' | 'restart_identity' | 'default' { if (kind === 'blocked-rejection') return 'blocked' + // Floor for consumers not yet wired to render the email sheet: show the + // contact-support variant, never the 'default' re-verify CTA (the user's + // identity is already verified — bouncing them into Sumsub is wrong). + if (kind === 'provide-email') return 'blocked' if (kind === 'restart-identity') return 'restart_identity' if (kind === 'fixable-rejection') return 'provider_rejection' if (kind === 'needs-enrollment') return 'cross_region'