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..ebc0769ba 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 { diff --git a/src/app/actions/types/sumsub.types.ts b/src/app/actions/types/sumsub.types.ts index 8565d2961..f7c2cd192 100644 --- a/src/app/actions/types/sumsub.types.ts +++ b/src/app/actions/types/sumsub.types.ts @@ -4,6 +4,13 @@ export interface InitiateSumsubKycResponse { status: SumsubKycStatus } -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/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..c6cc004f2 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -176,7 +176,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 +192,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent isRealtimeFlowRef.current = false clearPreparingTimer() - await originalHandleInitiateKyc(overrideIntent, levelName) + await originalHandleInitiateKyc(overrideIntent, levelName, crossRegion) }, [originalHandleInitiateKyc, clearPreparingTimer, regionIntent, acquisitionSource] ) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 47566ba0b..e02954b74 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -67,7 +67,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) @@ -119,7 +120,7 @@ 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 setIsLoading(true) setError(null) @@ -128,6 +129,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const response = await initiateSumsubKyc({ regionIntent: overrideIntent ?? regionIntent, levelName, + crossRegion, }) if (response.error) { @@ -148,11 +150,12 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (effectiveIntent) regionIntentRef.current = effectiveIntent levelNameRef.current = levelName - // if already approved and no token returned, kyc is done. + // 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. additional-docs flow), we still need to show the SDK. - if (response.data?.status === 'APPROVED' && !response.data?.token) { - prevStatusRef.current = 'APPROVED' + // when a token IS returned (e.g. cross-region or additional-docs flow), 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 }