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 eda417834..3e773c247 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -30,9 +30,10 @@ import { getLimitsWarningCardProps } from '@/features/limits/utils' import { useExchangeRate } from '@/hooks/useExchangeRate' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' // Step type for URL state -type BridgeBankStep = 'inputAmount' | 'kyc' | 'showDetails' +type BridgeBankStep = 'inputAmount' | 'showDetails' export default function OnrampBankPage() { const router = useRouter() @@ -42,7 +43,7 @@ export default function OnrampBankPage() { // Example: /add-money/mexico/bank?step=inputAmount&amount=500 const [urlState, setUrlState] = useQueryStates( { - step: parseAsStringEnum(['inputAmount', 'kyc', 'showDetails']), + step: parseAsStringEnum(['inputAmount', 'showDetails']), amount: parseAsString, }, { history: 'push' } @@ -53,6 +54,7 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) + const [showKycModal, setShowKycModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) const [liveKycStatus, setLiveKycStatus] = useState(undefined) const { setError, error, setOnrampData, onrampData } = useOnrampFlow() @@ -152,30 +154,12 @@ export default function OnrampBankPage() { currency: 'USD', }) - // Determine initial step based on KYC status (only when URL has no step) + // Default to inputAmount step when no step in URL useEffect(() => { - // If URL already has a step, respect it (allows deep linking) if (urlState.step) return - - // Wait for user to be fetched before determining initial step if (user === null) return - - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus - const isUserKycVerified = currentKycStatus === 'approved' - - if (!isUserKycVerified) { - setUrlState({ step: 'kyc' }) - } else { - setUrlState({ step: 'inputAmount' }) - } - }, [liveKycStatus, user, urlState.step, setUrlState]) - - // Handle KYC completion - useEffect(() => { - if (urlState.step === 'kyc' && liveKycStatus === 'approved') { - setUrlState({ step: 'inputAmount' }) - } - }, [liveKycStatus, urlState.step, setUrlState]) + setUrlState({ step: 'inputAmount' }) + }, [user, urlState.step, setUrlState]) const validateAmount = useCallback( (amountStr: string): boolean => { @@ -217,9 +201,17 @@ export default function OnrampBankPage() { }, [rawTokenAmount, validateAmount, setError]) const handleAmountContinue = () => { - if (validateAmount(rawTokenAmount)) { - setShowWarningModal(true) + if (!validateAmount(rawTokenAmount)) return + + const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus + const isUserKycVerified = currentKycStatus === 'approved' + + if (!isUserKycVerified) { + setShowKycModal(true) + return } + + setShowWarningModal(true) } const handleWarningConfirm = async () => { @@ -271,12 +263,6 @@ export default function OnrampBankPage() { } } - useEffect(() => { - if (urlState.step === 'kyc') { - sumsubFlow.handleInitiateKyc('STANDARD') - } - }, [urlState.step]) // eslint-disable-line react-hooks/exhaustive-deps - // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) useEffect(() => { if (urlState.step === 'showDetails' && !onrampData?.transferId) { @@ -303,15 +289,6 @@ export default function OnrampBankPage() { return } - if (urlState.step === 'kyc') { - return ( -
- - -
- ) - } - if (urlState.step === 'showDetails') { // Show loading while useEffect redirects if data is missing if (!onrampData?.transferId) { @@ -408,6 +385,18 @@ export default function OnrampBankPage() { amount={rawTokenAmount} currency={getCurrencySymbol(getCurrencyConfig(selectedCountry.id, 'onramp').currency)} /> + + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD') + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + /> + + ) } diff --git a/src/app/(mobile-ui)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx index 3a543e76f..9285f1396 100644 --- a/src/app/(mobile-ui)/points/invites/page.tsx +++ b/src/app/(mobile-ui)/points/invites/page.tsx @@ -86,7 +86,7 @@ const InvitesPage = () => { {invites?.invitees?.map((invite: PointsInvite, i: number) => { const username = invite.username const fullName = invite.fullName - const isVerified = invite.kycStatus === 'approved' + const isVerified = invite.kycVerified const pointsEarned = invite.contributedPoints ?? 0 // respect user's showFullName preference for avatar and display name const displayName = invite.showFullName && fullName ? fullName : username diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 7690531a5..05baf8d35 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -238,7 +238,7 @@ const PointsPage = () => { {invites.invitees?.slice(0, 5).map((invite: PointsInvite, i: number) => { const username = invite.username const fullName = invite.fullName - const isVerified = invite.kycStatus === 'approved' + const isVerified = invite.kycVerified const pointsEarned = invite.contributedPoints ?? 0 // respect user's showFullName preference for avatar and display name const displayName = invite.showFullName && fullName ? fullName : username diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 3b54a8a35..baac44283 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -461,8 +461,8 @@ export default function MantecaWithdrawFlow() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index 5929fed25..07e1498ef 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -194,6 +194,7 @@ export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean } Authorization: `Bearer ${jwtToken}`, 'api-key': API_KEY, }, + body: JSON.stringify({}), }) const responseJson = await response.json() if (!response.ok) { diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index c5f88bf6b..eab40ae05 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -194,8 +194,8 @@ const MantecaAddMoney: FC = () => { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 639b02fd2..0472cdffa 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -98,15 +98,16 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { payload: AddBankAccountPayload, rawData: IBankAccountDetails ): Promise<{ error?: string }> => { - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus + // re-fetch user to ensure we have the latest KYC status + // (the multi-phase flow may have completed but websocket/state not yet propagated) + const freshUser = await fetchUser() + const currentKycStatus = freshUser?.user?.bridgeKycStatus || liveKycStatus || user?.user.bridgeKycStatus const isUserKycVerified = currentKycStatus === 'approved' - const hasEmailOnLoad = !!user?.user.email - // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly - // note: we no longer check for fullName as account owner name is now always collected from the form - if (isUserKycVerified && (hasEmailOnLoad || rawData.email)) { - const currentAccountIds = new Set(user?.accounts.map((acc) => acc.id) ?? []) + // email and name are now collected by sumsub — no need to check them here + if (isUserKycVerified) { + const currentAccountIds = new Set((freshUser?.accounts ?? user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) if (result.error) { diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index c94b48157..b0996cdb3 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -430,12 +430,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D })} )} - {flow !== 'claim' && - !hideEmailInput && - !user?.user?.email && - renderInput('email', 'E-mail', { - required: 'Email is required', - })} {isMx ? renderInput('clabe', 'CLABE', { diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 318eba629..cb61325d7 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -22,6 +22,7 @@ import { getTokenLogo, getChainLogo, } from '@/utils/general.utils' +import { isUserKycVerified } from '@/constants/kyc.consts' import * as Sentry from '@sentry/nextjs' import { useQuery } from '@tanstack/react-query' import type { Hash } from 'viem' @@ -189,7 +190,7 @@ export const Claim = ({}) => { peanutFeeDetails: { amountDisplay: '$ 0.00', }, - isVerified: claimLinkData.sender?.bridgeKycStatus === 'approved', + isVerified: isUserKycVerified(claimLinkData.sender), haveSentMoneyToUser: claimLinkData.sender?.userId ? interactions[claimLinkData.sender?.userId] || false : false, @@ -396,7 +397,7 @@ export const Claim = ({}) => { // redirect to bank flow if user is KYC approved and step is bank useEffect(() => { const stepFromURL = searchParams.get('step') - if (user?.user.bridgeKycStatus === 'approved' && stepFromURL === 'bank') { + if (isUserKycVerified(user?.user) && stepFromURL === 'bank') { setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList) } }, [user]) diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index e014c639b..0598ac4b1 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -120,8 +120,8 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - setShowKycModal(false) await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) }} isLoading={sumsubFlow.isLoading} /> diff --git a/src/components/Global/PostSignupActionManager/index.tsx b/src/components/Global/PostSignupActionManager/index.tsx index 4c0821810..b58d6143f 100644 --- a/src/components/Global/PostSignupActionManager/index.tsx +++ b/src/components/Global/PostSignupActionManager/index.tsx @@ -7,6 +7,7 @@ import ActionModal from '../ActionModal' import { POST_SIGNUP_ACTIONS } from './post-signup-action.consts' import { type IconName } from '../Icons/Icon' import { useAuth } from '@/context/authContext' +import { isUserKycVerified } from '@/constants/kyc.consts' export const PostSignupActionManager = ({ onActionModalVisibilityChange, @@ -26,7 +27,7 @@ export const PostSignupActionManager = ({ const checkClaimModalAfterKYC = () => { const redirectUrl = getRedirectUrl() - if (user?.user.bridgeKycStatus === 'approved' && redirectUrl) { + if (isUserKycVerified(user?.user) && redirectUrl) { const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(redirectUrl)) if (matchedAction) { setActionConfig({ diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx index b7867765d..d8a043bda 100644 --- a/src/components/Home/HomeCarouselCTA/index.tsx +++ b/src/components/Home/HomeCarouselCTA/index.tsx @@ -8,14 +8,15 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useHomeCarouselCTAs, type CarouselCTA as CarouselCTAType } from '@/hooks/useHomeCarouselCTAs' import { perksApi, type PendingPerk } from '@/services/perks' import { useAuth } from '@/context/authContext' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useWebSocket } from '@/hooks/useWebSocket' import { extractInviteeName } from '@/utils/general.utils' import PerkClaimModal from '../PerkClaimModal' import underMaintenanceConfig from '@/config/underMaintenance.config' const HomeCarouselCTA = () => { - const { carouselCTAs, setCarouselCTAs } = useHomeCarouselCTAs() - const { user } = useAuth() + const { carouselCTAs, setCarouselCTAs, showBridgeTos, setShowBridgeTos } = useHomeCarouselCTAs() + const { user, fetchUser } = useAuth() const queryClient = useQueryClient() // Perk claim modal state @@ -89,6 +90,17 @@ const HomeCarouselCTA = () => { setSelectedPerk(null) }, []) + // bridge ToS handlers + const handleTosComplete = useCallback(async () => { + setShowBridgeTos(false) + setCarouselCTAs((prev) => prev.filter((c) => c.id !== 'bridge-tos')) + await fetchUser() + }, [setShowBridgeTos, setCarouselCTAs, fetchUser]) + + const handleTosSkip = useCallback(() => { + setShowBridgeTos(false) + }, [setShowBridgeTos]) + // don't render carousel if there are no CTAs if (!allCTAs.length) return null @@ -130,6 +142,9 @@ const HomeCarouselCTA = () => { onClaimed={handlePerkClaimed} /> )} + + {/* Bridge ToS iframe */} + ) } diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 9a5a67c57..0cd6d7027 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -15,8 +15,6 @@ import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem' import { groupKycByRegion } from '@/utils/kyc-grouping.utils' -import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' -import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -45,8 +43,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h const { fetchBalance } = useWallet() const { triggerHaptic } = useHaptic() const { fetchUser } = useAuth() - const { needsBridgeTos } = useBridgeTosStatus() - const isViewingOwnHistory = useMemo( () => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username), [isLoggedIn, username, user?.user.username] @@ -251,7 +247,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (

Activity

- {isViewingOwnHistory && needsBridgeTos && } {isViewingOwnHistory && user?.user && (() => { @@ -290,9 +285,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (
- {/* bridge ToS reminder for users who haven't accepted yet */} - {isViewingOwnHistory && needsBridgeTos && } - {/* link to the full history page */} {pendingRequests.length > 0 && ( <> diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx deleted file mode 100644 index 7db489a4b..000000000 --- a/src/components/Kyc/BridgeTosReminder.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import { useState, useCallback } from 'react' -import Card from '@/components/Global/Card' -import { Icon } from '@/components/Global/Icons/Icon' -import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' -import { useAuth } from '@/context/authContext' -import { type CardPosition } from '@/components/Global/Card/card.utils' - -interface BridgeTosReminderProps { - position?: CardPosition -} - -// shown in the activity feed when user has bridge rails needing ToS acceptance. -// clicking opens the bridge ToS flow. -export const BridgeTosReminder = ({ position = 'single' }: BridgeTosReminderProps) => { - const { fetchUser } = useAuth() - const [showTosStep, setShowTosStep] = useState(false) - const [tosJustAccepted, setTosJustAccepted] = useState(false) - - const handleClick = useCallback(() => { - setShowTosStep(true) - }, []) - - const handleComplete = useCallback(async () => { - setShowTosStep(false) - setTosJustAccepted(true) // optimistically hide — backend rail transition is async - await fetchUser() - }, [fetchUser]) - - const handleSkip = useCallback(() => { - setShowTosStep(false) - }, []) - - if (tosJustAccepted) return null - - return ( - <> - -
-
- -
-
-

Accept terms of service

-

Required to enable bank transfers

-
- -
-
- - - - ) -} diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 12152dbb3..bba3ca60c 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -4,8 +4,9 @@ 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' interface BridgeTosStepProps { visible: boolean @@ -62,29 +63,9 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp setShowIframe(false) if (source === 'tos_accepted') { - // confirm with backend that bridge actually accepted the ToS - const result = await confirmBridgeTos() - - if (result.data?.accepted) { - await fetchUser() - onComplete() - return - } - - // bridge hasn't registered acceptance yet — poll once after a short delay - await new Promise((resolve) => setTimeout(resolve, 2000)) - const retry = await confirmBridgeTos() - - if (retry.data?.accepted) { - await fetchUser() - onComplete() - } else { - // will be caught by poller/webhook eventually - await fetchUser() - onComplete() - } + await confirmBridgeTosAndAwaitRails(fetchUser) + onComplete() } else { - // user closed without accepting — skip, activity feed will remind them onSkip() } }, diff --git a/src/components/Kyc/CountryRegionRow.tsx b/src/components/Kyc/CountryRegionRow.tsx index 00519a908..aa247b36d 100644 --- a/src/components/Kyc/CountryRegionRow.tsx +++ b/src/components/Kyc/CountryRegionRow.tsx @@ -4,9 +4,10 @@ import { CountryFlagAndName } from './CountryFlagAndName' interface CountryRegionRowProps { countryCode?: string | null isBridge?: boolean + hideBottomBorder?: boolean } -export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProps) => { +export const CountryRegionRow = ({ countryCode, isBridge, hideBottomBorder }: CountryRegionRowProps) => { if (!isBridge && !countryCode) { return null } @@ -15,6 +16,7 @@ export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProp } + hideBottomBorder={hideBottomBorder} /> ) } diff --git a/src/components/Kyc/KycFailedContent.tsx b/src/components/Kyc/KycFailedContent.tsx new file mode 100644 index 000000000..18151263f --- /dev/null +++ b/src/components/Kyc/KycFailedContent.tsx @@ -0,0 +1,24 @@ +import { RejectLabelsList } from './RejectLabelsList' +import InfoCard from '@/components/Global/InfoCard' + +interface KycFailedContentProps { + rejectLabels?: string[] | null + isTerminal: boolean +} + +// shared rejection details — used by both KycFailed (drawer) and KycFailedModal. +// renders reject labels (non-terminal) or terminal error info card. +export const KycFailedContent = ({ rejectLabels, isTerminal }: KycFailedContentProps) => { + if (isTerminal) { + return ( + + ) + } + + return +} diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index 25f2249a9..3c6c9acf6 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,6 +1,7 @@ import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' +import { KycNotStarted } from './states/KycNotStarted' import { KycProcessing } from './states/KycProcessing' import { KycRequiresDocuments } from './states/KycRequiresDocuments' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' @@ -11,6 +12,7 @@ import { useUserStore } from '@/redux/hooks' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { useCallback } from 'react' interface KycStatusDrawerProps { isOpen: boolean @@ -18,10 +20,19 @@ interface KycStatusDrawerProps { verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus region?: 'STANDARD' | 'LATAM' + /** keep this component mounted even after drawer closes (so SumsubKycModals persists) */ + onKeepMounted?: (keep: boolean) => void } // this component determines which kyc state to show inside the drawer and fetches rejection reasons if the kyc has failed. -export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus, region }: KycStatusDrawerProps) => { +export const KycStatusDrawer = ({ + isOpen, + onClose, + verification, + bridgeKycStatus, + region, + onKeepMounted, +}: KycStatusDrawerProps) => { const { user } = useUserStore() const status = verification ? verification.status : bridgeKycStatus @@ -34,19 +45,35 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined ) as KYCRegionIntent | undefined + // close drawer and release the keep-mounted hold + const handleFlowDone = useCallback(() => { + onClose() + onKeepMounted?.(false) + }, [onClose, onKeepMounted]) + const sumsubFlow = useMultiPhaseKycFlow({ - onKycSuccess: onClose, - onManualClose: onClose, + onKycSuccess: handleFlowDone, + onManualClose: handleFlowDone, // don't pass regionIntent for completed kyc — prevents the mount effect // in useSumsubKycFlow from calling initiateSumsubKyc(), which triggers // the undefined->APPROVED transition that auto-closes the drawer regionIntent: statusCategory === 'completed' ? undefined : sumsubRegionIntent, }) - // all kyc retries now go through sumsub - const onRetry = async () => { - await sumsubFlow.handleInitiateKyc() - } + // close drawer but keep mounted so SumsubKycModals persists, then start kyc + const closeAndStartKyc = useCallback( + async (regionIntent?: KYCRegionIntent, levelName?: string) => { + onKeepMounted?.(true) + onClose() + try { + await sumsubFlow.handleInitiateKyc(regionIntent, levelName) + } catch (e) { + onKeepMounted?.(false) + throw e + } + }, + [onKeepMounted, onClose, sumsubFlow] + ) // check if any bridge rail needs additional documents const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( @@ -69,11 +96,18 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const sumsubFailureCount = user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 - const handleSubmitAdditionalDocs = async () => { - await sumsubFlow.handleInitiateKyc(undefined, 'peanut-additional-docs') - } + const handleSubmitAdditionalDocs = useCallback( + () => closeAndStartKyc(undefined, 'peanut-additional-docs'), + [closeAndStartKyc] + ) const renderContent = () => { + // user initiated kyc but abandoned before submitting — close drawer visually + // but keep component mounted so SumsubKycModals persists for the SDK flow + if (verification && isKycStatusNotStarted(status)) { + return + } + // bridge additional document requirement — but don't mask terminal kyc states if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') { return ( @@ -99,18 +133,13 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus { - if (region === 'STANDARD') return r.rail.provider.code === 'BRIDGE' - if (region === 'LATAM') return r.rail.provider.code === 'MANTECA' - return true - })} + isBridge={isBridgeKyc || region === 'STANDARD'} /> ) case 'action_required': return ( @@ -126,7 +155,7 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus bridgeKycRejectedAt={verification?.updatedAt ?? user?.user?.bridgeKycRejectedAt} countryCode={countryCode ?? undefined} isBridge={isBridgeKyc} - onRetry={onRetry} + onRetry={closeAndStartKyc} isLoading={sumsubFlow.isLoading} /> ) diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 359e53a1f..45e04b036 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -51,6 +51,9 @@ export const KycStatusItem = ({ const { user } = useUserStore() const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [wsBridgeKycStatus, setWsBridgeKycStatus] = useState(undefined) + // keep drawer component mounted when SDK flow is active (so SumsubKycModals persists + // even after the drawer visually closes) + const [keepDrawerMounted, setKeepDrawerMounted] = useState(false) const handleCloseDrawer = useCallback(() => { setIsDrawerOpen(false) @@ -77,11 +80,11 @@ export const KycStatusItem = ({ const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { - if (isInitiatedButNotStarted) return 'In progress' + if (isInitiatedButNotStarted) return 'Not completed' if (isActionRequired) return 'Action needed' - if (isPending) return 'Under review' - if (isApproved) return 'Approved' - if (isRejected) return 'Rejected' + if (isPending) return 'Processing' + if (isApproved) return 'Completed' + if (isRejected) return 'Failed' return 'Unknown' }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) @@ -127,13 +130,14 @@ export const KycStatusItem = ({
- {isDrawerOpen && ( + {(isDrawerOpen || keepDrawerMounted) && ( )} diff --git a/src/components/Kyc/modals/KycRejectedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx similarity index 64% rename from src/components/Kyc/modals/KycRejectedModal.tsx rename to src/components/Kyc/modals/KycFailedModal.tsx index 9d2aece39..ab3a1f12d 100644 --- a/src/components/Kyc/modals/KycRejectedModal.tsx +++ b/src/components/Kyc/modals/KycFailedModal.tsx @@ -1,11 +1,10 @@ import { useMemo } from 'react' import ActionModal from '@/components/Global/ActionModal' -import InfoCard from '@/components/Global/InfoCard' -import { RejectLabelsList } from '../RejectLabelsList' +import { KycFailedContent } from '../KycFailedContent' import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' import { useModalsContext } from '@/context/ModalsContext' -interface KycRejectedModalProps { +interface KycFailedModalProps { visible: boolean onClose: () => void onRetry: () => void @@ -16,7 +15,7 @@ interface KycRejectedModalProps { } // shown when user clicks a locked region while their kyc is rejected -export const KycRejectedModal = ({ +export const KycFailedModal = ({ visible, onClose, onRetry, @@ -24,7 +23,7 @@ export const KycRejectedModal = ({ rejectLabels, rejectType, failureCount, -}: KycRejectedModalProps) => { +}: KycFailedModalProps) => { const { setIsSupportModalOpen } = useModalsContext() const isTerminal = useMemo( @@ -36,24 +35,13 @@ export const KycRejectedModal = ({ - - {isTerminal && ( - - )} +
+
} ctas={[ diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index f1fc3a79d..0c59259b9 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -6,8 +6,6 @@ import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' import { STAR_STRAIGHT_ICON } from '@/assets' -import { type IUserRail } from '@/interfaces' -import { getCurrencyFlagUrl } from '@/constants/countryCurrencyMapping' // @dev TODO: Remove hardcoded KYC points - this should come from backend // See comment in KycStatusItem.tsx for proper implementation plan @@ -18,12 +16,10 @@ export const KycCompleted = ({ bridgeKycApprovedAt, countryCode, isBridge, - rails, }: { bridgeKycApprovedAt?: string countryCode?: string | null isBridge?: boolean - rails?: IUserRail[] }) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -35,8 +31,6 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) - const enabledRails = useMemo(() => (rails ?? []).filter((r) => r.status === 'ENABLED'), [rails]) - return (
@@ -54,32 +48,6 @@ export const KycCompleted = ({ /> - {enabledRails.length > 0 && ( - - {enabledRails.map((r, index) => ( - - {getCurrencyFlagUrl(r.rail.method.currency) && ( - {`${r.rail.method.currency} - )} - {r.rail.method.name} -
- } - value={r.rail.method.currency} - hideBottomBorder={index === enabledRails.length - 1} - /> - ))} - - )}
) } diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 991ff9a3f..493a4ef8b 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,9 +1,8 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' -import { RejectLabelsList } from '../RejectLabelsList' +import { KycFailedContent } from '../KycFailedContent' import Card from '@/components/Global/Card' -import InfoCard from '@/components/Global/InfoCard' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -54,39 +53,34 @@ export const KycFailed = ({ [isSumsub, rejectType, failureCount, rejectLabels] ) - // formatted bridge reason (legacy display) - const formattedBridgeReason = useMemo(() => { - const reasonText = bridgeReason || 'There was an issue. Contact Support.' - const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') - if (lines.length === 1) return reasonText - return ( -
    - {lines.map((line, index) => ( -
  • {line}
  • - ))} -
- ) - }, [bridgeReason]) + // determine which row is last in the card for border handling + const hasCountryRow = isBridge || !!countryCode + const hasReasonRow = !isSumsub return (
- - - - {!isSumsub && } + + + + {hasReasonRow && ( + + )} - {isSumsub && } + {isSumsub && } {isTerminal ? ( -
- +
{/* TODO: auto-create crisp support ticket on terminal rejection */} +
+ ) +} diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index ef6137a33..8fbeba0da 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -15,7 +15,7 @@ import { checkIfInternalNavigation } from '@/utils/general.utils' import { useAuth } from '@/context/authContext' import ShareButton from '@/components/Global/ShareButton' import ActionModal from '@/components/Global/ActionModal' -import { MantecaKycStatus } from '@/interfaces' +import { isUserKycVerified } from '@/constants/kyc.consts' import BadgesRow from '@/components/Badges/BadgesRow' interface PublicProfileProps { @@ -54,14 +54,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa if (apiUser?.fullName) setFullName(apiUser.fullName) // get the profile owner's showFullName preference setShowFullName(apiUser?.showFullName ?? false) - if ( - apiUser?.bridgeKycStatus === 'approved' || - apiUser?.kycVerifications?.some((v) => v.status === MantecaKycStatus.ACTIVE) - ) { - setIsKycVerified(true) - } else { - setIsKycVerified(false) - } + setIsKycVerified(isUserKycVerified(apiUser)) // to check if the logged in user has sent money to the profile user, // we check the amount that the profile user has received from the logged in user. if (apiUser?.totalUsdReceivedFromCurrentUser) { diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 442d47dc9..b7fac0170 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -14,7 +14,7 @@ import useKycStatus from '@/hooks/useKycStatus' export const ProfileEditView = () => { const router = useRouter() const { user, fetchUser } = useAuth() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserKycApproved } = useKycStatus() const [isLoading, setIsLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') @@ -115,7 +115,7 @@ export const ProfileEditView = () => {
router.push('/profile')} /> - +
{ value={formData.name} onChange={(value) => handleChange('name', value)} placeholder="Add your name" - disabled={user?.user.bridgeKycStatus === 'approved'} + disabled={isUserKycApproved} /> { value={formData.surname} onChange={(value) => handleChange('surname', value)} placeholder="Add your surname" - disabled={user?.user.bridgeKycStatus === 'approved'} + disabled={isUserKycApproved} /> { rejectLabels={sumsubRejectLabels} /> - diff --git a/src/components/Send/views/Contacts.view.tsx b/src/components/Send/views/Contacts.view.tsx index 148b4e59b..73efbc09f 100644 --- a/src/components/Send/views/Contacts.view.tsx +++ b/src/components/Send/views/Contacts.view.tsx @@ -13,6 +13,7 @@ import PeanutLoading from '@/components/Global/PeanutLoading' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' import { useDebounce } from '@/hooks/useDebounce' +import { isUserKycVerified } from '@/constants/kyc.consts' import { ContactsListSkeleton } from '@/components/Common/ContactsListSkeleton' export default function ContactsView() { @@ -138,7 +139,7 @@ export default function ContactsView() {

Your contacts

{contacts.map((contact, index) => { - const isVerified = contact.bridgeKycStatus === 'approved' + const isVerified = isUserKycVerified(contact) const displayName = contact.showFullName ? contact.fullName || contact.username : contact.username diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts index f8fc5d507..d35557a54 100644 --- a/src/constants/kyc.consts.ts +++ b/src/constants/kyc.consts.ts @@ -31,6 +31,25 @@ const SUMSUB_IN_PROGRESS_STATUSES: ReadonlySet = new Set(['PENDING', 'IN export const isKycStatusApproved = (status: string | undefined | null): boolean => !!status && APPROVED_STATUSES.has(status) +/** + * check if a user (from API data) has completed kyc with any provider. + * works with user objects from getUserById, contacts, senders, recipients, etc. + * for current user, prefer useUnifiedKycStatus hook instead. + */ +export function isUserKycVerified( + user: + | { + bridgeKycStatus?: string | null + kycVerifications?: Array<{ status: string }> | null + } + | null + | undefined +): boolean { + if (!user) return false + if (user.bridgeKycStatus === 'approved') return true + return user.kycVerifications?.some((v) => isKycStatusApproved(v.status)) ?? false +} + /** check if a kyc status represents a failed/rejected state */ export const isKycStatusFailed = (status: string | undefined | null): boolean => !!status && FAILED_STATUSES.has(status) diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index ab641bd76..948ce2f3f 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -3,12 +3,50 @@ interface RejectLabelInfo { description: string } -// map of sumsub reject labels to user-friendly descriptions +// map of sumsub reject labels to user-friendly descriptions. +// source: https://docs.sumsub.com/reference/rejected +// source: https://docs.sumsub.com/reference/resubmission-requested const REJECT_LABEL_MAP: Record = { + // --- data & address issues --- + PROBLEMATIC_APPLICANT_DATA: { + title: 'Data mismatch', + description: + 'Your provided information does not match our records. Please check your name, date of birth, and other details.', + }, + WRONG_ADDRESS: { + title: 'Address mismatch', + description: 'The address you provided does not match your documents. Please correct it and try again.', + }, + DB_DATA_MISMATCH: { + title: 'Data inconsistency', + description: 'Your information does not match the government database. Please verify your details are correct.', + }, + DB_DATA_NOT_FOUND: { + title: 'Data not found', + description: 'Your data could not be found in the government database. Please double-check your details.', + }, + REQUESTED_DATA_MISMATCH: { + title: 'Document details mismatch', + description: 'The document details do not match the information you provided.', + }, + GPS_AS_POA_SKIPPED: { + title: 'Address details incomplete', + description: 'Insufficient address details were provided. Please provide your full address.', + }, + + // --- document quality & completeness --- DOCUMENT_BAD_QUALITY: { title: 'Low quality document', description: 'The document image was blurry, dark, or hard to read. Please upload a clearer photo.', }, + LOW_QUALITY: { + title: 'Low quality document', + description: 'The document quality is too low to process. Please upload a clearer image.', + }, + UNSATISFACTORY_PHOTOS: { + title: 'Unreadable photo', + description: 'The photo is not readable. Please upload a clearer image.', + }, DOCUMENT_DAMAGED: { title: 'Damaged document', description: 'The document appears damaged or worn. Please use a document in good condition.', @@ -17,18 +55,115 @@ const REJECT_LABEL_MAP: Record = { title: 'Incomplete document', description: 'Part of the document was cut off or missing. Make sure the full document is visible.', }, + INCOMPLETE_DOCUMENT: { + title: 'Incomplete document', + description: 'Some pages or sides of the document are missing. Please upload the complete document.', + }, + DOCUMENT_PAGE_MISSING: { + title: 'Missing page', + description: 'A required page of the document is missing. Please upload all pages.', + }, DOCUMENT_MISSING: { title: 'Missing document', description: 'A required document was not provided. Please upload all requested documents.', }, + BLACK_AND_WHITE: { + title: 'Color document required', + description: 'The document was provided in black and white. Please upload a color image.', + }, + SCREENSHOTS: { + title: 'Screenshots not accepted', + description: 'Screenshots are not accepted. Please upload a direct photo of the document.', + }, + DIGITAL_DOCUMENT: { + title: 'Original document required', + description: 'A digital version was uploaded. Please upload a photo of the original document.', + }, + + // --- document validity --- DOCUMENT_EXPIRED: { title: 'Expired document', description: 'The document has expired. Please use a valid, non-expired document.', }, + EXPIRATION_DATE: { + title: 'Expiration date issue', + description: 'The document is expired or expiring soon. Please use a document with a valid expiration date.', + }, + ID_INVALID: { + title: 'Invalid ID', + description: 'The identity document is not valid. Please use a different document.', + }, + UNSUPPORTED_DOCUMENT: { + title: 'Unsupported document type', + description: "This type of document is not accepted. Please use a passport, national ID, or driver's license.", + }, + WRONG_DOCUMENT: { + title: 'Wrong document provided', + description: 'The uploaded document does not match what was requested. Please upload the correct document.', + }, + NOT_DOCUMENT: { + title: 'Not a valid document', + description: 'The uploaded file is not a document. Please upload the correct file.', + }, + DOCUMENT_TEMPLATE: { + title: 'Template document', + description: 'The provided document appears to be a template. Please upload your actual document.', + }, + OUTDATED_DOCUMENT_VERSION: { + title: 'Outdated document', + description: 'This is not the most recent version of the document. Please provide the latest version.', + }, + UNFILLED_ID: { + title: 'Unreadable document', + description: 'The document could not be read. Please upload a clearer image.', + }, + UNSUITABLE_DOCUMENT: { + title: 'Unsuitable document', + description: 'The document does not meet the requirements. Please check the requirements and try again.', + }, + INCOMPATIBLE_LANGUAGE: { + title: 'Unsupported language', + description: 'The document is in an unsupported language. Please provide a translated or alternative document.', + }, + UNSUPPORTED_LANGUAGE: { + title: 'Unsupported language', + description: 'The document is in an unsupported language.', + }, + BAD_PROOF_OF_IDENTITY: { + title: 'Identity document issue', + description: 'There was an issue with your identity document. Please upload a valid, clear copy.', + }, + BAD_PROOF_OF_ADDRESS: { + title: 'Proof of address issue', + description: + 'There was an issue with your proof of address. Please upload a valid document with your full name and address.', + }, + BAD_PROOF_OF_PAYMENT: { + title: 'Payment proof issue', + description: 'There was an issue verifying your payment information.', + }, + ADDITIONAL_DOCUMENT_REQUIRED: { + title: 'Additional document needed', + description: 'An additional document is required. Please check the request and upload the needed document.', + }, + + // --- selfie & face matching --- SELFIE_MISMATCH: { title: 'Selfie does not match', description: 'The selfie did not match the photo on your document. Please try again with a clear selfie.', }, + BAD_FACE_MATCHING: { + title: 'Face not clearly visible', + description: 'Your face was not clearly visible. Please take a well-lit selfie showing your full face.', + }, + BAD_SELFIE: { + title: 'Selfie issue', + description: 'There was an issue with your selfie. Please take a clear, well-lit selfie.', + }, + BAD_VIDEO_SELFIE: { + title: 'Video selfie issue', + description: 'The video selfie check could not be completed. Please try again.', + }, SELFIE_BAD_QUALITY: { title: 'Low quality selfie', description: 'The selfie was blurry or poorly lit. Please take a clear, well-lit selfie.', @@ -37,30 +172,104 @@ const REJECT_LABEL_MAP: Record = { title: 'Selfie issue detected', description: 'A live selfie is required. Do not use a photo of a photo or a screen.', }, + FRAUDULENT_LIVENESS: { + title: 'Liveness check failed', + description: 'The liveness check could not be completed. Please try again with a live selfie.', + }, + + // --- fraud & forgery (terminal) --- DOCUMENT_FAKE: { title: 'Document could not be verified', description: 'We were unable to verify the authenticity of your document.', }, + FORGERY: { + title: 'Document could not be verified', + description: 'The document could not be verified. Please use an original, unaltered document.', + }, + GRAPHIC_EDITOR: { + title: 'Edited document detected', + description: 'The document appears to have been digitally altered.', + }, GRAPHIC_EDITOR_USAGE: { title: 'Edited document detected', description: 'The document appears to have been digitally altered.', }, + FRAUDULENT_PATTERNS: { + title: 'Verification failed', + description: 'Your verification could not be completed due to a security concern.', + }, + THIRD_PARTY_INVOLVED: { + title: 'Third party detected', + description: 'A third party was detected during verification. You must complete verification yourself.', + }, + BLOCKLIST: { + title: 'Account restricted', + description: 'Your account has been restricted. Please contact support.', + }, + SPAM: { + title: 'Too many attempts', + description: 'Too many files were uploaded. Please contact support.', + }, + + // --- regulatory & compliance (terminal) --- + AGE_REQUIREMENT_MISMATCH: { + title: 'Age requirement not met', + description: 'You must meet the minimum age requirement to use this service.', + }, AGE_BELOW_ACCEPTED_LIMIT: { title: 'Age requirement not met', description: 'You must be at least 18 years old to use this service.', }, - UNSUPPORTED_DOCUMENT: { - title: 'Unsupported document type', - description: "This type of document is not accepted. Please use a passport, national ID, or driver's license.", - }, - WRONG_DOCUMENT: { - title: 'Wrong document provided', - description: 'The uploaded document does not match what was requested. Please upload the correct document.', - }, REGULATIONS_VIOLATIONS: { title: 'Regulatory restriction', description: 'Verification could not be completed due to regulatory requirements.', }, + WRONG_USER_REGION: { + title: 'Unsupported region', + description: 'Your country or region is not currently supported.', + }, + DUPLICATE: { + title: 'Duplicate account', + description: 'An account with your details already exists.', + }, + RESTRICTED_PERSON: { + title: 'Verification restricted', + description: 'Your verification could not be completed. Please contact support.', + }, + + // --- compromised persons (terminal) --- + ADVERSE_MEDIA: { + title: 'Verification failed', + description: 'Your verification could not be completed. Please contact support.', + }, + CRIMINAL: { + title: 'Verification failed', + description: 'Your verification could not be completed. Please contact support.', + }, + COMPROMISED_PERSONS: { + title: 'Verification failed', + description: 'Your verification could not be completed. Please contact support.', + }, + PEP: { + title: 'Verification failed', + description: 'Your verification could not be completed due to compliance requirements.', + }, + SANCTIONS: { + title: 'Verification failed', + description: 'Your verification could not be completed due to compliance requirements.', + }, + + // --- profile & consistency --- + INCONSISTENT_PROFILE: { + title: 'Inconsistent documents', + description: 'The documents provided appear to belong to different individuals.', + }, + + // --- availability --- + CHECK_UNAVAILABLE: { + title: 'Verification temporarily unavailable', + description: 'The verification database is currently unavailable. Please try again later.', + }, } const FALLBACK_LABEL_INFO: RejectLabelInfo = { @@ -70,7 +279,23 @@ const FALLBACK_LABEL_INFO: RejectLabelInfo = { // labels that indicate a permanent rejection — used as a frontend heuristic // until backend provides rejectType -export const TERMINAL_REJECT_LABELS = new Set(['DOCUMENT_FAKE', 'GRAPHIC_EDITOR_USAGE', 'AGE_BELOW_ACCEPTED_LIMIT']) +export const TERMINAL_REJECT_LABELS = new Set([ + 'DOCUMENT_FAKE', + 'FORGERY', + 'GRAPHIC_EDITOR_USAGE', + 'GRAPHIC_EDITOR', + 'AGE_BELOW_ACCEPTED_LIMIT', + 'AGE_REQUIREMENT_MISMATCH', + 'FRAUDULENT_PATTERNS', + 'FRAUDULENT_LIVENESS', + 'BLOCKLIST', + 'ADVERSE_MEDIA', + 'CRIMINAL', + 'COMPROMISED_PERSONS', + 'PEP', + 'SANCTIONS', + 'DUPLICATE', +]) /** get human-readable info for a sumsub reject label, with a safe fallback */ export const getRejectLabelInfo = (label: string): RejectLabelInfo => { diff --git a/src/features/limits/hooks/useLimitsValidation.ts b/src/features/limits/hooks/useLimitsValidation.ts index b2743c350..406dc8e2d 100644 --- a/src/features/limits/hooks/useLimitsValidation.ts +++ b/src/features/limits/hooks/useLimitsValidation.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { useLimits } from '@/hooks/useLimits' -import useKycStatus from '@/hooks/useKycStatus' import type { MantecaLimit } from '@/interfaces' import { MAX_QR_PAYMENT_AMOUNT_FOREIGN, @@ -42,20 +41,17 @@ interface UseLimitsValidationOptions { } /** - * hook to validate amounts against user's transaction limits - * automatically determines if user is local (manteca) or foreign (bridge) based on their kyc status - * returns warning/blocking state based on remaining limits + * hook to validate amounts against user's transaction limits. + * uses the presence of API-returned limits (which are gated behind ENABLED rails on the backend) */ export function useLimitsValidation({ flowType, amount, currency: currencyInput }: UseLimitsValidationOptions) { const { mantecaLimits, bridgeLimits, isLoading, hasMantecaLimits, hasBridgeLimits } = useLimits() - const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus() // normalize currency to valid LimitCurrency type const currency = mapToLimitCurrency(currencyInput) - // determine if user is "local" (has manteca kyc for latam operations) - // this replaces the external isLocalUser parameter - const isLocalUser = isUserMantecaKycApproved + // determine if user is "local" (has manteca limits = enabled manteca rails) + const isLocalUser = hasMantecaLimits // parse amount to number - strip commas to handle "1,200" format const numericAmount = useMemo(() => { @@ -81,7 +77,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput // validate for manteca users (argentina/brazil) const mantecaValidation = useMemo(() => { - if (!isUserMantecaKycApproved || !relevantMantecaLimit) { + if (!hasMantecaLimits || !relevantMantecaLimit) { return { isBlocking: false, isWarning: false, @@ -155,12 +151,12 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput daysUntilReset: daysUntilMonthlyReset, limitCurrency: currency, } - }, [isUserMantecaKycApproved, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType]) + }, [hasMantecaLimits, relevantMantecaLimit, numericAmount, currency, daysUntilMonthlyReset, flowType]) // validate for bridge users (us/europe/mexico) - per transaction limits // bridge limits are always in USD const bridgeValidation = useMemo(() => { - if (!isUserBridgeKycApproved || !bridgeLimits) { + if (!hasBridgeLimits || !bridgeLimits) { return { isBlocking: false, isWarning: false, @@ -213,7 +209,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput daysUntilReset: null, limitCurrency: 'USD', } - }, [isUserBridgeKycApproved, bridgeLimits, flowType, numericAmount]) + }, [hasBridgeLimits, bridgeLimits, flowType, numericAmount]) // qr payment validation for foreign users (non-manteca kyc) // foreign qr limits are always in USD @@ -258,8 +254,8 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput const validation = useMemo(() => { // for qr payments if (flowType === 'qr-payment') { - // local users (manteca kyc) use manteca limits - if (isLocalUser && isUserMantecaKycApproved) { + // local users (manteca limits) use manteca limits + if (isLocalUser && hasMantecaLimits) { return mantecaValidation } // foreign users have fixed per-tx limit @@ -268,14 +264,14 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput // for onramp/offramp - check which provider applies // only use manteca if there's a relevant limit for the currency (prevents skipping bridge validation) - if (isUserMantecaKycApproved && hasMantecaLimits && relevantMantecaLimit) { + if (hasMantecaLimits && relevantMantecaLimit) { return mantecaValidation } - if (isUserBridgeKycApproved && hasBridgeLimits) { + if (hasBridgeLimits) { return bridgeValidation } - // no kyc - no limits to validate + // no enabled rails - no limits to validate return { isBlocking: false, isWarning: false, @@ -288,8 +284,6 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput }, [ flowType, isLocalUser, - isUserMantecaKycApproved, - isUserBridgeKycApproved, hasMantecaLimits, hasBridgeLimits, relevantMantecaLimit, @@ -305,7 +299,7 @@ export function useLimitsValidation({ flowType, amount, currency: currencyInput currency, // convenience getters hasLimits: hasMantecaLimits || hasBridgeLimits, - isMantecaUser: isUserMantecaKycApproved, - isBridgeUser: isUserBridgeKycApproved, + isMantecaUser: hasMantecaLimits, + isBridgeUser: hasBridgeLimits, } } diff --git a/src/features/limits/views/BridgeLimitsView.tsx b/src/features/limits/views/BridgeLimitsView.tsx index 8bbb6fc5e..8fa791ab0 100644 --- a/src/features/limits/views/BridgeLimitsView.tsx +++ b/src/features/limits/views/BridgeLimitsView.tsx @@ -4,7 +4,6 @@ import NavHeader from '@/components/Global/NavHeader' import Card from '@/components/Global/Card' import { Icon } from '@/components/Global/Icons/Icon' import { useLimits } from '@/hooks/useLimits' -import useKycStatus from '@/hooks/useKycStatus' import { useRouter } from 'next/navigation' import { MAX_QR_PAYMENT_AMOUNT_FOREIGN } from '@/constants/payment.consts' import Image from 'next/image' @@ -25,8 +24,7 @@ import EmptyState from '@/components/Global/EmptyStates/EmptyState' */ const BridgeLimitsView = () => { const router = useRouter() - const { bridgeLimits, isLoading, error } = useLimits() - const { isUserMantecaKycApproved } = useKycStatus() + const { bridgeLimits, isLoading, error, hasMantecaLimits } = useLimits() // url state for source region (where user came from) const [region] = useQueryState( @@ -90,7 +88,7 @@ const BridgeLimitsView = () => { )} {/* qr payment limits accordion - for bridge users without manteca kyc */} - {!isUserMantecaKycApproved && ( + {!hasMantecaLimits && (

QR payment limits:

diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 4ea063318..8eb45ec8b 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -6,6 +6,7 @@ import NavHeader from '@/components/Global/NavHeader' import StatusBadge from '@/components/Global/Badges/StatusBadge' import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification' import useKycStatus from '@/hooks/useKycStatus' +import { useLimits } from '@/hooks/useLimits' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useMemo } from 'react' @@ -18,7 +19,8 @@ import { getProviderRoute } from '../utils' const LimitsPageView = () => { const router = useRouter() const { unlockedRegions, lockedRegions } = useIdentityVerification() - const { isUserKycApproved, isUserBridgeKycUnderReview, isUserMantecaKycApproved } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { hasMantecaLimits } = useLimits() // check if user has any kyc at all const hasAnyKyc = isUserKycApproved @@ -63,7 +65,7 @@ const LimitsPageView = () => { {/* unlocked regions */} {unlockedRegions.length > 0 && ( - + )} {/* locked regions - only render if there are actual locked regions */} diff --git a/src/hooks/useDetermineBankClaimType.ts b/src/hooks/useDetermineBankClaimType.ts index 0d73234cb..69bef4914 100644 --- a/src/hooks/useDetermineBankClaimType.ts +++ b/src/hooks/useDetermineBankClaimType.ts @@ -3,6 +3,7 @@ import { useAuth } from '@/context/authContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' import { useEffect, useState } from 'react' import useKycStatus from './useKycStatus' +import { isUserKycVerified } from '@/constants/kyc.consts' export enum BankClaimType { GuestBankClaim = 'guest-bank-claim', @@ -23,12 +24,12 @@ export function useDetermineBankClaimType(senderUserId: string): { const { user } = useAuth() const [claimType, setClaimType] = useState(BankClaimType.ReceiverKycNeeded) const { setSenderDetails } = useClaimBankFlow() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserKycApproved } = useKycStatus() useEffect(() => { const determineBankClaimType = async () => { // check if receiver (logged in user) exists and is KYC approved - const receiverKycApproved = isUserBridgeKycApproved + const receiverKycApproved = isUserKycApproved if (receiverKycApproved) { // condition 1: Receiver is KYC approved → UserBankClaim @@ -48,7 +49,7 @@ export function useDetermineBankClaimType(senderUserId: string): { try { const senderDetails = await getUserById(senderUserId) - const senderKycApproved = senderDetails?.bridgeKycStatus === 'approved' + const senderKycApproved = isUserKycVerified(senderDetails) if (senderKycApproved) { // condition 3: Receiver not KYC approved BUT sender is → GuestBankClaim diff --git a/src/hooks/useDetermineBankRequestType.ts b/src/hooks/useDetermineBankRequestType.ts index 5f6583fee..4e84b763c 100644 --- a/src/hooks/useDetermineBankRequestType.ts +++ b/src/hooks/useDetermineBankRequestType.ts @@ -3,6 +3,7 @@ import { useAuth } from '@/context/authContext' import { useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { useEffect, useState } from 'react' import useKycStatus from './useKycStatus' +import { isUserKycVerified } from '@/constants/kyc.consts' export enum BankRequestType { GuestBankRequest = 'guest-bank-request', @@ -23,11 +24,11 @@ export function useDetermineBankRequestType(requesterUserId: string): { const { user } = useAuth() const [requestType, setRequestType] = useState(BankRequestType.PayerKycNeeded) const { setRequesterDetails } = useRequestFulfillmentFlow() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserKycApproved } = useKycStatus() useEffect(() => { const determineBankRequestType = async () => { - const payerKycApproved = isUserBridgeKycApproved + const payerKycApproved = isUserKycApproved if (payerKycApproved) { setRequestType(BankRequestType.UserBankRequest) @@ -45,7 +46,7 @@ export function useDetermineBankRequestType(requesterUserId: string): { try { const requesterDetails = await getUserById(requesterUserId) - const requesterKycApproved = requesterDetails?.bridgeKycStatus === 'approved' + const requesterKycApproved = isUserKycVerified(requesterDetails) if (requesterKycApproved) { setRequesterDetails(requesterDetails) diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx index dc408c42c..a81020025 100644 --- a/src/hooks/useHomeCarouselCTAs.tsx +++ b/src/hooks/useHomeCarouselCTAs.tsx @@ -12,6 +12,7 @@ import { DeviceType, useDeviceType } from './useGetDeviceType' import { usePWAStatus } from './usePWAStatus' import { useGeoLocation } from './useGeoLocation' import { useCardPioneerInfo } from './useCardPioneerInfo' +import { useBridgeTosStatus } from './useBridgeTosStatus' import { STAR_STRAIGHT_ICON } from '@/assets' import underMaintenanceConfig from '@/config/underMaintenance.config' @@ -50,6 +51,8 @@ export const useHomeCarouselCTAs = () => { hasPurchased: hasCardPioneerPurchased, isLoading: isCardPioneerLoading, } = useCardPioneerInfo() + const { needsBridgeTos } = useBridgeTosStatus() + const [showBridgeTos, setShowBridgeTos] = useState(false) const generateCarouselCTAs = useCallback(() => { const _carouselCTAs: CarouselCTA[] = [] @@ -58,6 +61,18 @@ export const useHomeCarouselCTAs = () => { const hasKycApproval = isUserKycApproved || isUserMantecaKycApproved const isLatamUser = userCountryCode === 'AR' || userCountryCode === 'BR' + // Bridge ToS acceptance — must be first CTA when user has pending ToS + if (needsBridgeTos) { + _carouselCTAs.push({ + id: 'bridge-tos', + title: 'Accept terms of service', + description: 'Required to enable bank transfers', + icon: 'alert', + iconContainerClassName: 'bg-yellow-1', + onClick: () => setShowBridgeTos(true), + }) + } + // Card Pioneer CTA - show to all users who haven't purchased yet // Eligibility check happens during the flow (geo screen) // Only show when we know for sure they haven't purchased (not while loading) @@ -215,6 +230,7 @@ export const useHomeCarouselCTAs = () => { isCardPioneerEligible, hasCardPioneerPurchased, isCardPioneerLoading, + needsBridgeTos, ]) useEffect(() => { @@ -226,5 +242,5 @@ export const useHomeCarouselCTAs = () => { generateCarouselCTAs() }, [user, generateCarouselCTAs, isPermissionGranted]) - return { carouselCTAs, setCarouselCTAs } + return { carouselCTAs, setCarouselCTAs, showBridgeTos, setShowBridgeTos } } diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index e3f396532..548555b0c 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -77,6 +77,9 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ }, ] +// precompute bridge alpha2 values for O(1) lookup +const BRIDGE_ALPHA2_SET = new Set(Object.values(BRIDGE_ALPHA3_TO_ALPHA2)) + /** maps a region path to the sumsub kyc template intent */ export const getRegionIntent = (regionPath: string): KYCRegionIntent => { return regionPath === 'latam' ? 'LATAM' : 'STANDARD' @@ -159,17 +162,31 @@ export const useIdentityVerification = () => { const isMantecaApproved = isUserMantecaKycApproved const isSumsubApproved = isUserSumsubKycApproved + // check if a provider's rails are in a functional state (not pending/failed) + const hasProviderAccess = (providerCode: string) => { + const providerRails = user?.rails?.filter((r) => r.rail.provider.code === providerCode) ?? [] + if (providerRails.length === 0) return false + return providerRails.some( + (r) => + r.status === 'ENABLED' || + r.status === 'REQUIRES_INFORMATION' || + r.status === 'REQUIRES_EXTRA_INFORMATION' + ) + } + // helper to check if a region should be unlocked const isRegionUnlocked = (regionName: string) => { // sumsub approval scoped by the regionIntent used during verification. - // 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions + rest of world. - // no intent (or rest-of-world) → unlocks rest of world only. + // 'LATAM' intent → unlocks LATAM. 'STANDARD' intent → unlocks Bridge regions. + // rest of world is always unlocked with any sumsub approval (crypto features). + // provider-specific regions require the provider rails to be functional + // (not still PENDING from submission or FAILED). if (isSumsubApproved) { + if (regionName === 'Rest of the world') return true if (sumsubVerificationRegionIntent === 'LATAM') { - return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + return hasProviderAccess('MANTECA') && MANTECA_SUPPORTED_REGIONS.includes(regionName) } - // STANDARD intent covers bridge regions + rest of world - return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + return hasProviderAccess('BRIDGE') && BRIDGE_SUPPORTED_REGIONS.includes(regionName) } return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || @@ -190,7 +207,13 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) + }, [ + isUserBridgeKycApproved, + isUserMantecaKycApproved, + isUserSumsubKycApproved, + sumsubVerificationRegionIntent, + user?.rails, + ]) /** * Check if a region is already unlocked by comparing region paths. @@ -270,12 +293,7 @@ export const useIdentityVerification = () => { const isBridgeSupportedCountry = useCallback((code: string) => { const upper = code.toUpperCase() - return ( - upper === 'US' || - upper === 'MX' || - Object.keys(BRIDGE_ALPHA3_TO_ALPHA2).includes(upper) || - Object.values(BRIDGE_ALPHA3_TO_ALPHA2).includes(upper) - ) + return upper === 'US' || upper === 'MX' || upper in BRIDGE_ALPHA3_TO_ALPHA2 || BRIDGE_ALPHA2_SET.has(upper) }, []) return { diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 0e722345a..f6064be1c 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -8,6 +8,27 @@ import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' const PREPARING_TIMEOUT_MS = 30000 +/** + * confirms bridge ToS acceptance (with one retry) then polls fetchUser + * until bridge rails leave REQUIRES_INFORMATION. max 3 attempts × 2s. + */ +export async function confirmBridgeTosAndAwaitRails(fetchUser: () => Promise) { + const result = await confirmBridgeTos() + if (!result.data?.accepted) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + await confirmBridgeTos() + } + + for (let i = 0; i < 3; i++) { + const updatedUser = await fetchUser() + const stillNeedsTos = (updatedUser?.rails ?? []).some( + (r: any) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + if (!stillNeedsTos) break + if (i < 2) await new Promise((resolve) => setTimeout(resolve, 2000)) + } +} + interface UseMultiPhaseKycFlowOptions { onKycSuccess?: () => void onManualClose?: () => void @@ -214,20 +235,9 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent setShowTosIframe(false) if (source === 'tos_accepted') { - // confirm with backend - const result = await confirmBridgeTos() - - if (!result.data?.accepted) { - // bridge may not have registered acceptance yet — retry after short delay - await new Promise((resolve) => setTimeout(resolve, 2000)) - const retryResult = await confirmBridgeTos() - if (!retryResult.data?.accepted) { - console.warn('[useMultiPhaseKycFlow] bridge ToS confirmation failed after retry') - } - } - - // optimistically complete — don't wait for rail status WebSocket - await fetchUser() + // show loading state while confirming + polling + setModalPhase('preparing') + await confirmBridgeTosAndAwaitRails(fetchUser) completeFlow() } // if manual close, stay on bridge_tos phase (user can try again) diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts index 87ea6ce20..ebe0241ab 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -19,6 +19,7 @@ import { type ChargeEntry } from '@/services/services.types' import { toWebAuthnKey } from '@zerodev/passkey-validator' import { USER_OPERATION_REVERT_REASON_TOPIC } from '@/constants/zerodev.consts' import { CHAIN_LOGOS, type ChainName } from '@/constants/rhino.consts' +import { isUserKycVerified } from '@/constants/kyc.consts' export function urlBase64ToUint8Array(base64String: string) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) @@ -984,7 +985,7 @@ export const getContributorsFromCharge = (charges: ChargeEntry[]) => { amount: charge.tokenAmount, username, fulfillmentPayment: charge.fulfillmentPayment, - isUserVerified: successfulPayment?.payerAccount?.user?.bridgeKycStatus === 'approved', + isUserVerified: isUserKycVerified(successfulPayment?.payerAccount?.user), isPeanutUser, } })