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 557064cb2..5bd306d2c 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -10,10 +10,14 @@ import { useWallet } from '@/hooks/wallet/useWallet' 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 { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' +import { useModalsContext } from '@/context/ModalsContext' import { useCreateOnramp } from '@/hooks/useCreateOnramp' -import { useRouter, useParams, useSearchParams } from 'next/navigation' +import { useRouter, useParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping' import { formatUnits } from 'viem' @@ -35,7 +39,6 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { addMoneyCountryUrl } from '@/utils/native-routes' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'showDetails' @@ -43,7 +46,6 @@ type BridgeBankStep = 'inputAmount' | 'showDetails' export default function OnrampBankPage() { const router = useRouter() const params = useParams() - const _searchParams = useSearchParams() // URL state - persisted in query params // Example: /add-money/mexico/bank?step=inputAmount&amount=500 @@ -72,14 +74,12 @@ export default function OnrampBankPage() { // regionIntent is NOT passed here to avoid creating a backend record on mount. // intent is passed at call time: handleInitiateKyc('STANDARD') const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: async () => { - await fetchUser() + onKycSuccess: () => { setUrlState({ step: 'inputAmount' }) }, }) - // read country from path params (web) or query params (native/capacitor) - const selectedCountryPath = (params.country as string) || _searchParams.get('country') || '' + const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { if (!selectedCountryPath) return null @@ -98,10 +98,14 @@ export default function OnrampBankPage() { // uk-specific check const isUK = isUKCountry(selectedCountryPath) - const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = - useKycStatus() - const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { gate } = useBridgeTransferReadiness() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const { setIsSupportModalOpen } = useModalsContext() + + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setShowKycModal(false) + }, [sumsubFlow.showWrapper]) useEffect(() => { fetchUser() @@ -198,18 +202,15 @@ export default function OnrampBankPage() { } }, [rawTokenAmount, validateAmount, setError]) - const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview - const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - if ( - needsBridgeEnrollment || - !isUserKycApproved || - bridgeRejection.state === 'fixable' || - bridgeRejection.state === 'blocked' - ) { - setShowKycModal(true) + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + guardWithTos() + } else { + setShowKycModal(true) + } return } @@ -230,11 +231,6 @@ export default function OnrampBankPage() { return } - if (guardWithTos()) { - setShowWarningModal(false) - return - } - setShowWarningModal(false) setIsRiskAccepted(false) try { @@ -280,7 +276,7 @@ export default function OnrampBankPage() { const handleBack = () => { if (selectedCountry) { - router.push(addMoneyCountryUrl(selectedCountry.path)) + router.push(`/add-money/${selectedCountry.path}`) } else { router.push('/add-money') } @@ -409,28 +405,24 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - // needsBridgeEnrollment takes priority: user has no bridge customer, - // so rejection state from a stale/deleted customer is irrelevant - if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') { + if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( 'STANDARD', undefined, - needsBridgeEnrollment || undefined + gate.type === 'needs_enrollment' || undefined ) } + }} + onContactSupport={() => { setShowKycModal(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - variant={ - needsBridgeEnrollment - ? 'cross_region' - : bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' - ? 'provider_rejection' - : 'default' - } - providerMessage={bridgeRejection.userMessage ?? undefined} + error={sumsubFlow.error} + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 020fa9195..a022047e0 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -19,7 +19,7 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { rainSpendingPowerToWei } from '@/utils/balance.utils' -import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' +import { isTxReverted, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { calculateSavingsInCents, isArgentinaMantecaQrPayment, getSavingsMessage } from '@/utils/qr-payment.utils' import ErrorAlert from '@/components/Global/ErrorAlert' @@ -61,6 +61,8 @@ import { useSumsubActionFlow } from '@/hooks/useSumsubActionFlow' import { initiateIncreaseLimits } from '@/app/actions/increase-limits' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { useLimits } from '@/hooks/useLimits' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' @@ -108,7 +110,8 @@ export default function QRPayPage() { }, [paymentProcessor]) const { shouldBlockPay, kycGateState } = useQrKycGate() - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const sumsubFlow = useMultiPhaseKycFlow({}) const queryClient = useQueryClient() const [isShaking, setIsShaking] = useState(false) const [shakeIntensity, setShakeIntensity] = useState('none') @@ -355,7 +358,12 @@ export default function QRPayPage() { } return mantecaApi.initiateQrPayment({ qrCode, qrType: qrType ?? undefined }) }, - enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock, + enabled: + paymentProcessor === 'MANTECA' && + !!qrCode && + isPaymentProcessorQR(qrCode) && + !paymentLock && + !shouldBlockPay, retry: (failureCount, error: any) => { // Don't retry provider-specific errors if (error?.message?.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { @@ -841,25 +849,19 @@ export default function QRPayPage() { isFixable ? { text: 'Upload document', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => sumsubFlow.handleSelfHealResubmit('MANTECA'), 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']) - } - }, + onClick: () => setIsSupportModalOpen(true), variant: 'stroke' as const, }, ]} /> + ) } @@ -882,10 +884,8 @@ export default function QRPayPage() { ctas={[ { text: 'Verify now', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => + sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -902,10 +902,8 @@ export default function QRPayPage() { ctas={[ { text: 'Continue verification', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => + sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -920,6 +918,7 @@ export default function QRPayPage() { }, ]} /> + ) } @@ -973,7 +972,8 @@ export default function QRPayPage() { } // check if we're still loading payment data before showing anything - const isLoadingPaymentData = isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency + const isLoadingPaymentData = + !shouldBlockPay && (isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency) if (waitingForMerchantAmount) { return diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 1a3b1ac04..17e01fea6 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -18,12 +18,10 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' import { AccountType, type Account } from '@/interfaces' import { formatIban, shortenStringLong, isTxReverted } from '@/utils/general.utils' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' -import { ErrorHandler } from '@/utils/friendly-error.utils' +import { ErrorHandler } from '@/utils/sdkErrorHandler.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' import { getOfframpCurrencyConfig, getCountryFromPath } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' @@ -33,16 +31,21 @@ 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 { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' +import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' +import { useSearchParams } from 'next/navigation' import { parseUnits } from 'viem' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { withdrawCountryUrl } from '@/utils/native-routes' type View = 'INITIAL' | 'SUCCESS' @@ -56,27 +59,26 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance } = useWallet() + const { address, sendMoney, balance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() - const queryClient = useQueryClient() const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [view, setView] = useState('INITIAL') const params = useParams() - // read country from path params (web) or query params (native/capacitor) - const country = (params.country as string) || searchParams.get('country') || '' + const country = params.country as string const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const { hasPendingTransactions } = usePendingTransactions() const { isBridgeSupportedCountry } = useIdentityVerification() - const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus() - const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: async () => { - await fetchUser() - }, - }) + const { gate } = useBridgeTransferReadiness() + const sumsubFlow = useMultiPhaseKycFlow({}) const [showKycModal, setShowKycModal] = useState(false) - const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId + const { setIsSupportModalOpen } = useModalsContext() + + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setShowKycModal(false) + }, [sumsubFlow.showWrapper]) // validate country is supported for bank withdrawals useEffect(() => { @@ -118,7 +120,7 @@ export default function WithdrawBankPage() { router.replace('/withdraw') } else if (!bankAccount && amountToWithdraw) { // If amount is set but no bank account, go to country method selection - router.replace(withdrawCountryUrl(country)) + router.replace(`/withdraw/${country}`) } }, [bankAccount, router, amountToWithdraw, country, view]) @@ -171,13 +173,15 @@ export default function WithdrawBankPage() { } const handleCreateAndInitiateOfframp = async () => { - if (needsBridgeEnrollment) { - setShowKycModal(true) + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + guardWithTos() + } else { + setShowKycModal(true) + } return } - if (guardWithTos()) return - setIsLoading(true) setError({ showError: false, errorMessage: '' }) @@ -235,24 +239,17 @@ export default function WithdrawBankPage() { } // Step 2: prepare and send the transaction from peanut wallet to the deposit address - const { receipt, userOpHash, txHash } = await sendMoney( + const { receipt, userOpHash } = await sendMoney( data.depositInstructions.toAddress as `0x${string}`, - createPayload.amount, - { kind: 'FIAT_OFFRAMP' } + createPayload.amount ) if (receipt !== null && isTxReverted(receipt)) { throw new Error('Transaction reverted by the network.') } - // Step 3: Confirm the transfer with the backend to make it visible in history. - // Prefer the on-chain tx hash; fall back to the collateral withdraw tx hash - // (collateral-only path) BEFORE the userOp hash. confirmOfframp expects a real - // 32-byte tx hash — userOpHash is an account-abstraction bundler hash, not a - // chain tx hash, and the BE rejects it. - const txIdentifier = receipt?.transactionHash ?? txHash ?? userOpHash - if (!txIdentifier) throw new Error('No transaction identifier returned from sendMoney') - const confirmResult = await confirmOfframp(data.transferId, txIdentifier) + // Step 3: Confirm the transfer with the backend to make it visible in history + const confirmResult = await confirmOfframp(data.transferId, receipt?.transactionHash ?? userOpHash) if (confirmResult.error) { // This is a tricky state. The on-chain tx succeeded, but the backend failed to record it. @@ -265,11 +262,6 @@ export default function WithdrawBankPage() { throw new Error(confirmResult.error) } - // Invalidate the transactions query so the Activity widget shows - // the pending OFFRAMP entry immediately, instead of waiting up to - // 30s tanstack staleTime + Bridge polling cadence. - queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) - setView('SUCCESS') posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { amount_usd: amountToWithdraw, @@ -466,11 +458,24 @@ export default function WithdrawBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) + } + }} + onContactSupport={() => { setShowKycModal(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - variant="cross_region" + error={sumsubFlow.error} + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={getCountryFromPath(country)?.title} /> diff --git a/src/assets/animations b/src/assets/animations index 7ebbc48eb..d475d960d 160000 --- a/src/assets/animations +++ b/src/assets/animations @@ -1 +1 @@ -Subproject commit 7ebbc48ebebef658b7d0ec08abc0ca2359376242 +Subproject commit d475d960d16a6009c2db3c5e35ef9aa31179ebfd diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 698f9ed64..4d063b717 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -8,11 +8,9 @@ import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' import { getColorForUsername } from '@/utils/color.utils' import Image, { type StaticImageData } from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { withdrawBankUrl, rewriteMethodPath } from '@/utils/native-routes' -import { isCapacitor } from '@/utils/capacitor' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' @@ -23,13 +21,20 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import useKycStatus from '@/hooks/useKycStatus' -import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal' 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' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' +import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useModalsContext } from '@/context/ModalsContext' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -68,19 +73,20 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const formRef = useRef<{ handleSubmit: () => void }>(null) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const { isUserKycApproved, isUserBridgeKycUnderReview, isUserSumsubKycApproved, isUserBridgeKycApproved } = - useKycStatus() - const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { gate } = useBridgeTransferReadiness() + const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const { setIsSupportModalOpen } = useModalsContext() const [showKycStatusModal, setShowKycStatusModal] = useState(false) - // read country from path params (web: /add-money/india) or query params (native: /add-money?country=india) - const countryFromQuery = searchParams.get('country') - const viewFromQuery = searchParams.get('view') - const rawCountry = countryFromQuery || params.country - const countryPathParts = Array.isArray(rawCountry) ? rawCountry : [rawCountry].filter(Boolean) - const isBankPage = viewFromQuery === 'bank' || countryPathParts[countryPathParts.length - 1] === 'bank' - const countrySlugFromUrl = - isBankPage && !viewFromQuery ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-') + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setIsKycModalOpen(false) + }, [sumsubFlow.showWrapper]) + + const countryPathParts = Array.isArray(params.country) ? params.country : [params.country] + const isBankPage = countryPathParts[countryPathParts.length - 1] === 'bank' + const countrySlugFromUrl = isBankPage ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-') const currentCountry = countryData.find( (country) => country.type === 'country' && country.path === countrySlugFromUrl @@ -89,25 +95,20 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const handleFormSubmit = async ( payload: AddBankAccountPayload, rawData: IBankAccountDetails - ): Promise<{ error?: string }> => { + ): Promise<{ error?: string; silent?: boolean }> => { // re-fetch user to ensure we have the latest KYC status // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() - // 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.' } - } - - // 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 {} + // unified bridge gate: tos → fixable rejection → blocked → enrollment + // return a non-visible error to prevent the form from treating this as success + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + guardWithTos() + } else { + setIsKycModalOpen(true) + } + return { error: 'gate_blocked', silent: true } } // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly @@ -148,7 +149,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (currentCountry) { const queryParams = isBankFromSend ? `?method=${methodParam}` : '' - router.push(withdrawBankUrl(currentCountry.path, queryParams)) + router.push(`/withdraw/${currentCountry.path}/bank${queryParams}`) } return {} } @@ -168,8 +169,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (method.path && method.path.includes('/manteca')) { // Manteca methods route directly (has own amount input) - const extraParams = isBankFromSend ? `method=${methodParam}` : undefined - router.push(rewriteMethodPath(method.path, extraParams)) + const separator = method.path.includes('?') ? '&' : '?' + const additionalParams = isBankFromSend ? `${separator}method=${methodParam}` : '' + router.push(`${method.path}${additionalParams}`) } else if (method.id.includes('default-bank-withdraw') || method.id.includes('sepa-instant-withdraw')) { if (isUserBridgeKycUnderReview) { setShowKycStatusModal(true) @@ -193,9 +195,10 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { }) router.push(`/withdraw${methodQueryParam}`) } else if (method.path) { - // Other methods with paths — rewrite dynamic routes for native - const extraParams = isBankFromSend ? `method=${methodParam}` : undefined - router.push(rewriteMethodPath(method.path, extraParams)) + // Other methods with paths + const separator = method.path.includes('?') ? '&' : '?' + const additionalParams = isBankFromSend ? `${separator}method=${methodParam}` : '' + router.push(`${method.path}${additionalParams}`) } } @@ -211,14 +214,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { return } - const target = rewriteMethodPath(method.path) - // force full navigation in capacitor — router.push to same page with - // different query params doesn't trigger useSearchParams re-render in static export - if (isCapacitor() && target.startsWith(window.location.pathname)) { - window.location.href = target - } else { - router.push(target) - } + router.push(method.path) } } @@ -301,13 +297,34 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { visible={isKycModalOpen} onClose={() => setIsKycModalOpen(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) + } + }} + onContactSupport={() => { setIsKycModalOpen(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - variant="cross_region" + error={sumsubFlow.error} + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={currentCountry?.title} /> + { + hideTos() + formRef.current?.handleSubmit() + }} + onSkip={hideTos} + /> ) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index b40109aee..0b27f89ff 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -55,7 +55,10 @@ export type IBankAccountDetails = { interface DynamicBankAccountFormProps { country: string countryName?: string - onSuccess: (payload: AddBankAccountPayload, rawData: IBankAccountDetails) => Promise<{ error?: string }> + onSuccess: ( + payload: AddBankAccountPayload, + rawData: IBankAccountDetails + ) => Promise<{ error?: string; silent?: boolean }> initialData?: Partial flow?: 'claim' | 'withdraw' actionDetailsProps?: Partial @@ -250,7 +253,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D name: data.name, }) if (result.error) { - setSubmissionError(result.error) + if (!result.silent) setSubmissionError(result.error) setIsSubmitting(false) } else { // Save form data to Redux after successful submission diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index c7f54eb85..5e7014d2c 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -33,6 +33,15 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' +import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import { useModalsContext } from '@/context/ModalsContext' type BankAccountWithId = IBankAccountDetails & ( @@ -75,6 +84,10 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const { isLoading, setLoadingState } = useContext(loadingStateContext) const { claimLink } = useClaimLink() const dispatch = useAppDispatch() + const { gate } = useBridgeTransferReadiness() + const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const [showKycModal, setShowKycModal] = useState(false) + const { setIsSupportModalOpen } = useModalsContext() // inline sumsub kyc flow for users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -145,11 +158,20 @@ export const BankFlowManager = (props: IClaimScreenProps) => { */ const handleCreateOfframpAndClaim = async (account: BankAccountWithId) => { try { - setLoadingState('Executing transaction') setError(null) - // determine user for offramp based on the bank claim type + // for logged-in users, check bridge readiness before proceeding const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim + if (!isGuestFlow && gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + guardWithTos() + } else { + setShowKycModal(true) + } + return + } + + setLoadingState('Executing transaction') const userForOfframp = isGuestFlow ? await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) : user?.user @@ -288,6 +310,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setBankDetails(bankDetails) setReceiverFullName(`${bankDetails.firstName} ${bankDetails.lastName}`) setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) + } else { + return { error: 'Failed to process bank account. Please try again.' } } } finally { setIsProcessingKycSuccess(false) @@ -466,22 +490,57 @@ export const BankFlowManager = (props: IClaimScreenProps) => { case ClaimBankFlowStep.BankConfirmClaim: if (localBankDetails) { return ( - handleCreateOfframpAndClaim(localBankDetails)} - onBack={() => { - setClaimBankFlowStep( - savedAccounts.length > 0 - ? ClaimBankFlowStep.SavedAccountsList - : ClaimBankFlowStep.BankDetailsForm - ) - setError(null) - }} - isProcessing={isLoading} - error={error} - bankDetails={localBankDetails} - fullName={receiverFullName} - /> + <> + handleCreateOfframpAndClaim(localBankDetails)} + onBack={() => { + setClaimBankFlowStep( + savedAccounts.length > 0 + ? ClaimBankFlowStep.SavedAccountsList + : ClaimBankFlowStep.BankDetailsForm + ) + setError(null) + }} + isProcessing={isLoading} + error={error} + bankDetails={localBankDetails} + fullName={receiverFullName} + /> + { + hideTos() + handleCreateOfframpAndClaim(localBankDetails) + }} + onSkip={hideTos} + /> + setShowKycModal(false)} + onVerify={async () => { + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) + } + // only close if sdk opened — if it errored, keep modal open to show error + if (sumsubFlow.showWrapper) setShowKycModal(false) + }} + onContactSupport={() => { + setShowKycModal(false) + setIsSupportModalOpen(true) + }} + isLoading={sumsubFlow.isLoading} + error={sumsubFlow.error} + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} + /> + ) } return null diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index e59fae072..816c313d9 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -71,7 +71,7 @@ const STEPS: Record, StepConfig> = { */ export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() - const { setIsQRScannerOpen } = useModalsContext() + const { setIsQRScannerOpen, setIsSupportModalOpen } = useModalsContext() const { hasFixableRejection, hasBlockedRejection, primaryRejection } = useProviderRejectionStatus() const lastTrackedStep = useRef(null) @@ -136,9 +136,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa className="mt-2 w-full" onClick={() => { if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) - } + setIsSupportModalOpen(true) } else if (activationStep === 'outbound' && !hasProviderRejection) { setIsQRScannerOpen(true) } else { diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 504876d00..982736bb2 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -1,14 +1,12 @@ 'use client' -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect } from 'react' import ActionModal from '@/components/Global/ActionModal' import IframeWrapper from '@/components/Global/IframeWrapper' import { type IconName } from '@/components/Global/Icons/Icon' -import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { getBridgeTosLink } from '@/app/actions/users' import { useAuth } from '@/context/authContext' import { confirmBridgeTosAndAwaitRails } from '@/hooks/useMultiPhaseKycFlow' -import { isCapacitor } from '@/utils/capacitor' -import { openExternalUrl } from '@/utils/capacitor' interface BridgeTosStepProps { visible: boolean @@ -17,40 +15,24 @@ interface BridgeTosStepProps { } // shown immediately after sumsub kyc approval when bridge rails need ToS acceptance. -// on web: opens bridge ToS in an iframe. -// on native: opens in system browser, polls for acceptance. +// displays a prompt, then opens the bridge ToS iframe. export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProps) => { const { fetchUser } = useAuth() const [showIframe, setShowIframe] = useState(false) const [tosLink, setTosLink] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) const [error, setError] = useState(null) - const pollRef = useRef(null) - // stop polling on unmount or when step becomes invisible + // reset state when step is hidden useEffect(() => { - if (!visible && pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - return () => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - } - }, [visible]) - - // auto-fetch ToS link when step becomes visible - useEffect(() => { - if (visible) { - handleAcceptTerms() - } else { + if (!visible) { setShowIframe(false) + setIsConfirming(false) setTosLink(null) setError(null) } - }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps + }, [visible]) const handleAcceptTerms = useCallback(async () => { setIsLoading(true) @@ -60,28 +42,12 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp const response = await getBridgeTosLink() if (response.error || !response.data?.tosLink) { + // if we can't get the tos link (e.g. bridge customer not created yet), + // skip this step — the activity feed will show a reminder later setError(response.error || 'Could not load terms. You can accept them later from your activity feed.') return } - // in capacitor, open in system browser and poll for acceptance - if (isCapacitor()) { - openExternalUrl(response.data.tosLink) - // poll every 3s to check if user accepted ToS in the browser - pollRef.current = setInterval(async () => { - try { - const result = await confirmBridgeTos() - if (result.data?.accepted) { - if (pollRef.current) clearInterval(pollRef.current) - pollRef.current = null - await confirmBridgeTosAndAwaitRails(fetchUser) - onComplete() - } - } catch {} - }, 3000) - return - } - setTosLink(response.data.tosLink) setShowIframe(true) } catch { @@ -89,16 +55,23 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp } finally { setIsLoading(false) } - }, [fetchUser, onComplete]) + }, []) const handleIframeClose = useCallback( async (source?: 'manual' | 'completed' | 'tos_accepted') => { - setShowIframe(false) - if (source === 'tos_accepted') { - await confirmBridgeTosAndAwaitRails(fetchUser) - onComplete() + setIsConfirming(true) + setShowIframe(false) + try { + await confirmBridgeTosAndAwaitRails(fetchUser) + onComplete() + } catch { + setError('Something went wrong confirming your terms. Please try again.') + } finally { + setIsConfirming(false) + } } else { + setShowIframe(false) onSkip() } }, @@ -107,65 +80,34 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp if (!visible) return null - // in capacitor, show a waiting modal while user accepts ToS in browser - if (isCapacitor() && !error) { - return ( + return ( + <> + {/* confirmation modal — hidden when iframe is open or ToS is being confirmed */} tosLink && openExternalUrl(tosLink), + text: isLoading ? 'Loading...' : error ? 'Try again' : 'Accept Terms', + onClick: handleAcceptTerms, + disabled: isLoading, variant: 'purple', className: 'w-full', shadowSize: '4', }, { - text: 'Skip for now', - onClick: () => { - if (pollRef.current) clearInterval(pollRef.current) - pollRef.current = null - onSkip() - }, + text: 'Not now', + onClick: onSkip, variant: 'transparent' as const, className: 'underline text-sm font-medium w-full h-fit mt-3', }, ]} /> - ) - } - - return ( - <> - {error && !showIframe && ( - - )} {tosLink && } diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index b7d9ca9f3..48926dec0 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -6,9 +6,12 @@ interface InitiateKycModalProps { visible: boolean onClose: () => void onVerify: () => void + onContactSupport?: () => void isLoading?: boolean + /** error message from a failed verify/resubmit attempt */ + error?: string | null /** when set, shows provider-specific messaging instead of generic "verify your identity" */ - variant?: 'default' | 'provider_rejection' | 'cross_region' + variant?: 'default' | 'provider_rejection' | 'blocked' | 'cross_region' providerMessage?: string /** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */ regionName?: string @@ -17,20 +20,34 @@ interface InitiateKycModalProps { // confirmation modal shown before starting KYC or provider resubmission. // for fresh KYC: "Verify your identity" // for provider rejections: "We need extra documents" +// for blocked: "Verification issue — contact support" // for cross-region: "Your identity is verified, we need a local ID" export const InitiateKycModal = ({ visible, onClose, onVerify, + onContactSupport, isLoading, + error, variant = 'default', providerMessage, regionName, }: InitiateKycModalProps) => { const isProviderRejection = variant === 'provider_rejection' + const isBlocked = variant === 'blocked' const isCrossRegion = variant === 'cross_region' + const getTitle = () => { + if (error) return 'Something went wrong' + if (isBlocked) return 'Verification issue' + if (isProviderRejection) return 'We need extra documents' + return 'Verify your identity' + } + const getDescription = () => { + if (error) return `${error} Please contact support for assistance.` + if (isBlocked) + return providerMessage || "We couldn't verify your identity. Please contact support for assistance." if (isProviderRejection) return providerMessage || 'Please upload a clearer photo of your ID to continue.' if (isCrossRegion) { const region = regionName ? ` from ${regionName}` : '' @@ -39,29 +56,53 @@ export const InitiateKycModal = ({ return 'To continue, you need to complete identity verification. This usually takes just a few minutes.' } + const getCta = () => { + if (error || isBlocked) { + return { + text: 'Contact support', + onClick: onContactSupport ?? onClose, + } + } + if (isProviderRejection) { + return { + text: isLoading ? 'Loading...' : 'Upload document', + onClick: onVerify, + icon: 'upload' as IconName, + } + } + return { + text: isLoading ? 'Loading...' : 'Start Verification', + onClick: onVerify, + icon: 'check-circle' as IconName, + } + } + + const cta = getCta() + return ( ) } diff --git a/src/components/Kyc/states/KycProviderRejection.tsx b/src/components/Kyc/states/KycProviderRejection.tsx index 716b35137..52afb5b20 100644 --- a/src/components/Kyc/states/KycProviderRejection.tsx +++ b/src/components/Kyc/states/KycProviderRejection.tsx @@ -4,6 +4,7 @@ import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import type { ProviderRejectionInfo } from '@/hooks/useProviderRejectionStatus' +import { useModalsContext } from '@/context/ModalsContext' /** * shown when a user is sumsub-approved but a provider (bridge/manteca) rejected their documents. @@ -18,6 +19,7 @@ export const KycProviderRejection = ({ rejection: ProviderRejectionInfo onStartResubmission?: () => void }) => { + const { setIsSupportModalOpen } = useModalsContext() const providerLabel = rejection.provider === 'BRIDGE' ? 'Bank transfers' : 'QR payments' const isFixable = rejection.state === 'fixable' @@ -59,15 +61,7 @@ export const KycProviderRejection = ({ Upload document ) : ( - )} diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 427fbaaf2..1e566b2f0 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -11,6 +11,7 @@ import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal' import ActionModal from '@/components/Global/ActionModal' +import { useModalsContext } from '@/context/ModalsContext' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' @@ -56,6 +57,7 @@ const RegionsVerification = () => { const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent, isSumsubApproved } = useUnifiedKycStatus() const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() + const { setIsSupportModalOpen } = useModalsContext() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation const displayRegionRef = useRef(null) @@ -218,10 +220,8 @@ const RegionsVerification = () => { : { text: 'Contact support', onClick: () => { - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) - } handleModalClose() + setIsSupportModalOpen(true) }, variant: 'purple' as const, shadowSize: '4' as const, diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index 243c58e06..51b99d253 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -110,6 +110,7 @@ export const ANALYTICS_EVENTS = { // ── QR ── QR_SCANNED: 'qr_scanned', QR_NOTIFY_ME_CLICKED: 'qr_notify_me_clicked', + QR_DECODING_ERROR_SHOWN: 'qr_decoding_error_shown', // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts new file mode 100644 index 000000000..2bc847975 --- /dev/null +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -0,0 +1,151 @@ +import { renderHook } from '@testing-library/react' +import { useBridgeTransferReadiness, getKycModalVariant, getGateProviderMessage } from '../useBridgeTransferReadiness' +import type { BridgeGateAction } from '../useBridgeTransferReadiness' + +// mock the three dependency hooks +jest.mock('../useBridgeTosStatus', () => ({ + useBridgeTosStatus: jest.fn(), +})) +jest.mock('../useProviderRejectionStatus', () => ({ + __esModule: true, + default: jest.fn(), +})) +jest.mock('../useKycStatus', () => ({ + __esModule: true, + default: jest.fn(), +})) + +import { useBridgeTosStatus } from '../useBridgeTosStatus' +import useProviderRejectionStatus from '../useProviderRejectionStatus' +import useKycStatus from '../useKycStatus' + +const mockTosStatus = useBridgeTosStatus as jest.MockedFunction +const mockRejectionStatus = useProviderRejectionStatus as jest.MockedFunction +const mockKycStatus = useKycStatus as jest.MockedFunction + +const defaultRejection = { + provider: 'BRIDGE' as const, + state: 'happy' as const, + userMessage: null, + rejectedRails: [], + kycVerification: null, + selfHealAttempt: 0, + maxAttempts: 3, +} + +function setup({ + needsBridgeTos = false, + bridgeState = 'happy' as const, + bridgeUserMessage = null as string | null, + isSumsubApproved = false, + isBridgeApproved = false, + isBridgeUnderReview = false, +} = {}) { + mockTosStatus.mockReturnValue({ + needsBridgeTos, + isBridgeFullyEnabled: false, + bridgeRails: [], + }) + mockRejectionStatus.mockReturnValue({ + bridge: { ...defaultRejection, state: bridgeState, userMessage: bridgeUserMessage }, + manteca: { ...defaultRejection, provider: 'MANTECA' }, + hasFixableRejection: bridgeState === 'fixable', + hasBlockedRejection: bridgeState === 'blocked', + hasAnyRejection: bridgeState === 'fixable' || bridgeState === 'blocked', + primaryRejection: null, + }) + mockKycStatus.mockReturnValue({ + isUserSumsubKycApproved: isSumsubApproved, + isUserBridgeKycApproved: isBridgeApproved, + isUserBridgeKycUnderReview: isBridgeUnderReview, + isUserMantecaKycApproved: false, + isUserKycApproved: isBridgeApproved, + }) +} + +describe('useBridgeTransferReadiness', () => { + afterEach(() => jest.resetAllMocks()) + + it('returns ready when no issues', () => { + setup({ isSumsubApproved: true, isBridgeApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('blocked_rejection takes priority over accept_tos', () => { + setup({ needsBridgeTos: true, bridgeState: 'blocked', bridgeUserMessage: 'permanently rejected' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('blocked_rejection') + expect((result.current.gate as any).userMessage).toBe('permanently rejected') + }) + + it('accept_tos fires when tos needed and no hard rejection', () => { + setup({ needsBridgeTos: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) + + it('fixable_rejection when selfHealable and no tos needed', () => { + setup({ bridgeState: 'fixable', bridgeUserMessage: 'upload clearer photo' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('fixable_rejection') + expect((result.current.gate as any).userMessage).toBe('upload clearer photo') + }) + + it('needs_enrollment when sumsub approved but bridge not started', () => { + setup({ isSumsubApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('needs_enrollment') + }) + + it('ready when sumsub approved and bridge under review (enrollment not needed)', () => { + setup({ isSumsubApproved: true, isBridgeUnderReview: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('ready when sumsub approved and bridge approved', () => { + setup({ isSumsubApproved: true, isBridgeApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('accept_tos takes priority over fixable_rejection', () => { + setup({ needsBridgeTos: true, bridgeState: 'fixable' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) + + it('accept_tos takes priority over needs_enrollment', () => { + setup({ needsBridgeTos: true, isSumsubApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) +}) + +describe('getKycModalVariant', () => { + it('maps gate types to modal variants', () => { + expect(getKycModalVariant('blocked_rejection')).toBe('blocked') + expect(getKycModalVariant('fixable_rejection')).toBe('provider_rejection') + expect(getKycModalVariant('needs_enrollment')).toBe('cross_region') + expect(getKycModalVariant('accept_tos')).toBe('default') + expect(getKycModalVariant('ready')).toBe('default') + }) +}) + +describe('getGateProviderMessage', () => { + it('returns userMessage for rejection gates', () => { + expect(getGateProviderMessage({ type: 'blocked_rejection', userMessage: 'blocked msg' })).toBe('blocked msg') + expect(getGateProviderMessage({ type: 'fixable_rejection', userMessage: 'fix msg' })).toBe('fix msg') + }) + + it('returns undefined for null userMessage', () => { + expect(getGateProviderMessage({ type: 'blocked_rejection', userMessage: null })).toBeUndefined() + }) + + it('returns undefined for non-rejection gates', () => { + expect(getGateProviderMessage({ type: 'accept_tos' })).toBeUndefined() + expect(getGateProviderMessage({ type: 'needs_enrollment' })).toBeUndefined() + expect(getGateProviderMessage({ type: 'ready' })).toBeUndefined() + }) +}) diff --git a/src/hooks/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts index 46f8b86e5..dc241db1b 100644 --- a/src/hooks/useBridgeTosStatus.ts +++ b/src/hooks/useBridgeTosStatus.ts @@ -9,7 +9,19 @@ export const useBridgeTosStatus = () => { return useMemo(() => { const rails: IUserRail[] = user?.rails ?? [] const bridgeRails = rails.filter((r) => r.rail.provider.code === 'BRIDGE') - const needsBridgeTos = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION') + + const hasRequiresInformation = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION') + + // bridge can require tos_acceptance as part of additionalRequirements + // even when rails are in other states (REJECTED, REQUIRES_EXTRA_INFORMATION) + const hasTosInRequirements = bridgeRails.some((r) => { + const reqs = Array.isArray(r.metadata?.additionalRequirements) + ? (r.metadata.additionalRequirements as string[]) + : [] + return reqs.some((req) => req === 'tos_acceptance' || req === 'tos_v2_acceptance') + }) + + const needsBridgeTos = hasRequiresInformation || hasTosInRequirements const isBridgeFullyEnabled = bridgeRails.length > 0 && bridgeRails.every((r) => r.status === 'ENABLED') return { needsBridgeTos, isBridgeFullyEnabled, bridgeRails } diff --git a/src/hooks/useBridgeTransferReadiness.ts b/src/hooks/useBridgeTransferReadiness.ts new file mode 100644 index 000000000..6beb079cc --- /dev/null +++ b/src/hooks/useBridgeTransferReadiness.ts @@ -0,0 +1,70 @@ +'use client' + +import { useMemo } from 'react' +import { useBridgeTosStatus } from './useBridgeTosStatus' +import useProviderRejectionStatus from './useProviderRejectionStatus' +import useKycStatus from './useKycStatus' + +export type BridgeGateAction = + | { type: 'accept_tos' } + | { type: 'fixable_rejection'; userMessage: string | null } + | { type: 'blocked_rejection'; userMessage: string | null } + | { type: 'needs_enrollment' } + | { type: 'ready' } + +/** + * unified pre-transfer gate for bridge bank flows. + * determines what needs to happen before a transfer can proceed, + * in the correct priority order: + * 1. hard rejection (contact support — tos is moot) + * 2. tos acceptance + * 3. fixable rejection (user can submit additional details) + * 4. needs enrollment (sumsub approved, bridge not started) + * 5. ready + */ +export function useBridgeTransferReadiness() { + const { needsBridgeTos } = useBridgeTosStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + + const gate: BridgeGateAction = useMemo(() => { + // 1. hard rejection — contact support (checked first because tos is moot for hard-rejected users) + if (bridgeRejection.state === 'blocked') { + return { type: 'blocked_rejection', userMessage: bridgeRejection.userMessage } + } + + // 2. tos acceptance — only if user can actually proceed after accepting + if (needsBridgeTos) return { type: 'accept_tos' } + + // 3. fixable rejection — user can submit additional details + if (bridgeRejection.state === 'fixable') { + return { type: 'fixable_rejection', userMessage: bridgeRejection.userMessage } + } + + // 4. needs enrollment (sumsub approved but bridge not started/approved) + if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview) { + return { type: 'needs_enrollment' } + } + + // 5. ready + return { type: 'ready' } + }, [needsBridgeTos, bridgeRejection, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview]) + + return { gate } +} + +/** maps gate type to InitiateKycModal variant */ +export function getKycModalVariant(gateType: BridgeGateAction['type']) { + if (gateType === 'blocked_rejection') return 'blocked' as const + if (gateType === 'fixable_rejection') return 'provider_rejection' as const + if (gateType === 'needs_enrollment') return 'cross_region' as const + return 'default' as const +} + +/** extracts provider message from gate for InitiateKycModal */ +export function getGateProviderMessage(gate: BridgeGateAction): string | undefined { + if (gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection') { + return gate.userMessage ?? undefined + } + return undefined +} diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts index f9b560754..a86100ec5 100644 --- a/src/hooks/useProviderRejectionStatus.ts +++ b/src/hooks/useProviderRejectionStatus.ts @@ -88,14 +88,17 @@ export default function useProviderRejectionStatus() { let userMessage: string | null = null const reasons = firstRejectedMetadata.rejectionReasons const endorsementIssues = firstRejectedMetadata.endorsementIssues - if (Array.isArray(reasons) && reasons.length > 0) { + + if (!isFixable) { + // permanently rejected — generic message regardless of underlying reason + userMessage = "We couldn't verify your identity. Please contact support for assistance." + } else 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' - userMessage = `ID verification failed. Please upload a clearer photo.` + userMessage = 'ID verification failed. Please upload a clearer photo.' } return { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 84b9d6886..c34bec3d9 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -89,11 +89,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (!regionIntent) return // skip if handleInitiateKyc is already in progress — it handles status sync itself if (initiatingRef.current) return + // skip if user already initiated a flow in this session — the SDK or + // handleInitiateKyc manages status from here. without this guard, + // the async fetch can resolve after initiatingRef is reset but before + // showWrapperRef is updated by the batched render, causing a false + // APPROVED transition that closes the SDK. + if (userInitiatedRef.current) return const fetchCurrentStatus = async () => { try { const response = await initiateSumsubKyc({ regionIntent }) - if (response.data?.status && !initiatingRef.current) { + if (response.data?.status && !initiatingRef.current && !showWrapperRef.current) { setLiveKycStatus(response.data.status) } } catch { @@ -138,7 +144,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // for cross-region: pre-set prevStatusRef to APPROVED so the fetchCurrentStatus // effect (which also fires when regionIntent changes) doesn't trigger onKycSuccess - // when it sees the existing APPROVED status. + // when it sees the existing APPROVED status. save previous value to restore on failure. + const savedPrevStatus = prevStatusRef.current if (crossRegion) { prevStatusRef.current = 'APPROVED' } @@ -151,6 +158,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }) if (response.error) { + if (crossRegion) prevStatusRef.current = savedPrevStatus setError(response.error) return } @@ -237,6 +245,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError('Could not initiate verification. Please try again.') } } catch (e: unknown) { + if (crossRegion) prevStatusRef.current = savedPrevStatus const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message) } finally {