Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0e935d6
content: update submodule — add delete-account help article
chip-peanut-bot[bot] Apr 23, 2026
55bb0e1
Merge pull request #1895 from peanutprotocol/chip/update-content-2026…
kushagrasarathe Apr 23, 2026
00a10c0
fix: redirect /help/:slug to /en/help/:slug
chip-peanut-bot[bot] Apr 23, 2026
5e03109
fix: redirect /help/:slug to /en/help/:slug (formatted)
chip-peanut-bot[bot] Apr 23, 2026
0026e78
fix: redirect /help/:slug to /en/help/:slug
chip-peanut-bot[bot] Apr 23, 2026
20dec5e
fix: add missing newline at end of redirects.json
kushagrasarathe Apr 23, 2026
582c6aa
Merge pull request #1896 from peanutprotocol/chip/help-redirect-fix-2…
Hugo0 Apr 23, 2026
d63c904
feat: add provider rejection status hook and self-heal server action
kushagrasarathe Apr 27, 2026
9bef94c
feat: add KycProviderRejection component for self-heal drawer state
kushagrasarathe Apr 27, 2026
de5f188
fix: address review comments — blocked user flow, error handling, ref…
kushagrasarathe Apr 27, 2026
4726935
fix: address round 2 review — blocked resubmit, ref cleanup, dead var…
kushagrasarathe Apr 27, 2026
58d955f
chore: format
kushagrasarathe Apr 27, 2026
91d9210
fix: release onKeepMounted when self-heal resubmit fails to open SDK
kushagrasarathe Apr 27, 2026
132404e
fix: clear selfHealProviderRef on SDK completion (CTO review #14)
kushagrasarathe Apr 28, 2026
1c075eb
Merge pull request #1905 from peanutprotocol/feat/self-heal
kushagrasarathe Apr 29, 2026
f9f0385
hotfix: disable cross-chain claims via underMaintenance config
chip-peanut-bot[bot] Apr 29, 2026
69bbf82
hotfix: also disable cross-chain withdraw + fix friendly error path
Hugo0 Apr 29, 2026
190b40a
Merge pull request #1913 from peanutprotocol/hotfix/disable-xchain-cl…
Hugo0 Apr 29, 2026
5faf1db
fix: revert wildcard redirect + add delete-account help article
chip-peanut-bot[bot] Apr 30, 2026
6cb8f65
Merge pull request #1925 from peanutprotocol/chip/fix-delete-account-…
Hugo0 Apr 30, 2026
aa870b8
fix: pass crossRegion to manteca KYC flows + surface region errors
kushagrasarathe Apr 30, 2026
1648a92
fix: use ErrorAlert in MantecaFlowManager for consistent error UI
kushagrasarathe Apr 30, 2026
82244d6
fix: add country check to isCrossRegion LATAM path
kushagrasarathe May 1, 2026
6c94bf1
fix: progress modal for action flows (manteca, self-heal)
kushagrasarathe May 1, 2026
be0f2c1
feat: cross-region modal copy for already-verified users
kushagrasarathe May 1, 2026
d5c547b
fix(ci): add --scope=squirrellabs to vercel preview deploy
kushagrasarathe May 1, 2026
cd97a0e
Merge pull request #1930 from peanutprotocol/fix/vercel-preview-scope
Hugo0 May 1, 2026
7fb60e5
feat: JIT bridge enrollment for LATAM-first users in payment flows
kushagrasarathe May 1, 2026
c5c57f0
fix: JIT bridge enrollment modal + preventClose for Dialog conflicts
kushagrasarathe May 1, 2026
9570cc0
Merge pull request #1928 from peanutprotocol/fix/manteca-gating
Hugo0 May 1, 2026
655197d
chore: resolve merge conflicts syncing main into dev
kushagrasarathe May 5, 2026
1b9da41
fix: resolve typecheck errors from main→dev merge
kushagrasarathe May 5, 2026
e496cc4
fix: address CTO review findings
kushagrasarathe May 5, 2026
de242aa
fix: address remaining CodeRabbit review comments
kushagrasarathe May 5, 2026
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
6 changes: 3 additions & 3 deletions .github/workflows/preview.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ jobs:
- name: Install Vercel CLI
run: pnpm add --global vercel@latest
- name: Link to Project
run: vercel link --yes --project=peanut-wallet --token=${{ secrets.VERCEL_TOKEN }}
run: vercel link --yes --project=peanut-wallet --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }}
- name: Pull Vercel Environment Information
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
run: vercel pull --yes --environment=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: vercel build --target=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: vercel deploy --prebuilt --archive=tgz --target=preview --token=${{ secrets.VERCEL_TOKEN }}
run: vercel deploy --prebuilt --archive=tgz --target=preview --scope=squirrellabs --token=${{ secrets.VERCEL_TOKEN }}
38 changes: 34 additions & 4 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatAmount } from '@/utils/general.utils'
import { countryData } from '@/components/AddMoney/consts'
import { useAuth } from '@/context/authContext'
import useKycStatus from '@/hooks/useKycStatus'
import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus'
import { useCreateOnramp } from '@/hooks/useCreateOnramp'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
Expand Down Expand Up @@ -71,7 +72,8 @@ export default function OnrampBankPage() {
// 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: () => {
onKycSuccess: async () => {
await fetchUser()
setUrlState({ step: 'inputAmount' })
},
})
Expand All @@ -96,7 +98,9 @@ export default function OnrampBankPage() {
// uk-specific check
const isUK = isUKCountry(selectedCountryPath)

const { isUserKycApproved } = useKycStatus()
const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } =
useKycStatus()
const { bridge: bridgeRejection } = useProviderRejectionStatus()
const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard()

useEffect(() => {
Expand Down Expand Up @@ -194,10 +198,17 @@ export default function OnrampBankPage() {
}
}, [rawTokenAmount, validateAmount, setError])

const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview

const handleAmountContinue = () => {
if (!validateAmount(rawTokenAmount)) return

if (!isUserKycApproved) {
if (
needsBridgeEnrollment ||
!isUserKycApproved ||
bridgeRejection.state === 'fixable' ||
bridgeRejection.state === 'blocked'
) {
Comment thread
kushagrasarathe marked this conversation as resolved.
setShowKycModal(true)
return
}
Expand Down Expand Up @@ -398,10 +409,29 @@ export default function OnrampBankPage() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
await sumsubFlow.handleInitiateKyc('STANDARD')
// needsBridgeEnrollment takes priority: user has no bridge customer,
// so rejection state from a stale/deleted customer is irrelevant
if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
'STANDARD',
undefined,
needsBridgeEnrollment || undefined
)
}
setShowKycModal(false)
}}
isLoading={sumsubFlow.isLoading}
variant={
needsBridgeEnrollment
? 'cross_region'
: bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked'
? 'provider_rejection'
: 'default'
}
providerMessage={bridgeRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
/>

<SumsubKycModals flow={sumsubFlow} autoStartSdk />
Expand Down
49 changes: 49 additions & 0 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1108,12 +1108,61 @@ export default function QRPayPage() {
const needsKycVerification =
kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION ||
kycGateState === QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS
const hasProviderRejection =
kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE || kycGateState === QrKycState.PROVIDER_REJECTION_BLOCKED

// show loading while KYC state is being determined
if (isLoadingKycState) {
return <PeanutLoading />
}

// provider rejection: user is sumsub-approved but manteca rejected
if (hasProviderRejection) {
const isFixable = kycGateState === QrKycState.PROVIDER_REJECTION_FIXABLE
return (
<div className="flex min-h-[inherit] flex-col gap-8">
<NavHeader title="Pay" />
<ActionModal
visible
onClose={() => router.back()}
title={isFixable ? 'We need an updated document' : 'QR payments are not available'}
description={
isFixable
? 'We need an updated document to enable QR payments. Please upload a clearer photo of your ID.'
: 'QR payments are not available for your account. Contact support for help.'
}
icon={
methodIcon ? (
<Image src={methodIcon} alt="Payment method" width={48} height={48} priority />
) : undefined
}
ctas={[
isFixable
? {
text: 'Upload document',
onClick: () => {
saveRedirectUrl()
router.push('/profile/identity-verification')
},
variant: 'purple' as const,
shadowSize: '4' as const,
icon: 'upload',
}
: {
text: 'Contact support',
onClick: () => {
if (typeof window !== 'undefined' && (window as any).$crisp) {
;(window as any).$crisp.push(['do', 'chat:open'])
}
},
variant: 'stroke' as const,
},
]}
/>
</div>
)
}

// show KYC screens before any error screens - user needs to verify first
if (needsKycVerification) {
return (
Expand Down
30 changes: 30 additions & 0 deletions src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import { createOfframp, confirmOfframp } from '@/app/actions/offramp'
import { useAuth } from '@/context/authContext'
import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import useKycStatus from '@/hooks/useKycStatus'
import ExchangeRate from '@/components/ExchangeRate'
import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping'
import { useIdentityVerification } from '@/hooks/useIdentityVerification'
Expand Down Expand Up @@ -65,6 +69,14 @@ export default function WithdrawBankPage() {
const [balanceErrorMessage, setBalanceErrorMessage] = useState<string | null>(null)
const { hasPendingTransactions } = usePendingTransactions()
const { isBridgeSupportedCountry } = useIdentityVerification()
const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus()
const sumsubFlow = useMultiPhaseKycFlow({
onKycSuccess: async () => {
await fetchUser()
},
})
const [showKycModal, setShowKycModal] = useState(false)
const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId

// validate country is supported for bank withdrawals
useEffect(() => {
Expand Down Expand Up @@ -159,6 +171,11 @@ export default function WithdrawBankPage() {
}

const handleCreateAndInitiateOfframp = async () => {
if (needsBridgeEnrollment) {
setShowKycModal(true)
return
}

if (guardWithTos()) return

setIsLoading(true)
Expand Down Expand Up @@ -444,6 +461,19 @@ export default function WithdrawBankPage() {
}}
onSkip={hideTos}
/>

<InitiateKycModal
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true)
setShowKycModal(false)
}}
isLoading={sumsubFlow.isLoading}
variant="cross_region"
regionName={getCountryFromPath(country)?.title}
/>
<SumsubKycModals flow={sumsubFlow} />
</div>
)
}
37 changes: 33 additions & 4 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { SoundPlayer } from '@/components/Global/SoundPlayer'
import { useQueryClient } from '@tanstack/react-query'
import { captureException } from '@sentry/nextjs'
import useKycStatus from '@/hooks/useKycStatus'
import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
Expand Down Expand Up @@ -92,7 +93,8 @@ export default function MantecaWithdrawFlow() {
const { user } = useAuth()
const { setIsSupportModalOpen, openSupportWithMessage } = useModalsContext()
const queryClient = useQueryClient()
const { isUserMantecaKycApproved } = useKycStatus()
const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus()
const { manteca: mantecaRejection } = useProviderRejectionStatus()
const { hasPendingTransactions } = usePendingTransactions()

// inline sumsub kyc flow for manteca users who need LATAM verification
Expand All @@ -101,6 +103,7 @@ export default function MantecaWithdrawFlow() {
const sumsubFlow = useMultiPhaseKycFlow({})
const [showKycModal, setShowKycModal] = useState(false)
const [isRedirectingToOnboarding, setIsRedirectingToOnboarding] = 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.
Expand Down Expand Up @@ -532,10 +535,32 @@ export default function MantecaWithdrawFlow() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
await sumsubFlow.handleInitiateKyc('LATAM')
if (mantecaRejection.state === 'blocked') {
// blocked users cannot self-heal — route to support
if (typeof window !== 'undefined' && (window as any).$crisp) {
;(window as any).$crisp.push(['do', 'chat:open'])
}
setShowKycModal(false)
return
}
const hasRejection = mantecaRejection.state === 'fixable'
if (hasRejection) {
await sumsubFlow.handleSelfHealResubmit('MANTECA')
} else {
await sumsubFlow.handleInitiateKyc('LATAM', undefined, true)
}
setShowKycModal(false)
}}
isLoading={sumsubFlow.isLoading}
variant={
mantecaRejection.state === 'fixable' || mantecaRejection.state === 'blocked'
? 'provider_rejection'
: isUserSumsubKycApproved
? 'cross_region'
: 'default'
}
providerMessage={mantecaRejection.userMessage ?? undefined}
regionName={selectedCountry?.title}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
<SumsubKycModals flow={sumsubFlow} />
<SumsubKycWrapper
Expand Down Expand Up @@ -747,7 +772,9 @@ export default function MantecaWithdrawFlow() {
: 'Review'}
</Button>

{errorMessage && <ErrorAlert description={errorMessage} />}
{(errorMessage || sumsubFlow.error) && (
<ErrorAlert description={(errorMessage || sumsubFlow.error)!} />
)}
</div>
</div>
)}
Expand Down Expand Up @@ -804,7 +831,9 @@ export default function MantecaWithdrawFlow() {
>
{isLoading ? loadingState : 'Withdraw'}
</Button>
{errorMessage && <ErrorAlert description={errorMessage} />}
{(errorMessage || sumsubFlow.error) && (
<ErrorAlert description={(errorMessage || sumsubFlow.error)!} />
)}
</div>
)}
</div>
Expand Down
44 changes: 43 additions & 1 deletion src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const initiateSumsubKyc = async (params?: {
const responseJson = await response.json()

if (!response.ok) {
return { error: responseJson.message || responseJson.error || 'Failed to initiate identity verification' }
return {
error: responseJson.userMessage || responseJson.error || 'Failed to initiate identity verification',
}
}

return {
Expand All @@ -38,3 +40,43 @@ export const initiateSumsubKyc = async (params?: {
return { error: message }
}
}

export interface SelfHealResubmissionResponse {
token: string
applicantId: string
actionId: string
externalActionId: string
requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT'
userMessage: string
attempt: number
maxAttempts: number
}

// initiate self-heal document resubmission for a provider-rejected user
export const initiateSelfHealResubmission = async (
provider: 'BRIDGE' | 'MANTECA'
): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => {
try {
const response = await serverFetch('/users/identity/resubmit', {
method: 'POST',
body: JSON.stringify({ provider }),
})

const responseJson = await response.json()

if (!response.ok) {
return {
error: responseJson.userMessage || responseJson.error || 'Failed to initiate document resubmission',
}
}

if (!responseJson.token || !responseJson.applicantId) {
return { error: 'Invalid response from server' }
}

return { data: responseJson }
Comment thread
kushagrasarathe marked this conversation as resolved.
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'An unexpected error occurred'
return { error: message }
}
}
Loading
Loading