diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index f058b3361..c5cb43d2c 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -9,31 +9,30 @@ import { useOnrampFlow } from '@/context/OnrampFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' -import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' -import { useWebSocket } from '@/hooks/useWebSocket' import { useAuth } from '@/context/authContext' +import useKycStatus from '@/hooks/useKycStatus' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams } from 'next/navigation' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping' import { formatUnits } from 'viem' import PeanutLoading from '@/components/Global/PeanutLoading' import EmptyState from '@/components/Global/EmptyStates/EmptyState' -import { UserDetailsForm, type UserDetailsFormData } from '@/components/AddMoney/UserDetailsForm' -import { updateUserById } from '@/app/actions/users' import AddMoneyBankDetails from '@/components/AddMoney/components/AddMoneyBankDetails' import { getCurrencyConfig, getCurrencySymbol, getMinimumAmount } from '@/utils/bridge.utils' import { OnrampConfirmationModal } from '@/components/AddMoney/components/OnrampConfirmationModal' -import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' import InfoCard from '@/components/Global/InfoCard' import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' import { getLimitsWarningCardProps } from '@/features/limits/utils' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' // Step type for URL state -type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails' +type BridgeBankStep = 'inputAmount' | 'showDetails' export default function OnrampBankPage() { const router = useRouter() @@ -43,7 +42,7 @@ export default function OnrampBankPage() { // Example: /add-money/mexico/bank?step=inputAmount&amount=500 const [urlState, setUrlState] = useQueryStates( { - step: parseAsStringEnum(['inputAmount', 'kyc', 'collectUserDetails', 'showDetails']), + step: parseAsStringEnum(['inputAmount', 'showDetails']), amount: parseAsString, }, { history: 'push' } @@ -54,20 +53,23 @@ export default function OnrampBankPage() { // Local UI state (not URL-appropriate - transient) const [showWarningModal, setShowWarningModal] = useState(false) + const [showKycModal, setShowKycModal] = useState(false) const [isRiskAccepted, setIsRiskAccepted] = useState(false) - const [isKycModalOpen, setIsKycModalOpen] = useState(false) - const [liveKycStatus, setLiveKycStatus] = useState(undefined) - const [isUpdatingUser, setIsUpdatingUser] = useState(false) - const [userUpdateError, setUserUpdateError] = useState(null) - const [isUserDetailsFormValid, setIsUserDetailsFormValid] = useState(false) - const { setError, error, setOnrampData, onrampData } = useOnrampFlow() - const formRef = useRef<{ handleSubmit: () => void }>(null) const { balance } = useWallet() const { user, fetchUser } = useAuth() const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp() + // inline sumsub kyc flow for bridge bank onramp + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: () => { + setUrlState({ step: 'inputAmount' }) + }, + }) + const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { @@ -87,19 +89,7 @@ export default function OnrampBankPage() { // uk-specific check const isUK = isUKCountry(selectedCountryPath) - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onKycStatusUpdate: (newStatus) => { - setLiveKycStatus(newStatus as BridgeKycStatus) - }, - }) - - useEffect(() => { - if (user?.user.bridgeKycStatus) { - setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus) - } - }, [user?.user.bridgeKycStatus]) + const { isUserKycApproved } = useKycStatus() useEffect(() => { fetchUser() @@ -150,30 +140,12 @@ export default function OnrampBankPage() { currency: 'USD', }) - // Determine initial step based on KYC status (only when URL has no step) + // Default to inputAmount step when no step in URL useEffect(() => { - // If URL already has a step, respect it (allows deep linking) if (urlState.step) return - - // Wait for user to be fetched before determining initial step if (user === null) return - - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus - const isUserKycVerified = currentKycStatus === 'approved' - - if (!isUserKycVerified) { - setUrlState({ step: 'collectUserDetails' }) - } else { - setUrlState({ step: 'inputAmount' }) - } - }, [liveKycStatus, user, urlState.step, setUrlState]) - - // Handle KYC completion - useEffect(() => { - if (urlState.step === 'kyc' && liveKycStatus === 'approved') { - setUrlState({ step: 'inputAmount' }) - } - }, [liveKycStatus, urlState.step, setUrlState]) + setUrlState({ step: 'inputAmount' }) + }, [user, urlState.step, setUrlState]) const validateAmount = useCallback( (amountStr: string): boolean => { @@ -215,9 +187,14 @@ export default function OnrampBankPage() { }, [rawTokenAmount, validateAmount, setError]) const handleAmountContinue = () => { - if (validateAmount(rawTokenAmount)) { - setShowWarningModal(true) + if (!validateAmount(rawTokenAmount)) return + + if (!isUserKycApproved) { + setShowKycModal(true) + return } + + setShowWarningModal(true) } const handleWarningConfirm = async () => { @@ -261,39 +238,6 @@ export default function OnrampBankPage() { setIsRiskAccepted(false) } - const handleKycSuccess = () => { - setIsKycModalOpen(false) - setUrlState({ step: 'inputAmount' }) - } - - const handleKycModalClose = () => { - setIsKycModalOpen(false) - } - - const handleUserDetailsSubmit = 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() - setUrlState({ step: 'kyc' }) - } catch (error: any) { - setUserUpdateError(error.message) - return { error: error.message } - } finally { - setIsUpdatingUser(false) - } - return {} - } - const handleBack = () => { if (selectedCountry) { router.push(`/add-money/${selectedCountry.path}`) @@ -302,20 +246,6 @@ export default function OnrampBankPage() { } } - const initialUserDetails: Partial = useMemo( - () => ({ - fullName: user?.user.fullName ?? '', - email: user?.user.email ?? '', - }), - [user?.user.fullName, user?.user.email] - ) - - useEffect(() => { - if (urlState.step === 'kyc') { - setIsKycModalOpen(true) - } - }, [urlState.step]) - // Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation) useEffect(() => { if (urlState.step === 'showDetails' && !onrampData?.transferId) { @@ -342,49 +272,6 @@ export default function OnrampBankPage() { return } - if (urlState.step === 'collectUserDetails') { - return ( -
- -
-

Verify your details

- - - {userUpdateError && } -
-
- ) - } - - if (urlState.step === 'kyc') { - return ( -
- router.push(`/add-money/${selectedCountry.path}`)} - flow="add" - /> -
- ) - } - if (urlState.step === 'showDetails') { // Show loading while useEffect redirects if data is missing if (!onrampData?.transferId) { @@ -477,6 +364,18 @@ export default function OnrampBankPage() { amount={rawTokenAmount} currency={getCurrencySymbol(getCurrencyConfig(selectedCountry.id, 'onramp').currency)} /> + + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('STANDARD') + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + /> + + ) } diff --git a/src/app/(mobile-ui)/history/page.tsx b/src/app/(mobile-ui)/history/page.tsx index 2ace526be..534e4aa1f 100644 --- a/src/app/(mobile-ui)/history/page.tsx +++ b/src/app/(mobile-ui)/history/page.tsx @@ -12,7 +12,8 @@ import { useTransactionHistory } from '@/hooks/useTransactionHistory' import { useUserStore } from '@/redux/hooks' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import * as Sentry from '@sentry/nextjs' -import { isKycStatusItem } from '@/hooks/useBridgeKycFlow' +import { isKycStatusItem } from '@/components/Kyc/KycStatusItem' +import { groupKycByRegion } from '@/utils/kyc-grouping.utils' import { useAuth } from '@/context/authContext' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -140,6 +141,10 @@ const HistoryPage = () => { console.log('KYC status updated via WebSocket:', newStatus) await fetchUser() }, + onSumsubKycStatusUpdate: async (newStatus: string) => { + console.log('Sumsub KYC status updated via WebSocket:', newStatus) + await fetchUser() + }, }) const allEntries = useMemo(() => historyData?.pages.flatMap((page) => page.entries) ?? [], [historyData]) @@ -165,30 +170,10 @@ const HistoryPage = () => { }) }) - if (user) { - if (user.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { - // Use appropriate timestamp based on KYC status - const bridgeKycTimestamp = (() => { - const status = user.user.bridgeKycStatus - if (status === 'approved') return user.user.bridgeKycApprovedAt - if (status === 'rejected') return user.user.bridgeKycRejectedAt - return user.user.bridgeKycStartedAt - })() - entries.push({ - isKyc: true, - timestamp: bridgeKycTimestamp ?? user.user.createdAt ?? new Date().toISOString(), - uuid: 'bridge-kyc-status-item', - bridgeKycStatus: user.user.bridgeKycStatus, - }) - } - user.user.kycVerifications?.forEach((verification) => { - entries.push({ - isKyc: true, - timestamp: verification.approvedAt ?? verification.updatedAt ?? verification.createdAt, - uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, - verification, - }) - }) + // add one kyc entry per region (STANDARD, LATAM) + if (user?.user) { + const regionEntries = groupKycByRegion(user.user) + entries.push(...regionEntries) } entries.sort((a, b) => { @@ -272,6 +257,7 @@ const HistoryPage = () => { bridgeKycStartedAt={ item.bridgeKycStatus ? user?.user.bridgeKycStartedAt : undefined } + region={item.region} /> ) : isBadgeHistoryItem(item) ? ( diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index e4ed39a98..bdcca9036 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)/points/invites/page.tsx b/src/app/(mobile-ui)/points/invites/page.tsx index 3a543e76f..9285f1396 100644 --- a/src/app/(mobile-ui)/points/invites/page.tsx +++ b/src/app/(mobile-ui)/points/invites/page.tsx @@ -86,7 +86,7 @@ const InvitesPage = () => { {invites?.invitees?.map((invite: PointsInvite, i: number) => { const username = invite.username const fullName = invite.fullName - const isVerified = invite.kycStatus === 'approved' + const isVerified = invite.kycVerified const pointsEarned = invite.contributedPoints ?? 0 // respect user's showFullName preference for avatar and display name const displayName = invite.showFullName && fullName ? fullName : username diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx index 7690531a5..05baf8d35 100644 --- a/src/app/(mobile-ui)/points/page.tsx +++ b/src/app/(mobile-ui)/points/page.tsx @@ -238,7 +238,7 @@ const PointsPage = () => { {invites.invitees?.slice(0, 5).map((invite: PointsInvite, i: number) => { const username = invite.username const fullName = invite.fullName - const isVerified = invite.kycStatus === 'approved' + const isVerified = invite.kycVerified const pointsEarned = invite.contributedPoints ?? 0 // respect user's showFullName preference for avatar and display name const displayName = invite.showFullName && fullName ? fullName : username diff --git a/src/app/(mobile-ui)/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 6b4a7805c..4282c07ca 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/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 8371f74f8..baac44283 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -17,22 +17,28 @@ import { loadingStateContext } from '@/context' import { countryData } from '@/components/AddMoney/consts' import Image from 'next/image' import { formatAmount, formatNumberForDisplay } from '@/utils/general.utils' -import { validateCbuCvuAlias, validatePixKey, normalizePixPhoneNumber, isPixPhoneNumber } from '@/utils/withdraw.utils' +import { + validateCbuCvuAlias, + validatePixKey, + normalizePixPhoneNumber, + isPixPhoneNumber, + isPixEmvcoQr, +} from '@/utils/withdraw.utils' import ValidatedInput from '@/components/Global/ValidatedInput' import AmountInput from '@/components/Global/AmountInput' import { formatUnits, parseUnits } from 'viem' import type { TransactionReceipt, Hash } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' import { useAuth } from '@/context/authContext' -import { useWebSocket } from '@/hooks/useWebSocket' import { useModalsContext } from '@/context/ModalsContext' import Select from '@/components/Global/Select' import { SoundPlayer } from '@/components/Global/SoundPlayer' import { useQueryClient } from '@tanstack/react-query' import { captureException } from '@sentry/nextjs' import useKycStatus from '@/hooks/useKycStatus' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' @@ -68,7 +74,6 @@ export default function MantecaWithdrawFlow() { const [selectedBank, setSelectedBank] = useState(null) const [accountType, setAccountType] = useState(null) const [errorMessage, setErrorMessage] = useState(null) - const [isKycModalOpen, setIsKycModalOpen] = useState(false) const [isDestinationAddressValid, setIsDestinationAddressValid] = useState(false) const [isDestinationAddressChanging, setIsDestinationAddressChanging] = useState(false) // price lock state - holds the locked price from /withdraw/init @@ -78,11 +83,17 @@ export default function MantecaWithdrawFlow() { const { sendMoney, balance } = useWallet() const { signTransferUserOp } = useSignUserOp() const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) - const { user, fetchUser } = useAuth() + const { user } = useAuth() const { setIsSupportModalOpen } = useModalsContext() const queryClient = useQueryClient() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserMantecaKycApproved } = useKycStatus() const { hasPendingTransactions } = usePendingTransactions() + + // inline sumsub kyc flow for manteca users who need LATAM verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) // Get method and country from URL parameters const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc. const countryFromUrl = searchParams.get('country') // argentina, brazil, etc. @@ -106,9 +117,6 @@ export default function MantecaWithdrawFlow() { isLoading: isCurrencyLoading, } = useCurrency(selectedCountry?.currency!) - // Initialize KYC flow hook - const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry }) - // validates withdrawal against user's limits // currency comes from country config - hook normalizes it internally const limitsValidation = useLimitsValidation({ @@ -117,19 +125,6 @@ export default function MantecaWithdrawFlow() { currency: selectedCountry?.currency, }) - // WebSocket listener for KYC status updates - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onMantecaKycStatusUpdate: (newStatus) => { - if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') { - fetchUser() - setIsKycModalOpen(false) - setStep('review') // Proceed to review after successful KYC - } - }, - }) - // Get country flag code const countryFlagCode = useMemo(() => { return selectedCountry?.iso2?.toLowerCase() @@ -164,6 +159,7 @@ export default function MantecaWithdrawFlow() { } break case 'brazil': + value = isPixEmvcoQr(value.trim()) ? value.trim() : value.replace(/\s/g, '') const pixResult = validatePixKey(value) isValid = pixResult.valid if (!pixResult.valid) { @@ -200,14 +196,8 @@ export default function MantecaWithdrawFlow() { } setErrorMessage(null) - // check if we still need to determine KYC status - if (isMantecaKycRequired === null) { - return - } - - // check KYC status before proceeding to review - if (isMantecaKycRequired === true) { - setIsKycModalOpen(true) + if (!isUserMantecaKycApproved) { + setShowKycModal(true) return } @@ -250,7 +240,7 @@ export default function MantecaWithdrawFlow() { usdAmount, currencyCode, currencyAmount, - isMantecaKycRequired, + isUserMantecaKycApproved, isLockingPrice, ]) @@ -342,7 +332,6 @@ export default function MantecaWithdrawFlow() { setSelectedBank(null) setAccountType(null) setErrorMessage(null) - setIsKycModalOpen(false) setIsDestinationAddressValid(false) setIsDestinationAddressChanging(false) setBalanceErrorMessage(null) @@ -468,6 +457,16 @@ export default function MantecaWithdrawFlow() { } return (
+ setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + /> + { @@ -586,10 +585,15 @@ export default function MantecaWithdrawFlow() { value={destinationAddress} placeholder={countryConfig!.accountNumberLabel} onUpdate={(update) => { - // Auto-normalize PIX phone numbers for Brazil + // Auto-normalize PIX keys for Brazil: strip whitespace and normalize phone numbers let normalizedValue = update.value - if (countryPath === 'brazil' && isPixPhoneNumber(update.value)) { - normalizedValue = normalizePixPhoneNumber(update.value) + if (countryPath === 'brazil') { + normalizedValue = isPixEmvcoQr(normalizedValue.trim()) + ? normalizedValue.trim() + : normalizedValue.replace(/\s/g, '') + if (isPixPhoneNumber(normalizedValue)) { + normalizedValue = normalizePixPhoneNumber(normalizedValue) + } } setDestinationAddress(normalizedValue) setIsDestinationAddressValid(update.isValid) @@ -649,23 +653,6 @@ export default function MantecaWithdrawFlow() { {errorMessage && }
- - {/* KYC Modal */} - {isKycModalOpen && selectedCountry && ( - setIsKycModalOpen(false)} - onManualClose={() => setIsKycModalOpen(false)} - onKycSuccess={() => { - setIsKycModalOpen(false) - fetchUser() - setStep('review') - }} - selectedCountry={selectedCountry} - /> - )} )} 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..07e1498ef 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -160,3 +160,48 @@ 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, + }, + body: JSON.stringify({}), + }) + 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/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx deleted file mode 100644 index c9a585839..000000000 --- a/src/components/AddMoney/UserDetailsForm.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' -import { forwardRef, useEffect, useImperativeHandle } from 'react' -import { useForm, Controller } from 'react-hook-form' -import BaseInput from '@/components/0_Bruddle/BaseInput' -import ErrorAlert from '@/components/Global/ErrorAlert' - -export type UserDetailsFormData = { - fullName: string - email: string -} - -interface UserDetailsFormProps { - onSubmit: (data: UserDetailsFormData) => Promise<{ error?: string }> - isSubmitting: boolean - onValidChange?: (isValid: boolean) => void - initialData?: Partial -} - -export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>( - ({ onSubmit, onValidChange, initialData }, ref) => { - const { - control, - handleSubmit, - formState: { errors, isValid }, - } = useForm({ - defaultValues: { - fullName: initialData?.fullName ?? '', - email: initialData?.email ?? '', - }, - mode: 'all', - }) - - useEffect(() => { - onValidChange?.(isValid) - }, [isValid, onValidChange]) - - // Note: Submission errors are handled by the parent component - useImperativeHandle(ref, () => ({ - handleSubmit: handleSubmit(async (data) => { - await onSubmit(data) - }), - })) - - const renderInput = ( - name: keyof UserDetailsFormData, - placeholder: string, - rules: any, - type: string = 'text' - ) => { - return ( -
-
- ( - - )} - /> -
-
- {errors[name] && } -
-
- ) - } - return ( -
-
-
{ - e.preventDefault() - }} - className="space-y-4" - > -
- {renderInput('fullName', 'Full Name', { required: 'Full name is required' })} - {renderInput('email', 'E-mail', { - required: 'Email is required', - pattern: { - value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: 'Invalid email address', - }, - })} -
-
-
-
- ) - } -) - -UserDetailsForm.displayName = 'UserDetailsForm' diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index f7e902f18..eab40ae05 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -2,18 +2,18 @@ import { type FC, useEffect, useMemo, useState, useCallback } from 'react' import MantecaDepositShareDetails from '@/components/AddMoney/components/MantecaDepositShareDetails' import InputAmountStep from '@/components/AddMoney/components/InputAmountStep' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { type CountryData, countryData } from '@/components/AddMoney/consts' import { type MantecaDepositResponseData } from '@/types/manteca.types' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow' import { useCurrency } from '@/hooks/useCurrency' import { useAuth } from '@/context/authContext' -import { useWebSocket } from '@/hooks/useWebSocket' import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' import { useQueryClient } from '@tanstack/react-query' import useKycStatus from '@/hooks/useKycStatus' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { MIN_MANTECA_DEPOSIT_AMOUNT } from '@/constants/payment.consts' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { TRANSACTIONS } from '@/constants/query.consts' @@ -28,7 +28,6 @@ type CurrencyDenomination = 'USD' | 'ARS' | 'BRL' | 'MXN' | 'EUR' const MantecaAddMoney: FC = () => { const params = useParams() - const router = useRouter() const queryClient = useQueryClient() // URL state - persisted in query params @@ -57,16 +56,20 @@ const MantecaAddMoney: FC = () => { const [isCreatingDeposit, setIsCreatingDeposit] = useState(false) const [error, setError] = useState(null) const [depositDetails, setDepositDetails] = useState() - const [isKycModalOpen, setIsKycModalOpen] = useState(false) const selectedCountryPath = params.country as string const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry as CountryData }) - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserMantecaKycApproved } = useKycStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') - const { user, fetchUser } = useAuth() + const { user } = useAuth() + + // inline sumsub kyc flow for manteca users who need LATAM verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) // validates deposit amount against user's limits // currency comes from country config - hook normalizes it internally @@ -76,18 +79,6 @@ const MantecaAddMoney: FC = () => { currency: selectedCountry?.currency, }) - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onMantecaKycStatusUpdate: (newStatus) => { - // listen for manteca kyc status updates, either when the user is approved or when the widget is finished to continue with the flow - if (newStatus === 'ACTIVE' || newStatus === 'WIDGET_FINISHED') { - fetchUser() - setIsKycModalOpen(false) - } - }, - }) - // Validate USD amount (min check only - max is handled by limits validation) useEffect(() => { // if user hasn't entered any amount yet, don't show error @@ -118,13 +109,6 @@ const MantecaAddMoney: FC = () => { } }, [step, queryClient]) - const handleKycCancel = () => { - setIsKycModalOpen(false) - if (selectedCountry?.path) { - router.push(`/add-money/${selectedCountry.path}`) - } - } - // Handle displayed amount change - save to URL // This is called by AmountInput with the currently DISPLAYED value const handleDisplayedAmountChange = useCallback( @@ -156,14 +140,8 @@ const MantecaAddMoney: FC = () => { if (!selectedCountry?.currency) return if (isCreatingDeposit) return - // check if we still need to determine KYC status - if (isMantecaKycRequired === null) { - // still loading/determining KYC status, don't proceed yet - return - } - - if (isMantecaKycRequired === true) { - setIsKycModalOpen(true) + if (!isUserMantecaKycApproved) { + setShowKycModal(true) return } @@ -191,14 +169,14 @@ const MantecaAddMoney: FC = () => { } finally { setIsCreatingDeposit(false) } - }, [currentDenomination, selectedCountry, displayedAmount, isMantecaKycRequired, isCreatingDeposit, setUrlState]) - - // handle verification modal opening - useEffect(() => { - if (isMantecaKycRequired) { - setIsKycModalOpen(true) - } - }, [isMantecaKycRequired]) + }, [ + currentDenomination, + selectedCountry, + displayedAmount, + isUserMantecaKycApproved, + isCreatingDeposit, + setUrlState, + ]) // Redirect to inputAmount if depositDetails is accessed without required data (deep link / back navigation) useEffect(() => { @@ -212,6 +190,16 @@ const MantecaAddMoney: FC = () => { if (step === 'inputAmount') { return ( <> + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + /> + { limitsValidation={limitsValidation} limitsCurrency={limitsValidation.currency} /> - {isKycModalOpen && ( - { - // close the modal and let the user continue with amount input - setIsKycModalOpen(false) - fetchUser() - }} - selectedCountry={selectedCountry} - /> - )} ) } diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index de668ef0d..8ff2b9480 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -10,23 +10,22 @@ import Image, { type StaticImageData } from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' -import { addBankAccount, updateUserById } from '@/app/actions/users' -import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' +import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' -import { useWebSocket } from '@/hooks/useWebSocket' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { type Account } from '@/interfaces' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' -import { InitiateBridgeKYCModal } from '../Kyc/InitiateBridgeKYCModal' import useKycStatus from '@/hooks/useKycStatus' import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal' import { ActionListCard } from '@/components/ActionListCard' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -48,32 +47,26 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const { setSelectedBankAccount, amountToWithdraw, setSelectedMethod, setAmountToWithdraw } = useWithdrawFlow() const dispatch = useAppDispatch() + // inline sumsub kyc flow for bridge bank users who need verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: () => { + setIsKycModalOpen(false) + setView('form') + }, + onManualClose: () => setIsKycModalOpen(false), + }) + // component level states const [view, setView] = useState<'list' | 'form'>(flow === 'withdraw' && amountToWithdraw ? 'form' : 'list') const [isKycModalOpen, setIsKycModalOpen] = useState(false) const formRef = useRef<{ handleSubmit: () => void }>(null) - const [liveKycStatus, setLiveKycStatus] = useState( - user?.user?.bridgeKycStatus as BridgeKycStatus - ) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const { isUserBridgeKycUnderReview } = useKycStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() const [showKycStatusModal, setShowKycStatusModal] = useState(false) - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onKycStatusUpdate: (newStatus) => { - setLiveKycStatus(newStatus as BridgeKycStatus) - }, - }) - - useEffect(() => { - if (user?.user.bridgeKycStatus) { - setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus) - } - }, [user?.user.bridgeKycStatus]) - const countryPathParts = Array.isArray(params.country) ? params.country : [params.country] const isBankPage = countryPathParts[countryPathParts.length - 1] === 'bank' const countrySlugFromUrl = isBankPage ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-') @@ -86,15 +79,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { payload: AddBankAccountPayload, rawData: IBankAccountDetails ): Promise<{ error?: string }> => { - const currentKycStatus = liveKycStatus || user?.user.bridgeKycStatus - const isUserKycVerified = currentKycStatus === 'approved' - - const hasEmailOnLoad = !!user?.user.email + // re-fetch user to ensure we have the latest KYC status + // (the multi-phase flow may have completed but websocket/state not yet propagated) + await fetchUser() // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly - // note: we no longer check for fullName as account owner name is now always collected from the form - if (isUserKycVerified && (hasEmailOnLoad || rawData.email)) { - const currentAccountIds = new Set(user?.accounts.map((acc) => acc.id) ?? []) + // email and name are now collected by sumsub — no need to check them here + if (isUserKycApproved) { + const currentAccountIds = new Set((user?.accounts ?? []).map((acc) => acc.id)) const result = await addBankAccount(payload) if (result.error) { @@ -135,53 +127,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } // scenario (2): if the user hasn't completed kyc yet - if (!isUserKycVerified) { - // update user's name and email if they are not present - const hasNameOnLoad = !!user?.user.fullName - const hasEmailOnLoad = !!user?.user.email - - if (!hasNameOnLoad || !hasEmailOnLoad) { - if (user?.user.userId) { - // Build update payload to only update missing fields - const updatePayload: Record = { userId: user.user.userId } - - if (!hasNameOnLoad && rawData.accountOwnerName) { - updatePayload.fullName = rawData.accountOwnerName.trim() - } - - if (!hasEmailOnLoad && rawData.email) { - updatePayload.email = rawData.email.trim() - } - - // Only call update if we have fields to update - if (Object.keys(updatePayload).length > 1) { - const result = await updateUserById(updatePayload) - if (result.error) { - return { error: result.error } - } - try { - await fetchUser() - } catch (err) { - console.error('Failed to refresh user data after update:', err) - } - } - } - } - - setIsKycModalOpen(true) + // name and email are now collected by sumsub sdk — no need to save them beforehand + if (!isUserKycApproved) { + await sumsubFlow.handleInitiateKyc('STANDARD') } return {} } - const handleKycSuccess = () => { - // only transition to form if this component initiated the KYC modal - if (isKycModalOpen) { - setIsKycModalOpen(false) - setView('form') - } - } - const handleWithdrawMethodClick = (method: SpecificPaymentMethod) => { // preserve method param only if coming from bank send flow (not crypto) const methodQueryParam = isBankFromSend ? `?method=${methodParam}` : '' @@ -312,11 +265,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { initialData={{}} error={null} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) } @@ -431,11 +380,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { isKycApprovedModalOpen={showKycStatusModal} onClose={() => setShowKycStatusModal(false)} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) } diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index c94b48157..b0996cdb3 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -430,12 +430,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D })} )} - {flow !== 'claim' && - !hideEmailInput && - !user?.user?.email && - renderInput('email', 'E-mail', { - required: 'Email is required', - })} {isMx ? renderInput('clabe', 'CLABE', { diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index 318eba629..cb61325d7 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -22,6 +22,7 @@ import { getTokenLogo, getChainLogo, } from '@/utils/general.utils' +import { isUserKycVerified } from '@/constants/kyc.consts' import * as Sentry from '@sentry/nextjs' import { useQuery } from '@tanstack/react-query' import type { Hash } from 'viem' @@ -189,7 +190,7 @@ export const Claim = ({}) => { peanutFeeDetails: { amountDisplay: '$ 0.00', }, - isVerified: claimLinkData.sender?.bridgeKycStatus === 'approved', + isVerified: isUserKycVerified(claimLinkData.sender), haveSentMoneyToUser: claimLinkData.sender?.userId ? interactions[claimLinkData.sender?.userId] || false : false, @@ -396,7 +397,7 @@ export const Claim = ({}) => { // redirect to bank flow if user is KYC approved and step is bank useEffect(() => { const stepFromURL = searchParams.get('step') - if (user?.user.bridgeKycStatus === 'approved' && stepFromURL === 'bank') { + if (isUserKycVerified(user?.user) && stepFromURL === 'bank') { setClaimBankFlowStep(ClaimBankFlowStep.BankCountryList) } }, [user]) diff --git a/src/components/Claim/Link/MantecaFlowManager.tsx b/src/components/Claim/Link/MantecaFlowManager.tsx index b27e54a4d..0598ac4b1 100644 --- a/src/components/Claim/Link/MantecaFlowManager.tsx +++ b/src/components/Claim/Link/MantecaFlowManager.tsx @@ -12,9 +12,9 @@ import MantecaReviewStep from './views/MantecaReviewStep' import { Button } from '@/components/0_Bruddle/Button' import { useRouter } from 'next/navigation' import useKycStatus from '@/hooks/useKycStatus' -import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal' -import { useAuth } from '@/context/authContext' -import { type CountryData } from '@/components/AddMoney/consts' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' interface MantecaFlowManagerProps { claimLinkData: ClaimLinkData @@ -27,33 +27,23 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount const [currentStep, setCurrentStep] = useState(MercadoPagoStep.DETAILS) const router = useRouter() const [destinationAddress, setDestinationAddress] = useState('') - const [isKYCModalOpen, setIsKYCModalOpen] = useState(false) - const argentinaCountryData = { - id: 'AR', - type: 'country', - title: 'Argentina', - currency: 'ARS', - path: 'argentina', - iso2: 'AR', - iso3: 'ARG', - } as CountryData + const { isUserMantecaKycApproved } = useKycStatus() - const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus() - const { fetchUser } = useAuth() + // inline sumsub kyc flow for manteca users who need LATAM verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('LATAM') + const sumsubFlow = useMultiPhaseKycFlow({}) + const [showKycModal, setShowKycModal] = useState(false) const isSuccess = currentStep === MercadoPagoStep.SUCCESS const selectedCurrency = selectedCountry?.currency || 'ARS' const regionalMethodLogo = regionalMethodType === 'mercadopago' ? MERCADO_PAGO : PIX const logo = selectedCountry?.id ? undefined : regionalMethodLogo - const handleKycCancel = () => { - setIsKYCModalOpen(false) - onPrev() - } - + // show confirmation modal if user hasn't completed manteca verification useEffect(() => { if (!isUserMantecaKycApproved) { - setIsKYCModalOpen(true) + setShowKycModal(true) } }, [isUserMantecaKycApproved]) @@ -125,23 +115,17 @@ const MantecaFlowManager: FC = ({ claimLinkData, amount /> {renderStepDetails()} - - {isKYCModalOpen && ( - { - // close the modal and let the user continue with amount input - setIsKYCModalOpen(false) - fetchUser() - }} - selectedCountry={selectedCountry || argentinaCountryData} - /> - )} + setShowKycModal(false)} + onVerify={async () => { + await sumsubFlow.handleInitiateKyc('LATAM') + setShowKycModal(false) + }} + isLoading={sumsubFlow.isLoading} + /> + ) } diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 2e6519249..d7da95828 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -3,7 +3,7 @@ import { type IClaimScreenProps } from '../../Claim.consts' import { DynamicBankAccountForm, type IBankAccountDetails } from '@/components/AddWithdraw/DynamicBankAccountForm' import { ClaimBankFlowStep, useClaimBankFlow } from '@/context/ClaimBankFlowContext' -import { useCallback, useContext, useState, useRef, useEffect } from 'react' +import { useCallback, useContext, useState, useRef } from 'react' import { loadingStateContext } from '@/context' import { createBridgeExternalAccountForGuest } from '@/app/actions/external-accounts' import { confirmOfframp, createOfframp, createOfframpForGuest } from '@/app/actions/offramp' @@ -18,21 +18,20 @@ import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/servi import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import peanut from '@squirrel-labs/peanut-sdk' -import { addBankAccount, getUserById, updateUserById } from '@/app/actions/users' +import { addBankAccount, getUserById } from '@/app/actions/users' import SavedAccountsView from '../../../Common/SavedAccountsView' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' import { ConfirmBankClaimView } from './Confirm.bank-claim.view' import { CountryListRouter } from '@/components/Common/CountryListRouter' import NavHeader from '@/components/Global/NavHeader' -import { useWebSocket } from '@/hooks/useWebSocket' -import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' -import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal' import { useSearchParams } from 'next/navigation' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' type BankAccountWithId = IBankAccountDetails & ( @@ -76,33 +75,28 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const { claimLink } = useClaimLink() const dispatch = useAppDispatch() + // inline sumsub kyc flow for users who need verification + // regionIntent is NOT passed here to avoid creating a backend record on mount. + // intent is passed at call time: handleInitiateKyc('STANDARD') + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: async () => { + if (justCompletedKyc) return + setIsKycModalOpen(false) + await fetchUser() + setJustCompletedKyc(true) + setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) + }, + onManualClose: () => setIsKycModalOpen(false), + }) + // local states for this component const [localBankDetails, setLocalBankDetails] = useState(null) const [receiverFullName, setReceiverFullName] = useState('') const [error, setError] = useState(null) const formRef = useRef<{ handleSubmit: () => void }>(null) - const [liveKycStatus, setLiveKycStatus] = useState( - user?.user?.bridgeKycStatus as BridgeKycStatus - ) const [isProcessingKycSuccess, setIsProcessingKycSuccess] = useState(false) const [offrampData, setOfframpData] = useState(null) - // websocket for real-time KYC status updates - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: !!user?.user.username, - onKycStatusUpdate: (newStatus) => { - setLiveKycStatus(newStatus as BridgeKycStatus) - }, - }) - - // effect to update live KYC status from user object - useEffect(() => { - if (user?.user.bridgeKycStatus) { - setLiveKycStatus(user.user.bridgeKycStatus as BridgeKycStatus) - } - }, [user?.user.bridgeKycStatus]) - /** * @name handleConfirmClaim * @description claims the link to the deposit address provided by the off-ramp api and confirms the transfer. @@ -241,23 +235,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setError(null) // scenario 1: receiver needs KYC + // name and email are now collected by sumsub sdk — no need to save them beforehand if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { - // update user's name and email if they are not present - const hasNameOnLoad = !!user?.user.fullName - const hasEmailOnLoad = !!user?.user.email - if (!hasNameOnLoad || !hasEmailOnLoad) { - if (user?.user.userId && rawData.firstName && rawData.lastName && rawData.email) { - const result = await updateUserById({ - userId: user.user.userId, - fullName: `${rawData.firstName} ${rawData.lastName}`.trim(), - email: rawData.email, - }) - if (result.error) return { error: result.error } - await fetchUser() - } - } - - setIsKycModalOpen(true) + await sumsubFlow.handleInitiateKyc('STANDARD') return {} } @@ -391,19 +371,6 @@ export const BankFlowManager = (props: IClaimScreenProps) => { return {} } - /** - * @name handleKycSuccess - * @description callback for when the KYC process is successfully completed. - */ - const handleKycSuccess = useCallback(async () => { - if (justCompletedKyc) return - - setIsKycModalOpen(false) - await fetchUser() - setJustCompletedKyc(true) - setClaimBankFlowStep(ClaimBankFlowStep.BankDetailsForm) - }, [fetchUser, setClaimBankFlowStep, setIsKycModalOpen, setJustCompletedKyc, justCompletedKyc]) - // main render logic based on the current flow step switch (claimBankFlowStep) { case ClaimBankFlowStep.SavedAccountsList: @@ -492,11 +459,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { initialData={{}} error={error} /> - setIsKycModalOpen(false)} - onKycSuccess={handleKycSuccess} - /> + ) case ClaimBankFlowStep.BankConfirmClaim: diff --git a/src/components/Global/Badges/StatusBadge.tsx b/src/components/Global/Badges/StatusBadge.tsx index 8eaf7335d..1f37ab48a 100644 --- a/src/components/Global/Badges/StatusBadge.tsx +++ b/src/components/Global/Badges/StatusBadge.tsx @@ -30,8 +30,9 @@ const StatusBadge: React.FC = ({ status, className, size = 'sm return 'bg-secondary-4 text-yellow-6 border border-yellow-7' case 'failed': case 'cancelled': - case 'refunded': return 'bg-error-1 text-error border border-error-2' + case 'refunded': + return 'bg-secondary-4 text-yellow-6 border border-yellow-7' case 'soon': case 'custom': return 'bg-primary-3 text-primary-4' @@ -41,6 +42,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 +64,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/Icons/Icon.tsx b/src/components/Global/Icons/Icon.tsx index faf8bafb9..5ec925560 100644 --- a/src/components/Global/Icons/Icon.tsx +++ b/src/components/Global/Icons/Icon.tsx @@ -66,6 +66,7 @@ import { WarningRounded, SpeedRounded, InfoRounded, + UndoRounded, } from '@mui/icons-material' import { DocsIcon } from './docs' import { PeanutSupportIcon } from './peanut-support' @@ -148,6 +149,7 @@ export type IconName = | 'split' | 'globe-lock' | 'bulb' + | 'undo' | 'upload-cloud' | 'alert-filled' | 'paste' @@ -288,6 +290,7 @@ const iconComponents: Record>> = 'txn-off': TxnOffIcon, docs: DocsIcon, bulb: BulbIcon, + undo: (props) => , 'upload-cloud': (props) => , 'invite-heart': InviteHeartIcon, 'alert-filled': (props) => , 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/Global/IframeWrapper/index.tsx b/src/components/Global/IframeWrapper/index.tsx index f41e820d3..e33a74c9b 100644 --- a/src/components/Global/IframeWrapper/index.tsx +++ b/src/components/Global/IframeWrapper/index.tsx @@ -12,14 +12,15 @@ export type IFrameWrapperProps = { visible: boolean onClose: (source?: 'manual' | 'completed' | 'tos_accepted') => void closeConfirmMessage?: string + skipStartView?: boolean } -const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWrapperProps) => { +const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage, skipStartView }: IFrameWrapperProps) => { const enableConfirmationPrompt = closeConfirmMessage !== undefined const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble') const [copied, setCopied] = useState(false) - const [isVerificationStarted, setIsVerificationStarted] = useState(false) + const [isVerificationStarted, setIsVerificationStarted] = useState(skipStartView ?? false) const router = useRouter() const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/components/Global/PostSignupActionManager/index.tsx b/src/components/Global/PostSignupActionManager/index.tsx index 4c0821810..b58d6143f 100644 --- a/src/components/Global/PostSignupActionManager/index.tsx +++ b/src/components/Global/PostSignupActionManager/index.tsx @@ -7,6 +7,7 @@ import ActionModal from '../ActionModal' import { POST_SIGNUP_ACTIONS } from './post-signup-action.consts' import { type IconName } from '../Icons/Icon' import { useAuth } from '@/context/authContext' +import { isUserKycVerified } from '@/constants/kyc.consts' export const PostSignupActionManager = ({ onActionModalVisibilityChange, @@ -26,7 +27,7 @@ export const PostSignupActionManager = ({ const checkClaimModalAfterKYC = () => { const redirectUrl = getRedirectUrl() - if (user?.user.bridgeKycStatus === 'approved' && redirectUrl) { + if (isUserKycVerified(user?.user) && redirectUrl) { const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(redirectUrl)) if (matchedAction) { setActionConfig({ diff --git a/src/components/Global/StatusPill/index.tsx b/src/components/Global/StatusPill/index.tsx index 269888fe7..4f7f45a40 100644 --- a/src/components/Global/StatusPill/index.tsx +++ b/src/components/Global/StatusPill/index.tsx @@ -13,7 +13,7 @@ const StatusPill = ({ status }: StatusPillProps) => { completed: 'border-success-5 bg-success-2 text-success-4', pending: 'border-yellow-8 bg-secondary-4 text-yellow-6', cancelled: 'border-error-2 bg-error-1 text-error', - refunded: 'border-error-2 bg-error-1 text-error', + refunded: 'border-yellow-8 bg-secondary-4 text-yellow-6', failed: 'border-error-2 bg-error-1 text-error', processing: 'border-yellow-8 bg-secondary-4 text-yellow-6', soon: 'border-yellow-8 bg-secondary-4 text-yellow-6', @@ -27,7 +27,7 @@ const StatusPill = ({ status }: StatusPillProps) => { soon: 'pending', pending: 'pending', cancelled: 'cancel', - refunded: 'cancel', + refunded: 'undo', closed: 'success', } @@ -38,7 +38,7 @@ const StatusPill = ({ status }: StatusPillProps) => { soon: 7, pending: 8, cancelled: 6, - refunded: 6, + refunded: 8, closed: 7, } diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index e04c59019..2bd5e3681 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -25,7 +25,7 @@ const desktopPaths: NavPathProps[] = [ { name: 'Add', href: '/add-money', icon: 'arrow-down', size: 15 }, { name: 'Withdraw', href: '/withdraw', icon: 'arrow-up', size: 15 }, { name: 'History', href: '/history', icon: 'history', size: 15 }, - { name: 'Docs', href: 'https://docs.peanut.me/', icon: 'docs', size: 14 }, + { name: 'Docs', href: '/en/help', icon: 'docs', size: 14 }, { name: 'Support', href: '/support', icon: 'peanut-support', size: 14 }, ] diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx index b7867765d..d8a043bda 100644 --- a/src/components/Home/HomeCarouselCTA/index.tsx +++ b/src/components/Home/HomeCarouselCTA/index.tsx @@ -8,14 +8,15 @@ import { type IconName } from '@/components/Global/Icons/Icon' import { useHomeCarouselCTAs, type CarouselCTA as CarouselCTAType } from '@/hooks/useHomeCarouselCTAs' import { perksApi, type PendingPerk } from '@/services/perks' import { useAuth } from '@/context/authContext' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useWebSocket } from '@/hooks/useWebSocket' import { extractInviteeName } from '@/utils/general.utils' import PerkClaimModal from '../PerkClaimModal' import underMaintenanceConfig from '@/config/underMaintenance.config' const HomeCarouselCTA = () => { - const { carouselCTAs, setCarouselCTAs } = useHomeCarouselCTAs() - const { user } = useAuth() + const { carouselCTAs, setCarouselCTAs, showBridgeTos, setShowBridgeTos } = useHomeCarouselCTAs() + const { user, fetchUser } = useAuth() const queryClient = useQueryClient() // Perk claim modal state @@ -89,6 +90,17 @@ const HomeCarouselCTA = () => { setSelectedPerk(null) }, []) + // bridge ToS handlers + const handleTosComplete = useCallback(async () => { + setShowBridgeTos(false) + setCarouselCTAs((prev) => prev.filter((c) => c.id !== 'bridge-tos')) + await fetchUser() + }, [setShowBridgeTos, setCarouselCTAs, fetchUser]) + + const handleTosSkip = useCallback(() => { + setShowBridgeTos(false) + }, [setShowBridgeTos]) + // don't render carousel if there are no CTAs if (!allCTAs.length) return null @@ -130,6 +142,9 @@ const HomeCarouselCTA = () => { onClaimed={handlePerkClaimed} /> )} + + {/* Bridge ToS iframe */} + ) } diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index e73e916c7..a1992a528 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -13,8 +13,8 @@ import { twMerge } from 'tailwind-merge' 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 { isKycStatusItem, type KycHistoryEntry } from '@/hooks/useBridgeKycFlow' +import { KycStatusItem, isKycStatusItem, type KycHistoryEntry } from '../Kyc/KycStatusItem' +import { groupKycByRegion } from '@/utils/kyc-grouping.utils' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' import { isBadgeHistoryItem } from '@/components/Badges/badge.types' @@ -43,7 +43,6 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h const { fetchBalance } = useWallet() const { triggerHaptic } = useHaptic() const { fetchUser } = useAuth() - const isViewingOwnHistory = useMemo( () => (isLoggedIn && !username) || (isLoggedIn && username === user?.user.username), [isLoggedIn, username, user?.user.username] @@ -72,6 +71,13 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h }, [fetchUser] ), + onSumsubKycStatusUpdate: useCallback( + async (newStatus: string) => { + console.log('Sumsub KYC status updated via WebSocket:', newStatus) + await fetchUser() + }, + [fetchUser] + ), }) // Combine fetched history with real-time updates @@ -177,32 +183,10 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h } } - // Add KYC status item if applicable and the user is - // viewing their own history - if (isViewingOwnHistory) { - if (user?.user?.bridgeKycStatus && user.user.bridgeKycStatus !== 'not_started') { - // Use appropriate timestamp based on KYC status - const bridgeKycTimestamp = (() => { - const status = user.user.bridgeKycStatus - if (status === 'approved') return user.user.bridgeKycApprovedAt - if (status === 'rejected') return user.user.bridgeKycRejectedAt - return user.user.bridgeKycStartedAt - })() - entries.push({ - isKyc: true, - timestamp: bridgeKycTimestamp ?? user.user.createdAt ?? new Date().toISOString(), - uuid: 'bridge-kyc-status-item', - bridgeKycStatus: user.user.bridgeKycStatus, - }) - } - user?.user.kycVerifications?.forEach((verification) => { - entries.push({ - isKyc: true, - timestamp: verification.approvedAt ?? verification.updatedAt ?? verification.createdAt, - uuid: verification.providerUserId ?? `${verification.provider}-${verification.mantecaGeo}`, - verification, - }) - }) + // add one kyc entry per region (STANDARD, LATAM) + if (isViewingOwnHistory && user?.user) { + const regionEntries = groupKycByRegion(user.user) + entries.push(...regionEntries) } // Check cancellation before setting state @@ -271,38 +255,29 @@ const HomeHistory = ({ username, hideTxnAmount = false }: { username?: string; h

Activity

{isViewingOwnHistory && - ((user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started') || - (user?.user.kycVerifications && user?.user.kycVerifications.length > 0)) && ( -
- {user?.user.bridgeKycStatus && user?.user.bridgeKycStatus !== 'not_started' && ( - - )} - {user?.user.kycVerifications?.map((verification) => ( - - ))} -
- )} - - {isViewingOwnHistory && - !user?.user.bridgeKycStatus && - (!user?.user.kycVerifications || user?.user.kycVerifications.length === 0) && ( - - )} + user?.user && + (() => { + const regionEntries = groupKycByRegion(user.user) + return regionEntries.length > 0 ? ( +
+ {regionEntries.map((entry) => ( + + ))} +
+ ) : ( + + ) + })()} {!isViewingOwnHistory && ( ) } diff --git a/src/components/Home/KycCompletedModal/index.tsx b/src/components/Home/KycCompletedModal/index.tsx index 1377b3a50..0b9fdff39 100644 --- a/src/components/Home/KycCompletedModal/index.tsx +++ b/src/components/Home/KycCompletedModal/index.tsx @@ -6,29 +6,27 @@ import InfoCard from '@/components/Global/InfoCard' import { useAuth } from '@/context/authContext' import { MantecaKycStatus } from '@/interfaces' import { countryData, MantecaSupportedExchanges, type CountryData } from '@/components/AddMoney/consts' -import useKycStatus from '@/hooks/useKycStatus' +import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import { useIdentityVerification } from '@/hooks/useIdentityVerification' const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { const { user } = useAuth() const [approvedCountryData, setApprovedCountryData] = useState(null) - const { isUserBridgeKycApproved, isUserMantecaKycApproved } = useKycStatus() + const { isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent } = + useUnifiedKycStatus() const { getVerificationUnlockItems } = useIdentityVerification() const kycApprovalType = useMemo(() => { - if (isUserBridgeKycApproved && isUserMantecaKycApproved) { - return 'all' - } - - if (isUserBridgeKycApproved) { + if (isSumsubApproved) { + if (sumsubVerificationRegionIntent === 'LATAM') return 'manteca' return 'bridge' } - if (isUserMantecaKycApproved) { - return 'manteca' - } + if (isBridgeApproved && isMantecaApproved) return 'all' + if (isBridgeApproved) return 'bridge' + if (isMantecaApproved) return 'manteca' return 'none' - }, [isUserBridgeKycApproved, isUserMantecaKycApproved]) + }, [isBridgeApproved, isMantecaApproved, isSumsubApproved, sumsubVerificationRegionIntent]) const items = useMemo(() => { return getVerificationUnlockItems(approvedCountryData?.title) @@ -36,7 +34,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = useEffect(() => { // If manteca KYC is approved, then we need to get the approved country - if (isUserMantecaKycApproved) { + if (isMantecaApproved) { const supportedCountries = Object.keys(MantecaSupportedExchanges) let approvedCountry: string | undefined | null @@ -58,7 +56,7 @@ const KycCompletedModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () = setApprovedCountryData(_approvedCountryData || null) } } - }, [isUserMantecaKycApproved, user]) + }, [isMantecaApproved, user]) return ( + QR Payments in Argentina and Brazil +

+) + +const BRIDGE_UNLOCK_ITEMS: Array = [ +

+ Europe SEPA transfers (+30 countries) +

, +

+ UK Faster payment transfers +

, +

+ United States ACH and Wire transfers +

, +

+ Mexico SPEI transfers +

, + QR_PAYMENTS, +] + +// unlock benefits shown per region +export const REGION_UNLOCK_ITEMS: Record> = { + latam: [ +

+ Bank transfers to your own accounts in LATAM +

, + QR_PAYMENTS, + ], + + europe: BRIDGE_UNLOCK_ITEMS, + + 'north-america': BRIDGE_UNLOCK_ITEMS, + + 'rest-of-the-world': [QR_PAYMENTS], +} + +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 +83,9 @@ const StartVerificationModal = ({ { shadowSize: '4', icon: 'check-circle', - text: 'Verify now', + text: isLoading ? 'Loading...' : 'Verify now', onClick: onStartVerification, + disabled: isLoading, }, ]} content={ @@ -86,11 +96,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/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx index 789cecc5a..711d66430 100644 --- a/src/components/Invites/JoinWaitlistPage.tsx +++ b/src/components/Invites/JoinWaitlistPage.tsx @@ -28,6 +28,7 @@ const JoinWaitlistPage = () => { const [inviteCode, setInviteCode] = useState(setupInviteCode) const { requestPermission, afterPermissionAttempt, isPermissionGranted } = useNotifications() + const [notificationSkipped, setNotificationSkipped] = useState(false) const { data, isLoading: isLoadingWaitlistPosition } = useQuery({ queryKey: ['waitlist-position'], @@ -85,7 +86,7 @@ const JoinWaitlistPage = () => { )} >
- {!isPermissionGranted && ( + {!isPermissionGranted && !notificationSkipped && (

Enable notifications

We'll send you an update as soon as you get access.

@@ -99,10 +100,13 @@ const JoinWaitlistPage = () => { > Yes, notify me +
)} - {isPermissionGranted && ( + {(isPermissionGranted || notificationSkipped) && (

You're still in Peanut jail

diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx new file mode 100644 index 000000000..bba3ca60c --- /dev/null +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -0,0 +1,109 @@ +'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 } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' +import { confirmBridgeTosAndAwaitRails } from '@/hooks/useMultiPhaseKycFlow' + +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) + + // auto-fetch ToS link when step becomes visible so the iframe opens directly + // (skips the intermediate "Accept Terms" confirmation modal) + useEffect(() => { + if (visible) { + handleAcceptTerms() + } else { + setShowIframe(false) + setTosLink(null) + setError(null) + } + }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps + + 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') { + await confirmBridgeTosAndAwaitRails(fetchUser) + onComplete() + } else { + onSkip() + } + }, + [fetchUser, onComplete, onSkip] + ) + + if (!visible) return null + + return ( + <> + {/* only show modal on error — normal flow goes straight to iframe */} + {error && !showIframe && ( + + )} + + {tosLink && } + + ) +} diff --git a/src/components/Kyc/CountryFlagAndName.tsx b/src/components/Kyc/CountryFlagAndName.tsx index dbc72d5de..3398a38bf 100644 --- a/src/components/Kyc/CountryFlagAndName.tsx +++ b/src/components/Kyc/CountryFlagAndName.tsx @@ -16,6 +16,7 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA icons={[ 'https://flagcdn.com/w160/us.png', 'https://flagcdn.com/w160/eu.png', + 'https://flagcdn.com/w160/gb.png', 'https://flagcdn.com/w160/mx.png', ]} iconSize={80} @@ -31,7 +32,7 @@ export const CountryFlagAndName = ({ countryCode, isBridgeRegion }: CountryFlagA loading="lazy" /> )} - {isBridgeRegion ? 'US/EU/MX' : countryName} + {isBridgeRegion ? 'US/EU/UK/MX' : countryName}
) } diff --git a/src/components/Kyc/CountryRegionRow.tsx b/src/components/Kyc/CountryRegionRow.tsx index 00519a908..aa247b36d 100644 --- a/src/components/Kyc/CountryRegionRow.tsx +++ b/src/components/Kyc/CountryRegionRow.tsx @@ -4,9 +4,10 @@ import { CountryFlagAndName } from './CountryFlagAndName' interface CountryRegionRowProps { countryCode?: string | null isBridge?: boolean + hideBottomBorder?: boolean } -export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProps) => { +export const CountryRegionRow = ({ countryCode, isBridge, hideBottomBorder }: CountryRegionRowProps) => { if (!isBridge && !countryCode) { return null } @@ -15,6 +16,7 @@ export const CountryRegionRow = ({ countryCode, isBridge }: CountryRegionRowProp } + hideBottomBorder={hideBottomBorder} /> ) } diff --git a/src/components/Kyc/InitiateBridgeKYCModal.tsx b/src/components/Kyc/InitiateBridgeKYCModal.tsx deleted file mode 100644 index dfd8b1887..000000000 --- a/src/components/Kyc/InitiateBridgeKYCModal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import ActionModal from '@/components/Global/ActionModal' -import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow' -import IframeWrapper from '@/components/Global/IframeWrapper' -import { KycVerificationInProgressModal } from './KycVerificationInProgressModal' -import { type IconName } from '@/components/Global/Icons/Icon' -import { saveRedirectUrl } from '@/utils/general.utils' -import useClaimLink from '../Claim/useClaimLink' -import { useEffect } from 'react' - -interface BridgeKycModalFlowProps { - isOpen: boolean - onClose: () => void - onKycSuccess?: () => void - onManualClose?: () => void - flow?: 'add' | 'withdraw' | 'request_fulfillment' -} - -export const InitiateBridgeKYCModal = ({ - isOpen, - onClose, - onKycSuccess, - onManualClose, - flow, -}: BridgeKycModalFlowProps) => { - const { - isLoading, - error, - iframeOptions, - isVerificationProgressModalOpen, - handleInitiateKyc, - handleIframeClose, - closeVerificationProgressModal, - resetError, - } = useBridgeKycFlow({ onKycSuccess, flow, onManualClose }) - const { addParamStep } = useClaimLink() - - // Reset error when modal opens to ensure clean state - useEffect(() => { - if (isOpen) { - resetError() - } - }, [isOpen, resetError]) - - const handleVerifyClick = async () => { - // Only add step param for claim flows (not add-money flow which has its own URL state) - if (flow !== 'add') { - addParamStep('bank') - } - const result = await handleInitiateKyc() - if (result?.success) { - saveRedirectUrl() - onClose() - } - } - - return ( - <> - - - - - ) -} diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx new file mode 100644 index 000000000..3d9a8ea55 --- /dev/null +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -0,0 +1,38 @@ +import ActionModal from '@/components/Global/ActionModal' +import { type IconName } from '@/components/Global/Icons/Icon' +import { PeanutDoesntStoreAnyPersonalInformation } from '@/components/Kyc/PeanutDoesntStoreAnyPersonalInformation' + +interface InitiateKycModalProps { + visible: boolean + onClose: () => void + onVerify: () => void + isLoading?: boolean +} + +// confirmation modal shown before starting KYC. +// user must click "Start Verification" to proceed to the sumsub SDK. +export const InitiateKycModal = ({ visible, onClose, onVerify, isLoading }: InitiateKycModalProps) => { + return ( + } + /> + ) +} diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx deleted file mode 100644 index d72f425cc..000000000 --- a/src/components/Kyc/InitiateMantecaKYCModal.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client' - -import ActionModal from '@/components/Global/ActionModal' -import IframeWrapper from '@/components/Global/IframeWrapper' -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 { useEffect } from 'react' - -interface Props { - isOpen: boolean - onClose: () => void - onKycSuccess?: () => void - onManualClose?: () => void - country: CountryData - title?: string | React.ReactNode - description?: string | React.ReactNode - ctaText?: string - footer?: React.ReactNode - autoStartKyc?: boolean -} - -const InitiateMantecaKYCModal = ({ - isOpen, - onClose, - onKycSuccess, - onManualClose, - country, - title, - description, - ctaText, - footer, - autoStartKyc, -}: Props) => { - const { isLoading, iframeOptions, openMantecaKyc, handleIframeClose } = useMantecaKycFlow({ - onClose: onManualClose, // any non-success close from iframe is a manual close in case of Manteca KYC - onSuccess: onKycSuccess, - onManualClose, - country, - }) - - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - if (event.data.source === 'peanut-kyc-success') { - onKycSuccess?.() - } - } - - window.addEventListener('message', handleMessage) - - return () => { - window.removeEventListener('message', handleMessage) - } - }, []) - - useEffect(() => { - if (autoStartKyc) { - openMantecaKyc(country) - } - }, [autoStartKyc]) - - const isAutoStarting = autoStartKyc && isLoading - const displayTitle = isAutoStarting ? 'Starting verification...' : (title ?? 'Verify your identity first') - const displayDescription = isAutoStarting - ? 'Please wait while we start your verification...' - : (description ?? - 'To continue, you need to complete identity verification. This usually takes just a few minutes.') - - return ( - <> - openMantecaKyc(country), - variant: 'purple', - disabled: isLoading, - shadowSize: '4', - icon: 'check-circle', - className: 'h-11', - }, - ]} - footer={footer} - /> - - - ) -} - -export const MantecaGeoSpecificKycModal = ({ - isUserBridgeKycApproved, - selectedCountry, - setIsMantecaModalOpen, - isMantecaModalOpen, - onKycSuccess, - onClose, - onManualClose, -}: { - isUserBridgeKycApproved: boolean - selectedCountry: { id: string; title: string } - setIsMantecaModalOpen: (isOpen: boolean) => void - isMantecaModalOpen: boolean - onKycSuccess: () => void - onClose?: () => void - onManualClose?: () => void -}) => { - return ( - - You're already verified in Europe, USA, and Mexico, but to use features in{' '} - {selectedCountry.title} you need to complete a separate verification.
Since{' '} - we don't keep personal data, your previous KYC can't be reused. -

- ) : ( -

- Verify your identity to start using features like Mercado Pago payments in{' '} - {selectedCountry.title}.{' '} -

- ) - } - footer={ - isUserBridgeKycApproved ? ( - - ) : ( - - ) - } - ctaText="Start Verification" - isOpen={isMantecaModalOpen} - onClose={() => { - setIsMantecaModalOpen(false) - onClose?.() - }} - onKycSuccess={() => { - setIsMantecaModalOpen(false) - onKycSuccess?.() - }} - onManualClose={() => { - setIsMantecaModalOpen(false) - onManualClose?.() - }} - country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }} - /> - ) -} 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/KycFailedContent.tsx b/src/components/Kyc/KycFailedContent.tsx new file mode 100644 index 000000000..18151263f --- /dev/null +++ b/src/components/Kyc/KycFailedContent.tsx @@ -0,0 +1,24 @@ +import { RejectLabelsList } from './RejectLabelsList' +import InfoCard from '@/components/Global/InfoCard' + +interface KycFailedContentProps { + rejectLabels?: string[] | null + isTerminal: boolean +} + +// shared rejection details — used by both KycFailed (drawer) and KycFailedModal. +// renders reject labels (non-terminal) or terminal error info card. +export const KycFailedContent = ({ rejectLabels, isTerminal }: KycFailedContentProps) => { + if (isTerminal) { + return ( + + ) + } + + return +} diff --git a/src/components/Kyc/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..03b71e432 100644 --- a/src/components/Kyc/KycStatusDrawer.tsx +++ b/src/components/Kyc/KycStatusDrawer.tsx @@ -1,41 +1,38 @@ +import { KycActionRequired } from './states/KycActionRequired' import { KycCompleted } from './states/KycCompleted' import { KycFailed } from './states/KycFailed' +import { KycNotStarted } from './states/KycNotStarted' import { KycProcessing } from './states/KycProcessing' +import { KycRequiresDocuments } from './states/KycRequiresDocuments' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' 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 { 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 { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { getKycStatusCategory, isKycStatusNotStarted } from '@/constants/kyc.consts' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' +import { useCallback } from 'react' interface KycStatusDrawerProps { isOpen: boolean onClose: () => void verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus + region?: 'STANDARD' | 'LATAM' + /** keep this component mounted even after drawer closes (so SumsubKycModals persists) */ + onKeepMounted?: (keep: boolean) => void } // this component determines which kyc state to show inside the drawer and fetches rejection reasons if the kyc has failed. -export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus }: KycStatusDrawerProps) => { +export const KycStatusDrawer = ({ + isOpen, + onClose, + verification, + bridgeKycStatus, + region, + onKeepMounted, +}: KycStatusDrawerProps) => { const { user } = useUserStore() const status = verification ? verification.status : bridgeKycStatus @@ -43,39 +40,85 @@ 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, - iframeOptions: bridgeIframeOptions, - handleIframeClose: handleBridgeIframeClose, - isLoading: isBridgeLoading, - } = useBridgeKycFlow({ onKycSuccess: onClose, onManualClose: onClose }) + // close drawer and release the keep-mounted hold + const handleFlowDone = useCallback(() => { + onClose() + onKeepMounted?.(false) + }, [onClose, onKeepMounted]) - const country = countryCode ? countryData.find((c) => c.id.toUpperCase() === countryCode.toUpperCase()) : undefined - - const { - openMantecaKyc, - iframeOptions: mantecaIframeOptions, - handleIframeClose: handleMantecaIframeClose, - isLoading: isMantecaLoading, - } = useMantecaKycFlow({ - onSuccess: onClose, - onClose: onClose, - onManualClose: onClose, - country: country as CountryData, + const sumsubFlow = useMultiPhaseKycFlow({ + onKycSuccess: handleFlowDone, + onManualClose: handleFlowDone, + // don't pass regionIntent for completed kyc — prevents the mount effect + // in useSumsubKycFlow from calling initiateSumsubKyc(), which triggers + // the undefined->APPROVED transition that auto-closes the drawer + regionIntent: statusCategory === 'completed' ? undefined : sumsubRegionIntent, }) - const onRetry = async () => { - if (provider === 'MANTECA') { - await openMantecaKyc(country as CountryData) - } else { - await initiateBridgeKyc() - } - } + // close drawer but keep mounted so SumsubKycModals persists, then start kyc + const closeAndStartKyc = useCallback( + async (regionIntent?: KYCRegionIntent, levelName?: string) => { + onKeepMounted?.(true) + onClose() + try { + await sumsubFlow.handleInitiateKyc(regionIntent, levelName) + } catch (e) { + onKeepMounted?.(false) + throw e + } + }, + [onKeepMounted, onClose, sumsubFlow] + ) - const isLoadingKyc = isBridgeLoading || isMantecaLoading + // 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. + const sumsubFailureCount = + user?.user?.kycVerifications?.filter((v) => v.provider === 'SUMSUB' && v.status === 'REJECTED').length ?? 0 + + const handleSubmitAdditionalDocs = useCallback( + () => closeAndStartKyc(undefined, 'peanut-additional-docs'), + [closeAndStartKyc] + ) const renderContent = () => { + // user initiated kyc but abandoned before submitting — close drawer visually + // but keep component mounted so SumsubKycModals persists for the SDK flow + if (verification && isKycStatusNotStarted(status)) { + return + } + + // bridge additional document requirement — but don't mask terminal kyc states + if (needsAdditionalDocs && statusCategory !== 'failed' && statusCategory !== 'action_required') { + return ( + + ) + } + switch (statusCategory) { case 'processing': return ( @@ -90,18 +133,31 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus + ) + case 'action_required': + return ( + ) case 'failed': return ( ) default: @@ -109,8 +165,9 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus } } - // don't render the drawer if the kyc status is unknown or not started - if (status === 'not_started' || !status) { + // don't render the drawer if the kyc status is unknown or not started. + // if a verification record exists, the user has initiated KYC — show the drawer. + if (!verification && isKycStatusNotStarted(status)) { return null } @@ -120,10 +177,12 @@ export const KycStatusDrawer = ({ isOpen, onClose, verification, bridgeKycStatus KYC Status {renderContent()} + {sumsubFlow.error && provider === 'SUMSUB' && ( +

{sumsubFlow.error}

+ )}
- - + ) } diff --git a/src/components/Kyc/KycStatusItem.tsx b/src/components/Kyc/KycStatusItem.tsx index 1ca8536ed..80f9de1b4 100644 --- a/src/components/Kyc/KycStatusItem.tsx +++ b/src/components/Kyc/KycStatusItem.tsx @@ -10,6 +10,27 @@ 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' + +// kyc history entry type + type guard — used by HomeHistory and history page +export interface KycHistoryEntry { + isKyc: true + uuid: string + timestamp: string + verification?: IUserKycVerification + bridgeKycStatus?: BridgeKycStatus + region?: 'STANDARD' | 'LATAM' +} + +export const isKycStatusItem = (entry: object): entry is KycHistoryEntry => { + return 'isKyc' in entry && entry.isKyc === true +} // this component shows the current kyc status and opens a drawer with more details on click export const KycStatusItem = ({ @@ -18,16 +39,21 @@ export const KycStatusItem = ({ verification, bridgeKycStatus, bridgeKycStartedAt, + region, }: { position?: CardPosition className?: HTMLAttributes['className'] verification?: IUserKycVerification bridgeKycStatus?: BridgeKycStatus bridgeKycStartedAt?: string + region?: 'STANDARD' | 'LATAM' }) => { const { user } = useUserStore() const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [wsBridgeKycStatus, setWsBridgeKycStatus] = useState(undefined) + // keep drawer component mounted when SDK flow is active (so SumsubKycModals persists + // even after the drawer visually closes) + const [keepDrawerMounted, setKeepDrawerMounted] = useState(false) const handleCloseDrawer = useCallback(() => { setIsDrawerOpen(false) @@ -45,23 +71,36 @@ 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' + // check if any bridge rail needs additional documents + const hasBridgeDocsNeeded = useMemo( + () => + (user?.rails ?? []).some( + (r) => r.status === 'REQUIRES_EXTRA_INFORMATION' && r.rail.provider.code === 'BRIDGE' + ), + [user?.rails] + ) - 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 (hasBridgeDocsNeeded) return 'Action needed' + if (isInitiatedButNotStarted) return 'Not completed' + if (isActionRequired) return 'Action needed' + if (isPending) return 'Processing' + if (isApproved) return 'Verified' + if (isRejected) return 'Failed' + return 'Unknown' + }, [hasBridgeDocsNeeded, 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 && !hasBridgeDocsNeeded && isKycStatusNotStarted(kycStatus)) { return null } @@ -81,19 +120,31 @@ export const KycStatusItem = ({

Identity verification

{subtitle}

- +
- + {(isDrawerOpen || keepDrawerMounted) && ( + + )} ) } diff --git a/src/components/Kyc/KycVerificationInProgressModal.tsx b/src/components/Kyc/KycVerificationInProgressModal.tsx index 401aa0ba9..b314c53b9 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,114 @@ 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..2c1ab001c --- /dev/null +++ b/src/components/Kyc/SumsubKycFlow.tsx @@ -0,0 +1,32 @@ +import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types' + +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 flow = useMultiPhaseKycFlow({ onKycSuccess, onManualClose, regionIntent }) + + return ( + <> + + + {flow.error &&

{flow.error}

} + + + + ) +} diff --git a/src/components/Kyc/SumsubKycModals.tsx b/src/components/Kyc/SumsubKycModals.tsx new file mode 100644 index 000000000..25097b430 --- /dev/null +++ b/src/components/Kyc/SumsubKycModals.tsx @@ -0,0 +1,53 @@ +import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { KycVerificationInProgressModal } from '@/components/Kyc/KycVerificationInProgressModal' +import IframeWrapper from '@/components/Global/IframeWrapper' +import { type useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' + +interface SumsubKycModalsProps { + flow: ReturnType + autoStartSdk?: boolean +} + +/** + * shared modal rendering for the multi-phase kyc flow. + * renders the sumsub SDK wrapper, the multi-phase verification modal, + * and the bridge ToS iframe. + * + * pair with useMultiPhaseKycFlow hook for the logic. + */ +export const SumsubKycModals = ({ flow, autoStartSdk }: SumsubKycModalsProps) => { + return ( + <> + + + + + {flow.tosLink && ( + + )} + + ) +} diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx new file mode 100644 index 000000000..5bbbafc3f --- /dev/null +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -0,0 +1,331 @@ +'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 + /** multi-level workflow (e.g. LATAM) — don't close SDK on Level 1 submission */ + isMultiLevel?: boolean +} + +export const SumsubKycWrapper = ({ + visible, + accessToken, + onClose, + onComplete, + onError, + onRefreshToken, + autoStart, + isMultiLevel, +}: 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) + const isMultiLevelRef = useRef(isMultiLevel) + + useEffect(() => { + onCompleteRef.current = onComplete + onErrorRef.current = onError + onRefreshTokenRef.current = onRefreshToken + isMultiLevelRef.current = isMultiLevel + }, [onComplete, onError, onRefreshToken, isMultiLevel]) + + // 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 { + // track sdk init time so we can ignore stale onApplicantStatusChanged events + // that fire immediately when the applicant is already approved (e.g. additional-docs flow) + const sdkInitTime = Date.now() + + const handleSubmitted = () => { + console.log('[sumsub] onApplicantSubmitted fired') + // for multi-level workflows (LATAM), the SDK transitions to Level 2 + // internally. don't close the modal on Level 1 submission. + if (isMultiLevelRef.current) return + stableOnComplete() + } + // resubmission = user retried after rejection (ACTION_REQUIRED). + // always close SDK regardless of multi-level — the retry is a fresh submission. + const handleResubmitted = () => { + console.log('[sumsub] onApplicantResubmitted fired') + stableOnComplete() + } + const handleStatusChanged = (payload: { + reviewStatus?: string + reviewResult?: { reviewAnswer?: string } + }) => { + console.log('[sumsub] onApplicantStatusChanged fired', payload) + // ignore status events that fire within 3s of sdk init — these reflect + // pre-existing state (e.g. user already approved), not a new submission + if (Date.now() - sdkInitTime < 3000) { + console.log('[sumsub] ignoring early onApplicantStatusChanged (pre-existing state)') + return + } + // for multi-level workflows (LATAM), Level 1 fires completed+GREEN + // before Level 2 is shown. don't close the SDK. + if (isMultiLevelRef.current) return + // 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', + onClick: () => setIsSupportModalOpen(true), + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + ], + } + } + + return autoStart + ? { + title: 'Are you sure you want to exit?', + description: + "You are about to exit verification, you can come back to finish this later. Your progress won't be lost.", + icon: 'alert' as IconName, + iconContainerClassName: 'bg-secondary-1', + ctas: [ + { + text: 'Exit', + onClick: () => { + setIsHelpModalOpen(false) + onClose() + }, + variant: 'purple' as ButtonVariant, + shadowSize: '4' as const, + }, + { + text: 'Continue', + onClick: () => setIsHelpModalOpen(false), + variant: 'transparent' as ButtonVariant, + className: 'underline text-sm font-medium w-full h-fit mt-3', + }, + ], + } + : { + 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', + }, + ], + } + }, [autoStart, 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/KycFailedModal.tsx b/src/components/Kyc/modals/KycFailedModal.tsx new file mode 100644 index 000000000..ab3a1f12d --- /dev/null +++ b/src/components/Kyc/modals/KycFailedModal.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import { KycFailedContent } from '../KycFailedContent' +import { isTerminalRejection } from '@/constants/sumsub-reject-labels.consts' +import { useModalsContext } from '@/context/ModalsContext' + +interface KycFailedModalProps { + 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 KycFailedModal = ({ + visible, + onClose, + onRetry, + isLoading, + rejectLabels, + rejectType, + failureCount, +}: KycFailedModalProps) => { + const { setIsSupportModalOpen } = useModalsContext() + + const isTerminal = useMemo( + () => isTerminalRejection({ rejectType, failureCount, rejectLabels }), + [rejectType, failureCount, rejectLabels] + ) + + return ( + + + + } + 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/modals/KycProcessingModal.tsx b/src/components/Kyc/modals/KycProcessingModal.tsx new file mode 100644 index 000000000..f2e996c21 --- /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/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..0fd36b913 100644 --- a/src/components/Kyc/states/KycCompleted.tsx +++ b/src/components/Kyc/states/KycCompleted.tsx @@ -1,7 +1,9 @@ import Card from '@/components/Global/Card' +import InfoCard from '@/components/Global/InfoCard' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useMemo } from 'react' +import { REGION_UNLOCK_ITEMS } from '@/components/IdentityVerification/StartVerificationModal' import { formatDate } from '@/utils/general.utils' import { CountryRegionRow } from '../CountryRegionRow' import Image from 'next/image' @@ -16,10 +18,12 @@ export const KycCompleted = ({ bridgeKycApprovedAt, countryCode, isBridge, + region, }: { bridgeKycApprovedAt?: string countryCode?: string | null isBridge?: boolean + region?: 'STANDARD' | 'LATAM' }) => { const verifiedOn = useMemo(() => { if (!bridgeKycApprovedAt) return 'N/A' @@ -31,10 +35,13 @@ export const KycCompleted = ({ } }, [bridgeKycApprovedAt]) + const regionPath = region === 'LATAM' ? 'latam' : 'europe' + const benefits = REGION_UNLOCK_ITEMS[regionPath] ?? [] + return (
- - + + } /> - + {benefits.length > 0 && ( +
+

What you've unlocked:

+ +
+ )}
) diff --git a/src/components/Kyc/states/KycFailed.tsx b/src/components/Kyc/states/KycFailed.tsx index 6c7a45fe6..493a4ef8b 100644 --- a/src/components/Kyc/states/KycFailed.tsx +++ b/src/components/Kyc/states/KycFailed.tsx @@ -1,76 +1,108 @@ import { Button } from '@/components/0_Bruddle/Button' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { KycFailedContent } from '../KycFailedContent' import Card from '@/components/Global/Card' 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] + ) - return ( -
    - {lines.map((line, index) => ( -
  • {line}
  • - ))} -
- ) - }, [reason]) + // determine which row is last in the card for border handling + const hasCountryRow = isBridge || !!countryCode + const hasReasonRow = !isSumsub return (
- - - - - + + + + {hasReasonRow && ( + + )} - + + {isSumsub && } + + {isTerminal ? ( +
+ {/* TODO: auto-create crisp support ticket on terminal rejection */} + +
+ ) : ( + + )}
) } diff --git a/src/components/Kyc/states/KycNotStarted.tsx b/src/components/Kyc/states/KycNotStarted.tsx new file mode 100644 index 000000000..cb649ade9 --- /dev/null +++ b/src/components/Kyc/states/KycNotStarted.tsx @@ -0,0 +1,23 @@ +import InfoCard from '@/components/Global/InfoCard' +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' + +// shown when user initiated kyc but abandoned before submitting documents. +// provides a cta to resume the verification flow. +export const KycNotStarted = ({ onResume, isLoading }: { onResume: () => void; isLoading?: boolean }) => { + return ( +
+ + + + + +
+ ) +} diff --git a/src/components/Kyc/states/KycRequiresDocuments.tsx b/src/components/Kyc/states/KycRequiresDocuments.tsx new file mode 100644 index 000000000..c9a751615 --- /dev/null +++ b/src/components/Kyc/states/KycRequiresDocuments.tsx @@ -0,0 +1,49 @@ +import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' +import { Button } from '@/components/0_Bruddle/Button' +import InfoCard from '@/components/Global/InfoCard' +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 ( +
+ + +
+

Our payment provider requires additional verification documents.

+ {requirements.length > 0 ? ( + requirements.map((req) => { + const label = getRequirementLabel(req) + return ( + + ) + }) + ) : ( + + )} +
+ + +
+ ) +} 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/components/ProfileMenuItem.tsx b/src/components/Profile/components/ProfileMenuItem.tsx index 8cc30b28a..e1bb97bec 100644 --- a/src/components/Profile/components/ProfileMenuItem.tsx +++ b/src/components/Profile/components/ProfileMenuItem.tsx @@ -6,7 +6,6 @@ import NavigationArrow from '@/components/Global/NavigationArrow' import { Tooltip } from '@/components/Tooltip' import Link from 'next/link' import React from 'react' -import { twMerge } from 'tailwind-merge' interface ProfileMenuItemProps { icon: IconName | React.ReactNode @@ -21,6 +20,7 @@ interface ProfileMenuItemProps { showTooltip?: boolean toolTipText?: string badge?: string + highlight?: boolean } const ProfileMenuItem: React.FC = ({ @@ -36,6 +36,7 @@ const ProfileMenuItem: React.FC = ({ showTooltip = false, toolTipText, badge, + highlight = false, }) => { const content = (
@@ -47,7 +48,14 @@ const ProfileMenuItem: React.FC = ({ {icon}
)} - {label} +
+ + {highlight && ( +
+
+
+ )} +
{badge && } {showTooltip && ( diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index ef6137a33..8fbeba0da 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -15,7 +15,7 @@ import { checkIfInternalNavigation } from '@/utils/general.utils' import { useAuth } from '@/context/authContext' import ShareButton from '@/components/Global/ShareButton' import ActionModal from '@/components/Global/ActionModal' -import { MantecaKycStatus } from '@/interfaces' +import { isUserKycVerified } from '@/constants/kyc.consts' import BadgesRow from '@/components/Badges/BadgesRow' interface PublicProfileProps { @@ -54,14 +54,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa if (apiUser?.fullName) setFullName(apiUser.fullName) // get the profile owner's showFullName preference setShowFullName(apiUser?.showFullName ?? false) - if ( - apiUser?.bridgeKycStatus === 'approved' || - apiUser?.kycVerifications?.some((v) => v.status === MantecaKycStatus.ACTIVE) - ) { - setIsKycVerified(true) - } else { - setIsKycVerified(false) - } + setIsKycVerified(isUserKycVerified(apiUser)) // to check if the logged in user has sent money to the profile user, // we check the amount that the profile user has received from the logged in user. if (apiUser?.totalUsdReceivedFromCurrentUser) { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 09086deed..554d1b02e 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -7,7 +7,6 @@ import NavHeader from '../Global/NavHeader' import ProfileHeader from './components/ProfileHeader' import ProfileMenuItem from './components/ProfileMenuItem' import { useRouter } from 'next/navigation' -import { checkIfInternalNavigation } from '@/utils/general.utils' import { useState } from 'react' import useKycStatus from '@/hooks/useKycStatus' import underMaintenanceConfig from '@/config/underMaintenance.config' @@ -35,20 +34,7 @@ export const Profile = () => { return (
- { - // Check if the referrer is from the same domain (internal navigation) - const isInternalReferrer = checkIfInternalNavigation() - - if (isInternalReferrer && window.history.length > 1) { - router.back() - } else { - router.push('/home') - } - }} - /> + router.push('/home')} />
@@ -75,11 +61,13 @@ export const Profile = () => {
+ 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/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 442d47dc9..36d422051 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -14,7 +14,7 @@ import useKycStatus from '@/hooks/useKycStatus' export const ProfileEditView = () => { const router = useRouter() const { user, fetchUser } = useAuth() - const { isUserBridgeKycApproved } = useKycStatus() + const { isUserKycApproved } = useKycStatus() const [isLoading, setIsLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') @@ -98,7 +98,7 @@ export const ProfileEditView = () => { // refresh user data await fetchUser() - router.push('/profile') + router.replace('/profile') } catch (error) { console.error('Error updating profile:', error) setErrorMessage('Something went wrong. Please try again or contact support.') @@ -113,9 +113,9 @@ export const ProfileEditView = () => { return (
- router.push('/profile')} /> + router.back()} /> - +
{ value={formData.name} onChange={(value) => handleChange('name', value)} placeholder="Add your name" - disabled={user?.user.bridgeKycStatus === 'approved'} + disabled={isUserKycApproved} /> { value={formData.surname} onChange={(value) => handleChange('surname', value)} placeholder="Add your surname" - disabled={user?.user.bridgeKycStatus === 'approved'} + disabled={isUserKycApproved} /> { - 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..fdd051eb5 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -5,13 +5,110 @@ 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 { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' +import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' +import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal' +import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' +import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +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 } = 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) + // 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) + setAutoStartSdk(false) + }, []) + + const flow = useMultiPhaseKycFlow({ + regionIntent: activeRegionIntent, + onKycSuccess: handleFinalKycSuccess, + 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 flow.handleInitiateKyc(intent) + }, [flow.handleInitiateKyc, selectedRegion]) + + // re-submission: skip StartVerificationView since user already consented + const handleResubmitKyc = useCallback(async () => { + setAutoStartSdk(true) + await handleStartKyc() + }, [handleStartKyc]) return (
@@ -37,11 +134,47 @@ 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?

- + + + )}
+ + + + + + + + + + {flow.error &&

{flow.error}

} + +
) } @@ -51,9 +184,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 +204,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/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index c25bc440d..80cb02e01 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -21,6 +21,7 @@ import { useRouter } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useUserInteractions } from '@/hooks/useUserInteractions' import { useUserByUsername } from '@/hooks/useUserByUsername' +import { isUserKycVerified } from '@/constants/kyc.consts' interface DirectRequestInitialViewProps { username: string @@ -216,7 +217,7 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = recipientType={'USERNAME'} username={recipientUser?.username || username} fullName={recipientUser?.fullName} - isVerified={recipientUser?.bridgeKycStatus === 'approved'} + isVerified={isUserKycVerified(recipientUser)} haveSentMoneyToUser={recipientUser?.userId ? interactions[recipientUser.userId] || false : false} /> diff --git a/src/components/Send/views/Contacts.view.tsx b/src/components/Send/views/Contacts.view.tsx index 148b4e59b..73efbc09f 100644 --- a/src/components/Send/views/Contacts.view.tsx +++ b/src/components/Send/views/Contacts.view.tsx @@ -13,6 +13,7 @@ import PeanutLoading from '@/components/Global/PeanutLoading' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' import { useDebounce } from '@/hooks/useDebounce' +import { isUserKycVerified } from '@/constants/kyc.consts' import { ContactsListSkeleton } from '@/components/Common/ContactsListSkeleton' export default function ContactsView() { @@ -138,7 +139,7 @@ export default function ContactsView() {

Your contacts

{contacts.map((contact, index) => { - const isVerified = contact.bridgeKycStatus === 'approved' + const isVerified = isUserKycVerified(contact) const displayName = contact.showFullName ? contact.fullName || contact.username : contact.username diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx index 34243f177..d1496af07 100644 --- a/src/components/Setup/Views/InstallPWA.tsx +++ b/src/components/Setup/Views/InstallPWA.tsx @@ -209,12 +209,13 @@ const InstallPWA = ({ ) } - // Scenario 4: Fallback (cannot initiate automatic install) + // Scenario 4: Fallback (manual install instructions) return ( -
-

Could not initiate automatic installation.

-

Please try adding to Home Screen manually via your browser menu.

-
diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index aeb22bd3a..f6761b7f3 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -182,9 +182,13 @@ const TransactionCard: React.FC = ({
{/* display the action icon and type text */}
- {!isTestTransaction && getActionIcon(type, transaction.direction)} + {!isTestTransaction && getActionIcon(type, transaction.direction, status)} - {isTestTransaction ? 'Setup' : isPerkReward ? 'Cashback' : getActionText(type)} + {isTestTransaction + ? 'Setup' + : isPerkReward + ? 'Cashback' + : getActionText(type, status)} {status && }
@@ -232,7 +236,15 @@ const TransactionCard: React.FC = ({ } // helper functions -function getActionIcon(type: TransactionType, direction: TransactionDirection): React.ReactNode { +function getActionIcon( + type: TransactionType, + direction: TransactionDirection, + status?: StatusPillType +): React.ReactNode { + if (status === 'refunded') { + return + } + let iconName: IconName | null = null let iconSize = 7 @@ -275,7 +287,9 @@ function getActionIcon(type: TransactionType, direction: TransactionDirection): return } -function getActionText(type: TransactionType): string { +function getActionText(type: TransactionType, status?: StatusPillType): string { + if (status === 'refunded') return 'Refund' + let actionText: string = type switch (type) { case 'bank_withdraw': diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 92b95b4c3..d3fd2efd6 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -161,7 +161,9 @@ export const TransactionDetailsReceipt = ({ !( transaction.extraDataForDrawer?.originalType === EHistoryEntryType.SEND_LINK && transaction.extraDataForDrawer?.originalUserRole === EHistoryUserRole.SENDER - ) + ) && + // hide token and network for refunded entries + transaction.status !== 'refunded' ), txId: !!transaction.txHash, // show cancelled row if status is cancelled, use cancelledDate or fallback to createdAt @@ -1298,7 +1300,7 @@ export const TransactionDetailsReceipt = ({
)} - {isQRPayment && ( + {isQRPayment && transaction.status !== 'refunded' && (