Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -379,6 +383,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
onSkip={hideTos}
reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined}
/>
<ProvideEmailStep
visible={showProvideEmail}
onComplete={() => setShowProvideEmail(false)}
onSkip={() => setShowProvideEmail(false)}
/>
<SumsubKycModals flow={sumsubFlow} />
</>
)
Expand Down
66 changes: 49 additions & 17 deletions src/components/Home/ActivationCTAs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,7 +77,7 @@ const STEPS: Record<Exclude<ActivationStep, 'completed'>, 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
Expand All @@ -88,20 +89,31 @@ 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])

const [showProvideEmail, setShowProvideEmail] = useState(false)

const lastTrackedStep = useRef<ActivationStep | null>(null)
useEffect(() => {
Expand Down Expand Up @@ -137,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',
Expand All @@ -153,6 +177,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
activationStep,
hasProviderRejection,
hasFixableRejection,
isEmailBlocked,
primaryRejectionMessage,
isIdentityProcessing,
isIdentityActionRequired,
Expand Down Expand Up @@ -193,7 +218,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.
Expand All @@ -219,6 +246,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa
</button>
)}
</div>
<ProvideEmailStep
visible={showProvideEmail}
onComplete={() => setShowProvideEmail(false)}
onSkip={() => setShowProvideEmail(false)}
/>
</Card>
)
}
105 changes: 105 additions & 0 deletions src/components/Kyc/ProvideEmailStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
'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<string | null>(null)

useEffect(() => {
if (visible) {
setEmail('')
setError(null)
}
}, [visible])

const handleSave = useCallback(async () => {
const trimmed = email.trim()
if (!isValidEmail(trimmed)) {
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, 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 (
<ActionModal
visible={visible}
onClose={onSkip}
icon={'user-id' as IconName}
title="Add your email to continue"
description="We couldn't finish setting up your account because we're missing an email address. Add one and we'll retry automatically."
ctas={[
{
text: isSaving ? 'Saving...' : 'Save email',
onClick: handleSave,
disabled: isSaving || email.trim().length === 0,
variant: 'purple',
className: 'w-full',
shadowSize: '4',
},
{
text: 'Not now',
onClick: onSkip,
variant: 'transparent' as const,
className: 'underline text-sm font-medium w-full h-fit mt-3',
},
]}
content={
<div className="w-full pt-2 text-left">
<ProfileEditField
label="Email"
value={email}
onChange={setEmail}
placeholder="you@example.com"
type="email"
/>
{error && <p className="mt-2 text-sm text-error">{error}</p>}
</div>
}
/>
)
}
2 changes: 1 addition & 1 deletion src/types/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/utils/capability-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
26 changes: 26 additions & 0 deletions src/utils/capability-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }

Expand Down Expand Up @@ -200,6 +206,21 @@ 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 hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity')
Expand Down Expand Up @@ -287,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'
Expand All @@ -303,6 +328,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'
) {
Expand Down
Loading