diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 62b554b1f..51a3b1531 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -10,7 +10,7 @@ import { UserHeader } from '@/components/UserHeader' import { useAuth } from '@/context/authContext' import { useWallet } from '@/hooks/wallet/useWallet' import { useUserStore } from '@/redux/hooks' -import { formatExtendedNumber, getUserPreferences, updateUserPreferences, getRedirectUrl } from '@/utils/general.utils' +import { formatExtendedNumber, getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { printableUsdc } from '@/utils/balance.utils' import { useDisconnect } from '@reown/appkit/react' import Link from 'next/link' @@ -24,7 +24,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { PostSignupActionManager } from '@/components/Global/PostSignupActionManager' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' -import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType' +import { useDeviceType } from '@/hooks/useGetDeviceType' import { useNotifications } from '@/hooks/useNotifications' import useKycStatus from '@/hooks/useKycStatus' import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo' diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx deleted file mode 100644 index 8ffed617b..000000000 --- a/src/app/(mobile-ui)/profile/identity-verification/[region]/[country]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -'use client' -import IdentityVerificationView from '@/components/Profile/views/IdentityVerification.view' - -export default function IdentityVerificationCountryPage() { - return -} diff --git a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx b/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx deleted file mode 100644 index d1843f861..000000000 --- a/src/app/(mobile-ui)/profile/identity-verification/[region]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -'use client' -import RegionsPage from '@/components/Profile/views/RegionsPage.view' -import { useParams } from 'next/navigation' - -export default function IdentityVerificationRegionPage() { - const params = useParams() - const region = params.region as string - - return -} diff --git a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx index 29884066e..5f6049aa8 100644 --- a/src/app/(mobile-ui)/profile/identity-verification/layout.tsx +++ b/src/app/(mobile-ui)/profile/identity-verification/layout.tsx @@ -1,59 +1,7 @@ 'use client' import PageContainer from '@/components/0_Bruddle/PageContainer' -import ActionModal from '@/components/Global/ActionModal' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import { useParams, useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' export default function IdentityVerificationLayout({ children }: { children: React.ReactNode }) { - const [isAlreadyVerifiedModalOpen, setIsAlreadyVerifiedModalOpen] = useState(false) - const router = useRouter() - const { isRegionAlreadyUnlocked, isVerifiedForCountry } = useIdentityVerification() - const params = useParams() - const regionParams = params.region as string - const countryParams = params.country as string - - useEffect(() => { - const isAlreadyVerified = - (countryParams && isVerifiedForCountry(countryParams)) || - (regionParams && isRegionAlreadyUnlocked(regionParams)) - - if (isAlreadyVerified) { - setIsAlreadyVerifiedModalOpen(true) - } - }, [countryParams, regionParams, isVerifiedForCountry, isRegionAlreadyUnlocked]) - - return ( - - {children} - - { - setIsAlreadyVerifiedModalOpen(false) - router.push('/profile') - }} - title="You're already verified" - description={ -

- Your identity has already been successfully verified for this region. You can continue to use - features available in this region. No further action is needed. -

- } - icon="shield" - ctas={[ - { - text: 'Close', - shadowSize: '4', - className: 'md:py-2', - onClick: () => { - setIsAlreadyVerifiedModalOpen(false) - router.push('/profile') - }, - }, - ]} - /> -
- ) + return {children} } diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9fadf1fbb..92137bdc3 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -2,7 +2,7 @@ import { useSearchParams, useRouter } from 'next/navigation' import { useState, useCallback, useMemo, useEffect, useContext, useRef } from 'react' -import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/KycVerificationInProgressModal' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' import Card from '@/components/Global/Card' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts new file mode 100644 index 000000000..7222004c6 --- /dev/null +++ b/src/app/actions/sumsub.ts @@ -0,0 +1,54 @@ +'use server' + +import { type InitiateSumsubKycResponse, type KYCRegionIntent } from './types/sumsub.types' +import { fetchWithSentry } from '@/utils/sentry.utils' +import { PEANUT_API_URL } from '@/constants/general.consts' +import { getJWTCookie } from '@/utils/cookie-migration.utils' + +const API_KEY = process.env.PEANUT_API_KEY! + +// initiate kyc flow (using sumsub) and get websdk access token +export const initiateSumsubKyc = async (params?: { + regionIntent?: KYCRegionIntent + levelName?: string +}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + + if (!jwtToken) { + return { error: 'Authentication required' } + } + + const body: Record = { + regionIntent: params?.regionIntent, + levelName: params?.levelName, + } + + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/identity`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + body: JSON.stringify(body), + }) + + const responseJson = await response.json() + + if (!response.ok) { + return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' } + } + + return { + data: { + token: responseJson.token, + applicantId: responseJson.applicantId, + status: responseJson.status, + }, + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts new file mode 100644 index 000000000..8565d2961 --- /dev/null +++ b/src/app/actions/types/sumsub.types.ts @@ -0,0 +1,9 @@ +export interface InitiateSumsubKycResponse { + token: string | null // null when user is already APPROVED + applicantId: string | null + status: SumsubKycStatus +} + +export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED' + +export type KYCRegionIntent = 'STANDARD' | 'LATAM' diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index e530ebf17..5929fed25 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -160,3 +160,47 @@ export async function getContacts(params: { return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } + +// fetch bridge ToS acceptance link for users with pending ToS +export const getBridgeTosLink = async (): Promise<{ data?: { tosLink: string }; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-link`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.error || 'Failed to fetch Bridge ToS link' } + } + return { data: responseJson } + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } + } +} + +// confirm bridge ToS acceptance after user closes the ToS iframe +export const confirmBridgeTos = async (): Promise<{ data?: { accepted: boolean }; error?: string }> => { + const jwtToken = (await getJWTCookie())?.value + try { + const response = await fetchWithSentry(`${PEANUT_API_URL}/users/bridge-tos-confirm`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwtToken}`, + 'api-key': API_KEY, + }, + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.error || 'Failed to confirm Bridge ToS' } + } + return { data: responseJson } + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } + } +} diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 8eaf7335d..0aebee174 100644 --- a/src/components/Global/Badges/StatusBadge.tsx +++ b/src/components/Global/Badges/StatusBadge.tsx @@ -41,6 +41,10 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm } const getStatusText = () => { + // customText overrides the default label for any status type, + // allowing callers to use a specific status style with custom text + if (customText) return customText + switch (status) { case 'completed': return 'Completed' @@ -59,7 +63,7 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm case 'closed': return 'Closed' case 'custom': - return customText + return 'Custom' default: return status } diff --git a/src/components/Global/IframeWrapper/StartVerificationView.tsx b/src/components/Global/IframeWrapper/StartVerificationView.tsx index 5285ec405..abec9c2ea 100644 --- a/src/components/Global/IframeWrapper/StartVerificationView.tsx +++ b/src/components/Global/IframeWrapper/StartVerificationView.tsx @@ -33,10 +33,11 @@ const StartVerificationView = ({

Secure Verification. Limited Data Use.

- The verification is done by Persona, which only shares a yes/no with Peanut. + The verification is done using a trusted provider, which shares your verification status with + Peanut.

- Persona is trusted by millions and it operates under strict security and privacy standards. + It operates under industry-standard security and privacy practices.

Peanut never sees or stores your verification data.

diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index e73e916c7..c89f606b1 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -14,7 +14,9 @@ import Card from '../Global/Card' import { type CardPosition, getCardPosition } from '../Global/Card/card.utils' import EmptyState from '../Global/EmptyStates/EmptyState' import { KycStatusItem } from '../Kyc/KycStatusItem' +import { BridgeTosReminder } from '../Kyc/BridgeTosReminder' import { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -43,6 +45,7 @@ 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), @@ -270,6 +273,7 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h return (

Activity

+ {isViewingOwnHistory && needsBridgeTos && } {isViewingOwnHistory && ((user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started') || (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( @@ -317,6 +321,9 @@ 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/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 1377b3a50..80cd086bc 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -13,14 +13,17 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = const { user } = useAuth() const [approvedCountryData, setApprovedCountryData] = useState(null) - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { + // sumsub covers all regions, treat as 'all' + if (isUserSumsubKycApproved) { + return 'all' + } if (isUserBridgeKycApproved && isUserMantecaKycApproved) { return 'all' } - if (isUserBridgeKycApproved) { return 'bridge' } @@ -28,7 +31,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = return 'manteca' } return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) diff --git a/src/components/IdentityVerification/StartVerificationModal.tsx b/src/components/IdentityVerification/StartVerificationModal.tsx index d5a14a63b..045760bf8 100644 --- a/src/components/IdentityVerification/StartVerificationModal.tsx +++ b/src/components/IdentityVerification/StartVerificationModal.tsx @@ -3,69 +3,76 @@ import ActionModal from '../Global/ActionModal' import InfoCard from '../Global/InfoCard' import { Icon } from '../Global/Icons/Icon' -import { MantecaSupportedExchanges } from '../AddMoney/consts' -import { useMemo } from 'react' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' +import { type Region } from '@/hooks/useIdentityVerification' +import React from 'react' + +// unlock benefits shown per region +const REGION_UNLOCK_ITEMS: Record> = { + latam: [ +

+ Bank transfers to your own accounts in LATAM +

, +

+ QR Payments in Argentina and Brazil +

, + ], + europe: [ +

+ Europe SEPA transfers (+30 countries) +

, +

+ QR Payments in Argentina and Brazil +

, + ], + 'north-america': [ +

+ United States ACH and Wire transfers +

, +

+ Mexico SPEI transfers +

, +

+ QR Payments in Argentina and Brazil +

, + ], + 'rest-of-the-world': [ +

+ QR Payments in Argentina and Brazil +

, + ], +} + +const DEFAULT_UNLOCK_ITEMS = [

Bank transfers and local payment methods

] interface StartVerificationModalProps { visible: boolean onClose: () => void onStartVerification: () => void - selectedIdentityCountry: { id: string; title: string } - selectedCountry: { id: string; title: string } + selectedRegion: Region | null + isLoading?: boolean } const StartVerificationModal = ({ visible, onClose, onStartVerification, - selectedIdentityCountry, - selectedCountry, + selectedRegion, + isLoading, }: StartVerificationModalProps) => { - const { getVerificationUnlockItems } = useIdentityVerification() - - const items = useMemo(() => { - return getVerificationUnlockItems(selectedIdentityCountry.title) - }, [getVerificationUnlockItems, selectedIdentityCountry.title]) - - const isIdentityMantecaCountry = useMemo( - () => Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, selectedIdentityCountry.id.toUpperCase()), - [selectedIdentityCountry.id] - ) - - const isSelectedCountryMantecaCountry = useMemo( - () => Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, selectedCountry.id.toUpperCase()), - [selectedCountry] - ) - - const getDescription = () => { - if (isSelectedCountryMantecaCountry && isIdentityMantecaCountry) { - return ( -

- To send and receive money locally, you'll need to verify your identity with a - government-issued ID from {selectedCountry.title}. -

- ) - } - - if (isSelectedCountryMantecaCountry && !isIdentityMantecaCountry) { - return `Without an ${selectedCountry.title} Issued ID, you can still pay in stores using QR codes but you won't be able to transfer money directly to bank accounts.` - } - - return ( -

- To send money to and from bank accounts and local payment methods, verify your identity with a - government-issued ID. -

- ) - } + const unlockItems = selectedRegion + ? (REGION_UNLOCK_ITEMS[selectedRegion.path] ?? DEFAULT_UNLOCK_ITEMS) + : DEFAULT_UNLOCK_ITEMS return ( + To send and receive money in this region, verify your identity with a government-issued ID. +

+ } descriptionClassName="text-black" icon="shield" iconContainerClassName="bg-primary-1" @@ -74,8 +81,9 @@ const StartVerificationModal = ({ { shadowSize: '4', icon: 'check-circle', - text: 'Verify now', + text: isLoading ? 'Loading...' : 'Verify now', onClick: onStartVerification, + disabled: isLoading, }, ]} content={ @@ -86,11 +94,8 @@ const StartVerificationModal = ({ itemIcon="check" itemIconSize={12} itemIconClassName="text-secondary-7" - items={items - .filter((item) => item.type === (isIdentityMantecaCountry ? 'manteca' : 'bridge')) - .map((item) => item.title)} + items={unlockItems} /> -

Peanut doesn't store any of your documents.

diff --git a/src/components/Kyc/BridgeTosReminder.tsx b/src/components/Kyc/BridgeTosReminder.tsx new file mode 100644 index 000000000..e2965a62e --- /dev/null +++ b/src/components/Kyc/BridgeTosReminder.tsx @@ -0,0 +1,51 @@ +'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 handleClick = useCallback(() => { + setShowTosStep(true) + }, []) + + const handleComplete = useCallback(async () => { + setShowTosStep(false) + await fetchUser() + }, [fetchUser]) + + const handleSkip = useCallback(() => { + setShowTosStep(false) + }, []) + + return ( + <> + +
+
+ +
+
+

Accept terms of service

+

Required to enable bank transfers

+
+ +
+
+ + + + ) +} diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx new file mode 100644 index 000000000..ac23576ab --- /dev/null +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -0,0 +1,132 @@ +'use client' + +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 { useAuth } from '@/context/authContext' + +interface BridgeTosStepProps { + visible: boolean + onComplete: () => void + onSkip: () => void +} + +// shown immediately after sumsub kyc approval when bridge rails need ToS 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 [error, setError] = useState(null) + + // reset state when visibility changes + useEffect(() => { + if (!visible) { + setShowIframe(false) + setTosLink(null) + setError(null) + } + }, [visible]) + + const handleAcceptTerms = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + 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 + } + + setTosLink(response.data.tosLink) + setShowIframe(true) + } catch { + setError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoading(false) + } + }, []) + + const handleIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + 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() + } + } else { + // user closed without accepting — skip, activity feed will remind them + onSkip() + } + }, + [fetchUser, onComplete, onSkip] + ) + + if (!visible) return null + + return ( + <> + {!showIframe && ( + + )} + + {tosLink && } + + ) +} diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx index d72f425cc..6f547e6f3 100644 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx @@ -6,7 +6,7 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' import { type CountryData } from '@/components/AddMoney/consts' import { Button } from '@/components/0_Bruddle/Button' -import { PeanutDoesntStoreAnyPersonalInformation } from './KycVerificationInProgressModal' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' import { useEffect } from 'react' interface Props { diff --git a/src/components/Kyc/KYCStatusDrawerItem.tsx b/src/components/Kyc/KYCStatusDrawerItem.tsx index 0d2cae261..f377c2ad0 100644 --- a/src/components/Kyc/KYCStatusDrawerItem.tsx +++ b/src/components/Kyc/KYCStatusDrawerItem.tsx @@ -2,13 +2,13 @@ import Card from '@/components/Global/Card' import StatusBadge, { type StatusType } from '../Global/Badges/StatusBadge' import { KYCStatusIcon } from './KYCStatusIcon' -export const KYCStatusDrawerItem = ({ status }: { status: StatusType }) => { +export const KYCStatusDrawerItem = ({ status, customText }: { status: StatusType; customText?: string }) => { return (

Identity verification

- +
) diff --git a/src/components/Kyc/KycFlow.tsx b/src/components/Kyc/KycFlow.tsx index 88795f515..0ac66bd38 100644 --- a/src/components/Kyc/KycFlow.tsx +++ b/src/components/Kyc/KycFlow.tsx @@ -1,22 +1,22 @@ -import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' -import IframeWrapper from '@/components/Global/IframeWrapper' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' +import { type ButtonProps } from '@/components/0_Bruddle/Button' +import { SumsubKycFlow } from '@/components/Kyc/SumsubKycFlow' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' -// this component is the main entry point for the kyc flow -// it renders a button that, when clicked, initiates the process of fetching -// tos/kyc links, showing them in an iframe, and then displaying a status modal -export const KycFlow = (props: ButtonProps) => { - const { isLoading, error, iframeOptions, handleInitiateKyc, handleIframeClose } = useBridgeKycFlow() +interface KycFlowProps extends ButtonProps { + regionIntent?: KYCRegionIntent + onKycSuccess?: () => void + onManualClose?: () => void +} +// main entry point for the kyc flow. +// renders SumsubKycFlow with an optional region intent for context-aware verification. +export const KycFlow = ({ regionIntent, onKycSuccess, onManualClose, ...buttonProps }: KycFlowProps) => { return ( - <> - - - {error &&

{error}

} - - - + ) } diff --git a/src/components/Kyc/KycStatusDrawer.tsx b/src/components/Kyc/KycStatusDrawer.tsx index e8a80f9fd..07b6b292d 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,31 +1,21 @@ +import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' import { KycProcessing } from './states/KycProcessing' +import { KycRequiresDocuments } from './states/KycRequiresDocuments' import { Drawer, DrawerContent, DrawerTitle } from '../Global/Drawer' import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' -import { type IUserKycVerification, MantecaKycStatus } from '@/interfaces' +import { type IUserKycVerification } from '@/interfaces' import { useUserStore } from '@/redux/hooks' import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import { type CountryData, countryData } from '@/components/AddMoney/consts' import IFrameWrapper from '@/components/Global/IframeWrapper' - -// a helper to categorize the kyc status from the user object -const getKycStatusCategory = (status: BridgeKycStatus | MantecaKycStatus): 'processing' | 'completed' | 'failed' => { - switch (status) { - case 'approved': - case MantecaKycStatus.ACTIVE: - return 'completed' - case 'rejected': - case MantecaKycStatus.INACTIVE: - return 'failed' - case 'under_review': - case 'incomplete': - case MantecaKycStatus.ONBOARDING: - default: - return 'processing' - } -} +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' interface KycStatusDrawerProps { isOpen: boolean @@ -43,6 +33,10 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus const countryCode = verification ? verification.mantecaGeo || verification.bridgeGeo : null const isBridgeKyc = !verification && !!bridgeKycStatus const provider = verification ? verification.provider : 'BRIDGE' + // derive region intent from sumsub verification metadata so token uses correct level + const sumsubRegionIntent = ( + verification?.provider === 'SUMSUB' ? verification?.metadata?.regionIntent : undefined + ) as KYCRegionIntent | undefined const { handleInitiateKyc: initiateBridgeKyc, @@ -65,17 +59,71 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus country: country as CountryData, }) + const { + handleInitiateKyc: initiateSumsub, + showWrapper: showSumsubWrapper, + accessToken: sumsubAccessToken, + handleSdkComplete: handleSumsubComplete, + handleClose: handleSumsubClose, + refreshToken: sumsubRefreshToken, + isLoading: isSumsubLoading, + isVerificationProgressModalOpen: isSumsubProgressModalOpen, + closeVerificationProgressModal: closeSumsubProgressModal, + error: sumsubError, + } = useSumsubKycFlow({ onKycSuccess: onClose, onManualClose: onClose, regionIntent: sumsubRegionIntent }) + const onRetry = async () => { - if (provider === 'MANTECA') { + if (provider === 'SUMSUB') { + await initiateSumsub() + } else if (provider === 'MANTECA') { await openMantecaKyc(country as CountryData) } else { await initiateBridgeKyc() } } - const isLoadingKyc = isBridgeLoading || isMantecaLoading + const isLoadingKyc = isBridgeLoading || isMantecaLoading || isSumsubLoading + + // check if any bridge rail needs additional documents + const bridgeRailsNeedingDocs = (user?.rails ?? []).filter( + (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' + ) + const needsAdditionalDocs = bridgeRailsNeedingDocs.length > 0 + // aggregate requirements across all rails and deduplicate + const additionalRequirements: string[] = needsAdditionalDocs + ? [ + ...new Set( + bridgeRailsNeedingDocs.flatMap((r) => { + const reqs = r.metadata?.additionalRequirements + return Array.isArray(reqs) ? reqs : [] + }) + ), + ] + : [] + + // count sumsub rejections for failure lockout. + // counts total REJECTED entries — accurate if backend creates a new row per attempt. + // if backend updates in-place (single row), this will be 0 or 1 and the lockout + // won't trigger from count alone (terminal labels and rejectType still work). + const sumsubFailureCount = + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + + const handleSubmitAdditionalDocs = async () => { + await initiateSumsub(undefined, 'peanut-additional-docs') + } const renderContent = () => { + // bridge additional document requirement — but don't mask terminal kyc states + if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') { + return ( + + ) + } + switch (statusCategory) { case 'processing': return ( @@ -93,10 +141,22 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus isBridge={isBridgeKyc} /> ) + case 'action_required': + return ( + + ) case 'failed': return ( KYC Status {renderContent()} + {sumsubError && provider === 'SUMSUB' && ( +

{sumsubError}

+ )} + + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 1ca8536ed..7eae92316 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -10,6 +10,13 @@ import { twMerge } from 'tailwind-merge' import { type IUserKycVerification } from '@/interfaces' import StatusPill from '../Global/StatusPill' import { KYCStatusIcon } from './KYCStatusIcon' +import { + isKycStatusApproved, + isKycStatusPending, + isKycStatusFailed, + isKycStatusNotStarted, + isKycStatusActionRequired, +} from '@/constants/kyc.consts' // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ @@ -45,23 +52,26 @@ export const KycStatusItem = ({ const finalBridgeKycStatus = wsBridgeKycStatus || bridgeKycStatus || user?.user?.bridgeKycStatus const kycStatus = verification ? verification.status : finalBridgeKycStatus - // Check if KYC is approved to show points earned - const isApproved = kycStatus === 'approved' || kycStatus === 'ACTIVE' - - const isPending = kycStatus === 'under_review' || kycStatus === 'incomplete' || kycStatus === 'ONBOARDING' - const isRejected = kycStatus === 'rejected' || kycStatus === 'INACTIVE' + const isApproved = isKycStatusApproved(kycStatus) + const isPending = isKycStatusPending(kycStatus) + const isRejected = isKycStatusFailed(kycStatus) + const isActionRequired = isKycStatusActionRequired(kycStatus) + // if a verification record exists with NOT_STARTED, the user has initiated KYC + // (backend creates the record on initiation). only hide for bridge's default state. + const isInitiatedButNotStarted = !!verification && isKycStatusNotStarted(kycStatus) const subtitle = useMemo(() => { - if (isPending) { - return 'Under review' - } - if (isApproved) { - return 'Approved' - } - return 'Rejected' - }, [isPending, isApproved, isRejected]) + if (isInitiatedButNotStarted) return 'In progress' + if (isActionRequired) return 'Action needed' + if (isPending) return 'Under review' + if (isApproved) return 'Approved' + if (isRejected) return 'Rejected' + return 'Unknown' + }, [isInitiatedButNotStarted, isActionRequired, isPending, isApproved, isRejected]) - if (!kycStatus || kycStatus === 'not_started') { + // only hide for bridge's default "not_started" state. + // if a verification record exists, the user has initiated KYC — show it. + if (!verification && isKycStatusNotStarted(kycStatus)) { return null } @@ -81,19 +91,29 @@ export const KycStatusItem = ({

Identity verification

{subtitle}

- +
- + {isDrawerOpen && ( + + )} ) } diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 401aa0ba9..c7f23d04c 100644 --- a/src/components/Kyc/KycVerificationInProgressModal.tsx +++ b/src/components/Kyc/KycVerificationInProgressModal.tsx @@ -1,16 +1,34 @@ import { useRouter } from 'next/navigation' import ActionModal from '@/components/Global/ActionModal' -import { Icon, type IconName } from '@/components/Global/Icons/Icon' -import { twMerge } from 'tailwind-merge' +import { type IconName } from '@/components/Global/Icons/Icon' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' +import { type KycModalPhase } from '@/interfaces' interface KycVerificationInProgressModalProps { isOpen: boolean onClose: () => void + phase?: KycModalPhase + onAcceptTerms?: () => void + onSkipTerms?: () => void + onContinue?: () => void + tosError?: string | null + isLoadingTos?: boolean + preparingTimedOut?: boolean } -// this modal is shown after the user submits their kyc information. -// it waits for a final status from the websocket before disappearing. -export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificationInProgressModalProps) => { +// multi-phase modal shown during and after kyc verification. +// phase transitions are controlled by the parent orchestrator (SumsubKycFlow). +export const KycVerificationInProgressModal = ({ + isOpen, + onClose, + phase = 'verifying', + onAcceptTerms, + onSkipTerms, + onContinue, + tosError, + isLoadingTos, + preparingTimedOut, +}: KycVerificationInProgressModalProps) => { const router = useRouter() const handleGoHome = () => { @@ -18,42 +36,124 @@ export const KycVerificationInProgressModal = ({ isOpen, onClose }: KycVerificat router.push('/home') } - const descriptionWithInfo = ( -

- This usually takes less than a minute. You can stay here while we finish, or return to the home screen and - we'll notify you when it's done. -

- ) + if (phase === 'verifying') { + return ( + + This usually takes less than a minute. You can stay here while we finish, or return to the home + screen and we'll notify you when it's done. +

+ } + ctas={[ + { + text: 'Go to Home', + onClick: handleGoHome, + variant: 'purple', + className: 'w-full', + shadowSize: '4', + }, + ]} + preventClose + hideModalCloseButton + footer={} + /> + ) + } + + if (phase === 'preparing') { + return ( + + ) + } + + if (phase === 'bridge_tos') { + const description = + tosError || 'One more step: accept terms of service to enable bank transfers in the US, Europe, and Mexico.' + return ( + + ) + } + + // phase === 'complete' return ( } /> ) } - -export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { - return ( -
- - Peanut doesn't store any of your documents -
- ) -} diff --git a/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx new file mode 100644 index 000000000..3922f0c00 --- /dev/null +++ b/src/components/Kyc/PeanutDoesntStoreAnyPersonalInformation.tsx @@ -0,0 +1,11 @@ +import { Icon } from '@/components/Global/Icons/Icon' +import { twMerge } from 'tailwind-merge' + +export const PeanutDoesntStoreAnyPersonalInformation = ({ className }: { className?: string }) => { + return ( +
+ + Peanut doesn't store any of your documents +
+ ) +} diff --git a/src/components/Kyc/RejectLabelsList.tsx b/src/components/Kyc/RejectLabelsList.tsx new file mode 100644 index 000000000..69bfb63c5 --- /dev/null +++ b/src/components/Kyc/RejectLabelsList.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react' +import InfoCard from '@/components/Global/InfoCard' +import { getRejectLabelInfo } from '@/constants/sumsub-reject-labels.consts' + +// renders sumsub reject labels as individual InfoCards, with a generic fallback +// when no labels are provided. shared between drawer states and modals. +export const RejectLabelsList = ({ rejectLabels }: { rejectLabels?: string[] | null }) => { + const labels = rejectLabels?.length ? rejectLabels : null + + const reasons = useMemo(() => { + if (!labels) return null + return labels.map((label) => getRejectLabelInfo(label)) + }, [labels]) + + if (!reasons) { + return ( + + ) + } + + return ( +
+ {reasons.map((reason, i) => ( + + ))} +
+ ) +} diff --git a/src/components/Kyc/SumsubKycFlow.tsx b/src/components/Kyc/SumsubKycFlow.tsx new file mode 100644 index 000000000..e5d01ea85 --- /dev/null +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -0,0 +1,294 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useRailStatusTracking } from '@/hooks/useRailStatusTracking' +import { useAuth } from '@/context/authContext' +import { getBridgeTosLink, confirmBridgeTos } from '@/app/actions/users' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { type KycModalPhase } from '@/interfaces' + +const PREPARING_TIMEOUT_MS = 30000 + +interface SumsubKycFlowProps extends ButtonProps { + onKycSuccess?: () => void + onManualClose?: () => void + regionIntent?: KYCRegionIntent +} + +/** + * entry point for the kyc flow. + * renders a button that initiates kyc, the sumsub sdk wrapper modal, + * and a multi-phase verification modal that handles: + * verifying → preparing → bridge_tos (if applicable) → complete + */ +export const SumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent, ...buttonProps }: SumsubKycFlowProps) => { + const { fetchUser } = useAuth() + + // multi-phase modal state + const [modalPhase, setModalPhase] = useState('verifying') + const [forceShowModal, setForceShowModal] = useState(false) + const [preparingTimedOut, setPreparingTimedOut] = useState(false) + const preparingTimerRef = useRef(null) + const isRealtimeFlowRef = useRef(false) + + // bridge ToS state + const [tosLink, setTosLink] = useState(null) + const [showTosIframe, setShowTosIframe] = useState(false) + const [tosError, setTosError] = useState(null) + const [isLoadingTos, setIsLoadingTos] = useState(false) + + // ref for closeVerificationProgressModal (avoids circular dep with completeFlow) + const closeVerificationModalRef = useRef<() => void>(() => {}) + + // rail tracking + const { allSettled, needsBridgeTos, startTracking, stopTracking } = useRailStatusTracking() + + const clearPreparingTimer = useCallback(() => { + if (preparingTimerRef.current) { + clearTimeout(preparingTimerRef.current) + preparingTimerRef.current = null + } + }, []) + + // complete the flow — close everything, call original onKycSuccess + const completeFlow = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + setModalPhase('verifying') + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + clearPreparingTimer() + stopTracking() + closeVerificationModalRef.current() + onKycSuccess?.() + }, [onKycSuccess, clearPreparingTimer, stopTracking]) + + // called by useSumsubKycFlow when sumsub status transitions to APPROVED + const handleSumsubApproved = useCallback(async () => { + // for real-time flow, optimistically show "Identity verified!" while we check rails + if (isRealtimeFlowRef.current) { + setModalPhase('preparing') + setForceShowModal(true) + } + + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setModalPhase('bridge_tos') + setForceShowModal(true) + clearPreparingTimer() + return + } + + const anyPending = rails.some((r) => r.status === 'PENDING') + + if (anyPending || (rails.length === 0 && isRealtimeFlowRef.current)) { + // rails still being set up — show preparing and start tracking + setModalPhase('preparing') + setForceShowModal(true) + startTracking() + return + } + + // all settled — done + completeFlow() + }, [fetchUser, startTracking, clearPreparingTimer, completeFlow]) + + const { + isLoading, + error, + showWrapper, + accessToken, + liveKycStatus, + handleInitiateKyc: originalHandleInitiateKyc, + handleSdkComplete: originalHandleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) + + // keep ref in sync + useEffect(() => { + closeVerificationModalRef.current = closeVerificationProgressModal + }, [closeVerificationProgressModal]) + + // refresh user store when kyc status transitions to a non-success state + // so the drawer/status item reads the updated verification record + useEffect(() => { + if (liveKycStatus === 'ACTION_REQUIRED' || liveKycStatus === 'REJECTED') { + fetchUser() + } + }, [liveKycStatus, fetchUser]) + + // wrap handleSdkComplete to track real-time flow + const handleSdkComplete = useCallback(() => { + isRealtimeFlowRef.current = true + originalHandleSdkComplete() + }, [originalHandleSdkComplete]) + + // wrap handleInitiateKyc to reset state for new attempts + const handleInitiateKyc = useCallback( + async (overrideIntent?: KYCRegionIntent) => { + setModalPhase('verifying') + setForceShowModal(false) + setPreparingTimedOut(false) + setTosLink(null) + setShowTosIframe(false) + setTosError(null) + isRealtimeFlowRef.current = false + clearPreparingTimer() + + await originalHandleInitiateKyc(overrideIntent) + }, + [originalHandleInitiateKyc, clearPreparingTimer] + ) + + // 30s timeout for preparing phase + useEffect(() => { + if (modalPhase === 'preparing' && !preparingTimedOut) { + clearPreparingTimer() + preparingTimerRef.current = setTimeout(() => { + setPreparingTimedOut(true) + }, PREPARING_TIMEOUT_MS) + } else { + clearPreparingTimer() + } + }, [modalPhase, preparingTimedOut, clearPreparingTimer]) + + // phase transitions driven by rail tracking + useEffect(() => { + if (modalPhase === 'preparing') { + if (needsBridgeTos) { + setModalPhase('bridge_tos') + clearPreparingTimer() + } else if (allSettled) { + setModalPhase('complete') + clearPreparingTimer() + stopTracking() + } + } else if (modalPhase === 'bridge_tos') { + // after ToS accepted, rails transition to ENABLED + if (allSettled && !needsBridgeTos) { + setModalPhase('complete') + stopTracking() + } + } + }, [modalPhase, needsBridgeTos, allSettled, clearPreparingTimer, stopTracking]) + + // handle "Accept Terms" click in bridge_tos phase + const handleAcceptTerms = useCallback(async () => { + setIsLoadingTos(true) + setTosError(null) + + try { + const response = await getBridgeTosLink() + + if (response.error || !response.data?.tosLink) { + setTosError( + response.error || 'Could not load terms. You can accept them later from your activity feed.' + ) + return + } + + setTosLink(response.data.tosLink) + setShowTosIframe(true) + } catch { + setTosError('Something went wrong. You can accept terms later from your activity feed.') + } finally { + setIsLoadingTos(false) + } + }, []) + + // handle ToS iframe close + const handleTosIframeClose = useCallback( + async (source?: 'manual' | 'completed' | 'tos_accepted') => { + 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('[SumsubKycFlow] bridge ToS confirmation failed after retry') + } + } + + // refetch user — the phase-transition effect will handle moving to 'complete' + await fetchUser() + } + // if manual close, stay on bridge_tos phase (user can try again or skip) + }, + [fetchUser] + ) + + // handle "Skip for now" in bridge_tos phase + const handleSkipTerms = useCallback(() => { + completeFlow() + }, [completeFlow]) + + // handle modal close (Go to Home, etc.) + const handleModalClose = useCallback(() => { + isRealtimeFlowRef.current = false + setForceShowModal(false) + clearPreparingTimer() + stopTracking() + closeVerificationProgressModal() + }, [clearPreparingTimer, stopTracking, closeVerificationProgressModal]) + + // cleanup on unmount + useEffect(() => { + return () => { + clearPreparingTimer() + stopTracking() + } + }, [clearPreparingTimer, stopTracking]) + + const isModalOpen = isVerificationProgressModalOpen || forceShowModal + + return ( + <> + + + {error &&

{error}

} + + + + + + {tosLink && } + + ) +} diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx new file mode 100644 index 000000000..8fcf0bc57 --- /dev/null +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -0,0 +1,288 @@ +'use client' + +import { useEffect, useMemo, useState, useRef, useCallback } from 'react' +import Modal from '@/components/Global/Modal' +import ActionModal from '@/components/Global/ActionModal' +import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import { Button, type ButtonVariant } from '@/components/0_Bruddle/Button' +import { useModalsContext } from '@/context/ModalsContext' +import StartVerificationView from '../Global/IframeWrapper/StartVerificationView' + +// todo: move to consts +const SUMSUB_SDK_URL = 'https://static.sumsub.com/idensic/static/sns-websdk-builder.js' + +interface SumsubKycWrapperProps { + visible: boolean + accessToken: string | null + onClose: () => void + onComplete: () => void + onError?: (error: unknown) => void + onRefreshToken: () => Promise + /** skip StartVerificationView and launch SDK immediately (for re-submissions) */ + autoStart?: boolean +} + +export const SumsubKycWrapper = ({ + visible, + accessToken, + onClose, + onComplete, + onError, + onRefreshToken, + autoStart, +}: SumsubKycWrapperProps) => { + const [isVerificationStarted, setIsVerificationStarted] = useState(false) + const [sdkLoaded, setSdkLoaded] = useState(false) + const [sdkLoadError, setSdkLoadError] = useState(false) + const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) + const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') + const sdkContainerRef = useRef(null) + const sdkInstanceRef = useRef(null) + const { setIsSupportModalOpen } = useModalsContext() + + // callback refs to avoid stale closures in sdk init effect + const onCompleteRef = useRef(onComplete) + const onErrorRef = useRef(onError) + const onRefreshTokenRef = useRef(onRefreshToken) + + useEffect(() => { + onCompleteRef.current = onComplete + onErrorRef.current = onError + onRefreshTokenRef.current = onRefreshToken + }, [onComplete, onError, onRefreshToken]) + + // stable wrappers that read from refs + const stableOnComplete = useCallback(() => onCompleteRef.current(), []) + const stableOnError = useCallback((error: unknown) => onErrorRef.current?.(error), []) + const stableOnRefreshToken = useCallback(() => onRefreshTokenRef.current(), []) + + // load sumsub websdk script + useEffect(() => { + const existingScript = document.getElementById('sumsub-websdk') + if (existingScript) { + setSdkLoaded(true) + return + } + + const script = document.createElement('script') + script.id = 'sumsub-websdk' + script.src = SUMSUB_SDK_URL + script.async = true + script.onload = () => setSdkLoaded(true) + script.onerror = () => { + console.error('[sumsub] failed to load websdk script') + setSdkLoadError(true) + } + document.head.appendChild(script) + }, []) + + // initialize sdk when verification starts and all deps are ready + useEffect(() => { + if (!isVerificationStarted || !accessToken || !sdkLoaded || !sdkContainerRef.current) return + + // clean up previous instance + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + } + + try { + const handleSubmitted = () => { + console.log('[sumsub] onApplicantSubmitted fired') + stableOnComplete() + } + const handleResubmitted = () => { + console.log('[sumsub] onApplicantResubmitted fired') + stableOnComplete() + } + const handleStatusChanged = (payload: { + reviewStatus?: string + reviewResult?: { reviewAnswer?: string } + }) => { + console.log('[sumsub] onApplicantStatusChanged fired', payload) + // auto-close when sumsub shows success screen + if (payload?.reviewStatus === 'completed' && payload?.reviewResult?.reviewAnswer === 'GREEN') { + stableOnComplete() + } + } + + const sdk = window.snsWebSdk + .init(accessToken, stableOnRefreshToken) + .withConf({ lang: 'en', theme: 'light' }) + .withOptions({ addViewportTag: false, adaptIframeHeight: true }) + .on('onApplicantSubmitted', handleSubmitted) + .on('onApplicantResubmitted', handleResubmitted) + .on('onApplicantStatusChanged', handleStatusChanged) + // also listen for idCheck-prefixed events (some sdk versions use these) + .on('idCheck.onApplicantSubmitted', handleSubmitted) + .on('idCheck.onApplicantResubmitted', handleResubmitted) + .on('idCheck.onApplicantStatusChanged', handleStatusChanged) + .on('onError', (error: unknown) => { + console.error('[sumsub] sdk error', error) + stableOnError(error) + }) + .build() + + sdk.launch(sdkContainerRef.current) + sdkInstanceRef.current = sdk + } catch (error) { + console.error('[sumsub] failed to initialize sdk', error) + stableOnError(error) + } + + return () => { + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + sdkInstanceRef.current = null + } + } + }, [isVerificationStarted, accessToken, sdkLoaded, stableOnComplete, stableOnError, stableOnRefreshToken]) + + // reset state when modal closes, auto-start on re-submission + useEffect(() => { + if (!visible) { + setIsVerificationStarted(false) + setSdkLoadError(false) + if (sdkInstanceRef.current) { + try { + sdkInstanceRef.current.destroy() + } catch { + // ignore cleanup errors + } + sdkInstanceRef.current = null + } + } else if (autoStart) { + // skip StartVerificationView on re-submission (user already consented) + setIsVerificationStarted(true) + } + }, [visible, autoStart]) + + const modalDetails = useMemo(() => { + if (modalVariant === 'trouble') { + return { + title: 'Having trouble verifying?', + description: + "If the verification isn't loading or working properly, please contact our support team for help.", + icon: 'question-mark' as IconName, + iconContainerClassName: 'bg-primary-1', + ctas: [ + { + text: 'Chat with support', + icon: 'peanut-support' as IconName, + onClick: () => setIsSupportModalOpen(true), + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + ], + } + } + + return { + title: 'Stop verification?', + description: "If you exit now, your verification won't be completed and you'll need to start again later.", + icon: 'alert' as IconName, + iconContainerClassName: 'bg-secondary-1', + ctas: [ + { + text: 'Stop verification', + onClick: () => { + setIsHelpModalOpen(false) + onClose() + }, + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + { + text: 'Continue verifying', + onClick: () => setIsHelpModalOpen(false), + variant: 'transparent' as ButtonVariant, + className: 'underline text-sm font-medium w-full h-fit mt-3', + }, + ], + } + }, [modalVariant, onClose, setIsSupportModalOpen]) + + return ( + <> + + {!isVerificationStarted ? ( + setIsVerificationStarted(true)} + /> + ) : sdkLoadError ? ( +
+ +

+ Failed to load verification. Please check your connection and try again. +

+ +
+ ) : ( +
+
+
+
+ + +
+
+
+ )} + + {/* rendered outside the outer Modal to avoid pointer-events-none blocking clicks */} + setIsHelpModalOpen(false)} + title={modalDetails.title} + description={modalDetails.description} + icon={modalDetails.icon} + iconContainerClassName={modalDetails.iconContainerClassName} + modalPanelClassName="max-w-full" + ctaClassName="grid grid-cols-1 gap-3" + contentContainerClassName="px-6 py-6" + modalClassName="!z-[10001]" + preventClose={true} + ctas={modalDetails.ctas} + /> + + ) +} diff --git a/src/components/Kyc/modals/KycActionRequiredModal.tsx b/src/components/Kyc/modals/KycActionRequiredModal.tsx new file mode 100644 index 000000000..707f5ead6 --- /dev/null +++ b/src/components/Kyc/modals/KycActionRequiredModal.tsx @@ -0,0 +1,44 @@ +import ActionModal from '@/components/Global/ActionModal' +import { RejectLabelsList } from '../RejectLabelsList' + +interface KycActionRequiredModalProps { + visible: boolean + onClose: () => void + onResubmit: () => void + isLoading?: boolean + rejectLabels?: string[] | null +} + +// shown when user clicks a locked region while their kyc needs resubmission (soft reject) +export const KycActionRequiredModal = ({ + visible, + onClose, + onResubmit, + isLoading, + rejectLabels, +}: KycActionRequiredModalProps) => { + return ( + + +
+ } + ctas={[ + { + text: isLoading ? 'Loading...' : 'Re-submit verification', + icon: 'retry', + onClick: onResubmit, + disabled: isLoading, + shadowSize: '4', + }, + ]} + /> + ) +} diff --git a/src/components/Kyc/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx new file mode 100644 index 000000000..c904bd12d --- /dev/null +++ b/src/components/Kyc/modals/KycProcessingModal.tsx @@ -0,0 +1,27 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface KycProcessingModalProps { + visible: boolean + onClose: () => void +} + +// shown when user clicks a locked region while their kyc is pending/in review +export const KycProcessingModal = ({ visible, onClose }: KycProcessingModalProps) => { + return ( + + ) +} diff --git a/src/components/Kyc/modals/KycRejectedModal.tsx b/src/components/Kyc/modals/KycRejectedModal.tsx new file mode 100644 index 000000000..9d2aece39 --- /dev/null +++ b/src/components/Kyc/modals/KycRejectedModal.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import InfoCard from '@/components/Global/InfoCard' +import { RejectLabelsList } from '../RejectLabelsList' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +interface KycRejectedModalProps { + visible: boolean + onClose: () => void + onRetry: () => void + isLoading?: boolean + rejectLabels?: string[] | null + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number +} + +// shown when user clicks a locked region while their kyc is rejected +export const KycRejectedModal = ({ + visible, + onClose, + onRetry, + isLoading, + rejectLabels, + rejectType, + failureCount, +}: KycRejectedModalProps) => { + const { setIsSupportModalOpen } = useModalsContext() + + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) + + return ( + + + {isTerminal && ( + + )} + + } + ctas={[ + isTerminal + ? { + text: 'Contact support', + onClick: () => { + onClose() + setIsSupportModalOpen(true) + }, + shadowSize: '4', + } + : { + text: isLoading ? 'Loading...' : 'Retry verification', + icon: 'retry', + onClick: onRetry, + disabled: isLoading, + shadowSize: '4', + }, + ]} + /> + ) +} diff --git a/src/components/Kyc/states/KycActionRequired.tsx b/src/components/Kyc/states/KycActionRequired.tsx new file mode 100644 index 000000000..418f28842 --- /dev/null +++ b/src/components/Kyc/states/KycActionRequired.tsx @@ -0,0 +1,34 @@ +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { RejectLabelsList } from '../RejectLabelsList' +import { Button } from '@/components/0_Bruddle/Button' +import type { IconName } from '@/components/Global/Icons/Icon' + +// this component shows the kyc status when sumsub requires additional action from the user. +// displays specific rejection reasons when available (e.g. bad photo quality, expired doc). +export const KycActionRequired = ({ + onResume, + isLoading, + rejectLabels, +}: { + onResume: () => void + isLoading?: boolean + rejectLabels?: string[] | null +}) => { + return ( +
+ + + + + +
+ ) +} diff --git a/src/components/Kyc/states/KycCompleted.tsx b/src/components/Kyc/states/KycCompleted.tsx index 0c59259b9..a28420426 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -1,6 +1,8 @@ import Card from '@/components/Global/Card' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { BridgeTosReminder } from '../BridgeTosReminder' +import { useBridgeTosStatus } from '@/hooks/useBridgeTosStatus' import { useMemo } from 'react' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' @@ -21,6 +23,8 @@ export const KycCompleted = ({ countryCode?: string | null isBridge?: boolean }) => { + const { needsBridgeTos } = useBridgeTosStatus() + const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' try { @@ -34,6 +38,7 @@ export const KycCompleted = ({ return (
+ {needsBridgeTos && } diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 6c7a45fe6..991ff9a3f 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,47 +1,64 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { RejectLabelsList } from '../RejectLabelsList' 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' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' // this component shows the kyc status when it's failed/rejected. -// it displays the reason for the failure and provides a retry button. +// for sumsub: maps reject labels to human-readable reasons, handles terminal vs retryable states. +// for bridge: shows raw reason string as before. export const KycFailed = ({ - reason, + rejectLabels, + bridgeReason, + isSumsub, + rejectType, + failureCount, bridgeKycRejectedAt, countryCode, isBridge, onRetry, isLoading, }: { - reason: string | null + rejectLabels?: string[] | null + bridgeReason?: string | null + isSumsub?: boolean + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number bridgeKycRejectedAt?: string countryCode?: string | null isBridge?: boolean onRetry: () => void isLoading?: boolean }) => { + const { setIsSupportModalOpen } = useModalsContext() + const rejectedOn = useMemo(() => { if (!bridgeKycRejectedAt) return 'N/A' try { return formatDate(new Date(bridgeKycRejectedAt)) } catch (error) { - console.error('Failed to parse bridgeKycRejectedAt date:', error) + console.error('failed to parse bridgeKycRejectedAt date:', error) return 'N/A' } }, [bridgeKycRejectedAt]) - const formattedReason = useMemo(() => { - const reasonText = reason || 'There was an issue. Contact Support.' - // Split by actual newline characters (\n) or the escaped sequence (\\n) - const lines = reasonText.split(/\\n|\n/).filter((line) => line.trim() !== '') - - if (lines.length === 1) { - return reasonText - } + // only sumsub verifications can be terminal — bridge rejections always allow retry + const isTerminal = useMemo( + () => (isSumsub ? isTerminalRejection({ rejectType, failureCount, rejectLabels }) : false), + [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) => ( @@ -49,28 +66,49 @@ export const KycFailed = ({ ))}
) - }, [reason]) + }, [bridgeReason]) return (
+ - - - + {!isSumsub && } - + + {isSumsub && } + + {isTerminal ? ( +
+ + {/* TODO: auto-create crisp support ticket on terminal rejection */} + +
+ ) : ( + + )}
) } diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx new file mode 100644 index 000000000..16407bcea --- /dev/null +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -0,0 +1,45 @@ +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' +import { getRequirementLabel } from '@/constants/bridge-requirements.consts' + +// shows when a payment provider (bridge) needs additional documents from the user. +// displays the specific requirements with human-readable descriptions. +export const KycRequiresDocuments = ({ + requirements, + onSubmitDocuments, + isLoading, +}: { + requirements: string[] + onSubmitDocuments: () => void + isLoading?: boolean +}) => { + return ( +
+ + +
+

Your payment provider requires additional verification documents.

+ {requirements.length > 0 ? ( + requirements.map((req) => { + const label = getRequirementLabel(req) + return ( +
+

{label.title}

+

{label.description}

+
+ ) + }) + ) : ( +
+

Additional Document

+

Please provide the requested document.

+
+ )} +
+ + +
+ ) +} diff --git a/src/components/Profile/components/IdentityVerificationCountryList.tsx b/src/components/Profile/components/IdentityVerificationCountryList.tsx deleted file mode 100644 index 4146a8dad..000000000 --- a/src/components/Profile/components/IdentityVerificationCountryList.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client' -import { Icon } from '@/components/Global/Icons/Icon' -import { SearchInput } from '@/components/SearchInput' -import { getCountriesForRegion } from '@/utils/identityVerification' -import { MantecaSupportedExchanges } from '@/components/AddMoney/consts' -import StatusBadge from '@/components/Global/Badges/StatusBadge' -import { Button } from '@/components/0_Bruddle/Button' -import * as Accordion from '@radix-ui/react-accordion' -import { useRouter } from 'next/navigation' -import { useState } from 'react' -import CountryListSection from './CountryListSection' -import ActionModal from '@/components/Global/ActionModal' - -const IdentityVerificationCountryList = ({ region }: { region: string }) => { - const [searchTerm, setSearchTerm] = useState('') - const router = useRouter() - const [isUnavailableModalOpen, setIsUnavailableModalOpen] = useState(false) - const [selectedUnavailableCountry, setSelectedUnavailableCountry] = useState(null) - - const { supportedCountries, limitedAccessCountries, unsupportedCountries } = getCountriesForRegion(region) - - // Filter both arrays based on search term - const filteredSupportedCountries = supportedCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const filteredLimitedAccessCountries = limitedAccessCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const filteredUnsupportedCountries = unsupportedCountries.filter((country) => - country.title.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const isLatam = region === 'latam' - - return ( -
-
- setSearchTerm(e.target.value)} - onClear={() => setSearchTerm('')} - placeholder="Search by country name" - /> -
- - - { - if (isLatam) { - router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`) - } else { - router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`) - } - }} - rightContent={() => (isLatam ? undefined : )} - defaultOpen - /> - - { - // Check if country is in MantecaSupportedExchanges - const countryCode = country.iso2?.toUpperCase() - const isMantecaSupported = - countryCode && Object.prototype.hasOwnProperty.call(MantecaSupportedExchanges, countryCode) - - if (isMantecaSupported && isLatam) { - // Route to Manteca-specific KYC - router.push(`/profile/identity-verification/${region}/${encodeURIComponent(country.id)}`) - } else { - // Route to Bridge KYC for all other countries - router.push(`/profile/identity-verification/${region}/${encodeURIComponent('bridge')}`) - } - }} - rightContent={() => ( -
- - -
- )} - defaultOpen - /> - - {filteredUnsupportedCountries.length > 0 && ( - { - setSelectedUnavailableCountry(country.title) - setIsUnavailableModalOpen(true) - }} - rightContent={() => ( -
- -
- )} - defaultOpen - /> - )} -
- - { - setSelectedUnavailableCountry(null) - setIsUnavailableModalOpen(false) - }} - ctas={[ - { - text: 'I Understand', - shadowSize: '4', - onClick: () => { - setSelectedUnavailableCountry(null) - setIsUnavailableModalOpen(false) - }, - }, - ]} - /> -
- ) -} - -export default IdentityVerificationCountryList diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx deleted file mode 100644 index 91c402a04..000000000 --- a/src/components/Profile/views/IdentityVerification.view.tsx +++ /dev/null @@ -1,243 +0,0 @@ -'use client' -import { updateUserById } from '@/app/actions/users' -import { Button } from '@/components/0_Bruddle/Button' -import { countryData } from '@/components/AddMoney/consts' -import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' -import { CountryList } from '@/components/Common/CountryList' -import ErrorAlert from '@/components/Global/ErrorAlert' -import IframeWrapper from '@/components/Global/IframeWrapper' -import NavHeader from '@/components/Global/NavHeader' -import { - KycVerificationInProgressModal, - PeanutDoesntStoreAnyPersonalInformation, -} from '@/components/Kyc/KycVerificationInProgressModal' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useAuth } from '@/context/authContext' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' -import { useParams, useRouter } from 'next/navigation' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import useKycStatus from '@/hooks/useKycStatus' -import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils' -import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' - -const IdentityVerificationView = () => { - const router = useRouter() - const formRef = useRef<{ handleSubmit: () => void }>(null) - const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) - const [isUpdatingUser, setIsUpdatingUser] = useState(false) - const [userUpdateError, setUserUpdateError] = useState(null) - const [showUserDetailsForm, setShowUserDetailsForm] = useState(false) - const [isMantecaModalOpen, setIsMantecaModalOpen] = useState(false) - const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null) - const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null) - const { isUserBridgeKycApproved } = useKycStatus() - const { user, fetchUser } = useAuth() - const [isStartVerificationModalOpen, setIsStartVerificationModalOpen] = useState(false) - const params = useParams() - const countryParam = params.country as string - const { isMantecaSupportedCountry, isBridgeSupportedCountry } = useIdentityVerification() - - const handleRedirect = () => { - const redirectUrl = getRedirectUrl() - if (redirectUrl) { - clearRedirectUrl() - router.push(redirectUrl) - } else { - router.push('/profile') - } - } - - const handleBridgeKycSuccess = useCallback(async () => { - await fetchUser() - handleRedirect() - }, []) - - const { - iframeOptions, - handleInitiateKyc, - isVerificationProgressModalOpen, - handleIframeClose, - closeVerificationProgressModal, - error: kycError, - isLoading: isKycLoading, - } = useBridgeKycFlow({ - onKycSuccess: handleBridgeKycSuccess, - }) - - const initialUserDetails: Partial = useMemo( - () => ({ - fullName: user?.user.fullName ?? '', - email: user?.user.email ?? '', - }), - [user] - ) - - const handleUserDetailsSubmit = useCallback( - async (data: UserDetailsFormData) => { - setIsUpdatingUser(true) - setUserUpdateError(null) - try { - if (!user?.user.userId) throw new Error('User not found') - const result = await updateUserById({ - userId: user.user.userId, - fullName: data.fullName, - email: data.email, - }) - if (result.error) { - throw new Error(result.error) - } - await fetchUser() - await handleInitiateKyc() - } catch (error: any) { - setUserUpdateError(error.message) - return { error: error.message } - } finally { - setIsUpdatingUser(false) - } - return {} - }, - [user] - ) - - const handleBack = useCallback(() => { - if (showUserDetailsForm) { - setShowUserDetailsForm(false) - } else { - handleRedirect() - } - }, [showUserDetailsForm]) - - // Bridge country object for all bridge supported countries - const bridgeCountryObject = useMemo( - () => ({ title: 'Bridge', id: 'bridge', type: 'bridge', description: '', path: 'bridge' }), - [] - ) - - // Memoized country lookup from URL param - const selectedCountryParams = useMemo(() => { - if (countryParam) { - const country = countryData.find((country) => country.id.toUpperCase() === countryParam.toUpperCase()) - if (country) { - return country - } else { - return bridgeCountryObject - } - } - return null - }, [countryParam, bridgeCountryObject]) - - // Skip country selection if coming from a supported bridge country - useEffect(() => { - if (selectedCountryParams && (isBridgeSupportedCountry(countryParam) || countryParam === 'bridge')) { - setUserClickedCountry({ title: selectedCountryParams.title, id: selectedCountryParams.id }) - setIsStartVerificationModalOpen(true) - } - }, [countryParam, isBridgeSupportedCountry, selectedCountryParams]) - - useEffect(() => { - return () => { - setIsStartVerificationModalOpen(false) - } - }, []) - - return ( -
- - - {showUserDetailsForm ? ( -
-

Provide information to begin verification

- - - - - - - - {(userUpdateError || kycError) && } - - - - { - closeVerificationProgressModal() - handleRedirect() - }} - /> -
- ) : ( -
- { - const { id, title } = country - setUserClickedCountry({ id, title }) - setIsStartVerificationModalOpen(true) - }} - showLoadingState={false} // we don't want to show loading state when clicking a country, here because there is no async operation when clicking a country - /> -
- )} - - {selectedCountry && ( - - )} - - {userClickedCountry && selectedCountryParams && ( - { - // we dont show ID issuer country list for bridge countries - if ( - isBridgeSupportedCountry(selectedCountryParams.id) || - selectedCountryParams.id === 'bridge' - ) { - handleRedirect() - } else { - setIsStartVerificationModalOpen(false) - } - }} - onStartVerification={() => { - setIsStartVerificationModalOpen(false) - if (isMantecaSupportedCountry(userClickedCountry.id)) { - setSelectedCountry(userClickedCountry) - setIsMantecaModalOpen(true) - } else { - setShowUserDetailsForm(true) - } - }} - selectedIdentityCountry={userClickedCountry} - selectedCountry={selectedCountryParams} - /> - )} -
- ) -} - -export default IdentityVerificationView diff --git a/src/components/Profile/views/RegionsPage.view.tsx b/src/components/Profile/views/RegionsPage.view.tsx deleted file mode 100644 index af40c49c5..000000000 --- a/src/components/Profile/views/RegionsPage.view.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import NavHeader from '@/components/Global/NavHeader' -import { useIdentityVerification } from '@/hooks/useIdentityVerification' -import { useRouter } from 'next/navigation' -import IdentityVerificationCountryList from '../components/IdentityVerificationCountryList' -import { Button } from '@/components/0_Bruddle/Button' - -const RegionsPage = ({ path }: { path: string }) => { - const router = useRouter() - const { lockedRegions } = useIdentityVerification() - - const hideVerifyButtonPaths = ['latam', 'rest-of-the-world'] - - const region = lockedRegions.find((region) => region.path === path) - - if (!region) { - return null - } - - return ( -
-
- router.back()} /> - - -
- {!hideVerifyButtonPaths.includes(region.path) && ( -
- -
- )} -
- ) -} - -export default RegionsPage diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 9323f71be..1a48238eb 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -5,13 +5,140 @@ import { getCardPosition } from '@/components/Global/Card/card.utils' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' -import { useIdentityVerification, type Region } from '@/hooks/useIdentityVerification' +import StartVerificationModal from '@/components/IdentityVerification/StartVerificationModal' +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' +import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' +import { KycRejectedModal } from '@/components/Kyc/modals/KycRejectedModal' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' +import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' +import { useAuth } from '@/context/authContext' import Image from 'next/image' import { useRouter } from 'next/navigation' +import { useState, useCallback, useRef, useMemo } from 'react' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' + +type ModalVariant = 'start' | 'processing' | 'action_required' | 'rejected' + +// determine which modal to show based on sumsub status and clicked region intent +function getModalVariant( + sumsubStatus: string | null, + clickedRegionIntent: KYCRegionIntent | undefined, + existingRegionIntent: string | null +): ModalVariant { + // no verification or not started → start fresh + if (!sumsubStatus || sumsubStatus === 'NOT_STARTED') return 'start' + + // different region intent → allow new verification + if (existingRegionIntent && clickedRegionIntent && clickedRegionIntent !== existingRegionIntent) return 'start' + + switch (sumsubStatus) { + case 'PENDING': + case 'IN_REVIEW': + return 'processing' + case 'ACTION_REQUIRED': + return 'action_required' + case 'REJECTED': + case 'FAILED': + return 'rejected' + default: + return 'start' + } +} const RegionsVerification = () => { const router = useRouter() + const { user, fetchUser } = useAuth() const { unlockedRegions, lockedRegions } = useIdentityVerification() + const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent } = useUnifiedKycStatus() + const [selectedRegion, setSelectedRegion] = useState(null) + // keeps the region display stable during modal close animation + const displayRegionRef = useRef(null) + if (selectedRegion) displayRegionRef.current = selectedRegion + // persist region intent for the duration of the kyc session so token refresh + // and status checks use the correct template after the confirmation modal closes + const [activeRegionIntent, setActiveRegionIntent] = useState(undefined) + const [showBridgeTos, setShowBridgeTos] = useState(false) + // skip StartVerificationView when re-submitting (user already consented) + const [autoStartSdk, setAutoStartSdk] = useState(false) + + const sumsubFailureCount = useMemo( + () => + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0, + [user] + ) + + const clickedRegionIntent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined + const modalVariant = selectedRegion + ? getModalVariant(sumsubStatus, clickedRegionIntent, sumsubVerificationRegionIntent) + : null + + const handleFinalKycSuccess = useCallback(() => { + setSelectedRegion(null) + setActiveRegionIntent(undefined) + setShowBridgeTos(false) + setAutoStartSdk(false) + }, []) + + // intercept sumsub approval to check for bridge ToS + const handleKycApproved = useCallback(async () => { + const updatedUser = await fetchUser() + const rails = updatedUser?.rails ?? [] + const bridgeNeedsTos = rails.some( + (r) => r.rail.provider.code === 'BRIDGE' && r.status === 'REQUIRES_INFORMATION' + ) + + if (bridgeNeedsTos) { + setShowBridgeTos(true) + } else { + handleFinalKycSuccess() + } + }, [fetchUser, handleFinalKycSuccess]) + + const { + isLoading, + error, + showWrapper, + accessToken, + handleInitiateKyc, + handleSdkComplete, + handleClose: handleSumsubClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + } = useSumsubKycFlow({ + regionIntent: activeRegionIntent, + onKycSuccess: handleKycApproved, + onManualClose: () => { + setSelectedRegion(null) + setActiveRegionIntent(undefined) + setAutoStartSdk(false) + }, + }) + + const handleRegionClick = useCallback((region: Region) => { + setSelectedRegion(region) + }, []) + + const handleModalClose = useCallback(() => { + setSelectedRegion(null) + }, []) + + const handleStartKyc = useCallback(async () => { + const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined + if (intent) setActiveRegionIntent(intent) + setSelectedRegion(null) + await handleInitiateKyc(intent) + }, [handleInitiateKyc, selectedRegion]) + + // re-submission: skip StartVerificationView since user already consented + const handleResubmitKyc = useCallback(async () => { + setAutoStartSdk(true) + await handleStartKyc() + }, [handleStartKyc]) return (
@@ -37,11 +164,61 @@ const RegionsVerification = () => { -

Locked regions

-

Where do you want to send and receive money?

+ {lockedRegions.length > 0 && ( + <> +

Locked regions

+

Where do you want to send and receive money?

- + + + )}
+ + + + + + + + + + {error &&

{error}

} + + + + + +
) } @@ -51,9 +228,9 @@ export default RegionsVerification interface RegionsListProps { regions: Region[] isLocked: boolean + onRegionClick?: (region: Region) => void } -const RegionsList = ({ regions, isLocked }: RegionsListProps) => { - const router = useRouter() +const RegionsList = ({ regions, isLocked, onRegionClick }: RegionsListProps) => { return (
{regions.map((region, index) => ( @@ -71,8 +248,8 @@ const RegionsList = ({ regions, isLocked }: RegionsListProps) => { position={getCardPosition(index, regions.length)} title={region.name} onClick={() => { - if (isLocked) { - router.push(`/profile/identity-verification/${region.path}`) + if (isLocked && onRegionClick) { + onRegionClick(region) } }} isDisabled={!isLocked} diff --git a/src/constants/bridge-requirements.consts.ts b/src/constants/bridge-requirements.consts.ts new file mode 100644 index 000000000..d50ae2868 --- /dev/null +++ b/src/constants/bridge-requirements.consts.ts @@ -0,0 +1,41 @@ +interface RequirementLabelInfo { + title: string + description: string +} + +// map of bridge additional_requirements to user-friendly labels +const BRIDGE_REQUIREMENT_LABELS: Record = { + proof_of_address: { + title: 'Proof of Address', + description: + 'Upload a utility bill, bank statement, or government letter showing your current address (dated within 3 months).', + }, + additional_identity_document: { + title: 'Additional Identity Document', + description: 'Upload an additional government-issued ID document.', + }, + proof_of_source_of_funds: { + title: 'Proof of Source of Funds', + description: 'Upload documentation showing the origin of your funds (e.g. pay stub, tax return).', + }, + proof_of_tax_identification: { + title: 'Tax Identification', + description: 'Upload a document showing your tax identification number.', + }, +} + +const FALLBACK_LABEL: RequirementLabelInfo = { + title: 'Additional Document', + description: 'Please provide the requested document.', +} + +/** get human-readable label for a bridge additional requirement */ +export function getRequirementLabel(requirement: string): RequirementLabelInfo { + return ( + BRIDGE_REQUIREMENT_LABELS[requirement] ?? { + // auto-format unknown requirement codes as title case + title: requirement.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), + description: FALLBACK_LABEL.description, + } + ) +} diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts new file mode 100644 index 000000000..f8fc5d507 --- /dev/null +++ b/src/constants/kyc.consts.ts @@ -0,0 +1,59 @@ +import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types' +import { type MantecaKycStatus } from '@/interfaces' + +/** + * unified kyc status type across all providers. + * bridge uses lowercase strings, manteca uses its own enum, sumsub uses uppercase. + */ +export type KycVerificationStatus = MantecaKycStatus | SumsubKycStatus | string + +export type KycStatusCategory = 'completed' | 'processing' | 'failed' | 'action_required' + +// sets of status values by category — single source of truth +const APPROVED_STATUSES: ReadonlySet = new Set(['approved', 'ACTIVE', 'APPROVED']) +const FAILED_STATUSES: ReadonlySet = new Set(['rejected', 'INACTIVE', 'REJECTED']) +const PENDING_STATUSES: ReadonlySet = new Set([ + 'under_review', + 'incomplete', + 'ONBOARDING', + 'PENDING', + 'IN_REVIEW', +]) +const ACTION_REQUIRED_STATUSES: ReadonlySet = new Set(['ACTION_REQUIRED']) +const NOT_STARTED_STATUSES: ReadonlySet = new Set(['not_started', 'NOT_STARTED']) + +// sumsub-specific set for flow-level gating (e.g. useQrKycGate blocks payments). +// ACTION_REQUIRED is intentionally included here — user hasn't completed verification +// yet, so they should still be gated from features that require approved kyc. +const SUMSUB_IN_PROGRESS_STATUSES: ReadonlySet = new Set(['PENDING', 'IN_REVIEW', 'ACTION_REQUIRED']) + +/** check if a kyc status represents an approved/completed state */ +export const isKycStatusApproved = (status: string | undefined | null): boolean => + !!status && APPROVED_STATUSES.has(status) + +/** check if a kyc status represents a failed/rejected state */ +export const isKycStatusFailed = (status: string | undefined | null): boolean => !!status && FAILED_STATUSES.has(status) + +/** check if a kyc status represents a pending/in-review state */ +export const isKycStatusPending = (status: string | undefined | null): boolean => + !!status && PENDING_STATUSES.has(status) + +/** check if a kyc status represents an action-required state */ +export const isKycStatusActionRequired = (status: string | undefined | null): boolean => + !!status && ACTION_REQUIRED_STATUSES.has(status) + +/** check if a kyc status means "not started" (should not render status ui) */ +export const isKycStatusNotStarted = (status: string | undefined | null): boolean => + !status || NOT_STARTED_STATUSES.has(status) + +/** check if a sumsub status means verification is in progress */ +export const isSumsubStatusInProgress = (status: string | undefined | null): boolean => + !!status && SUMSUB_IN_PROGRESS_STATUSES.has(status) + +/** categorize any provider's kyc status into a display category */ +export const getKycStatusCategory = (status: string): KycStatusCategory => { + if (APPROVED_STATUSES.has(status)) return 'completed' + if (FAILED_STATUSES.has(status)) return 'failed' + if (ACTION_REQUIRED_STATUSES.has(status)) return 'action_required' + return 'processing' +} diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts new file mode 100644 index 000000000..ab641bd76 --- /dev/null +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -0,0 +1,101 @@ +interface RejectLabelInfo { + title: string + description: string +} + +// map of sumsub reject labels to user-friendly descriptions +const REJECT_LABEL_MAP: Record = { + DOCUMENT_BAD_QUALITY: { + title: 'Low quality document', + description: 'The document image was blurry, dark, or hard to read. Please upload a clearer photo.', + }, + DOCUMENT_DAMAGED: { + title: 'Damaged document', + description: 'The document appears damaged or worn. Please use a document in good condition.', + }, + DOCUMENT_INCOMPLETE: { + title: 'Incomplete document', + description: 'Part of the document was cut off or missing. Make sure the full document is visible.', + }, + DOCUMENT_MISSING: { + title: 'Missing document', + description: 'A required document was not provided. Please upload all requested documents.', + }, + DOCUMENT_EXPIRED: { + title: 'Expired document', + description: 'The document has expired. Please use a valid, non-expired document.', + }, + 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.', + }, + SELFIE_BAD_QUALITY: { + title: 'Low quality selfie', + description: 'The selfie was blurry or poorly lit. Please take a clear, well-lit selfie.', + }, + SELFIE_SPOOFING: { + title: 'Selfie issue detected', + description: 'A live selfie is required. Do not use a photo of a photo or a screen.', + }, + DOCUMENT_FAKE: { + title: 'Document could not be verified', + description: 'We were unable to verify the authenticity of your document.', + }, + GRAPHIC_EDITOR_USAGE: { + title: 'Edited document detected', + description: 'The document appears to have been digitally altered.', + }, + 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.', + }, +} + +const FALLBACK_LABEL_INFO: RejectLabelInfo = { + title: 'Verification issue', + description: 'There was an issue with your verification. Please try again or contact support.', +} + +// 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']) + +/** get human-readable info for a sumsub reject label, with a safe fallback */ +export const getRejectLabelInfo = (label: string): RejectLabelInfo => { + return REJECT_LABEL_MAP[label] ?? FALLBACK_LABEL_INFO +} + +/** check if any of the reject labels indicate a terminal (permanent) rejection */ +export const hasTerminalRejectLabel = (labels: string[]): boolean => { + return labels.some((label) => TERMINAL_REJECT_LABELS.has(label)) +} + +const MAX_RETRY_COUNT = 2 + +/** determine if a rejection is terminal (permanent, cannot be retried) */ +export const isTerminalRejection = ({ + rejectType, + failureCount, + rejectLabels, +}: { + rejectType?: 'RETRY' | 'FINAL' | null + failureCount?: number + rejectLabels?: string[] | null +}): boolean => { + if (rejectType === 'FINAL') return true + if (failureCount && failureCount >= MAX_RETRY_COUNT) return true + if (rejectLabels?.length && hasTerminalRejectLabel(rejectLabels)) return true + return false +} diff --git a/src/features/limits/views/LimitsPageView.tsx b/src/features/limits/views/LimitsPageView.tsx index 27c4ec23c..4ea063318 100644 --- a/src/features/limits/views/LimitsPageView.tsx +++ b/src/features/limits/views/LimitsPageView.tsx @@ -175,7 +175,7 @@ const LockedRegionsList = ({ regions, isBridgeKycPending }: LockedRegionsListPro title={region.name} onClick={() => { if (!isPending) { - router.push(`/profile/identity-verification/${region.path}`) + router.push('/profile/identity-verification') } }} isDisabled={isPending} diff --git a/src/hooks/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts new file mode 100644 index 000000000..46f8b86e5 --- /dev/null +++ b/src/hooks/useBridgeTosStatus.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react' +import { useUserStore } from '@/redux/hooks' +import { type IUserRail } from '@/interfaces' + +// derives bridge ToS status from the user's rails array +export const useBridgeTosStatus = () => { + const { user } = useUserStore() + + 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 isBridgeFullyEnabled = bridgeRails.length > 0 && bridgeRails.every((r) => r.status === 'ENABLED') + + return { needsBridgeTos, isBridgeFullyEnabled, bridgeRails } + }, [user?.rails]) +} diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 228722ac6..e3f396532 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -1,10 +1,12 @@ import { EUROPE_GLOBE_ICON, LATAM_GLOBE_ICON, NORTH_AMERICA_GLOBE_ICON, REST_OF_WORLD_GLOBE_ICON } from '@/assets' import type { StaticImageData } from 'next/image' import useKycStatus from './useKycStatus' +import useUnifiedKycStatus from './useUnifiedKycStatus' import { useMemo, useCallback } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' import { BRIDGE_ALPHA3_TO_ALPHA2, MantecaSupportedExchanges, countryData } from '@/components/AddMoney/consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' import React from 'react' /** Represents a geographic region with its display information */ @@ -75,6 +77,11 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ }, ] +/** maps a region path to the sumsub kyc template intent */ +export const getRegionIntent = (regionPath: string): KYCRegionIntent => { + return regionPath === 'latam' ? 'LATAM' : 'STANDARD' +} + /** * Hook for managing identity verification (KYC) status and region access. * @@ -96,7 +103,8 @@ const BRIDGE_SUPPORTED_LATAM_COUNTRIES: Region[] = [ */ export const useIdentityVerification = () => { const { user } = useAuth() - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { sumsubVerificationRegionIntent } = useUnifiedKycStatus() /** * Check if a country is supported by Manteca (LATAM countries). @@ -149,9 +157,20 @@ export const useIdentityVerification = () => { const { lockedRegions, unlockedRegions } = useMemo(() => { const isBridgeApproved = isUserBridgeKycApproved const isMantecaApproved = isUserMantecaKycApproved + const isSumsubApproved = isUserSumsubKycApproved - // Helper to check if a region should be unlocked + // 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. + if (isSumsubApproved) { + if (sumsubVerificationRegionIntent === 'LATAM') { + return MANTECA_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } + // STANDARD intent covers bridge regions + rest of world + return BRIDGE_SUPPORTED_REGIONS.includes(regionName) || regionName === 'Rest of the world' + } return ( (isBridgeApproved && BRIDGE_SUPPORTED_REGIONS.includes(regionName)) || (isMantecaApproved && MANTECA_SUPPORTED_REGIONS.includes(regionName)) @@ -161,9 +180,9 @@ export const useIdentityVerification = () => { const unlocked = SUPPORTED_REGIONS.filter((region) => isRegionUnlocked(region.name)) const locked = SUPPORTED_REGIONS.filter((region) => !isRegionUnlocked(region.name)) - // Bridge users get QR payment access in Argentina & Brazil - // even without full Manteca KYC (which unlocks bank transfers too) - if (isBridgeApproved && !isMantecaApproved) { + // bridge users get qr payment access in argentina & brazil + // even without full manteca kyc (which unlocks bank transfers too) + if (isBridgeApproved && !isMantecaApproved && !isSumsubApproved) { unlocked.push(...MANTECA_QR_ONLY_REGIONS, ...BRIDGE_SUPPORTED_LATAM_COUNTRIES) } @@ -171,7 +190,7 @@ export const useIdentityVerification = () => { lockedRegions: locked, unlockedRegions: unlocked, } - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isUserBridgeKycApproved, isUserMantecaKycApproved, isUserSumsubKycApproved, sumsubVerificationRegionIntent]) /** * Check if a region is already unlocked by comparing region paths. diff --git a/src/hooks/useKycStatus.tsx b/src/hooks/useKycStatus.tsx index 0b6eb9c5f..36b2909f3 100644 --- a/src/hooks/useKycStatus.tsx +++ b/src/hooks/useKycStatus.tsx @@ -1,36 +1,20 @@ 'use client' -import { useAuth } from '@/context/authContext' -import { MantecaKycStatus } from '@/interfaces' -import { useMemo } from 'react' +import useUnifiedKycStatus from './useUnifiedKycStatus' /** - * Used to get the user's KYC status for all providers - currently only bridge and manteca - * NOTE: This hook can be extended to support more providers in the future based on requirements - * @returns {object} An object with the user's KYC status for all providers and a combined status for all providers, if user is verified for any provider, return true + * thin wrapper around useUnifiedKycStatus for backward compatibility. + * existing consumers keep the same api shape. */ export default function useKycStatus() { - const { user } = useAuth() + const { isBridgeApproved, isMantecaApproved, isSumsubApproved, isKycApproved, isBridgeUnderReview } = + useUnifiedKycStatus() - const isUserBridgeKycApproved = useMemo(() => user?.user.bridgeKycStatus === 'approved', [user]) - - const isUserMantecaKycApproved = useMemo( - () => - user?.user.kycVerifications?.some((verification) => verification.status === MantecaKycStatus.ACTIVE) ?? - false, - [user] - ) - - const isUserKycApproved = useMemo( - () => isUserBridgeKycApproved || isUserMantecaKycApproved, - [isUserBridgeKycApproved, isUserMantecaKycApproved] - ) - - const isUserBridgeKycUnderReview = useMemo( - // Bridge kyc status is incomplete/under_review when user has started the kyc process - () => user?.user.bridgeKycStatus === 'under_review' || user?.user.bridgeKycStatus === 'incomplete', - [user] - ) - - return { isUserBridgeKycApproved, isUserMantecaKycApproved, isUserKycApproved, isUserBridgeKycUnderReview } + return { + isUserBridgeKycApproved: isBridgeApproved, + isUserMantecaKycApproved: isMantecaApproved, + isUserSumsubKycApproved: isSumsubApproved, + isUserKycApproved: isKycApproved, + isUserBridgeKycUnderReview: isBridgeUnderReview, + } } diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index c025d3561..747ef0e78 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -3,7 +3,7 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' -import { getBridgeCustomerCountry } from '@/app/actions/bridge/get-customer' +import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts' export enum QrKycState { LOADING = 'loading', @@ -61,6 +61,16 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } + // sumsub approved users (including foreign users) can proceed to qr pay. + // note: backend enforces per-rail access separately — frontend gate only checks identity verification. + const hasSumsubApproved = currentUser.kycVerifications?.some( + (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status) + ) + if (hasSumsubApproved) { + setKycGateState(QrKycState.PROCEED_TO_PAY) + return + } + const mantecaKycs = currentUser.kycVerifications?.filter((v) => v.provider === 'MANTECA') ?? [] const hasAnyMantecaKyc = mantecaKycs.length > 0 @@ -73,18 +83,8 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } - if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) { - try { - const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId) - // if (countryCode && countryCode.toUpperCase() === 'AR') { - if (false) { - } else { - setKycGateState(QrKycState.PROCEED_TO_PAY) - } - } catch { - // fail to require identity verification to avoid blocking pay due to rare outages - setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - } + if (currentUser.bridgeKycStatus === 'approved') { + setKycGateState(QrKycState.PROCEED_TO_PAY) return } @@ -100,6 +100,15 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | 'SIMPLEFI' | null): return } + // sumsub verification in progress + const hasSumsubInProgress = currentUser.kycVerifications?.some( + (v) => v.provider === 'SUMSUB' && isSumsubStatusInProgress(v.status) + ) + if (hasSumsubInProgress) { + setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) + return + } + setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) }, [user?.user, isFetchingUser, paymentProcessor, fetchUser]) diff --git a/src/hooks/useRailStatusTracking.ts b/src/hooks/useRailStatusTracking.ts new file mode 100644 index 000000000..10d3588ad --- /dev/null +++ b/src/hooks/useRailStatusTracking.ts @@ -0,0 +1,170 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react' +import { useUserStore } from '@/redux/hooks' +import { useAuth } from '@/context/authContext' +import { useWebSocket } from '@/hooks/useWebSocket' +import { type IUserRail, type ProviderDisplayStatus, type ProviderStatus } from '@/interfaces' +import { type RailStatusUpdate } from '@/services/websocket' + +interface RailStatusTrackingResult { + providers: ProviderStatus[] + allSettled: boolean + needsBridgeTos: boolean + needsAdditionalDocs: boolean + startTracking: () => void + stopTracking: () => void +} + +const POLL_INTERVAL_MS = 4000 + +// human-readable labels for provider groups +const PROVIDER_LABELS: Record = { + BRIDGE: 'Bank transfers', + MANTECA: 'QR payments and bank transfers', +} + +function deriveProviderDisplayName(providerCode: string, rails: IUserRail[]): string { + const base = PROVIDER_LABELS[providerCode] ?? providerCode + // add country context from rail methods + const countries = [...new Set(rails.map((r) => r.rail.method.country).filter(Boolean))] + if (countries.length > 0) { + return `${base} (${countries.join(', ')})` + } + return base +} + +function deriveStatus(rail: IUserRail): ProviderDisplayStatus { + switch (rail.status) { + case 'ENABLED': + return 'enabled' + case 'REQUIRES_EXTRA_INFORMATION': + return 'requires_documents' + case 'REQUIRES_INFORMATION': + return 'requires_tos' + case 'FAILED': + case 'REJECTED': + return 'failed' + case 'PENDING': + default: + return 'setting_up' + } +} + +// pick the "most advanced" status for a provider group +function deriveGroupStatus(rails: IUserRail[]): ProviderDisplayStatus { + const statuses = rails.map(deriveStatus) + // priority: requires_documents > requires_tos > enabled > failed > setting_up + if (statuses.includes('requires_documents')) return 'requires_documents' + if (statuses.includes('requires_tos')) return 'requires_tos' + if (statuses.includes('enabled')) return 'enabled' + if (statuses.includes('failed')) return 'failed' + return 'setting_up' +} + +export const useRailStatusTracking = (): RailStatusTrackingResult => { + const { user } = useUserStore() + const { fetchUser } = useAuth() + const [isTracking, setIsTracking] = useState(false) + const pollTimerRef = useRef(null) + const isMountedRef = useRef(true) + + // listen for rail status WebSocket events + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: isTracking, + onRailStatusUpdate: useCallback( + (_data: RailStatusUpdate) => { + // refetch user to get updated rails from server + if (isTracking) { + fetchUser() + } + }, + [isTracking, fetchUser] + ), + }) + + // derive provider statuses from current rails + const providers = useMemo((): ProviderStatus[] => { + const rails: IUserRail[] = user?.rails ?? [] + if (rails.length === 0) return [] + + // group by provider + const byProvider = new Map() + for (const rail of rails) { + const code = rail.rail.provider.code + const list = byProvider.get(code) ?? [] + list.push(rail) + byProvider.set(code, list) + } + + return Array.from(byProvider.entries()).map(([code, providerRails]) => ({ + providerCode: code, + displayName: deriveProviderDisplayName(code, providerRails), + status: deriveGroupStatus(providerRails), + rails: providerRails, + })) + }, [user?.rails]) + + const allSettled = useMemo(() => { + if (providers.length === 0) return false + return providers.every((p) => p.status !== 'setting_up') + }, [providers]) + + const needsBridgeTos = useMemo(() => { + return providers.some((p) => p.providerCode === 'BRIDGE' && p.status === 'requires_tos') + }, [providers]) + + const needsAdditionalDocs = useMemo(() => { + return providers.some((p) => p.status === 'requires_documents') + }, [providers]) + + // stop polling when all settled + useEffect(() => { + if (allSettled && isTracking) { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, [allSettled, isTracking]) + + const startTracking = useCallback(() => { + setIsTracking(true) + + // start polling as fallback + if (pollTimerRef.current) clearInterval(pollTimerRef.current) + pollTimerRef.current = setInterval(() => { + if (isMountedRef.current) { + fetchUser() + } + }, POLL_INTERVAL_MS) + }, [fetchUser]) + + const stopTracking = useCallback(() => { + setIsTracking(false) + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + // cleanup on unmount + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, []) + + return { + providers, + allSettled, + needsBridgeTos, + needsAdditionalDocs, + startTracking, + stopTracking, + } +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts new file mode 100644 index 000000000..7ea320706 --- /dev/null +++ b/src/hooks/useSumsubKycFlow.ts @@ -0,0 +1,190 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { useWebSocket } from '@/hooks/useWebSocket' +import { useUserStore } from '@/redux/hooks' +import { initiateSumsubKyc } from '@/app/actions/sumsub' +import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' + +interface UseSumsubKycFlowOptions { + onKycSuccess?: () => void + onManualClose?: () => void + regionIntent?: KYCRegionIntent +} + +export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseSumsubKycFlowOptions = {}) => { + const { user } = useUserStore() + const router = useRouter() + + const [accessToken, setAccessToken] = useState(null) + const [showWrapper, setShowWrapper] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false) + const [liveKycStatus, setLiveKycStatus] = useState(undefined) + const [rejectLabels, setRejectLabels] = useState(undefined) + const prevStatusRef = useRef(liveKycStatus) + // tracks the effective region intent across initiate + refresh so the correct template is always used + const regionIntentRef = useRef(regionIntent) + // tracks the level name across initiate + refresh (e.g. 'peanut-additional-docs') + const levelNameRef = useRef(undefined) + + useEffect(() => { + regionIntentRef.current = regionIntent + }, [regionIntent]) + + // listen for sumsub kyc status updates via websocket + useWebSocket({ + username: user?.user.username ?? undefined, + autoConnect: true, + onSumsubKycStatusUpdate: (newStatus, newRejectLabels) => { + setLiveKycStatus(newStatus as SumsubKycStatus) + if (newRejectLabels) setRejectLabels(newRejectLabels) + }, + }) + + // react to status transitions + useEffect(() => { + const prevStatus = prevStatusRef.current + prevStatusRef.current = liveKycStatus + + if (prevStatus !== 'APPROVED' && liveKycStatus === 'APPROVED') { + onKycSuccess?.() + } else if ( + liveKycStatus && + liveKycStatus !== prevStatus && + liveKycStatus !== 'APPROVED' && + liveKycStatus !== 'PENDING' + ) { + // close modal for any non-success terminal state (REJECTED, ACTION_REQUIRED, FAILED, etc.) + setIsVerificationProgressModalOpen(false) + } + }, [liveKycStatus, onKycSuccess]) + + // fetch current status to recover from missed websocket events. + // skip when regionIntent is undefined to avoid creating an applicant with the wrong template + // (e.g. RegionsVerification mounts with no region selected yet). + useEffect(() => { + if (!regionIntent) return + + const fetchCurrentStatus = async () => { + try { + const response = await initiateSumsubKyc({ regionIntent }) + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + } catch { + // silent failure - we just show the user an error when they try to initiate the kyc flow if the api call is failing + } + } + + fetchCurrentStatus() + }, [regionIntent]) + + const handleInitiateKyc = useCallback( + async (overrideIntent?: KYCRegionIntent, levelName?: string) => { + setIsLoading(true) + setError(null) + + try { + const response = await initiateSumsubKyc({ + regionIntent: overrideIntent ?? regionIntent, + levelName, + }) + + if (response.error) { + setError(response.error) + return + } + + // sync status from api response + if (response.data?.status) { + setLiveKycStatus(response.data.status) + } + + // update effective intent + level for token refresh + const effectiveIntent = overrideIntent ?? regionIntent + if (effectiveIntent) regionIntentRef.current = effectiveIntent + levelNameRef.current = levelName + + // if already approved, no token is returned. + // set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time. + if (response.data?.status === 'APPROVED') { + prevStatusRef.current = 'APPROVED' + onKycSuccess?.() + return + } + + if (response.data?.token) { + setAccessToken(response.data.token) + setShowWrapper(true) + } else { + setError('Could not initiate verification. Please try again.') + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) + } finally { + setIsLoading(false) + } + }, + [regionIntent, onKycSuccess] + ) + + // called when sdk signals applicant submitted + const handleSdkComplete = useCallback(() => { + setShowWrapper(false) + setIsVerificationProgressModalOpen(true) + }, []) + + // called when user manually closes the sdk modal + const handleClose = useCallback(() => { + setShowWrapper(false) + onManualClose?.() + }, [onManualClose]) + + // token refresh function passed to the sdk for when the token expires. + // uses regionIntentRef + levelNameRef so refresh always matches the template used during initiation. + const refreshToken = useCallback(async (): Promise => { + const response = await initiateSumsubKyc({ + regionIntent: regionIntentRef.current, + levelName: levelNameRef.current, + }) + + if (response.error || !response.data?.token) { + throw new Error(response.error || 'Failed to refresh token') + } + + setAccessToken(response.data.token) + return response.data.token + }, []) + + const closeVerificationProgressModal = useCallback(() => { + setIsVerificationProgressModalOpen(false) + }, []) + + const closeVerificationModalAndGoHome = useCallback(() => { + setIsVerificationProgressModalOpen(false) + router.push('/home') + }, [router]) + + const resetError = useCallback(() => { + setError(null) + }, []) + + return { + isLoading, + error, + showWrapper, + accessToken, + liveKycStatus, + rejectLabels, + handleInitiateKyc, + handleSdkComplete, + handleClose, + refreshToken, + isVerificationProgressModalOpen, + closeVerificationProgressModal, + closeVerificationModalAndGoHome, + resetError, + } +} diff --git a/src/hooks/useUnifiedKycStatus.ts b/src/hooks/useUnifiedKycStatus.ts new file mode 100644 index 000000000..bc5b410ae --- /dev/null +++ b/src/hooks/useUnifiedKycStatus.ts @@ -0,0 +1,88 @@ +'use client' + +import { useAuth } from '@/context/authContext' +import { MantecaKycStatus } from '@/interfaces' +import { useMemo } from 'react' +import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types' +import { isSumsubStatusInProgress } from '@/constants/kyc.consts' + +/** + * single source of truth for kyc status across all providers (bridge, manteca, sumsub). + * all kyc status checks should go through this hook. + */ +export default function useUnifiedKycStatus() { + const { user } = useAuth() + + const isBridgeApproved = useMemo(() => user?.user.bridgeKycStatus === 'approved', [user]) + + const isMantecaApproved = useMemo( + () => + user?.user.kycVerifications?.some( + (v) => v.provider === 'MANTECA' && v.status === MantecaKycStatus.ACTIVE + ) ?? false, + [user] + ) + + // pick the most recently updated sumsub verification in case of retries + const sumsubVerification = useMemo( + () => + user?.user.kycVerifications + ?.filter((v) => v.provider === 'SUMSUB') + .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] ?? null, + [user] + ) + + const isSumsubApproved = useMemo(() => sumsubVerification?.status === 'APPROVED', [sumsubVerification]) + + const sumsubStatus = useMemo(() => (sumsubVerification?.status as SumsubKycStatus) ?? null, [sumsubVerification]) + + const sumsubRejectLabels = useMemo(() => sumsubVerification?.rejectLabels ?? null, [sumsubVerification]) + + const sumsubRejectType = useMemo( + () => (sumsubVerification?.rejectType as 'RETRY' | 'FINAL' | null) ?? null, + [sumsubVerification] + ) + + // region intent used during the sumsub verification (stored in metadata by initiate-kyc) + const sumsubVerificationRegionIntent = useMemo( + () => (sumsubVerification?.metadata?.regionIntent as string) ?? null, + [sumsubVerification] + ) + + const isKycApproved = useMemo( + () => isBridgeApproved || isMantecaApproved || isSumsubApproved, + [isBridgeApproved, isMantecaApproved, isSumsubApproved] + ) + + const isBridgeUnderReview = useMemo( + () => user?.user.bridgeKycStatus === 'under_review' || user?.user.bridgeKycStatus === 'incomplete', + [user] + ) + + const isSumsubActionRequired = useMemo(() => sumsubStatus === 'ACTION_REQUIRED', [sumsubStatus]) + + const isSumsubInProgress = useMemo(() => isSumsubStatusInProgress(sumsubStatus), [sumsubStatus]) + + const isKycInProgress = useMemo( + () => isBridgeUnderReview || isSumsubInProgress, + [isBridgeUnderReview, isSumsubInProgress] + ) + + return { + // combined + isKycApproved, + isKycInProgress, + // bridge + isBridgeApproved, + isBridgeUnderReview, + // manteca + isMantecaApproved, + // sumsub + isSumsubApproved, + isSumsubActionRequired, + sumsubStatus, + sumsubRejectLabels, + sumsubRejectType, + sumsubVerificationRegionIntent, + } +} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 685adee39..94d435cdc 100644 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react' -import { PeanutWebSocket, getWebSocketInstance, type PendingPerk } from '@/services/websocket' +import { PeanutWebSocket, getWebSocketInstance, type PendingPerk, type RailStatusUpdate } from '@/services/websocket' import { type HistoryEntry } from './useTransactionHistory' type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error' @@ -10,8 +10,10 @@ interface UseWebSocketOptions { onHistoryEntry?: (entry: HistoryEntry) => void onKycStatusUpdate?: (status: string) => void onMantecaKycStatusUpdate?: (status: string) => void + onSumsubKycStatusUpdate?: (status: string, rejectLabels?: string[]) => void onTosUpdate?: (data: { accepted: boolean }) => void onPendingPerk?: (perk: PendingPerk) => void + onRailStatusUpdate?: (data: RailStatusUpdate) => void onConnect?: () => void onDisconnect?: () => void onError?: (error: Event) => void @@ -24,8 +26,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -39,8 +43,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -52,8 +58,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -62,8 +70,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { onHistoryEntry, onKycStatusUpdate, onMantecaKycStatusUpdate, + onSumsubKycStatusUpdate, onTosUpdate, onPendingPerk, + onRailStatusUpdate, onConnect, onDisconnect, onError, @@ -154,6 +164,14 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { } } + const handleSumsubKycStatusUpdate = (data: { status: string; rejectLabels?: string[] }) => { + if (callbacksRef.current.onSumsubKycStatusUpdate) { + callbacksRef.current.onSumsubKycStatusUpdate(data.status, data.rejectLabels) + } else { + console.log(`[WebSocket] No onSumsubKycStatusUpdate callback registered for user: ${username}`) + } + } + const handleTosUpdate = (data: { status: string }) => { if (callbacksRef.current.onTosUpdate) { callbacksRef.current.onTosUpdate({ accepted: data.status === 'approved' }) @@ -168,6 +186,12 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { } } + const handleRailStatusUpdate = (data: RailStatusUpdate) => { + if (callbacksRef.current.onRailStatusUpdate) { + callbacksRef.current.onRailStatusUpdate(data) + } + } + // Register event handlers ws.on('connect', handleConnect) ws.on('disconnect', handleDisconnect) @@ -175,8 +199,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.on('history_entry', handleHistoryEntry) ws.on('kyc_status_update', handleKycStatusUpdate) ws.on('manteca_kyc_status_update', handleMantecaKycStatusUpdate) + ws.on('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.on('persona_tos_status_update', handleTosUpdate) ws.on('pending_perk', handlePendingPerk) + ws.on('user_rail_status_changed', handleRailStatusUpdate) // Auto-connect if enabled if (autoConnect) { @@ -191,8 +217,10 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => { ws.off('history_entry', handleHistoryEntry) ws.off('kyc_status_update', handleKycStatusUpdate) ws.off('manteca_kyc_status_update', handleMantecaKycStatusUpdate) + ws.off('sumsub_kyc_status_update', handleSumsubKycStatusUpdate) ws.off('persona_tos_status_update', handleTosUpdate) ws.off('pending_perk', handlePendingPerk) + ws.off('user_rail_status_changed', handleRailStatusUpdate) } }, [autoConnect, connect, username]) diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index f121fc926..6ef8e0553 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -1,7 +1,21 @@ import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { type SumsubKycStatus } from '@/app/actions/types/sumsub.types' export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username' +// phases for the multi-phase kyc verification modal +export type KycModalPhase = 'verifying' | 'preparing' | 'bridge_tos' | 'complete' + +// per-provider rail status for tracking after kyc approval +export type ProviderDisplayStatus = 'setting_up' | 'requires_tos' | 'requires_documents' | 'enabled' | 'failed' + +export interface ProviderStatus { + providerCode: string + displayName: string + status: ProviderDisplayStatus + rails: IUserRail[] +} + // Moved here from bridge-accounts.utils.ts to avoid circular dependency export type BridgeKycStatus = 'not_started' | 'under_review' | 'approved' | 'rejected' | 'incomplete' @@ -230,13 +244,17 @@ export enum MantecaKycStatus { } export interface IUserKycVerification { - provider: 'MANTECA' | 'BRIDGE' + provider: 'MANTECA' | 'BRIDGE' | 'SUMSUB' mantecaGeo?: string | null bridgeGeo?: string | null - status: MantecaKycStatus + status: MantecaKycStatus | SumsubKycStatus | string approvedAt?: string | null providerUserId?: string | null providerRawStatus?: string | null + sumsubApplicantId?: string | null + rejectLabels?: string[] | null + rejectType?: 'RETRY' | 'FINAL' | null + metadata?: { regionIntent?: string; [key: string]: unknown } | null createdAt: string updatedAt: string } @@ -318,6 +336,26 @@ interface userInvites { inviteeUsername: string } +export type UserRailStatus = + | 'PENDING' + | 'ENABLED' + | 'REQUIRES_INFORMATION' + | 'REQUIRES_EXTRA_INFORMATION' + | 'REJECTED' + | 'FAILED' + +export interface IUserRail { + id: string + railId: string + status: UserRailStatus + metadata?: { bridgeCustomerId?: string; additionalRequirements?: string[]; [key: string]: unknown } | null + rail: { + id: string + provider: { code: string; name: string } + method: { code: string; name: string; country: string; currency: string } + } +} + export interface IUserProfile { // OLD Points V1 fields removed - use pointsV2 in stats instead // Points V2: Use stats.pointsV2.totalPoints, pointsV2.inviteCount, etc. @@ -328,6 +366,7 @@ export interface IUserProfile { totalPoints: number // Kept for backward compatibility - same as pointsV2.totalPoints hasPwaInstalled: boolean user: User + rails: IUserRail[] invitesSent: userInvites[] showEarlyUserModal: boolean invitedBy: string | null // Username of the person who invited this user diff --git a/src/services/websocket.ts b/src/services/websocket.ts index b49418771..8c64ec8e0 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,7 +1,12 @@ import { type HistoryEntry } from '@/hooks/useTransactionHistory' import { type PendingPerk } from '@/services/perks' export type { PendingPerk } -import { jsonStringify } from '@/utils/general.utils' + +export interface RailStatusUpdate { + railId: string + status: string + provider?: string +} export type WebSocketMessage = { type: @@ -10,9 +15,11 @@ export type WebSocketMessage = { | 'history_entry' | 'kyc_status_update' | 'manteca_kyc_status_update' + | 'sumsub_kyc_status_update' | 'persona_tos_status_update' | 'pending_perk' - data?: HistoryEntry | PendingPerk + | 'user_rail_status_changed' + data?: HistoryEntry | PendingPerk | RailStatusUpdate } export class PeanutWebSocket { @@ -126,6 +133,12 @@ export class PeanutWebSocket { } break + case 'sumsub_kyc_status_update': + if (message.data && 'status' in (message.data as object)) { + this.emit('sumsub_kyc_status_update', message.data) + } + break + case 'persona_tos_status_update': if (message.data && 'status' in (message.data as object)) { this.emit('persona_tos_status_update', message.data) @@ -138,6 +151,12 @@ export class PeanutWebSocket { } break + case 'user_rail_status_changed': + if (message.data && 'railId' in (message.data as object)) { + this.emit('user_rail_status_changed', message.data) + } + break + default: // Handle other message types if needed this.emit(message.type, message.data) diff --git a/src/types/sumsub-websdk.d.ts b/src/types/sumsub-websdk.d.ts new file mode 100644 index 000000000..4d6b7c5c7 --- /dev/null +++ b/src/types/sumsub-websdk.d.ts @@ -0,0 +1,27 @@ +// type declarations for sumsub websdk loaded via CDN script +// https://static.sumsub.com/idensic/static/sns-websdk-builder.js + +declare global { + interface SnsWebSdkInstance { + launch(container: HTMLElement): void + destroy(): void + } + + interface SnsWebSdkBuilderChain { + withConf(conf: { lang?: string; theme?: string }): SnsWebSdkBuilderChain + withOptions(opts: { addViewportTag?: boolean; adaptIframeHeight?: boolean }): SnsWebSdkBuilderChain + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- sumsub sdk event handlers have varying untyped signatures + on(event: string, handler: (...args: any[]) => void): SnsWebSdkBuilderChain + build(): SnsWebSdkInstance + } + + interface SnsWebSdkBuilder { + init(token: string, refreshCallback: () => Promise): SnsWebSdkBuilderChain + } + + interface Window { + snsWebSdk: SnsWebSdkBuilder + } +} + +export {}