Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9bc513b
fix: thread crossRegion flag for explicit cross-region KYC requests
kushagrasarathe Apr 6, 2026
f9f056b
fix: only send crossRegion flag when true, omit for same-region
kushagrasarathe Apr 7, 2026
3a38e9f
feat: add REVERIFYING to approved statuses and SumsubKycStatus type
kushagrasarathe Apr 7, 2026
2834cc6
fix: address review — REVERIFYING exclusion in status effect + token-…
kushagrasarathe Apr 7, 2026
3fa3389
Merge pull request #1874 from peanutprotocol/fix/rail-enrollment-timing
jjramirezn Apr 7, 2026
1746c14
fix: instrument reward analytics on QR payment page
Hugo0 Apr 9, 2026
de7136f
fix: reset analytics refs between QR payment flows
Hugo0 Apr 9, 2026
5c85fc0
Merge pull request #1878 from peanutprotocol/fix/qr-payment-analytics
Hugo0 Apr 9, 2026
1c3f8e0
feat: handle applicant action responses for cross-region KYC
kushagrasarathe Apr 10, 2026
fa8e13d
fix: reset isActionFlow in handleSdkComplete and handleClose
kushagrasarathe Apr 10, 2026
d9898aa
feat(analytics): detect Brave browser and set PostHog person property
Hugo0 Apr 12, 2026
5f12759
Merge pull request #1881 from peanutprotocol/hotfix/brave-browser-det…
Hugo0 Apr 12, 2026
edd7a8e
fix: reset submodule pointer, reset isActionFlow in bridge-direct path
kushagrasarathe Apr 13, 2026
3bdcd64
fix: prevent fetchCurrentStatus race closing SDK on cross-region init…
kushagrasarathe Apr 13, 2026
cfa77ce
fix: guard fetchCurrentStatus with initiatingRef to prevent race on c…
kushagrasarathe Apr 13, 2026
9c897a1
fix: add onApplicantActionStatusChanged handler for action SDK close
kushagrasarathe Apr 13, 2026
f2f94f7
fix: guard action status handler on terminal reviewStatus
kushagrasarathe Apr 13, 2026
316f713
Merge pull request #1879 from peanutprotocol/feat/applicant-actions
jjramirezn Apr 14, 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
10 changes: 10 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,14 @@ if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
capture_pageleave: true,
autocapture: true,
})

// Brave identifies as Chrome in User-Agent — detect it and set a person property
// so we can accurately measure our crypto-native Brave audience in PostHog
if (navigator.brave) {
navigator.brave.isBrave().then((isBrave) => {
if (isBrave) {
posthog.setPersonProperties({ browser_override: 'Brave' })
}
})
}
}
40 changes: 40 additions & 0 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { loadingStateContext } from '@/context'
import { getCurrencyPrice } from '@/app/actions/currency'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { captureException } from '@sentry/nextjs'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import {
isPaymentProcessorQR,
parseSimpleFiQr,
Expand Down Expand Up @@ -134,6 +136,10 @@ export default function QRPayPage() {
const [waitingForMerchantAmount, setWaitingForMerchantAmount] = useState(false)
const retryCount = useRef(0)

// Analytics tracking refs (declared before resetState so it can clear them)
const hasTrackedPerkShown = useRef(false)
const perkClaimedRef = useRef(false)

const resetState = () => {
setIsSuccess(false)
setErrorMessage(null)
Expand Down Expand Up @@ -165,6 +171,9 @@ export default function QRPayPage() {
// reset perk states
setIsClaimingPerk(false)
setPerkClaimed(false)
// reset analytics tracking refs so a new QR flow gets fresh tracking
hasTrackedPerkShown.current = false
perkClaimedRef.current = false
}

// Cleanup timers on unmount
Expand All @@ -177,6 +186,33 @@ export default function QRPayPage() {
}
}, [])

// Track reward claim shown + surprise moment when perk UI appears after payment
useEffect(() => {
perkClaimedRef.current = perkClaimed
}, [perkClaimed])

useEffect(() => {
if (isSuccess && qrPayment?.perk?.eligible && !perkClaimed && !hasTrackedPerkShown.current) {
hasTrackedPerkShown.current = true
const eventProps = {
amount_usd: qrPayment.perk.amountSponsored,
discount_pct: qrPayment.perk.discountPercentage,
merchant: qrPayment.details?.merchant?.name,
}
posthog.capture(ANALYTICS_EVENTS.REWARD_CLAIM_SHOWN, eventProps)
posthog.capture(ANALYTICS_EVENTS.SURPRISE_MOMENT_SHOWN, eventProps)
}
}, [isSuccess, qrPayment?.perk?.eligible, perkClaimed, qrPayment])

// Track dismiss: user navigated away after seeing perk but without claiming
useEffect(() => {
return () => {
if (hasTrackedPerkShown.current && !perkClaimedRef.current) {
posthog.capture(ANALYTICS_EVENTS.REWARD_CLAIM_DISMISSED)
}
}
}, [])

const handleSimpleFiStatusUpdate = useCallback(
async (entry: HistoryEntry) => {
if (!pendingSimpleFiPaymentId || entry.uuid !== pendingSimpleFiPaymentId) {
Expand Down Expand Up @@ -784,6 +820,10 @@ export default function QRPayPage() {
try {
const result = await mantecaApi.claimPerk(qrPayment.externalId)
if (result.success) {
posthog.capture(ANALYTICS_EVENTS.REWARD_CLAIMED, {
amount_usd: result.perk.amountSponsored,
discount_pct: result.perk.discountPercentage,
})
// Update qrPayment with actual claimed perk info from backend
setQrPayment({
...qrPayment,
Expand Down
5 changes: 4 additions & 1 deletion src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ const API_KEY = process.env.PEANUT_API_KEY!
export const initiateSumsubKyc = async (params?: {
regionIntent?: KYCRegionIntent
levelName?: string
crossRegion?: boolean
}): Promise<{ data?: InitiateSumsubKycResponse; error?: string }> => {
const jwtToken = (await getJWTCookie())?.value

if (!jwtToken) {
return { error: 'Authentication required' }
}

const body: Record<string, string | undefined> = {
const body: Record<string, string | boolean | undefined> = {
regionIntent: params?.regionIntent,
levelName: params?.levelName,
crossRegion: params?.crossRegion,
}

try {
Expand All @@ -45,6 +47,7 @@ export const initiateSumsubKyc = async (params?: {
token: responseJson.token,
applicantId: responseJson.applicantId,
status: responseJson.status,
actionType: responseJson.actionType,
},
}
} catch (e: unknown) {
Expand Down
14 changes: 12 additions & 2 deletions src/app/actions/types/sumsub.types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
export type KycActionType = 'manteca' | 'bridge-direct'

export interface InitiateSumsubKycResponse {
token: string | null // null when user is already APPROVED
token: string | null // null when user is already APPROVED or bridge-direct
applicantId: string | null
status: SumsubKycStatus
actionType?: KycActionType // present for cross-region responses
}

export type SumsubKycStatus = 'NOT_STARTED' | 'PENDING' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' | 'ACTION_REQUIRED'
export type SumsubKycStatus =
| 'NOT_STARTED'
| 'PENDING'
| 'IN_REVIEW'
| 'APPROVED'
| 'REJECTED'
| 'ACTION_REQUIRED'
| 'REVERIFYING'

export type KYCRegionIntent = 'STANDARD' | 'LATAM'
14 changes: 14 additions & 0 deletions src/components/Kyc/SumsubKycWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,31 @@ export const SumsubKycWrapper = ({
}
}

// for applicant actions, the SDK fires action-specific events.
// only close on terminal status to avoid premature SDK closure.
const handleActionCompleted = (payload: {
reviewStatus?: string
reviewResult?: { reviewAnswer?: string }
}) => {
console.log('[sumsub] onApplicantActionStatusChanged fired', payload)
if (payload?.reviewStatus === 'completed') {
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)
.on('onApplicantActionStatusChanged', handleActionCompleted)
// also listen for idCheck-prefixed events (some sdk versions use these)
.on('idCheck.onApplicantSubmitted', handleSubmitted)
.on('idCheck.onApplicantResubmitted', handleResubmitted)
.on('idCheck.onApplicantStatusChanged', handleStatusChanged)
.on('idCheck.onApplicantActionStatusChanged', handleActionCompleted)
.on('onError', (error: unknown) => {
console.error('[sumsub] sdk error', error)
stableOnError(error)
Expand Down
7 changes: 5 additions & 2 deletions src/components/Profile/views/RegionsVerification.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,12 @@ const RegionsVerification = () => {
const handleStartKyc = useCallback(async () => {
const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined
if (intent) setActiveRegionIntent(intent)
// only signal cross-region when user is switching to a different region
const crossRegion =
sumsubVerificationRegionIntent && intent && intent !== sumsubVerificationRegionIntent ? true : undefined
setSelectedRegion(null)
await flow.handleInitiateKyc(intent)
}, [flow.handleInitiateKyc, selectedRegion])
await flow.handleInitiateKyc(intent, undefined, crossRegion)
}, [flow.handleInitiateKyc, selectedRegion, sumsubVerificationRegionIntent])

// re-submission: skip StartVerificationView since user already consented
const handleResubmitKyc = useCallback(async () => {
Expand Down
4 changes: 3 additions & 1 deletion src/constants/kyc.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type KycVerificationStatus = MantecaKycStatus | SumsubKycStatus | string
export type KycStatusCategory = 'completed' | 'processing' | 'failed' | 'action_required'

// sets of status values by category — single source of truth
const APPROVED_STATUSES: ReadonlySet<string> = new Set(['approved', 'ACTIVE', 'APPROVED'])
// REVERIFYING = user is approved but re-verifying for a new region (cross-region KYC).
// treated as approved for access checks — user retains existing provider access.
const APPROVED_STATUSES: ReadonlySet<string> = new Set(['approved', 'ACTIVE', 'APPROVED', 'REVERIFYING'])
const FAILED_STATUSES: ReadonlySet<string> = new Set(['rejected', 'INACTIVE', 'REJECTED'])
const PENDING_STATUSES: ReadonlySet<string> = new Set([
'under_review',
Expand Down
9 changes: 6 additions & 3 deletions src/hooks/useMultiPhaseKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
refreshToken,
isVerificationProgressModalOpen,
closeVerificationProgressModal,
isActionFlow,
} = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent })

// keep ref in sync
Expand Down Expand Up @@ -176,7 +177,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent

// wrap handleInitiateKyc to reset state for new attempts
const handleInitiateKyc = useCallback(
async (overrideIntent?: KYCRegionIntent, levelName?: string) => {
async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => {
const intent = overrideIntent ?? regionIntent
posthog.capture(
intent === 'LATAM' ? ANALYTICS_EVENTS.MANTECA_KYC_INITIATED : ANALYTICS_EVENTS.KYC_INITIATED,
Expand All @@ -192,7 +193,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent
isRealtimeFlowRef.current = false
clearPreparingTimer()

await originalHandleInitiateKyc(overrideIntent, levelName)
await originalHandleInitiateKyc(overrideIntent, levelName, crossRegion)
},
[originalHandleInitiateKyc, clearPreparingTimer, regionIntent, acquisitionSource]
)
Expand Down Expand Up @@ -306,7 +307,9 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent

const isModalOpen = isVerificationProgressModalOpen || forceShowModal

const isMultiLevel = regionIntent === 'LATAM'
// multi-level only for first-time LATAM (workflow with conditional questionnaire).
// cross-region LATAM uses an applicant action (single level, not multi-level).
const isMultiLevel = regionIntent === 'LATAM' && !isActionFlow

// Derive preparing stage from elapsed time for progressive copy
const preparingStage = useMemo<'initial' | 'configuring' | 'slow'>(() => {
Expand Down
47 changes: 40 additions & 7 deletions src/hooks/useSumsubKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false)
const [liveKycStatus, setLiveKycStatus] = useState<SumsubKycStatus | undefined>(undefined)
const [rejectLabels, setRejectLabels] = useState<string[] | undefined>(undefined)
// true when the SDK is showing an applicant action (not a standard level)
const [isActionFlow, setIsActionFlow] = useState(false)
const prevStatusRef = useRef(liveKycStatus)
const showWrapperRef = useRef(showWrapper)
showWrapperRef.current = showWrapper
// tracks the effective region intent across initiate + refresh so the correct template is always used
const regionIntentRef = useRef<KYCRegionIntent | undefined>(regionIntent)
// tracks the level name across initiate + refresh (e.g. 'peanut-additional-docs')
const levelNameRef = useRef<string | undefined>(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.
// prevents stale websocket events or mount-time fetches from auto-closing the drawer.
const userInitiatedRef = useRef(false)
Expand Down Expand Up @@ -67,7 +71,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
liveKycStatus &&
liveKycStatus !== prevStatus &&
liveKycStatus !== 'APPROVED' &&
liveKycStatus !== 'PENDING'
liveKycStatus !== 'PENDING' &&
liveKycStatus !== 'REVERIFYING'
) {
// close modal for any non-success terminal state (REJECTED, ACTION_REQUIRED, FAILED, etc.)
setIsVerificationProgressModalOpen(false)
Expand All @@ -79,11 +84,13 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
// (e.g. RegionsVerification mounts with no region selected yet).
useEffect(() => {
if (!regionIntent) return
// skip if handleInitiateKyc is already in progress — it handles status sync itself
if (initiatingRef.current) return

const fetchCurrentStatus = async () => {
try {
const response = await initiateSumsubKyc({ regionIntent })
if (response.data?.status) {
if (response.data?.status && !initiatingRef.current) {
setLiveKycStatus(response.data.status)
}
} catch {
Expand Down Expand Up @@ -119,15 +126,24 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
}, [isVerificationProgressModalOpen])

const handleInitiateKyc = useCallback(
async (overrideIntent?: KYCRegionIntent, levelName?: string) => {
async (overrideIntent?: KYCRegionIntent, levelName?: string, crossRegion?: boolean) => {
userInitiatedRef.current = true
initiatingRef.current = true
setIsLoading(true)
setError(null)

// for cross-region: pre-set prevStatusRef to APPROVED so the fetchCurrentStatus
// effect (which also fires when regionIntent changes) doesn't trigger onKycSuccess
// when it sees the existing APPROVED status.
if (crossRegion) {
prevStatusRef.current = 'APPROVED'
}

try {
const response = await initiateSumsubKyc({
regionIntent: overrideIntent ?? regionIntent,
levelName,
crossRegion,
})

if (response.error) {
Expand All @@ -148,17 +164,30 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
if (effectiveIntent) regionIntentRef.current = effectiveIntent
levelNameRef.current = levelName

// if already approved and no token returned, kyc is done.
// set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time.
// when a token IS returned (e.g. additional-docs flow), we still need to show the SDK.
if (response.data?.status === 'APPROVED' && !response.data?.token) {
// cross-region: bridge-direct means no SDK needed — backend is handling
// rail enrollment + submission. go straight to the post-approval flow.
if (response.data?.actionType === 'bridge-direct') {
prevStatusRef.current = 'APPROVED'
userInitiatedRef.current = true
setIsActionFlow(false)
setIsVerificationProgressModalOpen(true)
onKycSuccess?.()
return
}

// if already approved (or reverifying) and no token returned, kyc is done.
// set prevStatusRef so the transition effect doesn't fire onKycSuccess a second time.
// when a token IS returned (e.g. cross-region action or additional-docs), we still need to show the SDK.
const status = response.data?.status
if ((status === 'APPROVED' || status === 'REVERIFYING') && !response.data?.token) {
prevStatusRef.current = status
onKycSuccess?.()
return
}

if (response.data?.token) {
setAccessToken(response.data.token)
setIsActionFlow(!!response.data.actionType)
setShowWrapper(true)
} else {
setError('Could not initiate verification. Please try again.')
Expand All @@ -168,6 +197,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
setError(message)
} finally {
setIsLoading(false)
initiatingRef.current = false
}
},
[regionIntent, onKycSuccess]
Expand All @@ -177,12 +207,14 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
const handleSdkComplete = useCallback(() => {
userInitiatedRef.current = true
setShowWrapper(false)
setIsActionFlow(false)
setIsVerificationProgressModalOpen(true)
}, [])

// called when user manually closes the sdk modal
const handleClose = useCallback(() => {
setShowWrapper(false)
setIsActionFlow(false)
onManualClose?.()
}, [onManualClose])

Expand Down Expand Up @@ -230,5 +262,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }:
closeVerificationProgressModal,
closeVerificationModalAndGoHome,
resetError,
isActionFlow,
}
}
6 changes: 6 additions & 0 deletions src/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ interface Window {
CRISP_TOKEN_ID?: string | null
CRISP_WEBSITE_ID?: string
}

interface Navigator {
brave?: {
isBrave: () => Promise<boolean>
}
}
Loading