diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 4f3c5e367..5082309fb 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -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' }) + } + }) + } } diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index e4b7661c4..03b68896b 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -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, @@ -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) @@ -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 @@ -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) { @@ -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, diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 7222004c6..6e1a2eea0 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -11,6 +11,7 @@ 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 @@ -18,9 +19,10 @@ export const initiateSumsubKyc = async (params?: { return { error: 'Authentication required' } } - const body: Record = { + const body: Record = { regionIntent: params?.regionIntent, levelName: params?.levelName, + crossRegion: params?.crossRegion, } try { @@ -45,6 +47,7 @@ export const initiateSumsubKyc = async (params?: { token: responseJson.token, applicantId: responseJson.applicantId, status: responseJson.status, + actionType: responseJson.actionType, }, } } catch (e: unknown) { diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts index 8565d2961..fa8b91d5f 100644 --- a/src/app/actions/types/sumsub.types.ts +++ b/src/app/actions/types/sumsub.types.ts @@ -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' diff --git a/src/components/Kyc/SumsubKycWrapper.tsx b/src/components/Kyc/SumsubKycWrapper.tsx index 01a553685..061b5705d 100644 --- a/src/components/Kyc/SumsubKycWrapper.tsx +++ b/src/components/Kyc/SumsubKycWrapper.tsx @@ -135,6 +135,18 @@ 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' }) @@ -142,10 +154,12 @@ export const SumsubKycWrapper = ({ .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) diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index fdd051eb5..9711d0483 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -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 () => { diff --git a/src/constants/kyc.consts.ts b/src/constants/kyc.consts.ts index d35557a54..308548ae3 100644 --- a/src/constants/kyc.consts.ts +++ b/src/constants/kyc.consts.ts @@ -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 = 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 = new Set(['approved', 'ACTIVE', 'APPROVED', 'REVERIFYING']) const FAILED_STATUSES: ReadonlySet = new Set(['rejected', 'INACTIVE', 'REJECTED']) const PENDING_STATUSES: ReadonlySet = new Set([ 'under_review', diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 6ec2f520a..953ae58ae 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -148,6 +148,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent refreshToken, isVerificationProgressModalOpen, closeVerificationProgressModal, + isActionFlow, } = useSumsubKycFlow({ onKycSuccess: handleSumsubApproved, onManualClose, regionIntent }) // keep ref in sync @@ -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, @@ -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] ) @@ -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'>(() => { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 47566ba0b..d92709a2e 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -22,6 +22,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const [isVerificationProgressModalOpen, setIsVerificationProgressModalOpen] = useState(false) const [liveKycStatus, setLiveKycStatus] = useState(undefined) const [rejectLabels, setRejectLabels] = useState(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 @@ -29,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) + // 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) @@ -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) @@ -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 { @@ -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) { @@ -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.') @@ -168,6 +197,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError(message) } finally { setIsLoading(false) + initiatingRef.current = false } }, [regionIntent, onKycSuccess] @@ -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]) @@ -230,5 +262,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: closeVerificationProgressModal, closeVerificationModalAndGoHome, resetError, + isActionFlow, } } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 9b6bf210b..0462983a0 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -4,3 +4,9 @@ interface Window { CRISP_TOKEN_ID?: string | null CRISP_WEBSITE_ID?: string } + +interface Navigator { + brave?: { + isBrave: () => Promise + } +}