diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index f02007d2a..ff91f1a7b 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -116,6 +116,17 @@ export default function QRPayPage() { return null } }, [qrType]) + const targetMantecaCountry = useMemo(() => { + switch (qrType) { + case EQrType.PIX: + return 'BR' + case EQrType.MERCADO_PAGO: + case EQrType.ARGENTINA_QR3: + return 'AR' + default: + return undefined + } + }, [qrType]) // Check if this payment provider is under maintenance const isProviderDisabled = useMemo(() => { @@ -123,7 +134,7 @@ export default function QRPayPage() { }, [paymentProcessor]) const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor) - const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { isUserSumsubKycApproved } = useKycStatus() const sumsubFlow = useMultiPhaseKycFlow({}) const queryClient = useQueryClient() const [isShaking, setIsShaking] = useState(false) @@ -1158,7 +1169,12 @@ export default function QRPayPage() { { text: 'Verify now', onClick: () => - sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), + sumsubFlow.handleInitiateKyc( + 'LATAM', + undefined, + isUserSumsubKycApproved || undefined, + targetMantecaCountry + ), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -1176,7 +1192,12 @@ export default function QRPayPage() { { text: 'Continue verification', onClick: () => - sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), + sumsubFlow.handleInitiateKyc( + 'LATAM', + undefined, + isUserSumsubKycApproved || undefined, + targetMantecaCountry + ), variant: 'purple', shadowSize: '4', icon: 'check-circle', diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index c8448eaaa..e94d0a0f9 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -27,7 +27,6 @@ import ValidatedInput from '@/components/Global/ValidatedInput' import AmountInput from '@/components/Global/AmountInput' import { formatUnits, parseUnits } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { useAuth } from '@/context/authContext' import { useModalsContext } from '@/context/ModalsContext' import Select from '@/components/Global/Select' import { SoundPlayer } from '@/components/Global/SoundPlayer' @@ -61,6 +60,7 @@ import { useSumsubActionFlow } from '@/hooks/useSumsubActionFlow' import { initiateIncreaseLimits } from '@/app/actions/increase-limits' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { useLimits } from '@/hooks/useLimits' +import { useIdentityVerification } from '@/hooks/useIdentityVerification' type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure' @@ -88,10 +88,10 @@ export default function MantecaWithdrawFlow() { const { sendMoney, balance } = useWallet() const { signTransferUserOp } = useSignUserOp() const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext) - const { user } = useAuth() const { setIsSupportModalOpen, openSupportWithMessage } = useModalsContext() const queryClient = useQueryClient() - const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { isUserSumsubKycApproved } = useKycStatus() + const { isVerifiedForCountry } = useIdentityVerification() const { manteca: mantecaRejection } = useProviderRejectionStatus() const { hasPendingTransactions } = usePendingTransactions() @@ -105,12 +105,11 @@ export default function MantecaWithdrawFlow() { // 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. - - // Determine country and currency from URL params or context - const countryPath = countryFromUrl || 'argentina' + const countryPath = countryFromUrl // Map country path to CountryData for KYC const selectedCountry = useMemo(() => { + if (!countryPath) return undefined return countryData.find((country) => country.type === 'country' && country.path === countryPath) }, [countryPath]) @@ -118,6 +117,7 @@ export default function MantecaWithdrawFlow() { if (!selectedCountry) return undefined return MANTECA_COUNTRIES_CONFIG[selectedCountry.id] }, [selectedCountry]) + const isUserMantecaKycApprovedForCountry = selectedCountry ? isVerifiedForCountry(selectedCountry.id) : false const { code: currencyCode, @@ -236,7 +236,7 @@ export default function MantecaWithdrawFlow() { } setErrorMessage(null) - if (!isUserMantecaKycApproved) { + if (!isUserMantecaKycApprovedForCountry) { setShowKycModal(true) return } @@ -281,7 +281,7 @@ export default function MantecaWithdrawFlow() { usdAmount, currencyCode, currencyAmount, - isUserMantecaKycApproved, + isUserMantecaKycApprovedForCountry, isLockingPrice, handleOnboardingError, ]) @@ -444,12 +444,12 @@ export default function MantecaWithdrawFlow() { } }, [step, queryClient]) - // redirect to withdraw page if country is not supported by manteca + // redirect to withdraw page if country is missing or not supported by manteca useEffect(() => { - if (!selectedCountry || !MANTECA_COUNTRIES_CONFIG[selectedCountry.id]) { + if (!countryFromUrl || !selectedCountry || !MANTECA_COUNTRIES_CONFIG[selectedCountry.id]) { router.replace('/withdraw') } - }, [selectedCountry, router]) + }, [countryFromUrl, selectedCountry, router]) if (isCurrencyLoading || !currencyPrice || !selectedCountry || !countryConfig) { return @@ -537,7 +537,7 @@ export default function MantecaWithdrawFlow() { if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id) } setShowKycModal(false) }} diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 8cb387990..dfb857d99 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -12,6 +12,7 @@ export const initiateSumsubKyc = async (params?: { regionIntent?: KYCRegionIntent levelName?: string crossRegion?: boolean + targetCountry?: string }): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => { const jwtToken = (await getJWTCookie())?.value @@ -23,6 +24,7 @@ export const initiateSumsubKyc = async (params?: { regionIntent: params?.regionIntent, levelName: params?.levelName, crossRegion: params?.crossRegion, + targetCountry: params?.targetCountry, } try { diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index ac03ac285..171537246 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -6,7 +6,6 @@ import { useParams } from 'next/navigation' import { type CountryData, countryData } from '@/components/AddMoney/consts' import { type MantecaDepositResponseData } from '@/types/manteca.types' import { useCurrency } from '@/hooks/useCurrency' -import { useAuth } from '@/context/authContext' import { mantecaApi } from '@/services/manteca' import { parseUnits } from 'viem' import { useQueryClient } from '@tanstack/react-query' @@ -22,6 +21,7 @@ import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { useIdentityVerification } from '@/hooks/useIdentityVerification' // Step type for URL state type MantecaStep = 'inputAmount' | 'depositDetails' @@ -64,16 +64,16 @@ const MantecaAddMoney: FC = () => { const selectedCountry = useMemo(() => { return countryData.find((country) => country.type === 'country' && country.path === selectedCountryPath) }, [selectedCountryPath]) - const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const { isUserSumsubKycApproved } = useKycStatus() + const { isVerifiedForCountry } = useIdentityVerification() const { manteca: mantecaRejection } = useProviderRejectionStatus() const currencyData = useCurrency(selectedCountry?.currency ?? 'ARS') - 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) + const isUserMantecaKycApprovedForCountry = selectedCountry ? isVerifiedForCountry(selectedCountry.id) : false // validates deposit amount against user's limits // currency comes from country config - hook normalizes it internally @@ -144,7 +144,7 @@ const MantecaAddMoney: FC = () => { if (!selectedCountry?.currency) return if (isCreatingDeposit) return - if (!isUserMantecaKycApproved) { + if (!isUserMantecaKycApprovedForCountry) { setShowKycModal(true) return } @@ -200,7 +200,7 @@ const MantecaAddMoney: FC = () => { currentDenomination, selectedCountry, displayedAmount, - isUserMantecaKycApproved, + isUserMantecaKycApprovedForCountry, isCreatingDeposit, setUrlState, usdAmount, @@ -227,7 +227,7 @@ const MantecaAddMoney: FC = () => { if (hasRejection) { await sumsubFlow.handleSelfHealResubmit('MANTECA') } else { - await sumsubFlow.handleInitiateKyc('LATAM', undefined, true) + await sumsubFlow.handleInitiateKyc('LATAM', undefined, true, selectedCountry?.id) } setShowKycModal(false) }} diff --git a/src/components/Global/TranslationSafeWrapper.tsx b/src/components/Global/TranslationSafeWrapper.tsx index 4e77af618..d05006cdd 100644 --- a/src/components/Global/TranslationSafeWrapper.tsx +++ b/src/components/Global/TranslationSafeWrapper.tsx @@ -1,18 +1,8 @@ 'use client' import { useTranslationMutationHandler } from '@/hooks/useTranslationMutationHandler' -import { useRef } from 'react' -// wraps the app to handle google translate dom mutations globally -// prevents "Failed to execute 'insertBefore' on 'Node'" errors -// while still allowing translations to work properly +// patches dom methods globally to prevent translation extension crashes export const TranslationSafeWrapper = ({ children }: { children: React.ReactNode }) => { - const wrapperRef = useRef(null) - // attach mutation observer to handle translation service dom changes - useTranslationMutationHandler(wrapperRef) - - return ( -
- {children} -
- ) + useTranslationMutationHandler() + return <>{children} } diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index 48926dec0..9c5814432 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -21,7 +21,7 @@ interface InitiateKycModalProps { // for fresh KYC: "Verify your identity" // for provider rejections: "We need extra documents" // for blocked: "Verification issue — contact support" -// for cross-region: "Your identity is verified, we need a local ID" +// for cross-region: "Your identity is verified, submit a local ID" export const InitiateKycModal = ({ visible, onClose, @@ -41,6 +41,7 @@ export const InitiateKycModal = ({ if (error) return 'Something went wrong' if (isBlocked) return 'Verification issue' if (isProviderRejection) return 'We need extra documents' + if (isCrossRegion) return 'Submit local ID' return 'Verify your identity' } @@ -70,6 +71,13 @@ export const InitiateKycModal = ({ icon: 'upload' as IconName, } } + if (isCrossRegion) { + return { + text: isLoading ? 'Loading...' : 'Submit document', + onClick: onVerify, + icon: 'upload' as IconName, + } + } return { text: isLoading ? 'Loading...' : 'Start Verification', onClick: onVerify, diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 64c6e0664..f044166ea 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -183,7 +183,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent // wrap handleInitiateKyc to reset state for new attempts const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => { + async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean, targetCountry?: string) => { const intent = overrideIntent ?? regionIntent posthog.capture( intent === 'LATAM' ? ANALYTICS_EVENTS.MANTECA_KYC_INITIATED : ANALYTICS_EVENTS.KYC_INITIATED, @@ -199,7 +199,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent isRealtimeFlowRef.current = false clearPreparingTimer() - await originalHandleInitiateKyc(overrideIntent, levelName, crossRegion) + await originalHandleInitiateKyc(overrideIntent, levelName, crossRegion, targetCountry) }, [originalHandleInitiateKyc, clearPreparingTimer, regionIntent, acquisitionSource] ) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 03c119288..3fe38a3e1 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -31,6 +31,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const regionIntentRef = useRef(regionIntent) // tracks the level name across initiate + refresh (e.g. 'peanut-additional-docs') const levelNameRef = useRef(undefined) + // tracks the selected target country across initiate + refresh for country-scoped Manteca actions + const targetCountryRef = useRef(undefined) // guards fetchCurrentStatus from running while handleInitiateKyc is in progress const initiatingRef = useRef(false) // guard: only fire onKycSuccess when the user initiated a kyc flow in this session. @@ -120,6 +122,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current, levelName: levelNameRef.current, + targetCountry: targetCountryRef.current, }) if (response.data?.status) { setLiveKycStatus(response.data.status) @@ -134,7 +137,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, [isVerificationProgressModalOpen]) const handleInitiateKyc = useCallback( - async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => { + async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean, targetCountry?: string) => { userInitiatedRef.current = true initiatingRef.current = true selfHealProviderRef.current = null @@ -154,6 +157,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: regionIntent: overrideIntent ?? regionIntent, levelName, crossRegion, + targetCountry, }) if (response.error) { @@ -174,6 +178,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const effectiveIntent = overrideIntent ?? regionIntent if (effectiveIntent) regionIntentRef.current = effectiveIntent levelNameRef.current = levelName + targetCountryRef.current = targetCountry // cross-region: bridge-direct means no SDK needed — backend is handling // rail enrollment + submission. go straight to the post-approval flow. @@ -246,6 +251,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const response = await initiateSumsubKyc({ regionIntent: regionIntentRef.current, levelName: levelNameRef.current, + targetCountry: targetCountryRef.current, }) if (response.error || !response.data?.token) { diff --git a/src/hooks/useTranslationMutationHandler.ts b/src/hooks/useTranslationMutationHandler.ts index e079660b0..2d7bc525e 100644 --- a/src/hooks/useTranslationMutationHandler.ts +++ b/src/hooks/useTranslationMutationHandler.ts @@ -1,67 +1,41 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' -// handles translation service dom mutations to prevent errors while allowing translations -export const useTranslationMutationHandler = (targetRef: React.RefObject) => { - // keep reference to observer instance for cleanup - const observerRef = useRef(null) +const PATCH_KEY = '__peanut_translation_patched__' +// patches removeChild and insertBefore to prevent crashes when browser +// translation extensions (google translate, brave translate, etc) move +// dom nodes out of their react-managed parents. without this, react's +// reconciliation throws "not a child of this node" during unmount/update. +// +// intentionally no useEffect cleanup — patches are permanent for the page +// lifetime. removing them on unmount would re-expose the crash. +export const useTranslationMutationHandler = () => { useEffect(() => { - const setupObserver = () => { - if (!targetRef.current) return + // window global survives HMR — module-scoped flag resets on hot reload, + // which would nest patched wrappers around each other + if ((window as any)[PATCH_KEY]) return + ;(window as any)[PATCH_KEY] = true - if (observerRef.current) { - observerRef.current.disconnect() + const originalRemoveChild = Node.prototype.removeChild + Node.prototype.removeChild = function (child: T): T { + if (child.parentNode !== this) { + if (process.env.NODE_ENV !== 'production') { + console.warn('[translation-patch] removeChild: node is not a child, skipping', child) + } + return child } - - observerRef.current = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === 1) { - try { - const element = node as Element - // handle translated content nodes that google translate adds - if (element.hasAttribute('data-translated')) { - const parent = element.parentElement - if (parent) { - // remove any duplicate translations to prevent conflicts - const existingTranslations = parent.querySelectorAll('[data-translated]') - existingTranslations.forEach((el) => { - if (el !== element && el.textContent === element.textContent) { - el.remove() - } - }) - - // append new translation if not already present - if (!parent.contains(element)) { - requestAnimationFrame(() => { - try { - parent.appendChild(element) - } catch (e) { - console.error(e) - } - }) - } - } - } - } catch (e) { - console.error(e) - } - } - }) - } - }) - }) - - // observe changes to dom structure and attributes - observerRef.current.observe(targetRef.current, { - childList: true, - subtree: true, - attributes: true, - }) + return originalRemoveChild.call(this, child) as T } - setupObserver() - return () => observerRef.current?.disconnect() - }, [targetRef]) + const originalInsertBefore = Node.prototype.insertBefore + Node.prototype.insertBefore = function (newNode: T, refNode: Node | null): T { + if (refNode && refNode.parentNode !== this) { + if (process.env.NODE_ENV !== 'production') { + console.warn('[translation-patch] insertBefore: ref node is not a child, skipping', refNode) + } + return newNode + } + return originalInsertBefore.call(this, newNode, refNode) as T + } + }, []) }