diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 17e3062aa2..1e39da95af 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -21,6 +21,7 @@ import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' import PageContainer from '@/components/0_Bruddle/PageContainer' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { initiateSelfHealResubmission } from '@/app/actions/sumsub' import { rainApi, type ApplyForCardResponse } from '@/services/rain' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { useCapabilities } from '@/hooks/useCapabilities' @@ -69,7 +70,7 @@ const CardPage: FC = () => { const { overview, isLoading: overviewLoading, error: overviewError } = useRainCardOverview() const { serializeGrant } = useGrantSessionKey() - const { railsForProvider, isLoading: capabilitiesLoading } = useCapabilities() + const { railsForProvider, nextActionsForRail, isLoading: capabilitiesLoading } = useCapabilities() const { setIsSupportModalOpen } = useModalsContext() const onBack = useSafeBack('/home') @@ -212,6 +213,62 @@ const CardPage: FC = () => { void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) }, [queryClient]) + // Proof-of-address self-heal — a dedicated, deliberately tiny flow. The + // multi-phase KYC machinery (useMultiPhaseKycFlow) is bank-onboarding + // shaped: its post-approval phase machine polls a mutating endpoint, can + // fan out to Bridge ToS modals, and completes on rail semantics that never + // match the Rain PoA lifecycle. Here we only need: mint an action token → + // open the SDK → on submit, thank the user and refetch. Separate surface + // from the card-application SumsubKycWrapper below — that one is driven by + // applyForCard tokens with its own refresh/poll semantics. + const [poaToken, setPoaToken] = useState(null) + const [poaError, setPoaError] = useState(null) + // Optimistic "we got your document" until the backend webhook flips the + // rail reason to its own review-wait state (may lag the SDK by seconds). + const [poaSubmitted, setPoaSubmitted] = useState(false) + + // The rain rail's self-serve proof-of-address action, when the backend + // classified the application as PoA-fixable (kind 'sumsub' + levelKey + // 'proof_of_address' — emitted for WRONG_ADDRESS-class denials). Absent → + // the status screens keep their contact-support-only shape. + const cardRail = railsForProvider('rain')[0] + const poaAction = cardRail + ? nextActionsForRail(cardRail.id).find( + (action) => action.kind === 'sumsub' && action.levelKey === 'proof_of_address' + ) + : undefined + // Double-click guard: two concurrent initiations race the backend's + // create-action idempotency into minting two Sumsub actions (the id + // collision path deliberately mints a suffixed fresh action). + const poaStartingRef = useRef(false) + const startPoaUpload = useCallback(async () => { + if (poaStartingRef.current) return + poaStartingRef.current = true + posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED, { source: 'poa-self-heal' }) + setPoaError(null) + try { + const response = await initiateSelfHealResubmission('RAIN') + if (response.error || !response.data?.token) { + // Surfaced inline on the status screen — a silent primary CTA on + // a stuck-application screen is worse than no CTA. + setPoaError(response.error ?? 'Could not start the upload. Please try again.') + return + } + setPoaToken(response.data.token) + } finally { + poaStartingRef.current = false + } + }, []) + const onUploadProofOfAddress = poaAction && !poaSubmitted ? () => void startPoaUpload() : undefined + + // Once the backend's own state takes over (the rail's sumsub action gives + // way to the review-wait state, an approval, or a different ask), drop the + // optimistic banner so a later re-offered upload isn't suppressed until + // remount. + useEffect(() => { + if (poaSubmitted && !poaAction) setPoaSubmitted(false) + }, [poaSubmitted, poaAction]) + // Routes a non-incomplete apply response to the right next screen. Shared // by the user-initiated apply path and the post-Sumsub poll, since both // need the same main-kyc-required / terms-required / default fan-out. @@ -599,12 +656,16 @@ const CardPage: FC = () => { ) } - const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -633,14 +694,18 @@ const CardPage: FC = () => { // (meaningless without its reason), the rejected screen is useful // on its own (reassurance + support CTA), so show it now and let // the reason fill in once capabilities resolve. - const cardRailReason = capabilitiesLoading - ? undefined - : railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : capabilitiesLoading + ? undefined + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -657,6 +722,25 @@ const CardPage: FC = () => { return ( {renderState()} + setPoaToken(null)} + onComplete={() => { + // Document submitted to Sumsub. Review + the backend webhook + // stamp happen async — flip the optimistic banner and refetch + // so the screen picks up the backend's wait state when ready. + setPoaToken(null) + setPoaSubmitted(true) + invalidateOverview() + void fetchUser() + }} + onRefreshToken={async () => { + const response = await initiateSelfHealResubmission('RAIN') + if (!response.data?.token) throw new Error(response.error ?? 'Failed to refresh token') + return response.data.token + }} + /> void + /** When the rail carries a self-serve proof-of-address action, this opens + * the Sumsub upload flow — rendered as the primary CTA so users fix it + * themselves instead of messaging support. */ + onUploadProofOfAddress?: () => void + /** Inline failure from starting the upload (a silent primary CTA on a + * stuck-application screen reads as broken). */ + uploadError?: string onPrev?: () => void } @@ -46,7 +54,14 @@ const COPY: Record = { /** Variants where support is the only path forward — these render the CTA. */ const SUPPORT_VARIANTS: ReadonlySet = new Set(['requires-info', 'requires-support', 'rejected']) -const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactSupport, onPrev }) => { +const ApplicationStatusScreen: FC = ({ + variant, + reasonMessage, + onContactSupport, + onUploadProofOfAddress, + uploadError, + onPrev, +}) => { const copy = COPY[variant] return (
@@ -69,6 +84,14 @@ const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactS {reasonMessage &&

{reasonMessage}

}

{copy.body}

+ {SUPPORT_VARIANTS.has(variant) && onUploadProofOfAddress && ( +
+ + {uploadError &&

{uploadError}

} +
+ )} {SUPPORT_VARIANTS.has(variant) && onContactSupport && (