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 (
-
-
-
-
-
-
-
- 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`,