Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 79 additions & 14 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Image from 'next/image'
import PeanutLoading from '@/components/Global/PeanutLoading'
import TokenAmountInput from '@/components/Global/TokenAmountInput'
import { useWallet } from '@/hooks/wallet/useWallet'
import { isTxReverted } from '@/utils/general.utils'
import { clearRedirectUrl, getRedirectUrl, isTxReverted } from '@/utils/general.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
import { formatUnits, parseUnits } from 'viem'
Expand All @@ -25,6 +25,10 @@ import { getCurrencyPrice } from '@/app/actions/currency'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { captureException } from '@sentry/nextjs'
import { isPaymentProcessorQR } from '@/components/Global/DirectSendQR/utils'
import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate'
import ActionModal from '@/components/Global/ActionModal'
import { saveRedirectUrl } from '@/utils/general.utils'
import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'

const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053'
const MAX_QR_PAYMENT_AMOUNT = '200'
Expand All @@ -48,6 +52,7 @@ export default function QRPayPage() {
const { openTransactionDetails, selectedTransaction, isDrawerOpen, closeTransactionDetails } =
useTransactionDetailsDrawer()
const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext)
const { shouldBlockPay, kycGateState } = useQrKycGate()

const resetState = () => {
setIsSuccess(false)
Expand All @@ -63,7 +68,7 @@ export default function QRPayPage() {
setLoadingState('Idle')
}

// First fetch for qrcode info
// First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
resetState()

Expand All @@ -72,17 +77,8 @@ export default function QRPayPage() {
return
}

mantecaApi
.initiateQrPayment({ qrCode })
.then((paymentLock) => {
setPaymentLock(paymentLock)
})
.catch((error) => {
setErrorInitiatingPayment(error.message)
})
.finally(() => {
setIsFirstLoad(false)
})
// defer until gating computed later in component
setIsFirstLoad(false)
// Trigger on rescan
}, [timestamp])

Expand Down Expand Up @@ -137,6 +133,20 @@ export default function QRPayPage() {
}
}, [paymentLock])

// fetch payment lock only when gating allows proceeding and we don't yet have a lock
useEffect(() => {
if (!qrCode || !isPaymentProcessorQR(qrCode)) return
if (!!paymentLock) return
if (kycGateState !== QrKycState.PROCEED_TO_PAY) return

setLoadingState('Fetching details')
mantecaApi
.initiateQrPayment({ qrCode })
.then((pl) => setPaymentLock(pl))
.catch((error) => setErrorInitiatingPayment(error.message))
.finally(() => setLoadingState('Idle'))
}, [kycGateState, paymentLock, qrCode, setLoadingState])

const merchantName = useMemo(() => {
if (!paymentLock) return null
return paymentLock.paymentRecipientName
Expand Down Expand Up @@ -223,6 +233,56 @@ export default function QRPayPage() {
)
}

if (shouldBlockPay) {
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Pay" />
<MantecaGeoSpecificKycModal
isUserBridgeKycApproved={kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER}
selectedCountry={{ id: 'AR', title: 'Argentina' }}
setIsMantecaModalOpen={() => {
router.back()
}}
isMantecaModalOpen={kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER}
onKycSuccess={() => {
saveRedirectUrl()
const redirectUrl = getRedirectUrl()
if (redirectUrl) {
clearRedirectUrl()
router.push(redirectUrl)
} else {
router.replace('/home')
}
}}
/>
<ActionModal
visible={kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION}
onClose={() => router.back()}
title="Verify your identity to continue"
description="You'll need to verify your identity before paying with a QR code. Don't worry it usually just takes a few minutes."
icon="shield"
ctas={[
{
text: 'Verify now',
onClick: () => {
saveRedirectUrl()
router.push('/profile/identity-verification')
},
variant: 'purple',
shadowSize: '4',
icon: 'check-circle',
},
]}
footer={
<p className="flex items-center justify-center gap-1 text-xs text-gray-400">
<Icon name="info" className="size-3" /> Peanut doesn't store any personal information
</p>
}
/>
</div>
)
}

if (isFirstLoad || !paymentLock || !currency) {
return <PeanutLoading />
}
Expand Down Expand Up @@ -361,7 +421,12 @@ export default function QRPayPage() {
shadowSize="4"
loading={isLoading}
disabled={
!!errorInitiatingPayment || !!errorMessage || !amount || isLoading || !!balanceErrorMessage
!!errorInitiatingPayment ||
!!errorMessage ||
!amount ||
isLoading ||
!!balanceErrorMessage ||
shouldBlockPay
}
>
{isLoading ? loadingState : 'Pay'}
Expand Down
66 changes: 66 additions & 0 deletions src/app/actions/bridge/get-customer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use server'

import { unstable_cache } from 'next/cache'
import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants'
import { countryData } from '@/components/AddMoney/consts'

type BridgeCustomer = {
id: string
email: string
type: string
status?: string
residential_address?: {
subdivision?: string | null
country?: string | null
} | null
}

// build a comprehensive ISO3 -> ISO2 map from our country list to normalize country codes and avoid issues with bridge kyc
const ISO3_TO_ISO2: Record<string, string> = (() => {
const map: Record<string, string> = {}
for (const c of countryData) {
const iso2 = (c as any).iso2 || c.id
const iso3 = (c as any).iso3
if (iso2 && iso3) {
map[String(iso3).toUpperCase()] = String(iso2).toUpperCase()
}
}
return map
})()

const normalizeCountry = (input?: string | null): string | null => {
if (!input) return null
const upper = input.toUpperCase()
if (upper.length === 2) return upper
return ISO3_TO_ISO2[upper] ?? null
}

export const getBridgeCustomerCountry = async (
bridgeCustomerId: string
): Promise<{ countryCode: string | null; rawCountry: string | null }> => {
const runner = unstable_cache(
async () => {
const response = await fetch(`${PEANUT_API_URL}/bridge/customers/${bridgeCustomerId}` as string, {
headers: {
'Content-Type': 'application/json',
'api-key': PEANUT_API_KEY,
},
cache: 'no-store',
})

if (!response.ok) {
// do not throw to avoid breaking callers; return nulls for graceful fallback
return { countryCode: null, rawCountry: null } as const
}
const data = (await response.json()) as BridgeCustomer
const raw = data?.residential_address?.country ?? null
const normalized = normalizeCountry(raw)
console.log('normalized kushagra', normalized)
return { countryCode: normalized, rawCountry: raw }
},
['getBridgeCustomerCountry', bridgeCustomerId],
{ revalidate: 5 * 60 }
)

return runner()
}
4 changes: 2 additions & 2 deletions src/components/AddMoney/UserDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ interface UserDetailsFormProps {
}

export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>(
({ onSubmit, isSubmitting, onValidChange, initialData }, ref) => {
({ onSubmit, onValidChange, initialData }, ref) => {
const [submissionError, setSubmissionError] = useState<string | null>(null)

const {
control,
handleSubmit,
formState: { errors, isValid, isValidating },
formState: { errors, isValid },
} = useForm<UserDetailsFormData>({
defaultValues: {
fullName: initialData?.fullName ?? '',
Expand Down
3 changes: 3 additions & 0 deletions src/components/Kyc/InitiateMantecaKYCModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ export const MantecaGeoSpecificKycModal = ({
selectedCountry,
setIsMantecaModalOpen,
isMantecaModalOpen,
onKycSuccess,
}: {
isUserBridgeKycApproved: boolean
selectedCountry: { id: string; title: string }
setIsMantecaModalOpen: (isOpen: boolean) => void
isMantecaModalOpen: boolean
onKycSuccess: () => void
}) => {
return (
<InitiateMantecaKYCModal
Expand Down Expand Up @@ -115,6 +117,7 @@ export const MantecaGeoSpecificKycModal = ({
onClose={() => setIsMantecaModalOpen(false)}
onKycSuccess={() => {
setIsMantecaModalOpen(false)
onKycSuccess?.()
}}
onManualClose={() => setIsMantecaModalOpen(false)}
country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }}
Expand Down
23 changes: 22 additions & 1 deletion src/components/Profile/views/IdentityVerification.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { MantecaKycStatus } from '@/interfaces'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import useKycStatus from '@/hooks/useKycStatus'
import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils'

const IdentityVerificationView = () => {
const { user, fetchUser } = useAuth()
Expand All @@ -34,6 +35,25 @@ const IdentityVerificationView = () => {
const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null)
const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null)

const handleRedirect = useCallback(() => {
const redirectUrl = getRedirectUrl()
if (redirectUrl) {
clearRedirectUrl()
router.push(redirectUrl)
} else {
router.replace('/profile')
}
}, [router])

const handleKycSuccess = useCallback(async () => {
await fetchUser()
handleRedirect()
}, [router, fetchUser])

const handleMantecaKycSuccess = useCallback(() => {
handleRedirect()
}, [router])

const {
iframeOptions,
handleInitiateKyc,
Expand All @@ -42,7 +62,7 @@ const IdentityVerificationView = () => {
closeVerificationProgressModal,
error: kycError,
isLoading: isKycLoading,
} = useBridgeKycFlow()
} = useBridgeKycFlow({ onKycSuccess: handleKycSuccess })

const { isUserBridgeKycApproved } = useKycStatus()

Expand Down Expand Up @@ -232,6 +252,7 @@ const IdentityVerificationView = () => {
selectedCountry={selectedCountry}
setIsMantecaModalOpen={setIsMantecaModalOpen}
isMantecaModalOpen={isMantecaModalOpen}
onKycSuccess={handleMantecaKycSuccess}
/>
)}
</div>
Expand Down
46 changes: 29 additions & 17 deletions src/hooks/useMantecaKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAuth } from '@/context/authContext'
import { CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts'
import { BASE_URL } from '@/constants'
import { MantecaKycStatus } from '@/interfaces'
import { useWebSocket } from './useWebSocket'

type UseMantecaKycFlowOptions = {
onClose?: () => void
Expand All @@ -21,11 +22,38 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
visible: false,
closeConfirmMessage: undefined,
})
const { user } = useAuth()
const { user, fetchUser } = useAuth()
const [isMantecaKycRequired, setNeedsMantecaKyc] = useState<boolean>(false)

const userKycVerifications = user?.user?.kycVerifications

const handleIframeClose = useCallback(
(source?: 'manual' | 'completed' | 'tos_accepted') => {
setIframeOptions((prev) => ({ ...prev, visible: false }))
if (source === 'completed') {
onSuccess?.()
return
}
if (source === 'manual') {
onManualClose?.()
return
}
onClose?.()
},
[onClose, onSuccess, onManualClose]
)

useWebSocket({
username: user?.user.username ?? undefined,
autoConnect: true,
onMantecaKycStatusUpdate: async (status) => {
if (status === MantecaKycStatus.ACTIVE || status === 'WIDGET_FINISHED') {
await fetchUser()
handleIframeClose('completed')
}
},
})

useEffect(() => {
// determine if manteca kyc is required based on geo data available in kycVerifications
const selectedGeo = country?.id
Expand Down Expand Up @@ -71,22 +99,6 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
}
}, [])

const handleIframeClose = useCallback(
(source?: 'manual' | 'completed' | 'tos_accepted') => {
setIframeOptions((prev) => ({ ...prev, visible: false }))
if (source === 'completed') {
onSuccess?.()
return
}
if (source === 'manual') {
onManualClose?.()
return
}
onClose?.()
},
[onClose, onSuccess, onManualClose]
)

return {
isLoading,
error,
Expand Down
Loading
Loading