diff --git a/app/(protected)/(tabs)/card/activate.tsx b/app/(protected)/(tabs)/card/activate.tsx index 97854cedb..b8ae6e9cc 100644 --- a/app/(protected)/(tabs)/card/activate.tsx +++ b/app/(protected)/(tabs)/card/activate.tsx @@ -9,6 +9,8 @@ import { CardActivationStep } from '@/components/Card/CardActivationStep'; import PageLayout from '@/components/PageLayout'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { track } from '@/lib/analytics'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useCardSteps } from '@/hooks/useCardSteps'; import { useCountryCheck } from '@/hooks/useCountryCheck'; @@ -59,6 +61,19 @@ export default function ActivateMobile() { Array.isArray(cardsEndorsement?.requirements?.pending) && cardsEndorsement.requirements.pending.length > 0; + // Track card activate page view (on mount only) + React.useEffect(() => { + track(TRACKING_EVENTS.CARD_ACTIVATE_PAGE_VIEWED, { + card_status: cardStatus, + kyc_status: _kycStatus, + is_card_pending: isCardPending, + is_card_blocked: isCardBlocked, + is_under_review: isUnderReview, + country_confirmed: countryConfirmed === 'true', + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // If the card is already active, skip the activation flow React.useEffect(() => { if (cardStatus === CardStatus.ACTIVE || cardStatus === CardStatus.FROZEN) { diff --git a/app/(protected)/(tabs)/kyc.tsx b/app/(protected)/(tabs)/kyc.tsx index 6523c2b4d..0abeb0002 100644 --- a/app/(protected)/(tabs)/kyc.tsx +++ b/app/(protected)/(tabs)/kyc.tsx @@ -2,6 +2,8 @@ import PageLayout from '@/components/PageLayout'; import { Text } from '@/components/ui/text'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { track } from '@/lib/analytics'; +import { getAttributionChannel } from '@/lib/attribution'; +import { useAttributionStore } from '@/store/useAttributionStore'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { ArrowLeft } from 'lucide-react-native'; import type { ClientOptions } from 'persona'; @@ -130,7 +132,22 @@ export default function Kyc({ onSuccess }: KycParams = {}) { templateVersionId: (options as any).templateVersionId ?? null, host: (options as any).host ?? null, onLoad: null, - onEvent: null, + onEvent: (name: string, meta: any) => { + // Track Persona KYC step progression + if (name === 'start') { + track(TRACKING_EVENTS.KYC_STEP_STARTED, { + step_name: meta?.name || 'unknown', + template_id: options.templateId, + inquiry_id: options.inquiryId, + }); + } else if (name === 'complete') { + track(TRACKING_EVENTS.KYC_STEP_COMPLETED, { + step_name: meta?.name || 'unknown', + template_id: options.templateId, + inquiry_id: options.inquiryId, + }); + } + }, onReady: () => { track(TRACKING_EVENTS.KYC_LINK_SDK_READY, { templateId: options.templateId, @@ -139,10 +156,16 @@ export default function Kyc({ onSuccess }: KycParams = {}) { setLoading(false); }, onComplete: ({ inquiryId: completedInquiryId, status }) => { + // Capture attribution for KYC conversion tracking + const attributionData = useAttributionStore.getState().getAttributionForEvent(); + const attributionChannel = getAttributionChannel(attributionData); + track(TRACKING_EVENTS.KYC_LINK_COMPLETED, { inquiryId: completedInquiryId, status, hasRedirectUri: !!redirectUri, + ...attributionData, + attribution_channel: attributionChannel, }); onSuccess?.(); diff --git a/app/(protected)/(tabs)/user-kyc-info.tsx b/app/(protected)/(tabs)/user-kyc-info.tsx index 3edc0330c..a67cd6eb3 100644 --- a/app/(protected)/(tabs)/user-kyc-info.tsx +++ b/app/(protected)/(tabs)/user-kyc-info.tsx @@ -2,13 +2,15 @@ import PageLayout from '@/components/PageLayout'; import { Text } from '@/components/ui/text'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowLeft } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Pressable, View } from 'react-native'; import { z } from 'zod'; import { UserInfoFooter, UserInfoForm, UserInfoHeader } from '@/components/UserKyc'; import { KycMode, type UserInfoFormData, userInfoSchema } from '@/components/UserKyc/types'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { track } from '@/lib/analytics'; import { createKycLink } from '@/lib/api'; import { startKycFlow } from '@/lib/utils/kyc'; import { useKycStore } from '@/store/useKycStore'; @@ -30,6 +32,14 @@ export default function UserKycInfo() { const { redirectUri, kycMode } = params; + // Track page view on mount + useEffect(() => { + track(TRACKING_EVENTS.USER_KYC_INFO_PAGE_VIEWED, { + kyc_mode: kycMode || 'unknown', + has_redirect_uri: !!redirectUri, + }); + }, [kycMode, redirectUri]); + const schema = userInfoSchema.superRefine((data, ctx) => { if ((kycMode as KycMode) === KycMode.CARD && data.agreedToEsign !== true) { ctx.addIssue({ @@ -63,6 +73,15 @@ export default function UserKycInfo() { const onSubmit = async (data: UserInfoFormData) => { setIsLoading(true); + // Track form submission + track(TRACKING_EVENTS.USER_KYC_INFO_FORM_STARTED, { + kyc_mode: kycMode || 'unknown', + has_email: !!data.email, + has_full_name: !!data.fullName, + agreed_to_terms: data.agreedToTerms, + agreed_to_esign: data.agreedToEsign, + }); + const redirectUrl = getRedirectUrl(); console.warn('redirectUrl', redirectUrl); diff --git a/app/_layout.tsx b/app/_layout.tsx index 27f6527c6..cf0d7bbb8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,6 +14,7 @@ import WhatsNewModal from '@/components/WhatsNewModal'; import WithdrawModalProvider from '@/components/Withdraw/WithdrawModalProvider'; import '@/global.css'; import { infoClient } from '@/graphql/clients'; +import { useAttributionInitialization } from '@/hooks/useAttributionInitialization'; import { useWhatsNew } from '@/hooks/useWhatsNew'; import { initAnalytics, trackScreen } from '@/lib/analytics'; import { config } from '@/lib/wagmi'; @@ -183,6 +184,9 @@ export default Sentry.wrap(function RootLayout() { const [splashScreenHidden, setSplashScreenHidden] = useState(false); const { whatsNew, isVisible, closeWhatsNew } = useWhatsNew(); + // Initialize attribution tracking automatically (handles web and mobile) + useAttributionInitialization(); + const [loaded, error] = useFonts({ MonaSans_200ExtraLight, MonaSans_300Light, @@ -243,10 +247,12 @@ export default Sentry.wrap(function RootLayout() { } }, [appIsReady, splashScreenHidden]); + // Track screen views on all platforms (web, iOS, Android) + // trackScreen() handles platform-specific routing internally: + // - Amplitude: tracks on all platforms + // - Firebase: tracks on web only useEffect(() => { - if (Platform.OS === 'web') { - trackScreen(pathname, params); - } + trackScreen(pathname, params); }, [pathname, params]); useEffect(() => { @@ -295,13 +301,6 @@ export default Sentry.wrap(function RootLayout() { animation: 'none', }} /> - !value.includes(' '), { error: 'Username cannot contain spaces' }), -}); - -type RegisterFormData = z.infer; - -export default function Register() { - const { handleLogin, handleDummyLogin, handleSignup } = useUser(); - const { signupInfo, loginInfo, setSignupInfo, setLoginInfo } = useUserStore(); - const { session } = useLocalSearchParams<{ session: string }>(); - // TODO: Add recovery flow - // const [showRecoveryFlow, setShowRecoveryFlow] = useState(false); - - // Reset signup and login info state when component mounts - useEffect(() => { - setSignupInfo({ status: Status.IDLE, message: '' }); - setLoginInfo({ status: Status.IDLE, message: '' }); - }, [setSignupInfo, setLoginInfo]); - - // Detect and save referral code from URL when component mounts - useEffect(() => { - try { - const detectedReferralCode = detectAndSaveReferralCode(); - if (detectedReferralCode) { - console.warn('Referral code detected from URL:', detectedReferralCode); - } - } catch (error) { - console.warn('Error detecting referral code:', error); - } - }, []); - - const { - control, - handleSubmit, - formState: { errors, isValid }, - watch, - reset, - } = useForm({ - resolver: zodResolver(registerSchema), - mode: 'onChange', - defaultValues: { - username: '', - }, - }); - - const watchedUsername = watch('username'); - - // Reset form after successful signup - useEffect(() => { - if (signupInfo.status === Status.SUCCESS) { - reset(); - } - }, [signupInfo.status, reset]); - - const handleSignupForm = async (data: RegisterFormData) => { - handleSignup(data.username, ''); - }; - - const getSignupButtonText = () => { - if (signupInfo.status === Status.PENDING) return 'Create account'; - if (!watchedUsername) return 'Create account'; - if (!isValid) return 'Enter valid information'; - return 'Continue'; - }; - - const getLoginButtonText = () => { - if (loginInfo.status === Status.PENDING) return 'Logging in'; - if (loginInfo.status === Status.ERROR) return 'Error logging in'; - return 'Login'; - }; - - const getSignupErrorText = useMemo(() => { - if (errors.username) return errors.username.message; - if (signupInfo.status === Status.ERROR) return signupInfo.message || 'Error creating account'; - return ''; - }, [errors.username, signupInfo.status, signupInfo.message]); - - const getLoginErrorText = useMemo(() => { - if (loginInfo.status === Status.ERROR) return loginInfo.message || 'Error logging in'; - return ''; - }, [loginInfo.status, loginInfo.message]); - - // const isSignupDisabled = () => { - // return signupInfo.status === Status.PENDING || !isValid || !watchedUsername; - // }; - - useEffect(() => { - if (session === 'expired') { - Toast.show({ - type: 'error', - text1: 'Session expired', - text2: 'Due to inactivity. Please login again.', - props: { - badgeText: '', - }, - }); - } - }, [session]); - - // TODO: Add recovery flow - // const handleRecoverySuccess = (organizationId: string, userId: string) => { - // // Handle successful recovery - this would typically redirect to the main app - // setShowRecoveryFlow(false); - // // You might want to automatically log the user in here - // }; - - // if (showRecoveryFlow) { - // return ( - // - // setShowRecoveryFlow(false)} - // /> - // - // ); - // } - - return ( - - - - Solid logo - - - - Welcome! - - {`Please enter a username and click on the "Create account" button`} - - - - - - ( - - )} - /> - {getSignupErrorText ? ( - - - {getSignupErrorText} - - ) : null} - - - - - - OR - - - - - {getLoginErrorText ? ( - - - {getLoginErrorText} - - ) : null} - {/* TODO: Add recovery flow */} - {/* */} - - {/* TODO: Remove when passkey works */} - {Platform.OS !== 'web' && __DEV__ && ( - - )} - - - Your Solid Account is secured with a passkey - a safer replacement for passwords.{' '} - - Learn more - - - - - - - ); -} diff --git a/app/signup/creating.tsx b/app/signup/creating.tsx index 290f3ed4f..62f3f9af3 100644 --- a/app/signup/creating.tsx +++ b/app/signup/creating.tsx @@ -20,7 +20,9 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events'; import useUser from '@/hooks/useUser'; import { track, trackIdentity } from '@/lib/analytics'; import { emailSignUp } from '@/lib/api'; +import { getAttributionChannel } from '@/lib/attribution'; import { User } from '@/lib/types'; +import { useAttributionStore } from '@/store/useAttributionStore'; import { useSignupFlowStore } from '@/store/useSignupFlowStore'; import { useUserStore } from '@/store/useUserStore'; @@ -115,12 +117,14 @@ export default function SignupCreating() { setStep, setError, } = useSignupFlowStore(); + const _attributionHydrated = useAttributionStore(state => state._hasHydrated); // Guard against duplicate execution (React Strict Mode double-invokes effects in dev) const isCreatingRef = useRef(false); useEffect(() => { - // Wait for both stores to hydrate before making decisions - if (!_hasHydrated || !userStoreHydrated) return; + // Wait for all stores to hydrate before making decisions + // This ensures attribution data is loaded from MMKV before signup + if (!_hasHydrated || !userStoreHydrated || !_attributionHydrated) return; // If user already exists (signup previously succeeded), redirect to home // This prevents duplicate createAccount() calls if user navigates back @@ -145,11 +149,17 @@ export default function SignupCreating() { createAccount(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [_hasHydrated, userStoreHydrated, users.length]); + }, [_hasHydrated, userStoreHydrated, _attributionHydrated, users.length]); const createAccount = async () => { + // Capture attribution context for signup tracking + const attributionData = useAttributionStore.getState().getAttributionForEvent(); + const attributionChannel = getAttributionChannel(attributionData); + track(TRACKING_EVENTS.SIGNUP_STARTED, { email, + ...attributionData, + attribution_channel: attributionChannel, }); try { @@ -188,7 +198,7 @@ export default function SignupCreating() { }; storeUser(selectedUser); - // Track identity + // Track identity with attribution for user profile enrichment trackIdentity(user._id, { username: user.username, email: user.email, @@ -196,6 +206,8 @@ export default function SignupCreating() { has_referral_code: !!user.referralCode, signup_method: 'email_passkey', platform: Platform.OS, + ...attributionData, + attribution_channel: attributionChannel, }); track(TRACKING_EVENTS.SIGNUP_COMPLETED, { @@ -205,6 +217,8 @@ export default function SignupCreating() { referral_code: referralCode, safe_address: safeAddress, has_passkey: true, + ...attributionData, + attribution_channel: attributionChannel, }); // Navigate to home/notifications @@ -225,6 +239,8 @@ export default function SignupCreating() { track(TRACKING_EVENTS.SIGNUP_FAILED, { email, error: errorMessage, + ...attributionData, + attribution_channel: attributionChannel, }); if (Platform.OS === 'web') { diff --git a/app/signup/email.tsx b/app/signup/email.tsx index f4227d099..8b7ca4c18 100644 --- a/app/signup/email.tsx +++ b/app/signup/email.tsx @@ -20,7 +20,8 @@ import { useDimension } from '@/hooks/useDimension'; import { track } from '@/lib/analytics'; import { emailExists, initSignupOtp } from '@/lib/api'; import { getAsset } from '@/lib/assets'; -import { detectAndSaveReferralCode, getReferralCodeForSignup } from '@/lib/utils/referral'; +import { getReferralCodeForSignup } from '@/lib/utils/referral'; +import { useAttributionStore } from '@/store/useAttributionStore'; import { useSignupFlowStore } from '@/store/useSignupFlowStore'; const emailSchema = z.object({ @@ -67,20 +68,6 @@ export default function SignupEmail() { const watchedEmail = watch('email'); - // Detect and save referral code from URL on mount - useEffect(() => { - // Wait for store hydration before any actions - if (!_hasHydrated) return; - try { - const detectedReferralCode = detectAndSaveReferralCode(); - if (detectedReferralCode) { - console.warn('Referral code detected from URL:', detectedReferralCode); - } - } catch (err) { - console.warn('Error detecting referral code:', err); - } - }, [_hasHydrated]); - // Clear rate limit error when email changes useEffect(() => { // Wait for store hydration before any actions @@ -90,14 +77,24 @@ export default function SignupEmail() { } }, [_hasHydrated, watchedEmail, email, rateLimitError, setRateLimitError]); - // Reset flow state on mount (only after hydration completes) - // Note: Using getState() to avoid dependency on reset function reference - // which can change during Zustand hydration and cause infinite loops + // Reset flow state on mount while preserving referral code + // Attribution hook captures referral code at root - we preserve it here useEffect(() => { - // Wait for store hydration before resetting to prevent race conditions + // Wait for store hydration before any actions if (!_hasHydrated) return; + + // Capture referral code BEFORE reset (from attribution or referral store) + const existingReferral = getReferralCodeForSignup(); + + // Reset the signup flow store useSignupFlowStore.getState().reset(); - }, [_hasHydrated]); + + // Restore referral code AFTER reset to prevent data loss + if (existingReferral) { + setReferralCode(existingReferral); + console.warn('✅ Referral code preserved after reset:', existingReferral); + } + }, [_hasHydrated, setReferralCode]); // Wait for store hydration before rendering if (!_hasHydrated) { @@ -130,10 +127,15 @@ export default function SignupEmail() { setMarketingConsent(data.marketingConsent); setLastOtpSentAt(Date.now()); - // Store referral code if present - const storedReferralCode = getReferralCodeForSignup() || ''; + // Store referral code with multi-source fallback for reliability + // Priority: URL params > referral store > attribution store + const storedReferralCode = + getReferralCodeForSignup() || + useAttributionStore.getState().attributionData.referral_code || + ''; if (storedReferralCode) { setReferralCode(storedReferralCode); + console.warn('✅ Referral code saved to signup flow:', storedReferralCode); } track(TRACKING_EVENTS.EMAIL_SUBMITTED, { diff --git a/components/AccountCenter/AccountCenterDropdown.tsx b/components/AccountCenter/AccountCenterDropdown.tsx index a9b291552..5aae40746 100644 --- a/components/AccountCenter/AccountCenterDropdown.tsx +++ b/components/AccountCenter/AccountCenterDropdown.tsx @@ -1,4 +1,5 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import useUser from '@/hooks/useUser'; import { DropdownMenu, @@ -14,7 +15,6 @@ import { AccountCenterTrigger, AccountCenterUsername, onAccountCenterSettingsPress, - useAccountCenterSignOutPress, } from '.'; const dropdownMenuItemClassName = 'h-12 flex-row items-center gap-2 px-4 web:cursor-pointer'; @@ -23,7 +23,7 @@ const cursorDefaultClassName = const AccountCenterDropdown = () => { const insets = useSafeAreaInsets(); - const onAccountCenterSignOutPress = useAccountCenterSignOutPress(); + const { handleLogout } = useUser(); const contentInsets = { top: insets.top, @@ -56,7 +56,7 @@ const AccountCenterDropdown = () => { diff --git a/components/AccountCenter/index.tsx b/components/AccountCenter/index.tsx index 3c0f72df1..f633b27af 100644 --- a/components/AccountCenter/index.tsx +++ b/components/AccountCenter/index.tsx @@ -1,6 +1,5 @@ import { router } from 'expo-router'; import { ChevronDown } from 'lucide-react-native'; -import { useCallback } from 'react'; import { Pressable, View } from 'react-native'; import ProfileIcon from '@/assets/images/profile'; @@ -68,18 +67,10 @@ const AccountCenterSignOut = () => { ); }; -const useAccountCenterSignOutPress = () => { - const { handleLogout } = useUser(); - return useCallback(() => { - handleLogout(); - }, [handleLogout]); -}; - export { AccountCenterSettings, AccountCenterSignOut, AccountCenterTrigger, AccountCenterUsername, onAccountCenterSettingsPress, - useAccountCenterSignOutPress, }; diff --git a/components/BankTransfer/BankTransferModalContent.tsx b/components/BankTransfer/BankTransferModalContent.tsx index 10e7a7638..0b5473ba9 100644 --- a/components/BankTransfer/BankTransferModalContent.tsx +++ b/components/BankTransfer/BankTransferModalContent.tsx @@ -1,9 +1,11 @@ import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useExchangeRate } from '@/hooks/useExchangeRate'; +import { track } from '@/lib/analytics'; import { useDepositStore } from '@/store/useDepositStore'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import AmountCard from './AmountCard'; import ArrowDivider from './ArrowDivider'; @@ -31,6 +33,19 @@ const BankTransferAmountModal = () => { bankTransfer.crypto || BridgeTransferCryptoCurrency.USDC, ); + // Track when amount modal is viewed (once per mount) + const hasTrackedView = useRef(false); + useEffect(() => { + if (!hasTrackedView.current) { + track(TRACKING_EVENTS.DEPOSIT_BANK_AMOUNT_VIEWED, { + deposit_method: 'bank_transfer', + fiat_currency: fiat, + crypto_currency: crypto, + }); + hasTrackedView.current = true; + } + }, [fiat, crypto]); + const allowedCrypto = useMemo(() => { return [BridgeTransferCryptoCurrency.USDC]; }, []); @@ -68,6 +83,14 @@ const BankTransferAmountModal = () => { }, [fiat, fiatAmount]); const handleContinue = () => { + track(TRACKING_EVENTS.DEPOSIT_BANK_AMOUNT_ENTERED, { + deposit_method: 'bank_transfer', + fiat_amount: fiatAmount, + fiat_currency: fiat, + crypto_amount: cryptoAmount, + crypto_currency: crypto, + }); + setBankTransferData({ fiatAmount, cryptoAmount, @@ -131,6 +154,20 @@ const BankTransferAmountModal = () => { const BankTransferPaymentMethodModal = () => { const { bankTransfer } = useDepositStore(); + // Track when payment method modal is viewed (once per mount) + const hasTrackedPaymentView = useRef(false); + useEffect(() => { + if (!hasTrackedPaymentView.current) { + track(TRACKING_EVENTS.DEPOSIT_BANK_PAYMENT_METHOD_VIEWED, { + deposit_method: 'bank_transfer', + fiat_currency: bankTransfer.fiat, + crypto_currency: bankTransfer.crypto, + fiat_amount: bankTransfer.fiatAmount, + }); + hasTrackedPaymentView.current = true; + } + }, [bankTransfer.fiat, bankTransfer.crypto, bankTransfer.fiatAmount]); + return ( { const { bankTransfer, setModal } = useDepositStore(); const data = bankTransfer.instructions; + // Track when instructions modal is viewed (once per mount) + const hasTrackedInstructionsView = useRef(false); + useEffect(() => { + if (!hasTrackedInstructionsView.current && data) { + track(TRACKING_EVENTS.DEPOSIT_BANK_INSTRUCTIONS_VIEWED, { + deposit_method: 'bank_transfer', + fiat_currency: data.currency, + fiat_amount: data.amount, + payment_rail: data.payment_rail, + bank_name: data.bank_name, + }); + hasTrackedInstructionsView.current = true; + } + }, [data]); + const Row = ({ label, value, diff --git a/components/BankTransfer/payment/PaymentMethodList.tsx b/components/BankTransfer/payment/PaymentMethodList.tsx index 67fa28529..152609469 100644 --- a/components/BankTransfer/payment/PaymentMethodList.tsx +++ b/components/BankTransfer/payment/PaymentMethodList.tsx @@ -21,7 +21,7 @@ import { import { startKycFlow } from '@/lib/utils/kyc'; import { useDepositStore } from '@/store/useDepositStore'; import { router } from 'expo-router'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { ActivityIndicator, View } from 'react-native'; import Toast from 'react-native-toast-message'; import { PaymentMethodTile } from './PaymentMethodTile'; @@ -49,6 +49,7 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: const [loadingMethod, setLoadingMethod] = useState(null); const { setBankTransferData, setModal } = useDepositStore(); const { user } = useUser(); + const methodSelectionStartTime = useRef(null); let filtered: BridgeTransferMethod[] = ALL_METHODS; @@ -94,6 +95,9 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: async function onPressed(method: BridgeTransferMethod) { try { + // Capture start time for time_to_create calculation + methodSelectionStartTime.current = Date.now(); + track(TRACKING_EVENTS.PAYMENT_METHOD_SELECTED, { user_id: user?.userId, safe_address: user?.safeAddress, @@ -102,6 +106,7 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: fiat_amount: fiatAmount, crypto_currency: crypto, has_customer: Boolean(customer), + requires_kyc: !customer, deposit_type: 'bank_transfer', }); @@ -123,6 +128,16 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: const endorsement = getEndorsementByMethod(method); + // Track KYC modal viewed for new customer + track(TRACKING_EVENTS.DEPOSIT_BANK_KYC_VIEWED, { + deposit_method: 'bank_transfer', + payment_method: method, + fiat_currency: normalizedFiat, + fiat_amount: fiatAmount, + kyc_type: 'new_customer', + kyc_endorsement: endorsement, + }); + // Start KYC flow in modal instead of navigating const { setKycData, setModal } = useDepositStore.getState(); setKycData({ @@ -239,6 +254,11 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: cryptoCurrency: String(crypto ?? ''), }); + // Calculate time from method selection to transfer creation + const timeToCreate = methodSelectionStartTime.current + ? Math.floor((Date.now() - methodSelectionStartTime.current) / 1000) + : undefined; + // Track bank transfer created successfully track(TRACKING_EVENTS.BANK_TRANSFER_CREATED, { user_id: user?.userId, @@ -249,6 +269,7 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: deposit_type: 'bank_transfer', deposit_method: method, has_instructions: Boolean(sourceDepositInstructions), + time_to_create: timeToCreate, }); if (isModal) { @@ -289,6 +310,27 @@ export function PaymentMethodList({ fiat, crypto, fiatAmount, isModal = false }: if (!kycLink) throw new Error('Failed to get KYC link'); + // Track KYC modal viewed for existing customer + track(TRACKING_EVENTS.DEPOSIT_BANK_KYC_VIEWED, { + deposit_method: 'bank_transfer', + payment_method: method, + fiat_currency: normalizedFiat, + fiat_amount: fiatAmount, + kyc_type: 'existing_customer', + kyc_endorsement: requiredEndorsement, + }); + + // Track KYC started when link is opened + track(TRACKING_EVENTS.DEPOSIT_BANK_KYC_STARTED, { + deposit_method: 'bank_transfer', + payment_method: method, + fiat_currency: normalizedFiat, + fiat_amount: fiatAmount, + kyc_type: 'existing_customer', + kyc_endorsement: requiredEndorsement, + kyc_link: kycLink.url, + }); + // Start KYC flow in modal instead of navigating const { setKycData, setModal } = useDepositStore.getState(); setKycData({ diff --git a/components/BuyCrypto/index.tsx b/components/BuyCrypto/index.tsx index c907d9bdb..301ab0f21 100644 --- a/components/BuyCrypto/index.tsx +++ b/components/BuyCrypto/index.tsx @@ -1,24 +1,50 @@ import { Text } from '@/components/ui/text'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import useUser from '@/hooks/useUser'; +import { track } from '@/lib/analytics'; import { createMercuryoTransaction, getClientIp } from '@/lib/api'; import { withRefreshToken } from '@/lib/utils'; import * as Crypto from 'expo-crypto'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, View } from 'react-native'; const BuyCrypto = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [finalUrl, setFinalUrl] = useState(''); + const loadStartTime = useRef(null); + const transactionId = useRef(null); const { user } = useUser(); + // Track widget loading on mount + useEffect(() => { + loadStartTime.current = Date.now(); + track(TRACKING_EVENTS.DEPOSIT_CARD_WIDGET_LOADING, { + deposit_method: 'credit_card', + }); + }, []); + // Handle iframe load events const handleIframeLoad = () => { + const loadTime = loadStartTime.current ? Date.now() - loadStartTime.current : null; + + track(TRACKING_EVENTS.DEPOSIT_CARD_WIDGET_LOADED, { + deposit_method: 'credit_card', + transaction_id: transactionId.current, + widget_load_time: loadTime ? Math.floor(loadTime / 1000) : undefined, + }); + setLoading(false); }; const handleIframeError = () => { + track(TRACKING_EVENTS.DEPOSIT_CARD_WIDGET_LOAD_FAILED, { + deposit_method: 'credit_card', + transaction_id: transactionId.current, + error: 'Failed to load Mercuryo widget', + }); + setError('Failed to load Mercuryo widget. Please try again later.'); setLoading(false); }; @@ -39,19 +65,30 @@ const BuyCrypto = () => { if (!userIp) throw new Error('Could not get user IP address'); - const transactionId = Crypto.randomUUID(); + const txId = Crypto.randomUUID(); + transactionId.current = txId; - const widgetUrl = await withRefreshToken(() => - createMercuryoTransaction(userIp, transactionId), - ); + const widgetUrl = await withRefreshToken(() => createMercuryoTransaction(userIp, txId)); if (!widgetUrl) { throw new Error('Failed to create Mercuryo transaction'); } + track(TRACKING_EVENTS.DEPOSIT_CARD_TRANSACTION_CREATED, { + deposit_method: 'credit_card', + transaction_id: txId, + }); + setFinalUrl(widgetUrl); } catch (err) { console.error('Error creating Mercuryo transaction:', err); + + track(TRACKING_EVENTS.DEPOSIT_CARD_TRANSACTION_CREATION_FAILED, { + deposit_method: 'credit_card', + error: err instanceof Error ? err.message : String(err), + error_type: err instanceof Error ? err.name : 'Unknown', + }); + setError('Failed to initialize widget'); setLoading(false); } diff --git a/components/CopyToClipboard.tsx b/components/CopyToClipboard.tsx index 128843cb3..e53ea50c7 100644 --- a/components/CopyToClipboard.tsx +++ b/components/CopyToClipboard.tsx @@ -1,6 +1,6 @@ import * as Clipboard from 'expo-clipboard'; import { Check, Copy } from 'lucide-react-native'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -10,18 +10,21 @@ const CopyToClipboard = ({ className, iconClassName, size = 14, + onCopy, }: { text: string; className?: string; iconClassName?: string; size?: number; + onCopy?: () => void; }) => { const [copied, setCopied] = useState(false); - const handleCopy = async () => { + const handleCopy = useCallback(async () => { setCopied(true); await Clipboard.setStringAsync(text); - }; + onCopy?.(); + }, [text, onCopy]); useEffect(() => { if (copied) { diff --git a/components/DepositNetwork/DepositNetworks.tsx b/components/DepositNetwork/DepositNetworks.tsx index ae9b9e266..116c08800 100644 --- a/components/DepositNetwork/DepositNetworks.tsx +++ b/components/DepositNetwork/DepositNetworks.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { View } from 'react-native'; import { Text } from '@/components/ui/text'; @@ -12,6 +13,19 @@ import DepositNetwork from './DepositNetwork'; const DepositNetworks = () => { const { setModal, setSrcChainId, setOutputToken } = useDepositStore(); const { user } = useUser(); + const hasTrackedNetworkView = useRef(false); + + // Track when wallet network selection screen is viewed + useEffect(() => { + if (!hasTrackedNetworkView.current) { + const networksCount = Object.keys(BRIDGE_TOKENS).length; + track(TRACKING_EVENTS.DEPOSIT_WALLET_NETWORK_VIEWED, { + deposit_method: 'wallet', + available_networks: networksCount, + }); + hasTrackedNetworkView.current = true; + } + }, []); const handlePress = (id: number) => { const network = BRIDGE_TOKENS[id]; @@ -26,6 +40,14 @@ const DepositNetworks = () => { deposit_method: 'cross_chain_bridge', }); + // Track wallet network selection specifically + track(TRACKING_EVENTS.DEPOSIT_WALLET_NETWORK_SELECTED, { + deposit_method: 'wallet', + chain_id: id, + network_name: network?.name, + estimated_time: id === 1 ? '5 min' : '20 min', + }); + setSrcChainId(id); setOutputToken('USDC'); setModal(DEPOSIT_MODAL.OPEN_FORM); diff --git a/components/DepositOption/DepositDirectlyAddress.tsx b/components/DepositOption/DepositDirectlyAddress.tsx index f06703807..3ded90c82 100644 --- a/components/DepositOption/DepositDirectlyAddress.tsx +++ b/components/DepositOption/DepositDirectlyAddress.tsx @@ -6,7 +6,9 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { BRIDGE_TOKENS } from '@/constants/bridge'; import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { path } from '@/constants/path'; +import { track } from '@/lib/analytics'; import { useMaxAPY } from '@/hooks/useAnalytics'; import { usePreviewDeposit } from '@/hooks/usePreviewDeposit'; @@ -18,7 +20,7 @@ import { useDepositStore } from '@/store/useDepositStore'; import { Image } from 'expo-image'; import { router } from 'expo-router'; import { Copy, Fuel, Info, MessageCircle, Share2 } from 'lucide-react-native'; -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Share, View } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import { formatUnits } from 'viem'; @@ -71,6 +73,7 @@ const DepositDirectlyAddress = () => { const [shareError, setShareError] = useState(false); const { maxAPY, isAPYsLoading } = useMaxAPY(); const intercom = useIntercom(); + const hasTrackedAddressView = useRef(false); // Get token address for exchange rate calculation const tokenAddress = @@ -85,6 +88,20 @@ const DepositDirectlyAddress = () => { const status: DepositStatus = 'pending'; const isExpired = false; + // Track address view on mount + useEffect(() => { + if (!hasTrackedAddressView.current && walletAddress) { + track(TRACKING_EVENTS.DEPOSIT_DIRECT_ADDRESS_VIEWED, { + deposit_method: 'deposit_directly', + session_id: directDepositSession.sessionId, + chain_id: chainId, + selected_token: selectedToken, + wallet_address: walletAddress, + }); + hasTrackedAddressView.current = true; + } + }, [walletAddress, chainId, selectedToken, directDepositSession.sessionId]); + // Clear share error after timeout useEffect(() => { if (!shareError) return; @@ -100,16 +117,44 @@ const DepositDirectlyAddress = () => { } }, [setModal, clearDirectDepositSession, directDepositSession.fromActivity]); + const handleCopy = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_DIRECT_ADDRESS_COPIED, { + deposit_method: 'deposit_directly', + session_id: directDepositSession.sessionId, + chain_id: chainId, + selected_token: selectedToken, + }); + }, [chainId, selectedToken, directDepositSession.sessionId]); + + const handleQrOpen = useCallback(() => { + setIsQrDialogOpen(true); + + track(TRACKING_EVENTS.DEPOSIT_DIRECT_QR_VIEWED, { + deposit_method: 'deposit_directly', + session_id: directDepositSession.sessionId, + chain_id: chainId, + selected_token: selectedToken, + }); + }, [chainId, selectedToken, directDepositSession.sessionId]); + const handleShare = useCallback(async () => { if (!walletAddress) return; try { await Share.share({ message: walletAddress, title: 'Solid deposit address' }); + + // Track successful share + track(TRACKING_EVENTS.DEPOSIT_DIRECT_ADDRESS_SHARED, { + deposit_method: 'deposit_directly', + session_id: directDepositSession.sessionId, + chain_id: chainId, + selected_token: selectedToken, + }); } catch (error) { console.error('Failed to share deposit address:', error); setShareError(true); } - }, [walletAddress]); + }, [walletAddress, chainId, selectedToken, directDepositSession.sessionId]); const estimatedTime = chainId === 1 ? '5 minutes' : '30 minutes'; const formattedAPY = maxAPY !== undefined ? `${maxAPY.toFixed(2)}%` : '—'; @@ -211,13 +256,14 @@ const DepositDirectlyAddress = () => { text={walletAddress || ''} className="h-10 w-10 bg-transparent" iconClassName="text-white" + onCopy={handleCopy} /> - diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 3b14161c0..54caa0a4c 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -9,7 +9,9 @@ import SwapParams from '@/components/Swap/SwapParams'; import TransactionStatus from '@/components/TransactionStatus'; import { Text } from '@/components/ui/text'; import { SWAP_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { path } from '@/constants/path'; +import { track } from '@/lib/analytics'; import getTokenIcon from '@/lib/getTokenIcon'; import { useIntercom } from '@/lib/intercom'; import { useSwapState } from '@/store/swapStore'; @@ -40,12 +42,19 @@ const SwapModalProvider = () => { const handleOpenChange = useCallback( (value: boolean) => { if (value) { + track(TRACKING_EVENTS.SWAP_MODAL_VIEWED, { + previous_modal: previousModal?.name, + }); setModal(SWAP_MODAL.OPEN_FORM); } else { + track(TRACKING_EVENTS.SWAP_MODAL_ABANDONED, { + last_modal: currentModal?.name, + previous_modal: previousModal?.name, + }); setModal(SWAP_MODAL.CLOSE); } }, - [setModal], + [setModal, currentModal, previousModal], ); const handleTransactionStatusPress = useCallback(() => { diff --git a/components/Swap/TokenCard.tsx b/components/Swap/TokenCard.tsx index 53e2064ba..de9ffeef8 100644 --- a/components/Swap/TokenCard.tsx +++ b/components/Swap/TokenCard.tsx @@ -1,12 +1,14 @@ import { Wallet } from 'lucide-react-native'; -import React, { memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform, TextInput, View } from 'react-native'; import { useBalance } from 'wagmi'; import Max from '@/components/Max'; import SwapTokenSelectorModal from '@/components/TokenSelector/SwapTokenSelectorModal'; import { Text } from '@/components/ui/text'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import useUser from '@/hooks/useUser'; +import { track } from '@/lib/analytics'; import { formatNumber } from '@/lib/utils'; import { Currency, Percent } from '@cryptoalgebra/fuse-sdk'; import { formatUnits } from 'viem'; @@ -48,6 +50,29 @@ const TokenCard: React.FC = ({ const { user } = useUser(); const account = user?.safeAddress; + // Track amount entry start (once per swap session) + const hasTrackedAmountEntry = useRef(false); + + useEffect(() => { + if (value && !hasTrackedAmountEntry.current && !disabled) { + hasTrackedAmountEntry.current = true; + track(TRACKING_EVENTS.SWAP_AMOUNT_ENTRY_STARTED, { + field: title || 'unknown', + currency_symbol: currency?.symbol, + }); + } + }, [value, disabled, title, currency?.symbol]); + + // Track token selector opened + useEffect(() => { + if (open) { + track(TRACKING_EVENTS.SWAP_TOKEN_SELECTOR_OPENED, { + field: title || 'unknown', + current_currency: currency?.symbol, + }); + } + }, [open, title, currency?.symbol]); + const { data: balance, isLoading: isBalanceLoading } = useBalance({ address: account, token: currency?.isNative ? undefined : (currency?.wrapped.address as `0x${string}`), diff --git a/constants/path.ts b/constants/path.ts index 3e9e6e9f5..f80dca774 100644 --- a/constants/path.ts +++ b/constants/path.ts @@ -2,7 +2,6 @@ import { Href, Route } from 'expo-router'; type Path = { ONBOARDING: Href; - REGISTER: Route; WELCOME: Href; HOME: Href; // Email-first signup flow @@ -42,7 +41,6 @@ type Path = { export const path: Path = { ONBOARDING: '/onboarding', - REGISTER: '/register', WELCOME: '/welcome', HOME: '/', // Email-first signup flow diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 5fc7d951b..cd04b156d 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -25,6 +25,10 @@ export const TRACKING_EVENTS = { WITHDRAW_TRANSACTION_ERROR: 'withdraw_transaction_error', // Transaction Flow Events - Swap + SWAP_MODAL_VIEWED: 'swap_modal_viewed', + SWAP_AMOUNT_ENTRY_STARTED: 'swap_amount_entry_started', + SWAP_TOKEN_SELECTOR_OPENED: 'swap_token_selector_opened', + SWAP_MODAL_ABANDONED: 'swap_modal_abandoned', SWAP_INITIATED: 'swap_initiated', SWAP_COMPLETED: 'swap_completed', SWAP_FAILED: 'swap_failed', @@ -95,9 +99,59 @@ export const TRACKING_EVENTS = { DEPOSIT_METHOD_SELECTED: 'deposit_method_selected', DEPOSIT_OPTIONS_VIEWED: 'deposit_options_viewed', DEPOSIT_OPTIONS_ABANDONED: 'deposit_options_abandoned', + DEPOSIT_TRIGGER_CLICKED: 'deposit_trigger_clicked', + DEPOSIT_AMOUNT_ENTRY_STARTED: 'deposit_amount_entry_started', + DEPOSIT_VALIDATION_ERROR: 'deposit_validation_error', + DEPOSIT_MAX_BUTTON_CLICKED: 'deposit_max_button_clicked', + WALLET_CONNECT_MODAL_VIEWED: 'wallet_connect_modal_viewed', NETWORK_SELECTED: 'network_selected', BANK_TRANSFER_CREATED: 'bank_transfer_created', + // Deposit Method: Crypto Wallet (Method 1) + DEPOSIT_WALLET_NETWORK_VIEWED: 'deposit_wallet_network_viewed', + DEPOSIT_WALLET_NETWORK_SELECTED: 'deposit_wallet_network_selected', + DEPOSIT_WALLET_FORM_VIEWED: 'deposit_wallet_form_viewed', + DEPOSIT_WALLET_FORM_SUBMITTED: 'deposit_wallet_form_submitted', + DEPOSIT_WALLET_NETWORK_ABANDONED: 'deposit_wallet_network_abandoned', + DEPOSIT_WALLET_FORM_ABANDONED: 'deposit_wallet_form_abandoned', + + // Deposit Method: Direct Deposit (Method 2) + DEPOSIT_DIRECT_NETWORK_VIEWED: 'deposit_direct_network_viewed', + DEPOSIT_DIRECT_NETWORK_SELECTED: 'deposit_direct_network_selected', + DEPOSIT_DIRECT_TOKEN_VIEWED: 'deposit_direct_token_viewed', + DEPOSIT_DIRECT_TOKEN_SELECTED: 'deposit_direct_token_selected', + DEPOSIT_DIRECT_ADDRESS_VIEWED: 'deposit_direct_address_viewed', + DEPOSIT_DIRECT_ADDRESS_COPIED: 'deposit_direct_address_copied', + DEPOSIT_DIRECT_QR_VIEWED: 'deposit_direct_qr_viewed', + DEPOSIT_DIRECT_ADDRESS_SHARED: 'deposit_direct_address_shared', + DEPOSIT_DIRECT_SESSION_CREATED: 'deposit_direct_session_created', + DEPOSIT_DIRECT_SESSION_CREATION_FAILED: 'deposit_direct_session_creation_failed', + DEPOSIT_DIRECT_SESSION_DETECTED: 'deposit_direct_session_detected', + DEPOSIT_DIRECT_SESSION_COMPLETED: 'deposit_direct_session_completed', + DEPOSIT_DIRECT_SESSION_DELETED: 'deposit_direct_session_deleted', + + // Deposit Method: Credit Card (Method 3) + DEPOSIT_CARD_WIDGET_LOADING: 'deposit_card_widget_loading', + DEPOSIT_CARD_WIDGET_LOADED: 'deposit_card_widget_loaded', + DEPOSIT_CARD_WIDGET_LOAD_FAILED: 'deposit_card_widget_load_failed', + DEPOSIT_CARD_TRANSACTION_CREATED: 'deposit_card_transaction_created', + DEPOSIT_CARD_TRANSACTION_CREATION_FAILED: 'deposit_card_transaction_creation_failed', + + // Deposit Method: Bank Deposit (Method 4) + DEPOSIT_BANK_AMOUNT_VIEWED: 'deposit_bank_amount_viewed', + DEPOSIT_BANK_AMOUNT_ENTERED: 'deposit_bank_amount_entered', + DEPOSIT_BANK_PAYMENT_METHOD_VIEWED: 'deposit_bank_payment_method_viewed', + DEPOSIT_BANK_KYC_VIEWED: 'deposit_bank_kyc_viewed', + DEPOSIT_BANK_KYC_STARTED: 'deposit_bank_kyc_started', + DEPOSIT_BANK_INSTRUCTIONS_VIEWED: 'deposit_bank_instructions_viewed', + DEPOSIT_BANK_INSTRUCTIONS_COPIED: 'deposit_bank_instructions_copied', + DEPOSIT_BANK_AMOUNT_ABANDONED: 'deposit_bank_amount_abandoned', + DEPOSIT_BANK_INSTRUCTIONS_ABANDONED: 'deposit_bank_instructions_abandoned', + + // Deposit Bonus Banner Events + DEPOSIT_BONUS_BANNER_VIEWED: 'deposit_bonus_banner_viewed', + DEPOSIT_BONUS_BANNER_INFLUENCED: 'deposit_bonus_banner_influenced', + // User Registration Events SIGNUP_STARTED: 'signup_started', SIGNUP_COMPLETED: 'signup_completed', @@ -151,6 +205,11 @@ export const TRACKING_EVENTS = { CARD_COUNTRY_CHECK_DETECTED: 'card_country_check_detected', CARD_KYC_COUNTRY_SUPPORTED: 'card_kyc_country_supported', CARD_COUNTRY_CHECK_FAILED: 'card_country_check_failed', + CARD_ACTIVATE_PAGE_VIEWED: 'card_activate_page_viewed', + USER_KYC_INFO_PAGE_VIEWED: 'user_kyc_info_page_viewed', + USER_KYC_INFO_FORM_STARTED: 'user_kyc_info_form_started', + KYC_STEP_STARTED: 'kyc_step_started', + KYC_STEP_COMPLETED: 'kyc_step_completed', // KYC Link Events (for debugging KYC flow issues) KYC_LINK_PAGE_LOADED: 'kyc_link_page_loaded', @@ -179,6 +238,7 @@ export const TRACKING_EVENTS = { // Global / Error Events ERROR_BOUNDARY: 'error_boundary', + RETRY_ATTEMPTED: 'retry_attempted', } as const; export type TrackingEvent = (typeof TRACKING_EVENTS)[keyof typeof TRACKING_EVENTS]; diff --git a/hooks/useAttribution.ts b/hooks/useAttribution.ts new file mode 100644 index 000000000..c240fb81c --- /dev/null +++ b/hooks/useAttribution.ts @@ -0,0 +1,315 @@ +import * as Sentry from '@sentry/react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Platform } from 'react-native'; + +import { + type AttributionChannel, + type AttributionValidationResult, + formatAttributionForLogging, + getAttributionChannel, + getCurrentURL, + getReferrer, + validateAttribution, +} from '@/lib/attribution'; +import { type AttributionData, useAttributionStore } from '@/store/useAttributionStore'; + +export interface UseAttributionReturn { + // Current attribution data + attribution: AttributionData; + attributionChannel: AttributionChannel; + isReady: boolean; + hasAttribution: boolean; + + // Validation + validation: AttributionValidationResult | null; + + // Actions + initializeAttribution: () => Promise; + refreshAttribution: () => Promise; + captureAttribution: (url?: string) => Promise; + trackEventWithAttribution: (eventName: string, params?: Record) => void; + clearAttribution: () => void; + + // Utilities + getAttributionSummary: () => string; + isAttributionExpired: (windowDays?: number) => boolean; +} + +/** + * React hook for managing attribution tracking + * Provides initialization, capture, and tracking functionality + */ +export const useAttribution = (): UseAttributionReturn => { + const attributionStore = useAttributionStore(); + const [isReady, setIsReady] = useState(false); + const [validation, setValidation] = useState(null); + const hasInitialized = useRef(false); + + // Get current attribution data + const attribution = attributionStore.attributionData; + const hasAttribution = attributionStore.hasAttribution(); + const attributionChannel = getAttributionChannel(attribution); + + /** + * Initialize attribution tracking on app launch + * Captures URL parameters, referrer, and sets up tracking + */ + const initializeAttribution = useCallback(async () => { + if (hasInitialized.current) { + console.warn('Attribution already initialized, skipping'); + return; + } + + try { + console.warn('Initializing attribution tracking...'); + + // Capture attribution from current context + if (Platform.OS === 'web') { + // Web: Capture from URL and referrer + const currentUrl = getCurrentURL(); + const referrer = getReferrer(); + + if (currentUrl) { + const captured = attributionStore.captureFromURL(currentUrl); + + // Add referrer if we captured attribution + if (captured && referrer) { + attributionStore.updateAttribution({ + landing_page_referrer: referrer, + }); + } + + if (captured) { + console.warn( + 'Attribution captured on initialization:', + formatAttributionForLogging(captured), + ); + } + } + } else { + // Mobile: Attribution will be captured from deep links + console.warn('Mobile platform - attribution will be captured from deep links'); + } + + // Validate attribution data + const validationResult = validateAttribution(attributionStore.attributionData); + setValidation(validationResult); + + if (validationResult.warnings.length > 0) { + console.warn('Attribution validation warnings:', validationResult.warnings); + } + + hasInitialized.current = true; + setIsReady(true); + + console.warn('Attribution initialization complete'); + } catch (error) { + console.error('Failed to initialize attribution:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_init_error' }, + level: 'error', + }); + setIsReady(true); // Set ready even on error to avoid blocking + } + }, [attributionStore]); + + /** + * Refresh attribution data (re-capture from URL if available) + */ + const refreshAttribution = useCallback(async () => { + try { + if (Platform.OS === 'web') { + const currentUrl = getCurrentURL(); + if (currentUrl) { + const captured = attributionStore.captureFromURL(currentUrl); + if (captured) { + console.warn('Attribution refreshed:', formatAttributionForLogging(captured)); + + // Re-validate + const validationResult = validateAttribution(attributionStore.attributionData); + setValidation(validationResult); + } + } + } + } catch (error) { + console.error('Failed to refresh attribution:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_refresh_error' }, + level: 'warning', + }); + } + }, [attributionStore]); + + /** + * Manually capture attribution from a URL + * Useful for handling deep links or custom navigation + */ + const captureAttribution = useCallback( + async (url?: string): Promise => { + try { + const targetUrl = url || getCurrentURL(); + if (!targetUrl) return null; + + const captured = attributionStore.captureFromURL(targetUrl); + + if (captured) { + console.warn('Attribution manually captured:', formatAttributionForLogging(captured)); + + // Update validation + const validationResult = validateAttribution(attributionStore.attributionData); + setValidation(validationResult); + + return captured; + } + + return null; + } catch (error) { + console.error('Failed to capture attribution:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_capture_error' }, + level: 'warning', + extra: { url }, + }); + return null; + } + }, + [attributionStore], + ); + + /** + * Track an event with attribution data automatically included + * This is a convenience method - actual tracking should use analytics.ts + */ + const trackEventWithAttribution = useCallback( + (eventName: string, params: Record = {}) => { + try { + const attributionData = attributionStore.getAttributionForEvent(); + + // Import and call the actual track function + // This is a placeholder - the real implementation will be in analytics.ts + console.warn(`Tracking event: ${eventName} with attribution:`, { + ...params, + ...attributionData, + attribution_channel: attributionChannel, + }); + } catch (error) { + console.error('Failed to track event with attribution:', error); + } + }, + [attributionStore, attributionChannel], + ); + + /** + * Clear all attribution data + */ + const clearAttribution = useCallback(() => { + attributionStore.clearAttribution(); + setValidation(null); + hasInitialized.current = false; + setIsReady(false); + }, [attributionStore]); + + /** + * Get human-readable attribution summary + */ + const getAttributionSummary = useCallback((): string => { + return formatAttributionForLogging(attribution); + }, [attribution]); + + /** + * Check if attribution has expired + */ + const isAttributionExpired = useCallback( + (windowDays: number = 30): boolean => { + return attributionStore.isAttributionExpired(windowDays); + }, + [attributionStore], + ); + + return { + // State + attribution, + attributionChannel, + isReady, + hasAttribution, + validation, + + // Actions + initializeAttribution, + refreshAttribution, + captureAttribution, + trackEventWithAttribution, + clearAttribution, + + // Utilities + getAttributionSummary, + isAttributionExpired, + }; +}; + +/** + * Hook specifically for deep link attribution (mobile) + * Separate hook to keep concerns separated + */ +export const useDeepLinkAttribution = () => { + const attributionStore = useAttributionStore(); + + const captureFromDeepLink = useCallback( + (deepLinkUrl: string): AttributionData | null => { + try { + const captured = attributionStore.captureFromDeepLink(deepLinkUrl); + + if (captured) { + console.warn('Deep link attribution captured:', formatAttributionForLogging(captured)); + } + + return captured; + } catch (error) { + console.error('Failed to capture deep link attribution:', error); + Sentry.captureException(error, { + tags: { type: 'deeplink_attribution_error' }, + level: 'warning', + extra: { deepLinkUrl }, + }); + return null; + } + }, + [attributionStore], + ); + + return { + captureFromDeepLink, + hasAttribution: attributionStore.hasAttribution(), + attribution: attributionStore.attributionData, + }; +}; + +/** + * Hook for monitoring attribution health + * Useful for debugging and monitoring dashboards + */ +export const useAttributionHealth = () => { + const attributionStore = useAttributionStore(); + const [healthMetrics, setHealthMetrics] = useState({ + hasAttribution: false, + completeness: 0, + isExpired: false, + channel: 'direct' as AttributionChannel, + warnings: [] as string[], + }); + + useEffect(() => { + const attribution = attributionStore.attributionData; + const validation = validateAttribution(attribution); + + setHealthMetrics({ + hasAttribution: attributionStore.hasAttribution(), + completeness: validation.completeness, + isExpired: attributionStore.isAttributionExpired(), + channel: getAttributionChannel(attribution), + warnings: validation.warnings, + }); + }, [attributionStore.attributionData, attributionStore]); + + return healthMetrics; +}; diff --git a/hooks/useAttributionInitialization.ts b/hooks/useAttributionInitialization.ts new file mode 100644 index 000000000..2864b559f --- /dev/null +++ b/hooks/useAttributionInitialization.ts @@ -0,0 +1,161 @@ +import * as Linking from 'expo-linking'; +import * as Sentry from '@sentry/react-native'; +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; + +import { formatAttributionForLogging, getCurrentURL, getReferrer } from '@/lib/attribution'; +import { useAttributionStore } from '@/store/useAttributionStore'; +import { useReferralStore } from '@/store/useReferralStore'; + +/** + * Hook to initialize attribution tracking on app launch + * + * Handles both web (URL search params) and mobile (deep links) attribution capture. + * Should be called once at app root (_layout.tsx) to ensure attribution is captured + * before any navigation occurs. + * + * Web: Captures UTM parameters from window.location.href and referrer + * Mobile: Captures initial deep link via Linking.getInitialURL() and listens for + * subsequent deep links via Linking.addEventListener() + * + * @example + * // In app/_layout.tsx + * export default function RootLayout() { + * useAttributionInitialization(); + * // ... rest of layout + * } + */ +export const useAttributionInitialization = () => { + const attributionStore = useAttributionStore(); + const _hasHydrated = useAttributionStore(state => state._hasHydrated); + const _referralHasHydrated = useReferralStore(state => state._hasHydrated); + const hasInitialized = useRef(false); + const subscriptionRef = useRef | null>(null); + + useEffect(() => { + // Wait for BOTH stores' hydration before initialization + // This ensures we don't overwrite existing first-touch attribution + // and prevents referral code sync failures + if (!_hasHydrated || !_referralHasHydrated) { + console.warn('Waiting for attribution and referral store hydration...'); + return; + } + + // Prevent double initialization (React Strict Mode in dev) + if (hasInitialized.current) { + console.warn('Attribution already initialized, skipping'); + return; + } + + // Set flag immediately to prevent race conditions + hasInitialized.current = true; + + const initializeAttribution = async () => { + try { + console.warn('Initializing attribution tracking...'); + + if (Platform.OS === 'web') { + // WEB: Capture from window.location.href + // This captures UTM parameters, referral codes, and advertising click IDs + const currentUrl = getCurrentURL(); + const referrer = getReferrer(); + + if (currentUrl) { + const captured = attributionStore.captureFromURL(currentUrl); + + // Verify referral code sync if present + if (captured && captured.referral_code) { + const verifyReferral = useReferralStore.getState().referralCode; + console.warn('✅ Referral code captured:', { + code: captured.referral_code, + synced: captured.referral_code === verifyReferral, + }); + + if (captured.referral_code !== verifyReferral) { + console.error('❌ REFERRAL SYNC FAILED - stores out of sync!'); + Sentry.captureMessage('Referral code sync mismatch', { + level: 'error', + extra: { + captured: captured.referral_code, + inReferralStore: verifyReferral, + }, + }); + } + } + + // Enrich with HTTP referrer if available + if (captured && referrer) { + attributionStore.updateAttribution({ + landing_page_referrer: referrer, + }); + } + + if (captured) { + console.warn('Attribution captured on web:', formatAttributionForLogging(captured)); + } else { + console.warn('No attribution parameters found in URL'); + } + } else { + console.warn('No current URL available for attribution capture'); + } + } else { + // MOBILE: Capture initial deep link URL + // This handles app launches from Layer3 links, referral links, etc. + const initialUrl = await Linking.getInitialURL(); + + if (initialUrl) { + console.warn('Initial deep link detected:', initialUrl); + const captured = attributionStore.captureFromDeepLink(initialUrl); + + if (captured) { + console.warn( + 'Attribution captured from initial deep link:', + formatAttributionForLogging(captured), + ); + } else { + console.warn('No attribution parameters in deep link'); + } + } else { + console.warn('No initial deep link URL - app launched normally'); + } + + // Set up listener for subsequent deep links + // This handles deep links received while app is already running + subscriptionRef.current = Linking.addEventListener('url', event => { + console.warn('Deep link received while app running:', event.url); + const captured = attributionStore.captureFromDeepLink(event.url); + + if (captured) { + console.warn( + 'Attribution updated from deep link:', + formatAttributionForLogging(captured), + ); + } + }); + } + + console.warn('Attribution initialization complete'); + } catch (error) { + console.error('Failed to initialize attribution:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_init_error' }, + level: 'error', + extra: { + platform: Platform.OS, + }, + }); + } + }; + + initializeAttribution(); + + // Cleanup: Remove mobile deep link listener on unmount + return () => { + if (subscriptionRef.current) { + console.warn('Removing deep link listener on unmount'); + subscriptionRef.current.remove(); + subscriptionRef.current = null; + } + }; + }, [_hasHydrated, _referralHasHydrated, attributionStore]); +}; diff --git a/hooks/useDeposit.ts b/hooks/useDeposit.ts index c2341716d..cf65f7d54 100644 --- a/hooks/useDeposit.ts +++ b/hooks/useDeposit.ts @@ -17,9 +17,11 @@ import { useActivity } from '@/hooks/useActivity'; import BridgePayamster_ABI from '@/lib/abis/BridgePayamster'; import ETHEREUM_TELLER_ABI from '@/lib/abis/EthereumTeller'; import { track, trackIdentity } from '@/lib/analytics'; +import { getAttributionChannel } from '@/lib/attribution'; import { ADDRESSES } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { Status, TransactionType } from '@/lib/types'; +import { useAttributionStore } from '@/store/useAttributionStore'; import useUser from './useUser'; type DepositResult = { @@ -48,6 +50,10 @@ const useDeposit = (): DepositResult => { }); const deposit = async (amount: string) => { + // Capture attribution context for conversion tracking + const attributionData = useAttributionStore.getState().getAttributionForEvent(); + const attributionChannel = getAttributionChannel(attributionData); + try { if (!user) { const error = new Error('User is not selected'); @@ -56,6 +62,8 @@ const useDeposit = (): DepositResult => { error: 'User not found', step: 'validation', source: 'useDeposit_hook', + ...attributionData, + attribution_channel: attributionChannel, }); Sentry.captureException(error, { tags: { @@ -79,6 +87,8 @@ const useDeposit = (): DepositResult => { deposit_type: 'safe_account', deposit_method: 'ethereum_safe_to_bridge', source: 'useDeposit_hook', + ...attributionData, + attribution_channel: attributionChannel, }); setDepositStatus(Status.PENDING); @@ -173,6 +183,8 @@ const useDeposit = (): DepositResult => { fee: fee?.toString() || '0', deposit_type: 'safe_account', source: 'useDeposit_hook', + ...attributionData, + attribution_channel: attributionChannel, }); Sentry.captureException(error, { tags: { @@ -205,6 +217,8 @@ const useDeposit = (): DepositResult => { deposit_method: 'ethereum_safe_to_bridge', is_first_deposit: !user.isDeposited, source: 'useDeposit_hook', + ...attributionData, + attribution_channel: attributionChannel, }); trackIdentity(user.userId, { @@ -212,6 +226,8 @@ const useDeposit = (): DepositResult => { last_deposit_date: new Date().toISOString(), last_deposit_method: 'ethereum_safe_to_bridge', last_deposit_chain: 'ethereum', + ...attributionData, + attribution_channel: attributionChannel, }); Sentry.addBreadcrumb({ @@ -240,6 +256,8 @@ const useDeposit = (): DepositResult => { user_cancelled: String(error).includes('cancelled'), deposit_type: 'safe_account', source: 'useDeposit_hook', + ...attributionData, + attribution_channel: attributionChannel, }); Sentry.captureException(error, { diff --git a/hooks/useDepositBuyCryptoOptions.tsx b/hooks/useDepositBuyCryptoOptions.tsx index 2a223a1a9..483f5f721 100644 --- a/hooks/useDepositBuyCryptoOptions.tsx +++ b/hooks/useDepositBuyCryptoOptions.tsx @@ -15,16 +15,18 @@ const useDepositBuyCryptoOptions = () => { const handleBankDepositPress = useCallback(() => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'bank_transfer', + bonus_banner_visible: isDepositBonusEnabled, }); setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT); - }, [setModal]); + }, [setModal, isDepositBonusEnabled]); const handleCreditCardPress = useCallback(() => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'credit_card', + bonus_banner_visible: isDepositBonusEnabled, }); setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO); - }, [setModal]); + }, [setModal, isDepositBonusEnabled]); const bonusBannerText = isDepositBonusEnabled ? `${Math.round(percentage * 100)}% bonus on deposits` diff --git a/hooks/useDepositFromEOA.ts b/hooks/useDepositFromEOA.ts index 81e4993c7..820fbaa24 100644 --- a/hooks/useDepositFromEOA.ts +++ b/hooks/useDepositFromEOA.ts @@ -26,6 +26,7 @@ import ETHEREUM_TELLER_ABI from '@/lib/abis/EthereumTeller'; import FiatTokenV2_2 from '@/lib/abis/FiatTokenV2_2'; import { track, trackIdentity } from '@/lib/analytics'; import { bridgeDeposit, createDeposit, getLifiQuote } from '@/lib/api'; +import { getAttributionChannel } from '@/lib/attribution'; import { ADDRESSES, EXPO_PUBLIC_BRIDGE_AUTO_DEPOSIT_ADDRESS, @@ -41,6 +42,7 @@ import { sendTransaction, } from '@/lib/utils/contract'; import { config, publicClient } from '@/lib/wagmi'; +import { useAttributionStore } from '@/store/useAttributionStore'; import { useDepositStore } from '@/store/useDepositStore'; import { useUserStore } from '@/store/useUserStore'; import useUser from './useUser'; @@ -345,9 +347,13 @@ const useDepositFromEOA = ( }; const deposit = async (amount: string) => { + // Capture attribution context for conversion tracking + const attributionData = useAttributionStore.getState().getAttributionForEvent(); + const attributionChannel = getAttributionChannel(attributionData); + let trackingId: string | undefined; try { - // Track deposit initiation + // Track deposit initiation with attribution for conversion funnel analysis track(TRACKING_EVENTS.DEPOSIT_INITIATED, { user_id: user?.userId, safe_address: user?.safeAddress, @@ -358,6 +364,8 @@ const useDepositFromEOA = ( chain_id: srcChainId, chain_name: isEthereum ? 'ethereum' : BRIDGE_TOKENS[srcChainId]?.name, is_sponsor: Number(amount) >= Number(EXPO_PUBLIC_MINIMUM_SPONSOR_AMOUNT), + ...attributionData, + attribution_channel: attributionChannel, }); if (!eoaAddress) { @@ -716,7 +724,7 @@ const useDepositFromEOA = ( }, }); - // Track deposit success + // Track deposit success with attribution for ROI measurement track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { user_id: user?.userId, safe_address: user?.safeAddress, @@ -729,6 +737,8 @@ const useDepositFromEOA = ( chain_name: isEthereum ? 'ethereum' : BRIDGE_TOKENS[srcChainId]?.name, is_sponsor: isSponsor, is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, }); trackIdentity(user?.userId, { @@ -736,6 +746,8 @@ const useDepositFromEOA = ( last_deposit_date: new Date().toISOString(), last_deposit_method: isEthereum ? 'ethereum_direct' : 'cross_chain_bridge', last_deposit_chain: isEthereum ? 'ethereum' : BRIDGE_TOKENS[srcChainId]?.name, + ...attributionData, + attribution_channel: attributionChannel, }); setDepositStatus({ status: Status.SUCCESS }); @@ -775,6 +787,8 @@ const useDepositFromEOA = ( deposit_status: depositStatus, source: 'deposit_from_eoa', error: errorMessage, + ...attributionData, + attribution_channel: attributionChannel, }); const msg = errorMessage?.toLowerCase(); diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index 1d2711f14..3552ddef4 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -23,8 +23,10 @@ import { Text } from '@/components/ui/text'; import { BRIDGE_TOKENS } from '@/constants/bridge'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useDirectDepositSession } from '@/hooks/useDirectDepositSession'; import useUser from '@/hooks/useUser'; +import { track } from '@/lib/analytics'; import getTokenIcon from '@/lib/getTokenIcon'; import { DepositModal } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; @@ -51,6 +53,9 @@ const useDepositOption = ({ outputToken, bankTransfer, directDepositSession, + sessionStartTime, + setSessionStartTime, + clearSessionStartTime, } = useDepositStore(); const activeAccount = useActiveAccount(); const status = useActiveWalletConnectionStatus(); @@ -282,12 +287,88 @@ const useDepositOption = ({ return ''; }; + // Helper: Map modal to deposit method for analytics + const getDepositMethodFromModal = (modal: DepositModal): string | null => { + const modalName = modal?.name; + if (!modalName) return null; + + // Wallet deposit method + if ( + modalName === DEPOSIT_MODAL.OPEN_NETWORKS.name || + modalName === DEPOSIT_MODAL.OPEN_FORM.name || + modalName === DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name + ) { + return 'wallet'; + } + + // Direct deposit method + if ( + modalName === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY.name || + modalName === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY_TOKENS.name || + modalName === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY_ADDRESS.name + ) { + return 'deposit_directly'; + } + + // Credit card method + if (modalName === DEPOSIT_MODAL.OPEN_BUY_CRYPTO.name) { + return 'credit_card'; + } + + // Bank transfer method + if ( + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_PAYMENT.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_PREVIEW.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_KYC_INFO.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_KYC_FRAME.name + ) { + return 'bank_transfer'; + } + + return null; + }; + + // Helper: Map modal to specific abandonment event + const getAbandonmentEventFromModal = (modal: DepositModal): string => { + const modalName = modal?.name; + if (!modalName) return TRACKING_EVENTS.DEPOSIT_OPTIONS_ABANDONED; + + // Wallet method abandonment + if (modalName === DEPOSIT_MODAL.OPEN_NETWORKS.name) { + return TRACKING_EVENTS.DEPOSIT_WALLET_NETWORK_ABANDONED; + } + if (modalName === DEPOSIT_MODAL.OPEN_FORM.name) { + return TRACKING_EVENTS.DEPOSIT_WALLET_FORM_ABANDONED; + } + + // Bank transfer abandonment + if (modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT.name) { + return TRACKING_EVENTS.DEPOSIT_BANK_AMOUNT_ABANDONED; + } + if ( + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_PREVIEW.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_KYC_INFO.name || + modalName === DEPOSIT_MODAL.OPEN_BANK_TRANSFER_KYC_FRAME.name + ) { + return TRACKING_EVENTS.DEPOSIT_BANK_INSTRUCTIONS_ABANDONED; + } + + // Default to general abandonment + return TRACKING_EVENTS.DEPOSIT_OPTIONS_ABANDONED; + }; + const handleOpenChange = (value: boolean) => { if (!value && isFormAndAddress && status === 'connecting') { return; } if (value) { + // Set session start time when modal opens (if not already set) + if (!sessionStartTime) { + setSessionStartTime(Date.now()); + } + // Check if user has email when opening deposit modal if (user && !user.email) { setModal(DEPOSIT_MODAL.OPEN_EMAIL_GATE); @@ -301,7 +382,26 @@ const useDepositOption = ({ setModal(modal); } } else { + // Calculate time spent in deposit flow + const timeSpent = sessionStartTime ? Math.floor((Date.now() - sessionStartTime) / 1000) : 0; + + // Get deposit method and appropriate abandonment event + const depositMethod = getDepositMethodFromModal(currentModal); + const abandonmentEvent = getAbandonmentEventFromModal(currentModal); + + // Track method-specific abandonment with enhanced properties + track(abandonmentEvent, { + last_step: currentModal.name, + previous_step: previousModal.name, + deposit_method: depositMethod, + time_on_step: timeSpent, + has_wallet_connected: !!address, + has_selected_chain: !!srcChainId, + is_first_deposit: !user?.isDeposited, + }); + setModal(DEPOSIT_MODAL.CLOSE); + clearSessionStartTime(); } }; diff --git a/hooks/useDirectDepositSession.ts b/hooks/useDirectDepositSession.ts index 0452e6c91..2e5120897 100644 --- a/hooks/useDirectDepositSession.ts +++ b/hooks/useDirectDepositSession.ts @@ -1,10 +1,12 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { createDirectDepositSession as createDirectDepositSessionApi, deleteDirectDepositSession as deleteDirectDepositSessionApi, getDirectDepositSession as getDirectDepositSessionApi, } from '@/lib/api'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { track } from '@/lib/analytics'; import { DirectDepositSessionResponse } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; import { withRefreshToken } from '@/lib/utils'; @@ -27,6 +29,15 @@ export const useDirectDepositSession = () => { if (!data) throw new Error('Failed to create direct deposit session'); + // Track successful session creation + track(TRACKING_EVENTS.DEPOSIT_DIRECT_SESSION_CREATED, { + deposit_method: 'deposit_directly', + session_id: data.sessionId, + chain_id: chainId, + selected_token: tokenSymbol, + wallet_address: data.walletAddress, + }); + // Store in zustand (explicitly clear fromActivity flag) setDirectDepositSession({ ...data, @@ -38,6 +49,15 @@ export const useDirectDepositSession = () => { return data; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + + // Track session creation failure + track(TRACKING_EVENTS.DEPOSIT_DIRECT_SESSION_CREATION_FAILED, { + deposit_method: 'deposit_directly', + chain_id: chainId, + selected_token: tokenSymbol, + error: errorMessage, + }); + setError(errorMessage); throw err; } finally { @@ -62,6 +82,12 @@ export const useDirectDepositSession = () => { const data = await withRefreshToken(() => deleteDirectDepositSessionApi(clientTxId)); + // Track successful session deletion + track(TRACKING_EVENTS.DEPOSIT_DIRECT_SESSION_DELETED, { + deposit_method: 'deposit_directly', + client_tx_id: clientTxId, + }); + // Clear from zustand store clearDirectDepositSession(); @@ -91,7 +117,9 @@ export const useDirectDepositSessionPolling = ( sessionId: string | undefined, enabled: boolean = true, ) => { - const { setDirectDepositSession } = useDepositStore(); + const { setDirectDepositSession, directDepositSession } = useDepositStore(); + const previousStatusRef = useRef(null); + const sessionStartTimeRef = useRef(null); const { data: session, @@ -107,6 +135,51 @@ export const useDirectDepositSessionPolling = ( if (!data) return null; + // Initialize session start time when first created + if (!sessionStartTimeRef.current && data.status === 'pending') { + sessionStartTimeRef.current = Date.now(); + } + + // Track status changes + const previousStatus = previousStatusRef.current; + const currentStatus = data.status; + + if (previousStatus !== currentStatus) { + // Track when deposit is detected + if (currentStatus === 'detected') { + const timeToDetection = sessionStartTimeRef.current + ? Math.floor((Date.now() - sessionStartTimeRef.current) / 1000) + : undefined; + + track(TRACKING_EVENTS.DEPOSIT_DIRECT_SESSION_DETECTED, { + deposit_method: 'deposit_directly', + session_id: data.sessionId, + chain_id: data.chainId, + selected_token: directDepositSession.selectedToken, + detected_amount: data.detectedAmount, + time_to_detection: timeToDetection, + }); + } + + // Track when deposit is completed + if (currentStatus === 'completed') { + const timeToCompletion = sessionStartTimeRef.current + ? Math.floor((Date.now() - sessionStartTimeRef.current) / 1000) + : undefined; + + track(TRACKING_EVENTS.DEPOSIT_DIRECT_SESSION_COMPLETED, { + deposit_method: 'deposit_directly', + session_id: data.sessionId, + chain_id: data.chainId, + selected_token: directDepositSession.selectedToken, + transaction_hash: data.transactionHash, + time_to_completion: timeToCompletion, + }); + } + + previousStatusRef.current = currentStatus; + } + // Update zustand store setDirectDepositSession(data); diff --git a/hooks/useUser.ts b/hooks/useUser.ts index f553456a7..6272348d0 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -1,23 +1,17 @@ import { ERRORS } from '@/constants/errors'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { track, trackIdentity } from '@/lib/analytics'; -import { - deleteAccount, - getSubOrgIdByUsername, - login, - signUp, - updateSafeAddress, - usernameExists, -} from '@/lib/api'; +import { getAmplitudeDeviceId, track, trackIdentity } from '@/lib/analytics'; +import { deleteAccount, login, updateSafeAddress, usernameExists } from '@/lib/api'; +import { getAttributionChannel } from '@/lib/attribution'; import { EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID, USER } from '@/lib/config'; import { useIntercom } from '@/lib/intercom'; import { pimlicoClient } from '@/lib/pimlico'; import { Status, User } from '@/lib/types'; import { getNonce, isHTTPError, setGlobalLogoutHandler, withRefreshToken } from '@/lib/utils'; -import { getReferralCodeForSignup } from '@/lib/utils/referral'; import { publicClient } from '@/lib/wagmi'; import { useActivityStore } from '@/store/useActivityStore'; +import { useAttributionStore } from '@/store/useAttributionStore'; import { useBalanceStore } from '@/store/useBalanceStore'; import { useKycStore } from '@/store/useKycStore'; import { usePointsStore } from '@/store/usePointsStore'; @@ -39,7 +33,6 @@ import { fetchIsDeposited } from './useAnalytics'; interface UseUserReturn { user: User | undefined; handleSignupStarted: (username: string, inviteCode: string) => Promise; - handleSignup: (username: string, inviteCode: string) => Promise; handleLogin: () => Promise; handleDummyLogin: () => Promise; handleSelectUser: (username: string) => void; @@ -239,183 +232,12 @@ const useUser = (): UseUserReturn => { [setSignupInfo, setSignupUser, router], ); - const handleSignup = useCallback( - async (username: string, inviteCode: string) => { - track(TRACKING_EVENTS.SIGNUP_STARTED, { - username, - }); - try { - setSignupInfo({ status: Status.PENDING }); - const subOrgId = await getSubOrgIdByUsername(username); - - if (subOrgId.organizationId) { - throw new Error(ERRORS.USERNAME_ALREADY_EXISTS); - } - - // Use the unified createPasskey from the new SDK - // This works on both web and native platforms automatically - const passkey = await createPasskey({ - name: username, - }); - - const { encodedChallenge: challenge, attestation } = passkey; - const credentialId = attestation.credentialId; - if (!challenge || !attestation) { - const error = new Error('Error creating passkey'); - Sentry.captureException(error, { - tags: { - type: 'passkey_creation_error', - }, - extra: { - username, - inviteCode, - }, - }); - throw error; - } - - // Get referral code from storage (if any) - const referralCode = getReferralCodeForSignup() || ''; - - const user = await signUp( - username, - challenge, - attestation, - inviteCode, - referralCode, - credentialId, - ); - - const smartAccountClient = await safeAA( - mainnet, - user.subOrganizationId, - user.walletAddress, - ); - - if (smartAccountClient && user) { - const selectedUser: User = { - safeAddress: smartAccountClient.account.address, - walletAddress: user.walletAddress, - username, - userId: user._id, - signWith: user.walletAddress, - suborgId: user.subOrganizationId, - selected: true, - tokens: user.tokens || null, - referralCode: user.referralCode, - turnkeyUserId: user.turnkeyUserId, - credentialId, - }; - storeUser(selectedUser); - - // Identify user in analytics - trackIdentity(user.userId, { - username, - safe_address: smartAccountClient.account.address, - has_referral_code: !!user.referralCode, - signup_method: 'passkey', - platform: Platform.OS, - }); - - // Track successful signup completion - track(TRACKING_EVENTS.SIGNUP_COMPLETED, { - user_id: user._id, - username, - invite_code: inviteCode, - referral_code: referralCode, - safe_address: smartAccountClient.account.address, - }); - - setSignupInfo({ status: Status.SUCCESS }); - - // Navigate immediately - let usePostSignupInit handle the rest - // On mobile, navigate to notifications for new signups - if (Platform.OS === 'web') { - router.replace(path.HOME); - } else { - router.replace(path.NOTIFICATIONS); - } - } else { - Sentry.captureException(new Error('Error while verifying passkey registration')); - const error = new Error('Error while verifying passkey registration'); - Sentry.captureException(error, { - tags: { - type: 'passkey_verification_error', - }, - extra: { - username, - }, - }); - throw error; - } - } catch (error: any) { - let message = ''; - - // Check for WebAuthn-specific errors first - if (error?.name === 'NotAllowedError' || error?.message?.includes('not allowed')) { - message = 'Passkey creation was cancelled or blocked by your browser. Please try again.'; - } else if ( - error?.name === 'TimeoutError' || - error?.message?.includes('timeout') || - error?.message?.includes('timed out') - ) { - message = - 'Passkey creation timed out. Please try again and complete the authentication prompt.'; - } else if (error?.name === 'InvalidStateError') { - message = 'This passkey already exists. Please try logging in instead.'; - } else if ( - error?.name === 'NotSupportedError' || - error?.message?.includes('not supported') - ) { - message = 'Passkeys are not supported in your current browser or context.'; - } else if (error?.message?.includes('embedded context')) { - message = 'Passkey creation is not supported in embedded or iframe context.'; - } else if ( - error?.status === 409 || - error.message?.includes(ERRORS.USERNAME_ALREADY_EXISTS) - ) { - message = ERRORS.USERNAME_ALREADY_EXISTS; - } else if ((await error?.text?.())?.toLowerCase()?.includes('invite')) { - message = ERRORS.INVALID_INVITE_CODE; - } - - if (message) { - Sentry.captureMessage(message, { - level: 'warning', - extra: { - username, - inviteCode, - error, - errorName: error?.name, - errorMessage: error?.message, - }, - }); - } else { - Sentry.captureException(new Error('Error signing up'), { - extra: { - username, - inviteCode, - error, - errorName: error?.name, - errorMessage: error?.message, - }, - }); - } - - track(TRACKING_EVENTS.SIGNUP_FAILED, { - username, - invite_code: inviteCode, - error: error.message || error.name || 'Unknown error', - }); - - setSignupInfo({ status: Status.ERROR, message }); - console.error(error); - } - }, - [createPasskey, checkBalance, safeAA, setSignupInfo, storeUser, router], - ); - const handleLogin = useCallback(async () => { + // Get attribution context for login tracking + const attributionStore = useAttributionStore.getState(); + const attributionData = attributionStore.getAttributionForEvent(); + const deviceId = getAmplitudeDeviceId(); + try { setLoginInfo({ status: Status.PENDING }); @@ -482,7 +304,7 @@ const useUser = (): UseUserReturn => { storeUser(selectedUser); await checkBalance(selectedUser); - // Identify user in analytics + // Identify user in analytics with full attribution context trackIdentity(user.userId, { username: user.username, safe_address: smartAccountClient.account.address, @@ -490,6 +312,9 @@ const useUser = (): UseUserReturn => { has_referral_code: !!user.referralCode, login_method: 'passkey', platform: Platform.OS, + device_id: deviceId, + ...attributionData, + attribution_channel: getAttributionChannel(attributionData), }); // Fetch points after successful login @@ -513,9 +338,12 @@ const useUser = (): UseUserReturn => { safe_address: smartAccountClient.account.address, has_email: !!user.email, is_deposited: !!user.isDeposited, + device_id: deviceId, + ...attributionData, + attribution_channel: getAttributionChannel(attributionData), }); - // Update user properties on login + // Update user properties on login with attribution trackIdentity(user.userId, { username: user.username, safe_address: smartAccountClient.account.address, @@ -523,6 +351,9 @@ const useUser = (): UseUserReturn => { is_deposited: !!user.isDeposited, last_login_date: new Date().toISOString(), platform: Platform.OS, + device_id: deviceId, + ...attributionData, + attribution_channel: getAttributionChannel(attributionData), }); router.replace(path.HOME); @@ -552,6 +383,9 @@ const useUser = (): UseUserReturn => { track(TRACKING_EVENTS.LOGIN_FAILED, { username: user?.username, error: error.message, + device_id: deviceId, + ...attributionData, + attribution_channel: getAttributionChannel(attributionData), }); console.error(error); @@ -782,7 +616,6 @@ const useUser = (): UseUserReturn => { return { user, handleSignupStarted, - handleSignup, handleLogin, handleDummyLogin, handleSelectUser, diff --git a/lib/analytics.ts b/lib/analytics.ts index 1b10612cf..0a7d7d15e 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -1,14 +1,41 @@ // Amplitude imports -import { Identify, add, identify, init as initAmplitudeSDK, setUserId as setAmplitudeUserId, track as trackAmplitude } from '@amplitude/analytics-react-native'; +import { + add, + getDeviceId, + getSessionId, + identify, + Identify, + init as initAmplitudeSDK, + setUserId as setAmplitudeUserId, + track as trackAmplitude, +} from '@amplitude/analytics-react-native'; // Firebase imports import AsyncStorage from '@react-native-async-storage/async-storage'; -import { getAnalytics, logEvent, setUserId as setFirebaseUserId, setUserProperties } from '@react-native-firebase/analytics'; +import { + getAnalytics, + logEvent, + setUserId as setFirebaseUserId, + setUserProperties, +} from '@react-native-firebase/analytics'; import { getApps, initializeApp, setReactNativeAsyncStorage } from '@react-native-firebase/app'; import { Platform } from 'react-native'; // Local imports -import { EXPO_PUBLIC_AMPLITUDE_API_KEY, EXPO_PUBLIC_AMPLITUDE_PROXY_URL, EXPO_PUBLIC_FIREBASE_API_KEY, EXPO_PUBLIC_FIREBASE_APP_ID, EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN, EXPO_PUBLIC_FIREBASE_DATABASE_URL, EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID, EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, EXPO_PUBLIC_FIREBASE_PROJECT_ID, EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET } from '@/lib/config'; +import { getAttributionChannel } from '@/lib/attribution'; +import { + EXPO_PUBLIC_AMPLITUDE_API_KEY, + EXPO_PUBLIC_AMPLITUDE_PROXY_URL, + EXPO_PUBLIC_FIREBASE_API_KEY, + EXPO_PUBLIC_FIREBASE_APP_ID, + EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN, + EXPO_PUBLIC_FIREBASE_DATABASE_URL, + EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID, + EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + EXPO_PUBLIC_FIREBASE_PROJECT_ID, + EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET, +} from '@/lib/config'; import { trackGTMEvent } from '@/lib/gtm'; import { sanitize, toTitleCase } from '@/lib/utils/utils'; +import { useAttributionStore } from '@/store/useAttributionStore'; // Firebase app instance const isFirebaseApp = getApps().length > 0; @@ -25,7 +52,10 @@ export enum FirebaseEvent { } export const formatAmplitudeEvent = (str: string) => { - return str.split('_').map(word => toTitleCase(word)).join(' '); + return str + .split('_') + .map(word => toTitleCase(word)) + .join(' '); }; // Initialize Amplitude @@ -36,9 +66,11 @@ const initAmplitude = async () => { if (Platform.OS === 'web') { // Web: add plugin BEFORE init const { sessionReplayPlugin } = await import('@amplitude/plugin-session-replay-browser'); - await add(sessionReplayPlugin({ - sampleRate, - })).promise; + await add( + sessionReplayPlugin({ + sampleRate, + }), + ).promise; } // Use proxy URL if configured (bypasses ad blockers in production) @@ -51,11 +83,13 @@ const initAmplitude = async () => { if (Platform.OS !== 'web') { // Native: add plugin AFTER init const { SessionReplayPlugin } = await import('@amplitude/plugin-session-replay-react-native'); - await add(new SessionReplayPlugin({ - sampleRate, - enableRemoteConfig: true, - autoStart: true, - })).promise; + await add( + new SessionReplayPlugin({ + sampleRate, + enableRemoteConfig: true, + autoStart: true, + }), + ).promise; } } catch (error) { console.error('Error initializing Amplitude:', error); @@ -101,6 +135,31 @@ export const initAnalytics = async () => { } }; +/** + * Get Amplitude device ID for anonymous user tracking + * This ID persists across sessions and is used to bridge anonymous -> identified users + */ +export const getAmplitudeDeviceId = (): string | undefined => { + try { + return getDeviceId(); + } catch (error) { + console.error('Error getting Amplitude device ID:', error); + return undefined; + } +}; + +/** + * Get Amplitude session ID for session-based analytics + */ +export const getAmplitudeSessionId = (): number | undefined => { + try { + return getSessionId(); + } catch (error) { + console.error('Error getting Amplitude session ID:', error); + return undefined; + } +}; + // Track Amplitude events const trackAmplitudeEvent = (event: string, params: Record) => { // Don't track events locally @@ -132,7 +191,7 @@ const trackFirebaseEvent = async (event: string, params: Record) => } }; -// Main track function +// Main track function with automatic attribution enrichment export const track = (event: string, params: Record = {}) => { // Don't track events locally if (__DEV__) { @@ -146,8 +205,30 @@ export const track = (event: string, params: Record = {}) => { return; } - // Sanitize params - remove undefined/null values and ensure serializable - const sanitizedParams = sanitize(params); + // Get attribution data from store + const attributionStore = useAttributionStore.getState(); + const attributionData = attributionStore.getAttributionForEvent(); + const deviceId = getAmplitudeDeviceId(); + const sessionId = getAmplitudeSessionId(); + + // Enrich params with attribution and device context + const enrichedParams = { + ...params, + // Attribution data (UTM params, referral codes, etc.) + ...attributionData, + // Attribution channel for easier filtering + attribution_channel: getAttributionChannel(attributionData), + // Device/session tracking for anonymous-to-identified user bridging + amplitude_device_id: deviceId, + amplitude_session_id: sessionId, + // Platform context + platform: Platform.OS, + // Timestamp + timestamp: Date.now(), + }; + + // Sanitize all params once - remove undefined/null values and ensure serializable + const sanitizedParams = sanitize(enrichedParams); // Track to all providers in parallel Promise.allSettled([ @@ -235,7 +316,10 @@ const trackAmplitudeIdentity = (id: string, params: Record) => { try { setAmplitudeUserId(id); const identifyObj = new Identify(); - identifyObj.set('user_properties', params); + // Set each field as individual user property for searchability in Amplitude + Object.entries(params).forEach(([key, value]) => { + identifyObj.set(key, value); + }); identify(identifyObj); } catch (error) { console.error('Error tracking Amplitude identity:', error); @@ -260,7 +344,7 @@ const trackFirebaseIdentity = async (id: string, params: Record) => } }; -// Main identity tracking function +// Main identity tracking function with attribution enrichment export const trackIdentity = (id: string, params: Record = {}) => { // Don't track identity locally if (__DEV__) { @@ -274,8 +358,28 @@ export const trackIdentity = (id: string, params: Record = {}) => { return; } + // Get attribution data and device IDs + const attributionStore = useAttributionStore.getState(); + const attributionData = attributionStore.getAttributionForEvent(); + const deviceId = getAmplitudeDeviceId(); + + // Enrich with FULL attribution context for user identification + const enrichedParams = { + ...params, + // Attribution data (critical for identifying which campaign brought this user) + ...sanitize(attributionData), + // Attribution channel + attribution_channel: getAttributionChannel(attributionData), + // Anonymous device ID for bridging pre-signup and post-signup sessions + anonymous_device_id: deviceId, + // Identification timestamp + identified_timestamp: Date.now(), + // Platform + platform: Platform.OS, + }; + // Sanitize params - const sanitizedParams = sanitize(params); + const sanitizedParams = sanitize(enrichedParams); // Track to all providers in parallel Promise.allSettled([ @@ -283,7 +387,7 @@ export const trackIdentity = (id: string, params: Record = {}) => { trackFirebaseIdentity(id, sanitizedParams), ]); - // Push user identification to GTM dataLayer + // Push user identification to GTM dataLayer with full attribution if (typeof window !== 'undefined' && window.dataLayer) { try { window.dataLayer.push({ diff --git a/lib/attribution.ts b/lib/attribution.ts new file mode 100644 index 000000000..3580faaa9 --- /dev/null +++ b/lib/attribution.ts @@ -0,0 +1,417 @@ +import * as Sentry from '@sentry/react-native'; +import { Platform } from 'react-native'; + +import type { AttributionData } from '@/store/useAttributionStore'; + +/** + * Attribution utility functions for parsing and managing marketing attribution data + */ + +/** + * Parse UTM parameters and advertising IDs from URL + */ +export const parseAttributionFromURL = (url: string): Partial => { + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + const attribution: Partial = {}; + + // UTM parameters (standard marketing attribution) + const utmSource = params.get('utm_source'); + const utmMedium = params.get('utm_medium'); + const utmCampaign = params.get('utm_campaign'); + const utmContent = params.get('utm_content'); + const utmTerm = params.get('utm_term'); + + if (utmSource) attribution.utm_source = utmSource.trim(); + if (utmMedium) attribution.utm_medium = utmMedium.trim(); + if (utmCampaign) attribution.utm_campaign = utmCampaign.trim(); + if (utmContent) attribution.utm_content = utmContent.trim(); + if (utmTerm) attribution.utm_term = utmTerm.trim(); + + // Advertising platform click IDs + const gclid = params.get('gclid'); // Google Ads + const fbclid = params.get('fbclid'); // Facebook Ads + const msclkid = params.get('msclkid'); // Microsoft Ads + const ttclid = params.get('ttclid'); // TikTok Ads + + if (gclid) attribution.gclid = gclid.trim(); + if (fbclid) attribution.fbclid = fbclid.trim(); + if (msclkid) attribution.msclkid = msclkid.trim(); + if (ttclid) attribution.ttclid = ttclid.trim(); + + // Referral codes (support multiple parameter names for compatibility) + const refCode = + params.get('ref') || + params.get('refCode') || + params.get('referralCode') || + params.get('referral'); + + if (refCode) { + attribution.referral_code = refCode.trim(); + attribution.referral_source = 'url'; + } + + // Landing page information + attribution.landing_page = url; + attribution.landing_page_path = urlObj.pathname; + + // Add timestamp + attribution.attribution_captured_at = Date.now(); + + return attribution; + } catch (error) { + console.warn('Failed to parse attribution from URL:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_url_parse_error' }, + level: 'warning', + extra: { url }, + }); + return {}; + } +}; + +/** + * Parse attribution from deep link URL (iOS Universal Links, Android App Links) + */ +export const parseAttributionFromDeepLink = (deepLinkUrl: string): Partial => { + try { + // Deep links use custom schemes (e.g., 'solid://') or universal links + // Parse them the same way as regular URLs + const attribution = parseAttributionFromURL(deepLinkUrl); + + // Mark referral source as deeplink if present + if (attribution.referral_code) { + attribution.referral_source = 'deeplink'; + } + + return attribution; + } catch (error) { + console.warn('Failed to parse attribution from deep link:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_deeplink_parse_error' }, + level: 'warning', + extra: { deepLinkUrl }, + }); + return {}; + } +}; + +/** + * Get current URL for web platform + */ +export const getCurrentURL = (): string | null => { + if (Platform.OS !== 'web') return null; + + try { + if (typeof window !== 'undefined' && window.location) { + return window.location.href; + } + } catch (error) { + console.warn('Failed to get current URL:', error); + } + + return null; +}; + +/** + * Get HTTP referrer for web platform + */ +export const getReferrer = (): string | null => { + if (Platform.OS !== 'web') return null; + + try { + if (typeof document !== 'undefined' && document.referrer) { + return document.referrer; + } + } catch (error) { + console.warn('Failed to get referrer:', error); + } + + return null; +}; + +/** + * Validate attribution data for completeness and quality + */ +export interface AttributionValidationResult { + isValid: boolean; + hasSource: boolean; + hasCampaign: boolean; + hasReferral: boolean; + hasClickId: boolean; + completeness: number; // 0-100% score + warnings: string[]; +} + +export const validateAttribution = ( + data: Partial, +): AttributionValidationResult => { + const warnings: string[] = []; + + // Check for presence of key attribution fields + const hasSource = !!data.utm_source; + const hasCampaign = !!data.utm_campaign; + const hasReferral = !!data.referral_code; + const hasClickId = !!(data.gclid || data.fbclid || data.msclkid || data.ttclid); + + // Calculate completeness score + let completeness = 0; + const fields = [ + data.utm_source, + data.utm_medium, + data.utm_campaign, + data.utm_content, + data.utm_term, + data.referral_code, + data.gclid || data.fbclid || data.msclkid || data.ttclid, + data.landing_page, + ]; + + const presentFields = fields.filter(field => !!field).length; + completeness = Math.round((presentFields / fields.length) * 100); + + // Validation warnings + if (hasSource && !hasCampaign) { + warnings.push('utm_source present but utm_campaign missing'); + } + + if (hasCampaign && !hasSource) { + warnings.push('utm_campaign present but utm_source missing'); + } + + if (data.utm_source && data.utm_source.length > 100) { + warnings.push('utm_source suspiciously long (>100 chars)'); + } + + if (data.utm_campaign && data.utm_campaign.length > 100) { + warnings.push('utm_campaign suspiciously long (>100 chars)'); + } + + // Check for potential tracking issues + if (data.utm_source?.toLowerCase() === 'direct') { + warnings.push('utm_source is "direct" - may indicate attribution loss'); + } + + const isValid = (hasSource && hasCampaign) || hasReferral || hasClickId || completeness >= 25; + + return { + isValid, + hasSource, + hasCampaign, + hasReferral, + hasClickId, + completeness, + warnings, + }; +}; + +/** + * Merge attribution data with precedence rules + * First-touch data takes precedence for source/campaign + * Last-touch data takes precedence for content/term + */ +export const mergeAttribution = ( + firstTouch: Partial, + lastTouch: Partial, +): AttributionData => { + return { + // First-touch takes precedence for source and campaign (never change initial attribution) + utm_source: firstTouch.utm_source || lastTouch.utm_source, + utm_campaign: firstTouch.utm_campaign || lastTouch.utm_campaign, + utm_medium: firstTouch.utm_medium || lastTouch.utm_medium, + + // Last-touch takes precedence for content and term (reflects most recent messaging) + utm_content: lastTouch.utm_content || firstTouch.utm_content, + utm_term: lastTouch.utm_term || firstTouch.utm_term, + + // Click IDs - prefer last-touch (most recent ad click) + gclid: lastTouch.gclid || firstTouch.gclid, + fbclid: lastTouch.fbclid || firstTouch.fbclid, + msclkid: lastTouch.msclkid || firstTouch.msclkid, + ttclid: lastTouch.ttclid || firstTouch.ttclid, + + // Referral - prefer first-touch (who originally referred the user) + referral_code: firstTouch.referral_code || lastTouch.referral_code, + referral_source: firstTouch.referral_source || lastTouch.referral_source, + + // Timestamps + first_visit_timestamp: firstTouch.first_visit_timestamp || lastTouch.first_visit_timestamp, + last_visit_timestamp: lastTouch.last_visit_timestamp || firstTouch.last_visit_timestamp, + attribution_captured_at: + lastTouch.attribution_captured_at || firstTouch.attribution_captured_at, + + // Device IDs - prefer last-touch (most recent session) + anonymous_device_id: lastTouch.anonymous_device_id || firstTouch.anonymous_device_id, + amplitude_device_id: lastTouch.amplitude_device_id || firstTouch.amplitude_device_id, + amplitude_session_id: lastTouch.amplitude_session_id || firstTouch.amplitude_session_id, + + // Landing page - prefer first-touch (initial entry point) + landing_page: firstTouch.landing_page || lastTouch.landing_page, + landing_page_path: firstTouch.landing_page_path || lastTouch.landing_page_path, + landing_page_referrer: firstTouch.landing_page_referrer || lastTouch.landing_page_referrer, + + // Attribution type + attribution_type: 'multi_touch', + }; +}; + +/** + * Check if attribution data has expired based on attribution window + * Standard marketing attribution windows: 7, 30, 60, 90 days + */ +export const isAttributionExpired = ( + attributionData: Partial, + windowDays: number = 30, +): boolean => { + if (!attributionData.first_visit_timestamp) return true; + + const windowMs = windowDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + const ageMs = now - attributionData.first_visit_timestamp; + + return ageMs > windowMs; +}; + +/** + * Get attribution source category for reporting + * Categorizes attribution sources into broad channels + */ +export type AttributionChannel = + | 'organic' + | 'paid_search' + | 'paid_social' + | 'social' + | 'email' + | 'referral' + | 'direct' + | 'other'; + +export const getAttributionChannel = (data: Partial): AttributionChannel => { + // Referral code takes precedence + if (data.referral_code) return 'referral'; + + // Click IDs indicate paid advertising + if (data.gclid || data.msclkid) return 'paid_search'; + if (data.fbclid || data.ttclid) return 'paid_social'; + + // UTM medium categorization + if (data.utm_medium) { + const medium = data.utm_medium.toLowerCase(); + + if (medium.includes('cpc') || medium.includes('ppc') || medium.includes('paid')) { + // Check source to determine search vs social + const source = data.utm_source?.toLowerCase() || ''; + if (source.includes('google') || source.includes('bing') || source.includes('search')) { + return 'paid_search'; + } + if ( + source.includes('facebook') || + source.includes('twitter') || + source.includes('instagram') || + source.includes('linkedin') || + source.includes('tiktok') + ) { + return 'paid_social'; + } + } + + if (medium.includes('social') || medium.includes('organic_social')) return 'social'; + if (medium.includes('email')) return 'email'; + if (medium.includes('organic')) return 'organic'; + } + + // UTM source categorization as fallback + if (data.utm_source) { + const source = data.utm_source.toLowerCase(); + + if ( + source.includes('google') || + source.includes('bing') || + source.includes('search') || + source.includes('seo') + ) { + return 'organic'; + } + + if ( + source.includes('facebook') || + source.includes('twitter') || + source.includes('instagram') || + source.includes('linkedin') || + source.includes('tiktok') + ) { + return 'social'; + } + + if (source.includes('email') || source.includes('newsletter')) { + return 'email'; + } + + if (source === 'direct' || source === '(direct)') { + return 'direct'; + } + } + + // Default to 'other' if we can't categorize + return data.utm_source || data.utm_campaign ? 'other' : 'direct'; +}; + +/** + * Sanitize attribution value to remove potential PII + * Checks for emails, phone numbers, and other sensitive patterns + */ +export const sanitizeAttributionValue = (value: string): string | null => { + if (!value || typeof value !== 'string') return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + // Check for PII patterns + const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/; + const phonePattern = /(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/; + const ssnPattern = /\d{3}-\d{2}-\d{4}/; + + if (emailPattern.test(trimmed) || phonePattern.test(trimmed) || ssnPattern.test(trimmed)) { + console.warn('PII detected in attribution value, removing:', value.substring(0, 10) + '...'); + return null; + } + + return trimmed; +}; + +/** + * Format attribution data for logging (safe for logs) + */ +export const formatAttributionForLogging = (data: Partial): string => { + const parts: string[] = []; + + if (data.utm_source) parts.push(`source:${data.utm_source}`); + if (data.utm_medium) parts.push(`medium:${data.utm_medium}`); + if (data.utm_campaign) parts.push(`campaign:${data.utm_campaign}`); + if (data.referral_code) parts.push(`referral:${data.referral_code}`); + if (data.gclid) parts.push('gclid:***'); + if (data.fbclid) parts.push('fbclid:***'); + + const channel = getAttributionChannel(data); + parts.push(`channel:${channel}`); + + return parts.join(' | '); +}; + +/** + * Create a fingerprint for attribution data (for deduplication) + */ +export const getAttributionFingerprint = (data: Partial): string => { + const components = [ + data.utm_source || '', + data.utm_medium || '', + data.utm_campaign || '', + data.referral_code || '', + data.gclid || '', + data.fbclid || '', + ]; + + return components.filter(c => !!c).join('|'); +}; diff --git a/lib/config.ts b/lib/config.ts index d517ef380..eb796a65c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -129,6 +129,7 @@ export const USER = { stakeStorageKey: 'flash_stake', depositFromSafeAccountStorageKey: 'flash_deposit_from_safe_account', referralStorageKey: 'flash_referral', + attributionStorageKey: 'flash_attribution', activityStorageKey: 'flash_activity', balanceStorageKey: 'flash_balance', poolsStorageKey: 'pools-storage', diff --git a/lib/gtm.ts b/lib/gtm.ts index 9345f037e..7dc90be05 100644 --- a/lib/gtm.ts +++ b/lib/gtm.ts @@ -24,12 +24,61 @@ export enum GTMEventType { STAKED = 'staked', } +/** + * Attribution data structure for GTM events + * Contains full marketing attribution context for Addressable and Google Ads tracking + */ +interface GTMAttributionData { + // UTM Parameters + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_content?: string; + utm_term?: string; + + // Advertising Click IDs + gclid?: string; + fbclid?: string; + msclkid?: string; + ttclid?: string; + + // Referral Attribution + referral_code?: string; + referral_source?: string; + + // Attribution Channel Classification + attribution_channel?: string; + + // Multi-touch Attribution + first_touch_utm_source?: string; + first_touch_utm_campaign?: string; + first_touch_utm_medium?: string; + first_touch_timestamp?: number; + last_touch_utm_source?: string; + last_touch_utm_campaign?: string; + last_touch_utm_medium?: string; + last_touch_timestamp?: number; + + // Device Tracking (for anonymous-to-identified bridging) + device_id?: string; + amplitude_device_id?: string; + amplitude_session_id?: number; + + // Landing Page Context + landing_page?: string; + landing_page_referrer?: string; + landing_page_path?: string; +} + interface BaseGTMEvent { event: string; user_id?: string; safe_address?: string; timestamp?: number; platform?: string; + + // Attribution data (auto-enriched from analytics.ts) + attribution?: GTMAttributionData; } interface SignupGTMEvent extends BaseGTMEvent { @@ -70,9 +119,20 @@ interface StakeGTMEvent extends BaseGTMEvent { token_symbol: string; } -export type GTMEvent = SignupGTMEvent | AccountCreationGTMEvent | EmailVerificationGTMEvent | DepositGTMEvent | StakeGTMEvent; +export type GTMEvent = + | SignupGTMEvent + | AccountCreationGTMEvent + | EmailVerificationGTMEvent + | DepositGTMEvent + | StakeGTMEvent; -// Track GTM events +/** + * Track GTM events with attribution enrichment + * Pushes events to Google Tag Manager dataLayer for Addressable and Google Ads + * + * Attribution data is automatically enriched by analytics.ts and structured + * into a nested attribution object for easier GTM trigger configuration + */ export const trackGTMEvent = (event: string, params: Record) => { try { // Only push to dataLayer on web platform where GTM is available @@ -88,24 +148,69 @@ export const trackGTMEvent = (event: string, params: Record) => { // Ensure dataLayer exists if (typeof window !== 'undefined' && window.dataLayer) { - // Sanitize event data - remove undefined/null values - const sanitizedData = Object.entries(params) - .filter(([_, value]) => value !== undefined && value !== null) - .reduce((acc, [key, value]) => { - // Ensure values are serializable - acc[key] = typeof value === 'object' && value !== null ? - JSON.stringify(value) : value; - return acc; - }, {} as Record); + // Extract attribution fields from params for structured organization + const attributionFields = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gclid', + 'fbclid', + 'msclkid', + 'ttclid', + 'referral_code', + 'referral_source', + 'attribution_channel', + 'first_touch_utm_source', + 'first_touch_utm_campaign', + 'first_touch_utm_medium', + 'first_touch_timestamp', + 'last_touch_utm_source', + 'last_touch_utm_campaign', + 'last_touch_utm_medium', + 'last_touch_timestamp', + 'device_id', + 'amplitude_device_id', + 'amplitude_session_id', + 'landing_page', + 'landing_page_referrer', + 'landing_page_path', + ]; + + // Build attribution object from params + const attribution: GTMAttributionData = {}; + attributionFields.forEach(field => { + if (params[field] !== undefined && params[field] !== null) { + attribution[field as keyof GTMAttributionData] = params[field] as any; + } + }); + + // Build event data without attribution fields (they're now in attribution object) + const eventData = Object.entries(params) + .filter( + ([key, value]) => + value !== undefined && value !== null && !attributionFields.includes(key), + ) + .reduce( + (acc, [key, value]) => { + // Ensure values are serializable + acc[key] = typeof value === 'object' && value !== null ? JSON.stringify(value) : value; + return acc; + }, + {} as Record, + ); const enrichedEvent = { event, - ...sanitizedData, + ...eventData, timestamp: params.timestamp || Date.now(), platform: params.platform || Platform.OS, + // Structured attribution object for easier GTM trigger configuration + attribution: Object.keys(attribution).length > 0 ? attribution : undefined, // Add standard GTM fields gtm_event_category: 'addressable', - gtm_event_version: '1.0', + gtm_event_version: '1.1', // Bumped version to indicate attribution structure change }; window.dataLayer.push(enrichedEvent); diff --git a/store/useAttributionStore.ts b/store/useAttributionStore.ts new file mode 100644 index 000000000..21cc3fe69 --- /dev/null +++ b/store/useAttributionStore.ts @@ -0,0 +1,450 @@ +import * as Sentry from '@sentry/react-native'; +import { Platform } from 'react-native'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { USER } from '@/lib/config'; +import mmkvStorage from '@/lib/mmvkStorage'; +import { useReferralStore } from './useReferralStore'; + +/** + * Attribution data structure capturing all marketing attribution parameters + */ +export interface AttributionData { + // UTM Parameters (standard marketing attribution) + utm_source?: string; // e.g., 'google', 'twitter', 'facebook' + utm_medium?: string; // e.g., 'cpc', 'social', 'email' + utm_campaign?: string; // e.g., 'summer_2026', 'launch_week' + utm_content?: string; // e.g., 'ad_variant_a', 'hero_cta' + utm_term?: string; // e.g., 'crypto_savings', 'defi_app' + + // Advertising Platform Click IDs + gclid?: string; // Google Click ID + fbclid?: string; // Facebook Click ID + msclkid?: string; // Microsoft Click ID + ttclid?: string; // TikTok Click ID + + // Referral Attribution + referral_code?: string; // User referral code + referral_source?: 'url' | 'storage' | 'deeplink' | null; // How referral was captured + + // Timestamps (for attribution windows) + first_visit_timestamp?: number; // When user first visited + last_visit_timestamp?: number; // Most recent visit + attribution_captured_at?: number; // When this attribution was captured + + // Session & Device Tracking + anonymous_device_id?: string; // Device ID before authentication + amplitude_device_id?: string; // Amplitude's device ID + amplitude_session_id?: number; // Amplitude's session ID + + // Landing Page Context + landing_page?: string; // URL of first page visited + landing_page_referrer?: string; // HTTP referrer header + landing_page_path?: string; // URL path only (e.g., '/signup') + + // Attribution Model Support + attribution_type?: 'first_touch' | 'last_touch' | 'multi_touch'; // Which attribution model +} + +/** + * First-touch and last-touch attribution data for multi-touch attribution + */ +export interface MultiTouchAttribution { + first_touch?: AttributionData; + last_touch?: AttributionData; +} + +interface AttributionState { + // Current attribution data (combines first/last touch logic) + attributionData: AttributionData; + + // Multi-touch attribution tracking + multiTouchData: MultiTouchAttribution; + + // Hydration state + _hasHydrated: boolean; + + // Actions + setAttributionData: (data: Partial) => void; + updateAttribution: (data: Partial) => void; + clearAttribution: () => void; + captureFromURL: (url?: string) => AttributionData | null; + captureFromDeepLink: (deepLinkUrl: string) => AttributionData | null; + saveFirstTouchAttribution: (data: AttributionData) => void; + saveLastTouchAttribution: (data: AttributionData) => void; + saveReferralAttribution: (data: { + referral_code: string; + referral_source: 'url' | 'storage' | 'deeplink'; + referral_timestamp: number; + }) => void; + getAttributionForEvent: () => AttributionData; + hasAttribution: () => boolean; + isAttributionExpired: (windowDays?: number) => boolean; + setHasHydrated: (state: boolean) => void; +} + +/** + * Parse UTM parameters and advertising IDs from URL query string + */ +const parseAttributionFromURL = (url: string): Partial => { + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + const attribution: Partial = {}; + + // UTM parameters + if (params.get('utm_source')) attribution.utm_source = params.get('utm_source')!; + if (params.get('utm_medium')) attribution.utm_medium = params.get('utm_medium')!; + if (params.get('utm_campaign')) attribution.utm_campaign = params.get('utm_campaign')!; + if (params.get('utm_content')) attribution.utm_content = params.get('utm_content')!; + if (params.get('utm_term')) attribution.utm_term = params.get('utm_term')!; + + // Advertising Click IDs + if (params.get('gclid')) attribution.gclid = params.get('gclid')!; + if (params.get('fbclid')) attribution.fbclid = params.get('fbclid')!; + if (params.get('msclkid')) attribution.msclkid = params.get('msclkid')!; + if (params.get('ttclid')) attribution.ttclid = params.get('ttclid')!; + + // Referral codes (multiple param names for compatibility) + const refCode = + params.get('ref') || + params.get('refCode') || + params.get('referralCode') || + params.get('referral'); + if (refCode) { + attribution.referral_code = refCode; + attribution.referral_source = 'url'; + } + + // Landing page info + attribution.landing_page = url; + attribution.landing_page_path = urlObj.pathname; + attribution.attribution_captured_at = Date.now(); + + return attribution; + } catch (error) { + console.warn('Failed to parse attribution from URL:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_url_parse_error' }, + level: 'warning', + }); + return {}; + } +}; + +/** + * Parse attribution from deep link URL (mobile) + */ +const parseAttributionFromDeepLink = (deepLinkUrl: string): Partial => { + try { + // Deep links often use custom schemes like 'solid://...' or universal links + // Extract query parameters the same way as web URLs + const attribution = parseAttributionFromURL(deepLinkUrl); + if (attribution.referral_code) { + attribution.referral_source = 'deeplink'; + } + return attribution; + } catch (error) { + console.warn('Failed to parse attribution from deep link:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_deeplink_parse_error' }, + level: 'warning', + }); + return {}; + } +}; + +/** + * Sanitize attribution data to remove PII and invalid values + */ +const sanitizeAttribution = (data: Partial): Partial => { + const sanitized: Partial = {}; + + // Remove any potential PII patterns (emails, phone numbers, etc.) + const piiPattern = + /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/; + + Object.entries(data).forEach(([key, value]) => { + if (value === null || value === undefined || value === '') return; + + // Check for PII in string values + if (typeof value === 'string') { + if (!piiPattern.test(value)) { + sanitized[key as keyof AttributionData] = value.trim() as any; + } else { + console.warn(`Potential PII detected in attribution field ${key}, removing`); + } + } else { + sanitized[key as keyof AttributionData] = value as any; + } + }); + + return sanitized; +}; + +/** + * Zustand store for managing attribution data with MMKV persistence + * Tracks first-touch, last-touch, and multi-touch attribution across sessions + */ +export const useAttributionStore = create()( + persist( + (set, get) => ({ + attributionData: {}, + multiTouchData: {}, + _hasHydrated: false, + setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), + + /** + * Set complete attribution data (replaces existing) + */ + setAttributionData: (data: Partial) => { + const sanitized = sanitizeAttribution(data); + set({ + attributionData: { + ...sanitized, + last_visit_timestamp: Date.now(), + }, + }); + }, + + /** + * Update attribution data (merges with existing) + */ + updateAttribution: (data: Partial) => { + const sanitized = sanitizeAttribution(data); + set(state => ({ + attributionData: { + ...state.attributionData, + ...sanitized, + last_visit_timestamp: Date.now(), + }, + })); + }, + + /** + * Clear all attribution data + */ + clearAttribution: () => { + set({ + attributionData: {}, + multiTouchData: {}, + }); + console.warn('Attribution data cleared'); + }, + + /** + * Capture attribution from current URL (web only) + */ + captureFromURL: (url?: string) => { + if (Platform.OS !== 'web') return null; + + try { + const targetUrl = url || (typeof window !== 'undefined' ? window.location.href : ''); + if (!targetUrl) return null; + + const parsed = parseAttributionFromURL(targetUrl); + + // Only save if we actually found attribution data + if (Object.keys(parsed).length > 0) { + const existing = get().attributionData; + + // Capture referrer if available + if (typeof document !== 'undefined' && document.referrer) { + parsed.landing_page_referrer = document.referrer; + } + + // If this is first visit, save as first-touch + if (!existing.first_visit_timestamp) { + parsed.first_visit_timestamp = Date.now(); + parsed.attribution_type = 'first_touch'; + get().saveFirstTouchAttribution(parsed as AttributionData); + } else { + // Subsequent visits are last-touch + parsed.attribution_type = 'last_touch'; + get().saveLastTouchAttribution(parsed as AttributionData); + } + + get().updateAttribution(parsed); + console.warn('Attribution captured from URL:', parsed); + + // Sync referral code to referral store for backward compatibility + if (parsed.referral_code) { + const referralStore = useReferralStore.getState(); + referralStore.setReferralCode(parsed.referral_code); + console.warn('Referral code synced to referral store:', parsed.referral_code); + } + + return parsed as AttributionData; + } + + return null; + } catch (error) { + console.warn('Failed to capture attribution from URL:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_capture_error' }, + level: 'warning', + }); + return null; + } + }, + + /** + * Capture attribution from deep link (mobile) + */ + captureFromDeepLink: (deepLinkUrl: string) => { + try { + const parsed = parseAttributionFromDeepLink(deepLinkUrl); + + if (Object.keys(parsed).length > 0) { + const existing = get().attributionData; + + if (!existing.first_visit_timestamp) { + parsed.first_visit_timestamp = Date.now(); + parsed.attribution_type = 'first_touch'; + get().saveFirstTouchAttribution(parsed as AttributionData); + } else { + parsed.attribution_type = 'last_touch'; + get().saveLastTouchAttribution(parsed as AttributionData); + } + + get().updateAttribution(parsed); + console.warn('Attribution captured from deep link:', parsed); + + // Sync referral code to referral store for backward compatibility + if (parsed.referral_code) { + const referralStore = useReferralStore.getState(); + referralStore.setReferralCode(parsed.referral_code); + console.warn('Referral code synced to referral store:', parsed.referral_code); + } + + return parsed as AttributionData; + } + + return null; + } catch (error) { + console.warn('Failed to capture attribution from deep link:', error); + Sentry.captureException(error, { + tags: { type: 'attribution_deeplink_capture_error' }, + level: 'warning', + }); + return null; + } + }, + + /** + * Save first-touch attribution (never overwrite once set) + */ + saveFirstTouchAttribution: (data: AttributionData) => { + set(state => { + // Only save if we don't already have first-touch data + if (state.multiTouchData.first_touch) { + return state; // Don't overwrite existing first-touch + } + + return { + multiTouchData: { + ...state.multiTouchData, + first_touch: { + ...data, + attribution_type: 'first_touch', + first_visit_timestamp: data.first_visit_timestamp || Date.now(), + }, + }, + }; + }); + }, + + /** + * Save last-touch attribution (always update with latest) + */ + saveLastTouchAttribution: (data: AttributionData) => { + set(state => ({ + multiTouchData: { + ...state.multiTouchData, + last_touch: { + ...data, + attribution_type: 'last_touch', + last_visit_timestamp: Date.now(), + }, + }, + })); + }, + + /** + * Save referral attribution (integrates with existing useReferralStore) + */ + saveReferralAttribution: (data: { + referral_code: string; + referral_source: 'url' | 'storage' | 'deeplink'; + referral_timestamp: number; + }) => { + get().updateAttribution({ + referral_code: data.referral_code, + referral_source: data.referral_source, + attribution_captured_at: data.referral_timestamp, + }); + }, + + /** + * Get attribution data for tracking events + * Returns merged first-touch and last-touch data with preference for first-touch + */ + getAttributionForEvent: () => { + const { attributionData, multiTouchData } = get(); + + // Return combined attribution with first-touch taking precedence for source/campaign + // but including both first and last touch data + return { + ...attributionData, + // First-touch attribution (never changes) + first_touch_utm_source: multiTouchData.first_touch?.utm_source, + first_touch_utm_campaign: multiTouchData.first_touch?.utm_campaign, + first_touch_utm_medium: multiTouchData.first_touch?.utm_medium, + first_touch_timestamp: multiTouchData.first_touch?.first_visit_timestamp, + // Last-touch attribution (most recent) + last_touch_utm_source: multiTouchData.last_touch?.utm_source, + last_touch_utm_campaign: multiTouchData.last_touch?.utm_campaign, + last_touch_utm_medium: multiTouchData.last_touch?.utm_medium, + last_touch_timestamp: multiTouchData.last_touch?.last_visit_timestamp, + }; + }, + + /** + * Check if we have any attribution data + */ + hasAttribution: () => { + const { attributionData } = get(); + return ( + !!attributionData.utm_source || + !!attributionData.utm_campaign || + !!attributionData.referral_code || + !!attributionData.gclid || + !!attributionData.fbclid + ); + }, + + /** + * Check if attribution has expired (default 30-day window) + */ + isAttributionExpired: (windowDays: number = 30) => { + const { attributionData } = get(); + if (!attributionData.first_visit_timestamp) return true; + + const windowMs = windowDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + const ageMs = now - attributionData.first_visit_timestamp; + + return ageMs > windowMs; + }, + }), + { + name: USER.attributionStorageKey, + storage: createJSONStorage(() => mmkvStorage(USER.attributionStorageKey)), + onRehydrateStorage: () => state => { + state?.setHasHydrated(true); + }, + }, + ), +); diff --git a/store/useDepositStore.ts b/store/useDepositStore.ts index 271ad3d83..e0c083196 100644 --- a/store/useDepositStore.ts +++ b/store/useDepositStore.ts @@ -58,6 +58,7 @@ interface DepositState { bankTransfer: BankTransferData; kyc: KycData; directDepositSession: DirectDepositSession; + sessionStartTime?: number; setModal: (modal: DepositModal) => void; setTransaction: (transaction: TransactionStatusModal) => void; setBankTransferData: (data: Partial) => void; @@ -68,6 +69,8 @@ interface DepositState { setOutputToken: (token: string) => void; setDirectDepositSession: (data: Partial) => void; clearDirectDepositSession: () => void; + setSessionStartTime: (time: number) => void; + clearSessionStartTime: () => void; } export const useDepositStore = create()( @@ -81,6 +84,7 @@ export const useDepositStore = create()( bankTransfer: {}, kyc: {}, directDepositSession: {}, + sessionStartTime: undefined, setModal: modal => { set({ @@ -98,6 +102,8 @@ export const useDepositStore = create()( setDirectDepositSession: data => set({ directDepositSession: { ...get().directDepositSession, ...data } }), clearDirectDepositSession: () => set({ directDepositSession: {} }), + setSessionStartTime: time => set({ sessionStartTime: time }), + clearSessionStartTime: () => set({ sessionStartTime: undefined }), }), { name: USER.depositStorageKey, diff --git a/store/useReferralStore.ts b/store/useReferralStore.ts index b63b58888..b305cd31a 100644 --- a/store/useReferralStore.ts +++ b/store/useReferralStore.ts @@ -8,7 +8,9 @@ import mmkvStorage from '@/lib/mmvkStorage'; interface ReferralState { referralCode: string | null; + _hasHydrated: boolean; setReferralCode: (code: string | null) => void; + setHasHydrated: (state: boolean) => void; clearReferralCode: () => void; detectAndSaveReferralCode: () => string | null; getReferralCodeForSignup: () => string | null; @@ -21,6 +23,7 @@ export const useReferralStore = create()( persist( (set, get) => ({ referralCode: null, + _hasHydrated: false, setReferralCode: (code: string | null) => { const trimmedCode = code && code.trim() ? code.trim() : null; @@ -30,6 +33,10 @@ export const useReferralStore = create()( } }, + setHasHydrated: (state: boolean) => { + set({ _hasHydrated: state }); + }, + clearReferralCode: () => { set({ referralCode: null }); console.warn('Referral code cleared'); @@ -95,6 +102,9 @@ export const useReferralStore = create()( { name: USER.referralStorageKey, storage: createJSONStorage(() => mmkvStorage(USER.referralStorageKey)), + onRehydrateStorage: () => state => { + state?.setHasHydrated(true); + }, }, ), ); diff --git a/store/useSignupFlowStore.ts b/store/useSignupFlowStore.ts index 29618b920..a029a491d 100644 --- a/store/useSignupFlowStore.ts +++ b/store/useSignupFlowStore.ts @@ -111,7 +111,14 @@ export const useSignupFlowStore = create()( setRateLimitError: rateLimitError => set({ rateLimitError }), setLastOtpSentAt: lastOtpSentAt => set({ lastOtpSentAt }), - reset: () => set({ ...initialState, _hasHydrated: true }), + reset: () => + set(state => ({ + ...initialState, + _hasHydrated: true, + // Preserve referralCode captured at root level (attribution hook) + // This prevents accidental loss during flow resets + referralCode: state.referralCode || initialState.referralCode, + })), }), { name: `${USER.storageKey}_signup_flow`,