Date: Tue, 10 Mar 2026 15:12:53 +0000
Subject: [PATCH 28/77] fix: add missing deviceType dep, remove redundant
window guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SetupPasskey useEffect: add deviceType to dependency array to avoid
stale closure capturing wrong device type in analytics event
- authContext: remove unnecessary typeof window guard around
posthog.identify() — already inside useEffect (client-only)
---
src/components/Setup/Views/SetupPasskey.tsx | 2 +-
src/context/authContext.tsx | 8 +++-----
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx
index be1eec970..9ad2f8901 100644
--- a/src/components/Setup/Views/SetupPasskey.tsx
+++ b/src/components/Setup/Views/SetupPasskey.tsx
@@ -112,7 +112,7 @@ const SetupPasskey = () => {
posthog.capture('signup_passkey_succeeded', { device_type: deviceType })
handleNext()
}
- }, [address, handleNext])
+ }, [address, handleNext, deviceType])
return (
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index df208d579..e28415f5f 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -77,11 +77,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
window.gtag('set', { user_id: user.user.userId })
}
// PostHog: identify user (stitches anonymous pre-login events to this user)
- if (typeof window !== 'undefined') {
- posthog.identify(user.user.userId, {
- username: user.user.username,
- })
- }
+ posthog.identify(user.user.userId, {
+ username: user.user.username,
+ })
}
}, [user])
From 00f1f87147dbc84bd6db9ecf3152e4a9d01e31b6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 15:23:02 +0000
Subject: [PATCH 29/77] refactor: use central ANALYTICS_EVENTS constants for
all PostHog events
Replace raw event name strings with typed constants from
analytics.consts.ts. Provides autocomplete, compile-time safety,
and a single place to audit the full event taxonomy.
---
src/app/(mobile-ui)/home/page.tsx | 3 +-
src/app/(mobile-ui)/withdraw/page.tsx | 3 +-
.../Global/BackendErrorScreen/index.tsx | 7 ++-
src/components/Global/DirectSendQR/index.tsx | 3 +-
src/components/Send/views/SendRouter.view.tsx | 3 +-
src/components/Setup/Views/InstallPWA.tsx | 7 ++-
src/components/Setup/Views/Landing.tsx | 5 +-
src/components/Setup/Views/SetupPasskey.tsx | 7 ++-
.../Setup/Views/SignTestTransaction.tsx | 9 +--
src/components/Setup/Views/Signup.tsx | 5 +-
src/components/Setup/Views/Welcome.tsx | 3 +-
src/constants/analytics.consts.ts | 55 +++++++++++++++++++
src/hooks/useBridgeKycFlow.ts | 13 +++--
src/hooks/useMantecaKycFlow.ts | 7 ++-
14 files changed, 99 insertions(+), 31 deletions(-)
create mode 100644 src/constants/analytics.consts.ts
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index 6fb232c7c..f0229f14a 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -36,6 +36,7 @@ import { useHaptic } from 'use-haptic'
import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary'
import underMaintenanceConfig from '@/config/underMaintenance.config'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// Lazy load heavy modal components (~20-30KB each) to reduce initial bundle size
// Components are only loaded when user triggers them
@@ -104,7 +105,7 @@ export default function Home() {
e.stopPropagation()
setIsBalanceHidden((prev: boolean) => {
const newValue = !prev
- posthog.capture('balance_visibility_toggled', { is_hidden: newValue })
+ posthog.capture(ANALYTICS_EVENTS.BALANCE_VISIBILITY_TOGGLED, { is_hidden: newValue })
if (user) {
updateUserPreferences(user.user.userId, { balanceHidden: newValue })
}
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index c9a81e9bc..fd9c264a5 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -20,6 +20,7 @@ import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
type WithdrawStep = 'inputAmount' | 'selectMethod'
@@ -253,7 +254,7 @@ export default function WithdrawPage() {
setAmountToWithdraw(rawTokenAmount)
const usdVal = (selectedTokenData?.price ?? 1) * parseFloat(rawTokenAmount)
setUsdAmount(usdVal.toString())
- posthog.capture('withdraw_amount_entered', {
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, {
amount_usd: usdVal,
method_type: selectedMethod.type,
country: selectedMethod.countryPath,
diff --git a/src/components/Global/BackendErrorScreen/index.tsx b/src/components/Global/BackendErrorScreen/index.tsx
index b4a784770..5f6c3f24a 100644
--- a/src/components/Global/BackendErrorScreen/index.tsx
+++ b/src/components/Global/BackendErrorScreen/index.tsx
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
import { useAuth } from '@/context/authContext'
import { Button } from '@/components/0_Bruddle/Button'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// inline peanut icon svg to ensure it works without needing to fetch external assets
const PeanutIcon = ({ className }: { className?: string }) => (
@@ -71,16 +72,16 @@ export default function BackendErrorScreen() {
const { logoutUser, isLoggingOut } = useAuth()
useEffect(() => {
- posthog.capture('backend_error_shown')
+ posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_SHOWN)
}, [])
const handleRetry = () => {
- posthog.capture('backend_error_retry')
+ posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_RETRY)
window.location.reload()
}
const handleForceLogout = () => {
- posthog.capture('backend_error_logout')
+ posthog.capture(ANALYTICS_EVENTS.BACKEND_ERROR_LOGOUT)
// Use skipBackendCall since backend is likely down (that's why we're on this screen)
logoutUser({ skipBackendCall: true })
}
diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx
index c455c1a6f..d3a235ab9 100644
--- a/src/components/Global/DirectSendQR/index.tsx
+++ b/src/components/Global/DirectSendQR/index.tsx
@@ -11,6 +11,7 @@ import QRScanner from '@/components/Global/QRScanner'
import { useAuth } from '@/context/authContext'
import { hitUserMetric } from '@/utils/metrics.utils'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import * as Sentry from '@sentry/nextjs'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useMemo, useState, type ChangeEvent } from 'react'
@@ -262,7 +263,7 @@ export default function DirectSendQr({
return originalData
}
hitUserMetric(user!.user.userId, 'scan-qr', { qrType, data: getLogData() })
- posthog.capture('qr_scanned', { qr_type: qrType })
+ posthog.capture(ANALYTICS_EVENTS.QR_SCANNED, { qr_type: qrType })
setQrType(qrType as EQrType)
switch (qrType) {
case EQrType.PEANUT_URL:
diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx
index cb9e02526..397a6d61e 100644
--- a/src/components/Send/views/SendRouter.view.tsx
+++ b/src/components/Send/views/SendRouter.view.tsx
@@ -16,6 +16,7 @@ import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptio
import { useContacts } from '@/hooks/useContacts'
import { getInitialsFromName } from '@/utils/general.utils'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useCallback, useMemo } from 'react'
import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
import ContactsView from './Contacts.view'
@@ -95,7 +96,7 @@ export const SendRouterView = () => {
// handle click on payment method options
const handleMethodClick = (methodId: string) => {
- posthog.capture('send_method_selected', { method: methodId })
+ posthog.capture(ANALYTICS_EVENTS.SEND_METHOD_SELECTED, { method: methodId })
switch (methodId) {
case 'peanut-contacts':
// navigate to contacts/user selection page
diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx
index bc9d7a464..7c4e1ddd0 100644
--- a/src/components/Setup/Views/InstallPWA.tsx
+++ b/src/components/Setup/Views/InstallPWA.tsx
@@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { captureException } from '@sentry/nextjs'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { DeviceType } from '@/hooks/useGetDeviceType'
import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState'
@@ -76,7 +77,7 @@ const InstallPWA = ({
useEffect(() => {
const handleAppInstalled = () => {
- posthog.capture('pwa_install_completed', { device_type: deviceType })
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_COMPLETED, { device_type: deviceType })
setTimeout(() => {
setInstallComplete(true)
setIsInstallInProgress(false)
@@ -124,12 +125,12 @@ const InstallPWA = ({
if (!deferredPrompt?.prompt) return
setIsInstallInProgress(true)
setInstallCancelled(false)
- posthog.capture('pwa_install_clicked', { device_type: deviceType })
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_CLICKED, { device_type: deviceType })
try {
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'dismissed') {
- posthog.capture('pwa_install_dismissed', { device_type: deviceType })
+ posthog.capture(ANALYTICS_EVENTS.PWA_INSTALL_DISMISSED, { device_type: deviceType })
setInstallCancelled(true)
setIsInstallInProgress(false)
}
diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx
index 5139a9183..88ca04257 100644
--- a/src/components/Setup/Views/Landing.tsx
+++ b/src/components/Setup/Views/Landing.tsx
@@ -8,6 +8,7 @@ import Link from 'next/link'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LandingStep = () => {
const { handleNext } = useSetupFlow()
@@ -23,7 +24,7 @@ const LandingStep = () => {
: 'An unexpected error occurred during login.'
toast.error(errorMessage)
Sentry.captureException(error, { extra: { errorCode: error.code } })
- posthog.capture('signup_login_error', { error_code: error.code })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_LOGIN_ERROR, { error_code: error.code })
}
const onLoginClick = async () => {
@@ -41,7 +42,7 @@ const LandingStep = () => {
shadowSize="4"
className="h-11"
onClick={() => {
- posthog.capture('signup_signup_clicked')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_CLICKED)
handleNext()
}}
>
diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx
index 9ad2f8901..34fda8fe9 100644
--- a/src/components/Setup/Views/SetupPasskey.tsx
+++ b/src/components/Setup/Views/SetupPasskey.tsx
@@ -11,6 +11,7 @@ import { PasskeySetupHelpModal } from './PasskeySetupHelpModal'
import ErrorAlert from '@/components/Global/ErrorAlert'
import * as Sentry from '@sentry/nextjs'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const SetupPasskey = () => {
const { username } = useSetupStore()
@@ -39,7 +40,7 @@ const SetupPasskey = () => {
// clear any previous inline errors
setInlineError(null)
setErrorName(null)
- posthog.capture('signup_passkey_started', { device_type: deviceType })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_STARTED, { device_type: deviceType })
try {
await withWebAuthnRetry(() => handleRegister(username), 'passkey-registration')
@@ -47,7 +48,7 @@ const SetupPasskey = () => {
} catch (error) {
const err = error as Error
console.error('Passkey registration failed:', err)
- posthog.capture('signup_passkey_failed', {
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_FAILED, {
device_type: deviceType,
error_name: err.name,
})
@@ -109,7 +110,7 @@ const SetupPasskey = () => {
// once passkey is registered successfully, move to test transaction step
useEffect(() => {
if (address) {
- posthog.capture('signup_passkey_succeeded', { device_type: deviceType })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_PASSKEY_SUCCEEDED, { device_type: deviceType })
handleNext()
}
}, [address, handleNext, deviceType])
diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx
index 7befa0ea9..d624539bb 100644
--- a/src/components/Setup/Views/SignTestTransaction.tsx
+++ b/src/components/Setup/Views/SignTestTransaction.tsx
@@ -11,6 +11,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.co
import { capturePasskeyDebugInfo } from '@/utils/passkeyDebug'
import * as Sentry from '@sentry/nextjs'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { twMerge } from 'tailwind-merge'
const SignTestTransaction = () => {
@@ -81,7 +82,7 @@ const SignTestTransaction = () => {
setIsSigning(true)
setError(null)
dispatch(setupActions.setLoading(true))
- posthog.capture('signup_test_tx_started')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_STARTED)
try {
// if test transaction already completed, skip signing and go straight to account creation
@@ -108,7 +109,7 @@ const SignTestTransaction = () => {
console.log('[SignTestTransaction] Transaction signed successfully', {
userOpHash: result.userOpHash,
})
- posthog.capture('signup_test_tx_signed')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_SIGNED)
setTestTransactionCompleted(true)
} else {
console.log('[SignTestTransaction] Test transaction already completed, retrying account creation')
@@ -130,7 +131,7 @@ const SignTestTransaction = () => {
// account setup complete - addAccount() already fetched and verified user data
console.log('[SignTestTransaction] Account setup complete, redirecting to the app')
- posthog.capture('signup_completed')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_COMPLETED)
// keep loading state active until redirect completes
} else {
@@ -155,7 +156,7 @@ const SignTestTransaction = () => {
},
})
- posthog.capture('signup_test_tx_failed', { error_name: (e as Error).name })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_TEST_TX_FAILED, { error_name: (e as Error).name })
setError(
"We're having trouble setting up your account. Our team has been notified. Please contact support for help."
)
diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx
index 947912a4d..b4f897979 100644
--- a/src/components/Setup/Views/Signup.tsx
+++ b/src/components/Setup/Views/Signup.tsx
@@ -9,6 +9,7 @@ import { fetchWithSentry } from '@/utils/sentry.utils'
import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useState } from 'react'
import { twMerge } from 'tailwind-merge'
@@ -54,7 +55,7 @@ const SignupStep = () => {
switch (res.status) {
case 200:
setError('Username already taken')
- posthog.capture('signup_username_validated', { is_valid: false, error_type: 'taken' })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'taken' })
return false
case 400:
setError('Username is invalid, please use a different one')
@@ -62,7 +63,7 @@ const SignupStep = () => {
case 404:
// handle is available
setError('')
- posthog.capture('signup_username_validated', { is_valid: true })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true })
return true
default:
// we dont expect any other status code
diff --git a/src/components/Setup/Views/Welcome.tsx b/src/components/Setup/Views/Welcome.tsx
index b3095112f..da4bdc8a7 100644
--- a/src/components/Setup/Views/Welcome.tsx
+++ b/src/components/Setup/Views/Welcome.tsx
@@ -9,6 +9,7 @@ import { useZeroDev } from '@/hooks/useZeroDev'
import { getRedirectUrl, sanitizeRedirectURL, clearRedirectUrl } from '@/utils/general.utils'
import * as Sentry from '@sentry/nextjs'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
@@ -67,7 +68,7 @@ const WelcomeStep = () => {
shadowSize="4"
className="h-11"
onClick={() => {
- posthog.capture('signup_create_wallet_clicked')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_CREATE_WALLET_CLICKED)
handleNext()
}}
>
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
new file mode 100644
index 000000000..b2dfffd49
--- /dev/null
+++ b/src/constants/analytics.consts.ts
@@ -0,0 +1,55 @@
+/**
+ * Central registry of all PostHog analytics event names.
+ * Provides autocomplete, compile-time safety, and a single place to audit the full taxonomy.
+ */
+export const ANALYTICS_EVENTS = {
+ // ── Signup funnel ──
+ SIGNUP_CLICKED: 'signup_signup_clicked',
+ SIGNUP_LOGIN_ERROR: 'signup_login_error',
+ SIGNUP_CREATE_WALLET_CLICKED: 'signup_create_wallet_clicked',
+ SIGNUP_USERNAME_VALIDATED: 'signup_username_validated',
+ SIGNUP_PASSKEY_STARTED: 'signup_passkey_started',
+ SIGNUP_PASSKEY_SUCCEEDED: 'signup_passkey_succeeded',
+ SIGNUP_PASSKEY_FAILED: 'signup_passkey_failed',
+ SIGNUP_TEST_TX_STARTED: 'signup_test_tx_started',
+ SIGNUP_TEST_TX_SIGNED: 'signup_test_tx_signed',
+ SIGNUP_TEST_TX_FAILED: 'signup_test_tx_failed',
+ SIGNUP_COMPLETED: 'signup_completed',
+
+ // ── PWA install ──
+ PWA_INSTALL_CLICKED: 'pwa_install_clicked',
+ PWA_INSTALL_DISMISSED: 'pwa_install_dismissed',
+ PWA_INSTALL_COMPLETED: 'pwa_install_completed',
+
+ // ── KYC (Bridge) ──
+ KYC_INITIATED: 'kyc_initiated',
+ KYC_TOS_ACCEPTED: 'kyc_tos_accepted',
+ KYC_SUBMITTED: 'kyc_submitted',
+ KYC_APPROVED: 'kyc_approved',
+ KYC_REJECTED: 'kyc_rejected',
+ KYC_ABANDONED: 'kyc_abandoned',
+
+ // ── KYC (Manteca) ──
+ MANTECA_KYC_INITIATED: 'manteca_kyc_initiated',
+ MANTECA_KYC_COMPLETED: 'manteca_kyc_completed',
+ MANTECA_KYC_ABANDONED: 'manteca_kyc_abandoned',
+
+ // ── Send ──
+ SEND_METHOD_SELECTED: 'send_method_selected',
+
+ // ── Withdraw ──
+ WITHDRAW_AMOUNT_ENTERED: 'withdraw_amount_entered',
+
+ // ── QR ──
+ QR_SCANNED: 'qr_scanned',
+
+ // ── Home ──
+ BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled',
+
+ // ── Error / Churn ──
+ BACKEND_ERROR_SHOWN: 'backend_error_shown',
+ BACKEND_ERROR_RETRY: 'backend_error_retry',
+ BACKEND_ERROR_LOGOUT: 'backend_error_logout',
+} as const
+
+export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]
diff --git a/src/hooks/useBridgeKycFlow.ts b/src/hooks/useBridgeKycFlow.ts
index 53af4f49e..6e2203cf2 100644
--- a/src/hooks/useBridgeKycFlow.ts
+++ b/src/hooks/useBridgeKycFlow.ts
@@ -8,6 +8,7 @@ import { type InitiateKycResponse } from '@/app/actions/types/users.types'
import { getKycDetails, updateUserById } from '@/app/actions/users'
import { type IUserKycVerification } from '@/interfaces'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
interface UseKycFlowOptions {
onKycSuccess?: () => void
@@ -68,11 +69,11 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl
const prevStatus = prevStatusRef.current
prevStatusRef.current = liveKycStatus
if (prevStatus !== 'approved' && liveKycStatus === 'approved') {
- posthog.capture('kyc_approved', { provider: 'bridge' })
+ posthog.capture(ANALYTICS_EVENTS.KYC_APPROVED, { provider: 'bridge' })
setIsVerificationProgressModalOpen(false)
onKycSuccess?.()
} else if (prevStatus !== 'rejected' && liveKycStatus === 'rejected') {
- posthog.capture('kyc_rejected', { provider: 'bridge' })
+ posthog.capture(ANALYTICS_EVENTS.KYC_REJECTED, { provider: 'bridge' })
setIsVerificationProgressModalOpen(false)
}
prevStatusRef.current = liveKycStatus
@@ -81,7 +82,7 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl
const handleInitiateKyc = async () => {
setIsLoading(true)
setError(null)
- posthog.capture('kyc_initiated', { provider: 'bridge', flow })
+ posthog.capture(ANALYTICS_EVENTS.KYC_INITIATED, { provider: 'bridge', flow })
try {
const response = await getKycDetails()
@@ -125,7 +126,7 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl
// handle tos acceptance: only act if the tos iframe is currently shown.
if (source === 'tos_accepted') {
- posthog.capture('kyc_tos_accepted', { provider: 'bridge' })
+ posthog.capture(ANALYTICS_EVENTS.KYC_TOS_ACCEPTED, { provider: 'bridge' })
if (wasShowingTos && apiResponse?.kycLink) {
const kycUrl = convertPersonaUrl(apiResponse.kycLink)
setIframeOptions({
@@ -140,7 +141,7 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl
// When KYC signals completion, close iframe and show progress modal
if (source === 'completed') {
- posthog.capture('kyc_submitted', { provider: 'bridge' })
+ posthog.capture(ANALYTICS_EVENTS.KYC_SUBMITTED, { provider: 'bridge' })
setIframeOptions((prev) => ({ ...prev, visible: false }))
setIsVerificationProgressModalOpen(true)
// set the status to under review explicitly to avoild delays from bridge webhook
@@ -153,7 +154,7 @@ export const useBridgeKycFlow = ({ onKycSuccess, flow, onManualClose }: UseKycFl
// manual abort: close modal; optionally redirect in add flow
if (source === 'manual') {
- posthog.capture('kyc_abandoned', { provider: 'bridge', flow })
+ posthog.capture(ANALYTICS_EVENTS.KYC_ABANDONED, { provider: 'bridge', flow })
setIframeOptions((prev) => ({ ...prev, visible: false }))
if (flow === 'add') {
router.push('/add-money')
diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts
index 229e8a2c4..8fa4e9910 100644
--- a/src/hooks/useMantecaKycFlow.ts
+++ b/src/hooks/useMantecaKycFlow.ts
@@ -7,6 +7,7 @@ import { MantecaKycStatus } from '@/interfaces'
import { useWebSocket } from './useWebSocket'
import { BASE_URL } from '@/constants/general.consts'
import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
type UseMantecaKycFlowOptions = {
onClose?: () => void
@@ -37,7 +38,7 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
return
}
if (source === 'manual') {
- posthog.capture('manteca_kyc_abandoned', { country: country?.id })
+ posthog.capture(ANALYTICS_EVENTS.MANTECA_KYC_ABANDONED, { country: country?.id })
onManualClose?.()
return
}
@@ -51,7 +52,7 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
autoConnect: true,
onMantecaKycStatusUpdate: async (status) => {
if (status === MantecaKycStatus.ACTIVE || status === 'WIDGET_FINISHED') {
- posthog.capture('manteca_kyc_completed', { country: country?.id })
+ posthog.capture(ANALYTICS_EVENTS.MANTECA_KYC_COMPLETED, { country: country?.id })
await handleIframeClose('completed')
}
},
@@ -82,7 +83,7 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
const openMantecaKyc = useCallback(async (countryParam?: CountryData) => {
setIsLoading(true)
setError(null)
- posthog.capture('manteca_kyc_initiated', { country: countryParam?.id })
+ posthog.capture(ANALYTICS_EVENTS.MANTECA_KYC_INITIATED, { country: countryParam?.id })
try {
const exchange = countryParam?.id
? MantecaSupportedExchanges[countryParam.id as keyof typeof MantecaSupportedExchanges]
From f11c810e72bb675996c3b5746f838f3dfa881962 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 15:32:00 +0000
Subject: [PATCH 30/77] fix: update pnpm lockfile and capture invalid username
validation
- Regenerate pnpm-lock.yaml for posthog-js (fixes CI frozen-lockfile)
- Add missing capture for 400/invalid username per CodeRabbit review
---
pnpm-lock.yaml | 291 +++++++++++++++++++++++++-
src/components/Setup/Views/Signup.tsx | 1 +
2 files changed, 289 insertions(+), 3 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 192bc6dff..671ed5bda 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,7 +69,7 @@ importers:
version: 9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
'@sentry/nextjs':
specifier: ^8.39.0
- version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)
+ version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)
'@serwist/next':
specifier: ^9.0.10
version: 9.5.0(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(webpack@5.104.1)
@@ -148,6 +148,9 @@ importers:
pix-utils:
specifier: ^2.8.2
version: 2.8.2
+ posthog-js:
+ specifier: ^1.360.0
+ version: 1.360.0
pulltorefreshjs:
specifier: ^0.1.22
version: 0.1.22
@@ -1640,6 +1643,10 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@opentelemetry/api-logs@0.208.0':
+ resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
+ engines: {node: '>=8.0.0'}
+
'@opentelemetry/api-logs@0.53.0':
resolution: {integrity: sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==}
engines: {node: '>=14'}
@@ -1668,6 +1675,24 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/core@2.2.0':
+ resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/core@2.6.0':
+ resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0':
+ resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
'@opentelemetry/instrumentation-amqplib@0.46.1':
resolution: {integrity: sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==}
engines: {node: '>=14'}
@@ -1830,6 +1855,18 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
+ '@opentelemetry/otlp-exporter-base@0.208.0':
+ resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-transformer@0.208.0':
+ resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
'@opentelemetry/redis-common@0.36.2':
resolution: {integrity: sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==}
engines: {node: '>=14'}
@@ -1840,12 +1877,42 @@ packages:
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/resources@2.2.0':
+ resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/resources@2.6.0':
+ resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-logs@0.208.0':
+ resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+
+ '@opentelemetry/sdk-metrics@2.2.0':
+ resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+
'@opentelemetry/sdk-trace-base@1.30.1':
resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==}
engines: {node: '>=14'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
+ '@opentelemetry/sdk-trace-base@2.2.0':
+ resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
'@opentelemetry/semantic-conventions@1.27.0':
resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==}
engines: {node: '>=14'}
@@ -1983,9 +2050,45 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@posthog/core@1.23.2':
+ resolution: {integrity: sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==}
+
+ '@posthog/types@1.360.0':
+ resolution: {integrity: sha512-roypbiJ49V3jWlV/lzhXGf0cKLLRj69L4H4ZHW6YsITHlnjQ12cgdPhPS88Bb9nW9xZTVSGWWDjfNGsdgAxsNg==}
+
'@prisma/instrumentation@5.22.0':
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.4':
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ '@protobufjs/eventemitter@1.1.0':
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ '@protobufjs/fetch@1.1.0':
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/inquire@1.1.0':
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.0':
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
'@puppeteer/browsers@2.10.10':
resolution: {integrity: sha512-3ZG500+ZeLql8rE0hjfhkycJjDj0pI/btEh3L9IkWUYcOrgP0xCNRq3HbtbqOPbvDhFaAWD88pDFtlLv8ns8gA==}
engines: {node: '>=18'}
@@ -3725,6 +3828,9 @@ packages:
cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
+ core-js@3.48.0:
+ resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -3993,6 +4099,10 @@ packages:
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
+ dompurify@3.3.2:
+ resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
+ engines: {node: '>=20'}
+
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -4292,6 +4402,9 @@ packages:
picomatch:
optional: true
+ fflate@0.4.8:
+ resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -5047,6 +5160,9 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -5741,6 +5857,9 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
+ posthog-js@1.360.0:
+ resolution: {integrity: sha512-jkyO+T97yi6RuiexOaXC7AnEGiC+yIfGU5DIUzI5rqBH6MltmtJw/ve2Oxc4jeua2WDr5sXMzo+SS+acbpueAA==}
+
preact@10.24.2:
resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==}
@@ -5836,6 +5955,10 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ protobufjs@7.5.4:
+ resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
+ engines: {node: '>=12.0.0'}
+
proxy-agent@6.5.0:
resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
engines: {node: '>= 14'}
@@ -5886,6 +6009,9 @@ packages:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
+ query-selector-shadow-dom@1.0.1:
+ resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
+
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -6915,6 +7041,9 @@ packages:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
+ web-vitals@5.1.0:
+ resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
+
webdriver-bidi-protocol@0.2.11:
resolution: {integrity: sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==}
@@ -8861,6 +8990,10 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
+ '@opentelemetry/api-logs@0.208.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+
'@opentelemetry/api-logs@0.53.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -8884,6 +9017,25 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+
'@opentelemetry/instrumentation-amqplib@0.46.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9128,6 +9280,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ protobufjs: 7.5.4
+
'@opentelemetry/redis-common@0.36.2': {}
'@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)':
@@ -9136,6 +9305,31 @@ snapshots:
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
+ '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
+ '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+
'@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9143,6 +9337,13 @@ snapshots:
'@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
+ '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+
'@opentelemetry/semantic-conventions@1.27.0': {}
'@opentelemetry/semantic-conventions@1.28.0': {}
@@ -9229,6 +9430,12 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@posthog/core@1.23.2':
+ dependencies:
+ cross-spawn: 7.0.6
+
+ '@posthog/types@1.360.0': {}
+
'@prisma/instrumentation@5.22.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9237,6 +9444,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.4': {}
+
+ '@protobufjs/eventemitter@1.1.0': {}
+
+ '@protobufjs/fetch@1.1.0':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/inquire@1.1.0': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.0': {}
+
'@puppeteer/browsers@2.10.10':
dependencies:
debug: 4.4.3
@@ -10310,7 +10540,7 @@ snapshots:
'@sentry/core@8.55.0': {}
- '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)':
+ '@sentry/nextjs@8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(next@16.0.10(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
@@ -10318,7 +10548,7 @@ snapshots:
'@sentry-internal/browser-utils': 8.55.0
'@sentry/core': 8.55.0
'@sentry/node': 8.55.0
- '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
+ '@sentry/opentelemetry': 8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)
'@sentry/react': 8.55.0(react@19.2.4)
'@sentry/vercel-edge': 8.55.0
'@sentry/webpack-plugin': 2.22.7(webpack@5.104.1)
@@ -10387,6 +10617,16 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.39.0
'@sentry/core': 8.55.0
+ '@sentry/opentelemetry@8.55.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0)
+ '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/semantic-conventions': 1.39.0
+ '@sentry/core': 8.55.0
+
'@sentry/react@8.55.0(react@19.2.4)':
dependencies:
'@sentry/browser': 8.55.0
@@ -12173,6 +12413,8 @@ snapshots:
cookie-es@1.2.2: {}
+ core-js@3.48.0: {}
+
core-util-is@1.0.3: {}
cosmiconfig@7.1.0:
@@ -12423,6 +12665,10 @@ snapshots:
dependencies:
webidl-conversions: 7.0.0
+ dompurify@3.3.2:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -12838,6 +13084,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.4.8: {}
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -13871,6 +14119,8 @@ snapshots:
lodash@4.17.23: {}
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -14817,6 +15067,22 @@ snapshots:
dependencies:
xtend: 4.0.2
+ posthog-js@1.360.0:
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+ '@opentelemetry/api-logs': 0.208.0
+ '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
+ '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
+ '@posthog/core': 1.23.2
+ '@posthog/types': 1.360.0
+ core-js: 3.48.0
+ dompurify: 3.3.2
+ fflate: 0.4.8
+ preact: 10.28.2
+ query-selector-shadow-dom: 1.0.1
+ web-vitals: 5.1.0
+
preact@10.24.2: {}
preact@10.28.2: {}
@@ -14860,6 +15126,21 @@ snapshots:
property-information@7.1.0: {}
+ protobufjs@7.5.4:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 20.4.2
+ long: 5.3.2
+
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
@@ -14932,6 +15213,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
+ query-selector-shadow-dom@1.0.1: {}
+
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
@@ -16080,6 +16363,8 @@ snapshots:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
+ web-vitals@5.1.0: {}
+
webdriver-bidi-protocol@0.2.11: {}
webextension-polyfill@0.10.0: {}
diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx
index b4f897979..a2a5d5abe 100644
--- a/src/components/Setup/Views/Signup.tsx
+++ b/src/components/Setup/Views/Signup.tsx
@@ -59,6 +59,7 @@ const SignupStep = () => {
return false
case 400:
setError('Username is invalid, please use a different one')
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'invalid' })
return false
case 404:
// handle is available
From 2b632bc6c00e8ae8f2db63050b0789943ea03e9c Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 15:37:47 +0000
Subject: [PATCH 31/77] style: fix prettier formatting in Signup.tsx
---
src/components/Setup/Views/Signup.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx
index a2a5d5abe..dfd185217 100644
--- a/src/components/Setup/Views/Signup.tsx
+++ b/src/components/Setup/Views/Signup.tsx
@@ -55,11 +55,17 @@ const SignupStep = () => {
switch (res.status) {
case 200:
setError('Username already taken')
- posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'taken' })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, {
+ is_valid: false,
+ error_type: 'taken',
+ })
return false
case 400:
setError('Username is invalid, please use a different one')
- posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'invalid' })
+ posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, {
+ is_valid: false,
+ error_type: 'invalid',
+ })
return false
case 404:
// handle is available
From 6aa8d910e8fa867f9fead86c5a11d404260030bd Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:12:08 +0000
Subject: [PATCH 32/77] =?UTF-8?q?fix:=20address=20review=20feedback=20?=
=?UTF-8?q?=E2=80=94=20fetchUser,=20email=20util,=20border,=20back=20nav?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Call fetchUser() after email save to keep user context in sync
- Move isValidEmail to format.utils.ts for reuse (Kushagra review)
- Fix border-n-2 → border-n-1 to match design system convention
- Add back navigation from notifications step to email step
---
src/components/Invites/JoinWaitlistPage.tsx | 12 ++++++++----
src/utils/format.utils.ts | 2 ++
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index 33c444616..f3beea6b2 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -17,8 +17,7 @@ import { useSetupStore } from '@/redux/hooks'
import { useNotifications } from '@/hooks/useNotifications'
import { updateUserById } from '@/app/actions/users'
import { useQueryState, parseAsStringEnum } from 'nuqs'
-
-const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
+import { isValidEmail } from '@/utils/format.utils'
type WaitlistStep = 'email' | 'notifications' | 'jail'
@@ -80,6 +79,7 @@ const JoinWaitlistPage = () => {
if (result.error) {
setEmailError(result.error)
} else {
+ await fetchUser()
setStep(isPermissionGranted ? 'jail' : 'notifications')
}
} catch {
@@ -179,7 +179,7 @@ const JoinWaitlistPage = () => {
onKeyDown={(e) => {
if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit()
}}
- className="h-12 w-full rounded-sm border border-n-2 px-4 text-base outline-none focus:border-black"
+ className="h-12 w-full rounded-sm border border-n-1 px-4 text-base outline-none focus:border-black"
/>
{emailError && }
@@ -208,6 +208,10 @@ const JoinWaitlistPage = () => {
setStep('jail')} className="text-sm underline">
Not now
+
+ setStep('email')} className="text-sm underline">
+ Back
+
)}
@@ -215,7 +219,7 @@ const JoinWaitlistPage = () => {
{step === 'jail' && (
{!isPermissionGranted && (
-
+
Enable notifications to get updates when you're unlocked
diff --git a/src/utils/format.utils.ts b/src/utils/format.utils.ts
index c0f0b2742..968ae9fea 100644
--- a/src/utils/format.utils.ts
+++ b/src/utils/format.utils.ts
@@ -108,3 +108,5 @@ export const formatCurrencyWithIntl = (
return numericValue.toFixed(minDigits)
}
}
+
+export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
From 4baa1de39837a7f19a874d68aa06839cbc4de57e Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:14:45 +0000
Subject: [PATCH 33/77] fix: exclude content submodule from prettier checks
The content submodule has its own formatting rules. Running prettier
on it causes CI failures unrelated to our code.
---
.prettierignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.prettierignore b/.prettierignore
index 40f0fdec7..6a2d8d3cc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,4 @@ node_modules/
pnpm-lock.yaml
**.md
src/assets/
+src/content/
From a520be69654d0248b6b8fb7944bb1165092f9182 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:19:06 +0000
Subject: [PATCH 34/77] fix: exclude content submodule from prettier checks
peanut-content is a separate repo with its own formatting conventions.
peanut-ui's prettier config (4-space YAML) shouldn't be enforced on it.
---
.prettierignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.prettierignore b/.prettierignore
index 40f0fdec7..6a2d8d3cc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,4 @@ node_modules/
pnpm-lock.yaml
**.md
src/assets/
+src/content/
From c05dec859089a45fe5cb286031a023c4425c5066 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:19:30 +0000
Subject: [PATCH 35/77] feat: add deposit rail routes (via-sepa, via-ach, etc.)
and fix locale routing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Route 10 existing deposit rail MDX pages (SEPA, ACH, wire, 7 crypto
networks) at /en/deposit/via-{rail}, alongside existing from-{exchange}
- Add rails to sitemap
- Remove React fallback from deposit page — MDX-only with notFound()
- Reserve all SUPPORTED_LOCALES in DEDICATED_ROUTES so locale codes
aren't treated as usernames by catch-all
- Add isLocaleSegment() for subtag patterns (pt-br, zh-Hans) with
regex that avoids false-positives on short usernames
- Refactor catch-all to use isReservedRoute() instead of duplicating logic
- Extract resolveDeposit() helper to DRY metadata + render validation
---
src/app/[...recipient]/page.tsx | 10 +--
.../(marketing)/deposit/[exchange]/page.tsx | 90 ++++++++++++-------
src/app/sitemap.ts | 11 ++-
src/constants/routes.ts | 28 +++++-
src/data/seo/exchanges.ts | 20 +++++
src/data/seo/index.ts | 2 +-
6 files changed, 118 insertions(+), 43 deletions(-)
diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx
index e7ce90791..03b7e2b79 100644
--- a/src/app/[...recipient]/page.tsx
+++ b/src/app/[...recipient]/page.tsx
@@ -8,7 +8,7 @@ import { printableAddress, isStableCoin } from '@/utils/general.utils'
import { chargesApi } from '@/services/charges'
import { parseAmountAndToken } from '@/lib/url-parser/parser'
import { notFound } from 'next/navigation'
-import { RESERVED_ROUTES } from '@/constants/routes'
+import { isReservedRoute } from '@/constants/routes'
type PageProps = {
params: Promise<{ recipient?: string[] }>
@@ -19,8 +19,8 @@ export async function generateMetadata({ params, searchParams }: any) {
const resolvedParams = await params
// Guard: Don't generate metadata for reserved routes (handled by their specific routes)
- const firstSegment = resolvedParams.recipient?.[0]?.toLowerCase()
- if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) {
+ const firstSegment = resolvedParams.recipient?.[0]
+ if (firstSegment && isReservedRoute(`/${firstSegment}`)) {
return {}
}
@@ -191,8 +191,8 @@ export default function Page(props: PageProps) {
// Guard: Reserved routes should be handled by their specific route files
// If we reach here, it means Next.js routing didn't catch it properly
- const firstSegment = recipient[0]?.toLowerCase()
- if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) {
+ const firstSegment = recipient[0]
+ if (firstSegment && isReservedRoute(`/${firstSegment}`)) {
notFound()
}
diff --git a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
index 8db8e6259..828c7181d 100644
--- a/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
+++ b/src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx
@@ -1,9 +1,10 @@
import { notFound } from 'next/navigation'
import { type Metadata } from 'next'
import { generateMetadata as metadataHelper } from '@/app/metadata'
-import { EXCHANGES } from '@/data/seo'
+import { EXCHANGES, DEPOSIT_RAILS } from '@/data/seo'
import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
-import { getTranslations } from '@/i18n'
+import type { Locale } from '@/i18n/types'
+import { getTranslations, t } from '@/i18n'
import { ContentPage } from '@/components/Marketing/ContentPage'
import { readPageContentLocalized, type ContentFrontmatter } from '@/lib/content'
import { renderContent } from '@/lib/mdx'
@@ -13,66 +14,93 @@ interface PageProps {
}
export async function generateStaticParams() {
- const exchanges = Object.keys(EXCHANGES)
- return SUPPORTED_LOCALES.flatMap((locale) =>
- exchanges.map((exchange) => ({ locale, exchange: `from-${exchange}` }))
- )
+ const exchangeParams = Object.keys(EXCHANGES).map((e) => `from-${e}`)
+ const railParams = Object.keys(DEPOSIT_RAILS).map((r) => `via-${r}`)
+ const allSlugs = [...exchangeParams, ...railParams]
+ return SUPPORTED_LOCALES.flatMap((locale) => allSlugs.map((exchange) => ({ locale, exchange })))
}
export const dynamicParams = false
-/** Strip the "from-" URL prefix to get the data key. Returns null if prefix missing. */
-function parseExchange(raw: string): string | null {
- if (!raw.startsWith('from-')) return null
- return raw.slice('from-'.length)
+/** Parse URL slug into { type, key }. Supports "from-binance" (exchange) and "via-sepa" (rail). */
+function parseDepositSlug(raw: string): { type: 'exchange' | 'rail'; key: string } | null {
+ if (raw.startsWith('from-')) return { type: 'exchange', key: raw.slice(5) }
+ if (raw.startsWith('via-')) return { type: 'rail', key: raw.slice(4) }
+ return null
+}
+
+/** Validate slug and return parsed info + display name, or null if invalid. */
+function resolveDeposit(rawSlug: string): { type: 'exchange' | 'rail'; key: string; displayName: string } | null {
+ const parsed = parseDepositSlug(rawSlug)
+ if (!parsed) return null
+ const { type, key } = parsed
+ if (type === 'exchange') {
+ const ex = EXCHANGES[key]
+ return ex ? { type, key, displayName: ex.name } : null
+ }
+ const name = DEPOSIT_RAILS[key]
+ return name ? { type, key, displayName: name } : null
}
export async function generateMetadata({ params }: PageProps): Promise {
- const { locale, exchange: rawExchange } = await params
+ const { locale, exchange: rawSlug } = await params
if (!isValidLocale(locale)) return {}
- const exchange = parseExchange(rawExchange)
- if (!exchange) return {}
- const ex = EXCHANGES[exchange]
- if (!ex) return {}
+ const deposit = resolveDeposit(rawSlug)
+ if (!deposit) return {}
+
+ const mdxContent = readPageContentLocalized('deposit', deposit.key, locale)
+ if (mdxContent && mdxContent.frontmatter.published !== false) {
+ return {
+ ...metadataHelper({
+ title: mdxContent.frontmatter.title,
+ description: mdxContent.frontmatter.description,
+ canonical: `/${locale}/deposit/${rawSlug}`,
+ dynamicOg: true,
+ }),
+ alternates: {
+ canonical: `/${locale}/deposit/${rawSlug}`,
+ languages: getAlternates('deposit', rawSlug),
+ },
+ }
+ }
- const mdxContent = readPageContentLocalized('deposit', exchange, locale)
- if (!mdxContent || mdxContent.frontmatter.published === false) return {}
+ // Fallback: i18n-based metadata (exchanges only — rails must have MDX)
+ if (deposit.type === 'rail') return {}
+ const ex = EXCHANGES[deposit.key]!
+ const i18n = getTranslations(locale as Locale)
return {
...metadataHelper({
- title: mdxContent.frontmatter.title,
- description: mdxContent.frontmatter.description,
- canonical: `/${locale}/deposit/from-${exchange}`,
- dynamicOg: true,
+ title: `${t(i18n.depositFrom, { exchange: ex.name })} | Peanut`,
+ description: `${t(i18n.depositFrom, { exchange: ex.name })}. ${i18n.recommendedNetwork}: ${ex.recommendedNetwork}.`,
+ canonical: `/${locale}/deposit/from-${deposit.key}`,
}),
alternates: {
- canonical: `/${locale}/deposit/from-${exchange}`,
- languages: getAlternates('deposit', `from-${exchange}`),
+ canonical: `/${locale}/deposit/from-${deposit.key}`,
+ languages: getAlternates('deposit', `from-${deposit.key}`),
},
}
}
export default async function DepositPageLocalized({ params }: PageProps) {
- const { locale, exchange: rawExchange } = await params
+ const { locale, exchange: rawSlug } = await params
if (!isValidLocale(locale)) notFound()
- const exchange = parseExchange(rawExchange)
- if (!exchange) notFound()
- const ex = EXCHANGES[exchange]
- if (!ex) notFound()
+ const deposit = resolveDeposit(rawSlug)
+ if (!deposit) notFound()
- const mdxSource = readPageContentLocalized('deposit', exchange, locale)
+ const mdxSource = readPageContentLocalized('deposit', deposit.key, locale)
if (!mdxSource || mdxSource.frontmatter.published === false) notFound()
const { content } = await renderContent(mdxSource.body)
const i18n = getTranslations(locale)
- const url = `/${locale}/deposit/from-${exchange}`
+ const url = `/${locale}/deposit/${rawSlug}`
return (
{
})
}
- // Deposit pages
+ // Deposit pages (exchanges + rails)
for (const exchange of Object.keys(EXCHANGES)) {
pages.push({
path: `/${locale}/deposit/from-${exchange}`,
@@ -89,6 +89,13 @@ async function generateSitemap(): Promise {
changeFrequency: 'monthly',
})
}
+ for (const rail of Object.keys(DEPOSIT_RAILS)) {
+ pages.push({
+ path: `/${locale}/deposit/via-${rail}`,
+ priority: 0.7 * basePriority,
+ changeFrequency: 'monthly',
+ })
+ }
// Pay-with pages
for (const method of PAYMENT_METHOD_SLUGS) {
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 64bff38aa..1e4ac2e27 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -56,10 +56,12 @@ export const DEDICATED_ROUTES = [
'faq',
'how-it-works',
- // Locale prefixes
+ // Locale prefixes (current SUPPORTED_LOCALES)
'en',
- 'es',
- 'pt',
+ 'es-419',
+ 'es-ar',
+ 'es-es',
+ 'pt-br',
] as const
/**
@@ -142,12 +144,30 @@ export const MIDDLEWARE_ROUTES: readonly string[] = [
'/qr/:path*',
]
+/**
+ * Matches locale tags with a required subtag to avoid false-positives on short
+ * strings like "go", "no", "max" that are valid usernames. Covers patterns like
+ * "pt-br", "es-419", "zh-Hans", "zh-Hans-CN" but NOT bare 2-letter codes (those
+ * must be listed explicitly in DEDICATED_ROUTES).
+ */
+const LOCALE_WITH_SUBTAG = /^[a-z]{2,3}-[a-z0-9]{2,8}(-[a-z0-9]{2,8})*$/i
+
+/**
+ * Helper to check if a path segment looks like a locale code.
+ * Bare 2-3 letter codes (en, es, pt) are caught by DEDICATED_ROUTES.
+ * This handles subtag variants (pt-br, es-419, zh-Hans) that aren't listed explicitly.
+ */
+export function isLocaleSegment(segment: string): boolean {
+ return LOCALE_WITH_SUBTAG.test(segment)
+}
+
/**
* Helper to check if a path is reserved (should not be handled by catch-all)
*/
export function isReservedRoute(path: string): boolean {
const firstSegment = path.split('/')[1]?.toLowerCase()
- return RESERVED_ROUTES.includes(firstSegment as any)
+ if (!firstSegment) return false
+ return RESERVED_ROUTES.includes(firstSegment as any) || isLocaleSegment(firstSegment)
}
/**
diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts
index 9486629d6..81a1e7f31 100644
--- a/src/data/seo/exchanges.ts
+++ b/src/data/seo/exchanges.ts
@@ -106,3 +106,23 @@ function estimateProcessingTime(network: string): string {
}
export const EXCHANGES: Record = loadExchanges()
+
+/**
+ * Deposit rails — payment methods and crypto networks with published MDX content
+ * in content/deposit/{slug}/. These don't have entity data like exchanges do;
+ * they're pure content pages served at /en/deposit/via-{slug}.
+ */
+export const DEPOSIT_RAILS: Record = {
+ // Fiat rails
+ ach: 'ACH Bank Transfer',
+ sepa: 'SEPA Bank Transfer',
+ wire: 'Wire Transfer',
+ // Crypto networks
+ arbitrum: 'Arbitrum',
+ avalanche: 'Avalanche',
+ base: 'Base',
+ ethereum: 'Ethereum',
+ polygon: 'Polygon',
+ solana: 'Solana',
+ tron: 'Tron',
+}
diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts
index e25b4fbe9..ae12a3e5b 100644
--- a/src/data/seo/index.ts
+++ b/src/data/seo/index.ts
@@ -4,7 +4,7 @@ export type { CountrySEO, Corridor } from './corridors'
export { COMPETITORS } from './comparisons'
export type { Competitor } from './comparisons'
-export { EXCHANGES } from './exchanges'
+export { EXCHANGES, DEPOSIT_RAILS } from './exchanges'
export type { Exchange } from './exchanges'
export { PAYMENT_METHODS, PAYMENT_METHOD_SLUGS } from './payment-methods'
From 5db52ffad11461b4bda365a27e7ac13e16990018 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:22:40 +0000
Subject: [PATCH 36/77] =?UTF-8?q?refactor:=20simplify=20waitlist=20page=20?=
=?UTF-8?q?=E2=80=94=20reuse=20BaseInput,=20dedupe=20email=20regex,=20opti?=
=?UTF-8?q?mize=20query?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Use BaseInput component instead of raw for design system consistency
- Extract nextStepAfterEmail helper to deduplicate step advancement logic
- Reuse isValidEmail from format.utils in withdraw.utils (remove duplicate regex)
- Gate waitlist-position query on step === 'jail' to avoid unnecessary API calls
- Move loading spinner inside step 3 so steps 1-2 render immediately
- Simplify onClick handler (remove unnecessary arrow wrapper)
---
src/components/Invites/JoinWaitlistPage.tsx | 29 ++++++++++-----------
src/utils/withdraw.utils.ts | 4 +--
2 files changed, 16 insertions(+), 17 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index f3beea6b2..db63af7ae 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -18,9 +18,13 @@ import { useNotifications } from '@/hooks/useNotifications'
import { updateUserById } from '@/app/actions/users'
import { useQueryState, parseAsStringEnum } from 'nuqs'
import { isValidEmail } from '@/utils/format.utils'
+import { BaseInput } from '@/components/0_Bruddle/BaseInput'
type WaitlistStep = 'email' | 'notifications' | 'jail'
+const nextStepAfterEmail = (isPermissionGranted: boolean): WaitlistStep =>
+ isPermissionGranted ? 'jail' : 'notifications'
+
const JoinWaitlistPage = () => {
const { fetchUser, isFetchingUser, logoutUser, user } = useAuth()
const router = useRouter()
@@ -31,9 +35,8 @@ const JoinWaitlistPage = () => {
const [step, setStep] = useQueryState(
'step',
parseAsStringEnum(['email', 'notifications', 'jail']).withDefault(
- // Determine initial step: skip completed steps
(() => {
- if (user?.user.email) return isPermissionGranted ? 'jail' : 'notifications'
+ if (user?.user.email) return nextStepAfterEmail(isPermissionGranted)
return 'email'
})()
)
@@ -55,13 +58,13 @@ const JoinWaitlistPage = () => {
const { data, isLoading: isLoadingWaitlistPosition } = useQuery({
queryKey: ['waitlist-position'],
queryFn: () => invitesApi.getWaitlistQueuePosition(),
- enabled: !!user?.user.userId,
+ enabled: !!user?.user.userId && step === 'jail',
})
// Redirect completed steps when user/permission state changes
useEffect(() => {
if (step === 'email' && user?.user.email) {
- setStep(isPermissionGranted ? 'jail' : 'notifications')
+ setStep(nextStepAfterEmail(isPermissionGranted))
} else if (step === 'notifications' && isPermissionGranted) {
setStep('jail')
}
@@ -80,7 +83,7 @@ const JoinWaitlistPage = () => {
setEmailError(result.error)
} else {
await fetchUser()
- setStep(isPermissionGranted ? 'jail' : 'notifications')
+ setStep(nextStepAfterEmail(isPermissionGranted))
}
} catch {
setEmailError('Something went wrong. Please try again.')
@@ -144,10 +147,6 @@ const JoinWaitlistPage = () => {
}
}, [isFetchingUser, user, router])
- if (isLoadingWaitlistPosition) {
- return
- }
-
const stepImage = step === 'jail' ? peanutAnim.src : chillPeanutAnim.src
return (
@@ -167,8 +166,9 @@ const JoinWaitlistPage = () => {
Enter your email so we can reach you when you get access.
- {
onKeyDown={(e) => {
if (e.key === 'Enter' && isValidEmail(emailValue)) handleEmailSubmit()
}}
- className="h-12 w-full rounded-sm border border-n-1 px-4 text-base outline-none focus:border-black"
+ className="h-12"
/>
{emailError && }
@@ -216,7 +216,8 @@ const JoinWaitlistPage = () => {
)}
{/* Step 3: Jail Screen */}
- {step === 'jail' && (
+ {step === 'jail' && isLoadingWaitlistPosition && }
+ {step === 'jail' && !isLoadingWaitlistPosition && (
{!isPermissionGranted && (
@@ -267,9 +268,7 @@ const JoinWaitlistPage = () => {
className="h-12 w-4/12"
loading={isLoading}
shadowSize="4"
- onClick={() => {
- handleAcceptInvite()
- }}
+ onClick={handleAcceptInvite}
disabled={!isValid || isChanging || isLoading}
>
Next
diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts
index bbdbc0396..508839158 100644
--- a/src/utils/withdraw.utils.ts
+++ b/src/utils/withdraw.utils.ts
@@ -1,4 +1,5 @@
import { countryData, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
+import { isValidEmail } from '@/utils/format.utils'
/**
* Extracts the country name from an IBAN by parsing the first 2 characters (country code)
@@ -271,8 +272,7 @@ export const validatePixKey = (pixKey: string): { valid: boolean; message?: stri
}
// 4. Email: Standard email format
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
- if (emailRegex.test(trimmed)) {
+ if (isValidEmail(trimmed)) {
if (trimmed.length > 77) {
return { valid: false, message: 'Email is too long (max 77 characters)' }
}
From 351ef02e1dcba88125036635df20f98f164785aa Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:23:37 +0000
Subject: [PATCH 37/77] fix: accept locale-less /help/ links in link validator
Content submodule uses bare /help/slug links without locale prefix.
Register both /{locale}/help/{slug} and /help/{slug} as valid paths.
Also exclude src/content/ from prettier (has its own formatting).
---
scripts/validate-links.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts
index f7880f371..430868946 100644
--- a/scripts/validate-links.ts
+++ b/scripts/validate-links.ts
@@ -104,9 +104,12 @@ function buildValidPaths(): Set {
}
// Help: /{locale}/help and /{locale}/help/{slug}
+ // Also register without locale prefix since content uses bare /help/... links
paths.add(`/${locale}/help`)
+ paths.add('/help')
for (const slug of helpSlugs) {
paths.add(`/${locale}/help/${slug}`)
+ paths.add(`/help/${slug}`)
}
// Use-cases: /{locale}/use-cases/{slug}
From ca98418d0bf3bd38b097630d7f85508c7bfcc1d6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 16:45:35 +0000
Subject: [PATCH 38/77] fix: make breadcrumb home link locale-aware across all
marketing pages
Change home breadcrumb href from '/' to '/${locale}' in all 10
marketing page types. Ensures JSON-LD BreadcrumbList structured data
points to the correct localized root for SEO.
---
src/app/[locale]/(marketing)/[country]/page.tsx | 2 +-
src/app/[locale]/(marketing)/blog/[slug]/page.tsx | 2 +-
src/app/[locale]/(marketing)/compare/[slug]/page.tsx | 2 +-
src/app/[locale]/(marketing)/deposit/[exchange]/page.tsx | 2 +-
src/app/[locale]/(marketing)/help/[slug]/page.tsx | 2 +-
src/app/[locale]/(marketing)/help/page.tsx | 2 +-
src/app/[locale]/(marketing)/pay-with/[method]/page.tsx | 2 +-
.../[locale]/(marketing)/receive-money-from/[country]/page.tsx | 2 +-
.../(marketing)/send-money-from/[from]/to/[to]/page.tsx | 2 +-
src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx | 2 +-
10 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx
index 1870c5124..5121343d9 100644
--- a/src/app/[locale]/(marketing)/[country]/page.tsx
+++ b/src/app/[locale]/(marketing)/[country]/page.tsx
@@ -58,7 +58,7 @@ export default async function CountryHubPage({ params }: PageProps) {
return (
diff --git a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
index 2ea1984d9..8e6689a62 100644
--- a/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
+++ b/src/app/[locale]/(marketing)/pay-with/[method]/page.tsx
@@ -58,7 +58,7 @@ export default async function PayWithPage({ params }: PageProps) {
return (
diff --git a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
index f37bbcc14..e8ddf1d58 100644
--- a/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
+++ b/src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
@@ -61,7 +61,7 @@ export default async function FromToCorridorPage({ params }: PageProps) {
return (
Date: Tue, 10 Mar 2026 17:09:21 +0000
Subject: [PATCH 39/77] feat: clickable legend toggles + accurate colors in
full-graph
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Legend items now use actual canvas colors (rgba values) instead of
Tailwind approximations, so legend matches what you see on screen
- Click any legend item (New/Active/Inactive/Jailed) to toggle visibility
without re-rendering the graph layout — nodes become nearly invisible
- Hidden items show as dimmed + strikethrough in legend
- Added hiddenStatuses to displaySettingsRef for canvas-level rendering
---
src/app/(mobile-ui)/dev/full-graph/page.tsx | 80 +++++++++++++++-----
src/components/Global/InvitesGraph/index.tsx | 46 +++++++----
2 files changed, 95 insertions(+), 31 deletions(-)
diff --git a/src/app/(mobile-ui)/dev/full-graph/page.tsx b/src/app/(mobile-ui)/dev/full-graph/page.tsx
index 93ec0b65f..ba4d5ff8f 100644
--- a/src/app/(mobile-ui)/dev/full-graph/page.tsx
+++ b/src/app/(mobile-ui)/dev/full-graph/page.tsx
@@ -136,6 +136,8 @@ export default function FullGraphPage() {
externalNodesError,
handleReset,
handleRecalculate,
+ hiddenStatuses,
+ setHiddenStatuses,
}) => (
<>
{/* Controls Panel - Top Right */}
@@ -739,27 +741,69 @@ export default function FullGraphPage() {
- {/* Compact Legend */}
+ {/* Compact Legend — click to toggle visibility */}
- {/* Nodes */}
+ {/* Nodes — clickable toggles */}
-
-
- New
-
-
-
- Active
-
-
-
- Inactive
-
-
-
- Jailed
-
+ {(
+ [
+ {
+ key: 'new',
+ label: 'New',
+ color: 'rgba(74, 222, 128, 0.85)',
+ border: false,
+ },
+ {
+ key: 'active',
+ label: 'Active',
+ color: 'rgba(255, 144, 232, 0.85)',
+ border: false,
+ },
+ {
+ key: 'inactive',
+ label: 'Inactive',
+ color: 'rgba(145, 145, 145, 0.7)',
+ border: false,
+ },
+ {
+ key: 'jailed',
+ label: 'Jailed',
+ color: 'rgba(156, 163, 175, 0.85)',
+ border: true,
+ },
+ ] as const
+ ).map(({ key, label, color, border }) => (
+ {
+ const next = new Set(hiddenStatuses)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ setHiddenStatuses(next)
+ }}
+ className="flex cursor-pointer items-center gap-0.5"
+ style={{ opacity: hiddenStatuses.has(key) ? 0.3 : 1 }}
+ title={hiddenStatuses.has(key) ? `Show ${label}` : `Hide ${label}`}
+ >
+
+
+ {label}
+
+
+ ))}
{/* External nodes */}
{externalNodesConfig.enabled && (
diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx
index 5f22dd86c..2f1984d1a 100644
--- a/src/components/Global/InvitesGraph/index.tsx
+++ b/src/components/Global/InvitesGraph/index.tsx
@@ -140,6 +140,9 @@ interface BaseProps {
handleResetView: () => void
handleReset: () => void
handleRecalculate: () => void
+ /** Set of activity statuses to hide visually (no re-layout). Values: 'new' | 'active' | 'inactive' | 'jailed' */
+ hiddenStatuses: Set
+ setHiddenStatuses: (v: Set) => void
}) => React.ReactNode
}
@@ -252,6 +255,8 @@ export default function InvitesGraph(props: InvitesGraphProps) {
const [showUsernames, setShowUsernames] = useState(initialShowUsernames)
// topNodes: limit to top N by points (0 = all). Backend-filtered, triggers refetch.
const [topNodes, setTopNodes] = useState(initialTopNodes)
+ // Hidden activity statuses — purely visual toggle (no re-layout)
+ const [hiddenStatuses, setHiddenStatuses] = useState>(new Set())
// Particle arrival popups for user mode (+1 pt animations)
// Map: linkId → { timestamp, x, y, nodeId }
@@ -948,6 +953,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
externalNodesConfig,
p2pActiveNodes,
inviterNodes,
+ hiddenStatuses,
})
useEffect(() => {
displaySettingsRef.current = {
@@ -960,6 +966,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
externalNodesConfig,
p2pActiveNodes,
inviterNodes,
+ hiddenStatuses,
}
}, [
showUsernames,
@@ -971,6 +978,7 @@ export default function InvitesGraph(props: InvitesGraphProps) {
externalNodesConfig,
p2pActiveNodes,
inviterNodes,
+ hiddenStatuses,
])
// Helper to determine user activity status
@@ -1167,30 +1175,40 @@ export default function InvitesGraph(props: InvitesGraphProps) {
}
}
+ // Check if this node's status is hidden via legend toggle
+ const { hiddenStatuses: hidden } = displaySettingsRef.current
+ const isJailed = !hasAccess
+ const isHidden = hidden.size > 0 && (hidden.has(activityStatus) || (isJailed && hidden.has('jailed')))
+ if (isHidden) {
+ ctx.globalAlpha = 0.03 // Nearly invisible but keeps layout stable
+ }
+
// Draw fill
ctx.beginPath()
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI)
ctx.fillStyle = fillColor
ctx.fill()
- // Draw outline based on access/selection
- ctx.globalAlpha = 1
- if (isSelected) {
- // Selected: golden outline
- ctx.strokeStyle = '#FFC900'
- ctx.lineWidth = 3
- ctx.stroke()
- } else if (!hasAccess) {
- // Jailed (no app access): black outline
- ctx.strokeStyle = '#000000'
- ctx.lineWidth = 2
- ctx.stroke()
+ // Draw outline based on access/selection (skip if hidden)
+ if (!isHidden) {
+ ctx.globalAlpha = 1
+ if (isSelected) {
+ // Selected: golden outline
+ ctx.strokeStyle = '#FFC900'
+ ctx.lineWidth = 3
+ ctx.stroke()
+ } else if (!hasAccess) {
+ // Jailed (no app access): black outline
+ ctx.strokeStyle = '#000000'
+ ctx.lineWidth = 2
+ ctx.stroke()
+ }
}
ctx.globalAlpha = 1 // Reset alpha
// In minimal mode, always show labels; otherwise require closer zoom
- if (showNames && (minimal || globalScale > 1.2)) {
+ if (!isHidden && showNames && (minimal || globalScale > 1.2)) {
const label = node.username
const fontSize = minimal ? 4 : 12 / globalScale
const { inviterNodes: inviterNodesSet } = displaySettingsRef.current
@@ -2209,6 +2227,8 @@ export default function InvitesGraph(props: InvitesGraphProps) {
handleResetView,
handleReset,
handleRecalculate,
+ hiddenStatuses,
+ setHiddenStatuses,
})}
>
From 932a9ab1897027788e407f466d8452832bcebd28 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 17:15:14 +0000
Subject: [PATCH 40/77] fix: remove notification banner from jail screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
User feedback: the notification hint on the jail screen was out of
design system and visually distracting. Notifications are already
offered in step 2 — no need to re-prompt on the jail screen.
---
src/components/Invites/JoinWaitlistPage.tsx | 18 ------------------
1 file changed, 18 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index db63af7ae..74bf5a68f 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -208,10 +208,6 @@ const JoinWaitlistPage = () => {
setStep('jail')} className="text-sm underline">
Not now
-
- setStep('email')} className="text-sm underline">
- Back
-
)}
@@ -219,20 +215,6 @@ const JoinWaitlistPage = () => {
{step === 'jail' && isLoadingWaitlistPosition && }
{step === 'jail' && !isLoadingWaitlistPosition && (
- {!isPermissionGranted && (
-
-
- Enable notifications to get updates when you're unlocked
-
-
- Enable
-
-
- )}
-
You're still in Peanut jail
Prisoner #{data?.position}
From 31feb5edb7be8ce23d96d120754e854d41909a49 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 17:18:51 +0000
Subject: [PATCH 41/77] fix: pass hiddenStatuses to second renderOverlays call
(build fix)
The minimal/mobile mode renderOverlays call was missing the new
hiddenStatuses and setHiddenStatuses props, causing a type error.
---
src/components/Global/InvitesGraph/index.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/components/Global/InvitesGraph/index.tsx b/src/components/Global/InvitesGraph/index.tsx
index 2f1984d1a..49de10c73 100644
--- a/src/components/Global/InvitesGraph/index.tsx
+++ b/src/components/Global/InvitesGraph/index.tsx
@@ -2564,6 +2564,8 @@ export default function InvitesGraph(props: InvitesGraphProps) {
handleResetView,
handleReset,
handleRecalculate,
+ hiddenStatuses,
+ setHiddenStatuses,
})}
>
From 3a220bd98aa51f7ff4e3a7ec97d749cbd4ad5962 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 17:22:45 +0000
Subject: [PATCH 42/77] =?UTF-8?q?=F0=9F=93=9A=20archive=20SOP.md,=20inline?=
=?UTF-8?q?=20chain-adding=20guide=20as=20code=20comment?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CONTRIBUTING.md | 2 +-
SOP.md => docs/archive/SOP.md | 0
src/constants/chains.consts.ts | 7 ++++++-
3 files changed, 7 insertions(+), 2 deletions(-)
rename SOP.md => docs/archive/SOP.md (100%)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5c3296b74..7eac9337d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -116,7 +116,7 @@ Button, Card (named export), BaseInput, BaseSelect, Checkbox, Divider, Title, To
## 📁 Documentation
-- **All docs go in `docs/`** (except root `README.md`, `SOP.md`, `CONTRIBUTING.md`).
+- **All docs go in `docs/`** (except root `README.md` and `CONTRIBUTING.md`).
- **Keep it concise** — AI tends to be verbose. No one reads that.
- **Check existing docs** before creating new ones — merge instead of duplicate.
- **Delete or edit outdated docs** instead of creating new ones.
diff --git a/SOP.md b/docs/archive/SOP.md
similarity index 100%
rename from SOP.md
rename to docs/archive/SOP.md
diff --git a/src/constants/chains.consts.ts b/src/constants/chains.consts.ts
index 4ff7b54e6..59352c55f 100644
--- a/src/constants/chains.consts.ts
+++ b/src/constants/chains.consts.ts
@@ -1,4 +1,9 @@
-// https://wagmi.sh/core/chains
+// How to add a new chain:
+// 1. If available in wagmi/chains (https://wagmi.sh/core/api/chains), import it directly.
+// 2. If NOT in wagmi/chains, create a custom chain object (see `milkomeda` below for reference).
+// 3. Add it to the `supportedPeanutChains` array at the bottom of this file.
+// Note: Every chain used in the SDK MUST be listed here, or wallet interactions will break.
+// Chains can be listed here even if not yet supported in the SDK.
import type { Chain } from 'viem'
import * as wagmiChains from 'wagmi/chains'
From 3d34c132f909c3ebce84801c9b64b1948f707cec Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 17:26:45 +0000
Subject: [PATCH 43/77] chore: enforce PR-only workflow in CONTRIBUTING.md
Add top-level rules: never push directly to main, always use feature
branches and PRs, wait for CI before merging.
---
CONTRIBUTING.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7eac9337d..efbeec3fd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,8 +14,10 @@ Single source of truth for developer and AI assistant rules. Tool-specific files
- **Never add AI co-author to commits** — no "Co-Authored-By" lines for AI assistants.
- **Do not generate .md files** unless explicitly told to.
-## 🔀 Parallel Work
+## 🔀 Git Workflow
+- **NEVER commit or push directly to main** — all changes must go through a pull request. No exceptions.
+- **Always work from a feature branch** — create a branch, push it, open a PR, wait for CI to pass, then merge.
- **Use git worktrees** for parallel work (`claude --worktree ` or `git worktree add`).
- Multiple agents/sessions must use separate worktrees to avoid collisions.
From 9ef3cd0c58e5544a0ed70b8c8d7a254d859d9da6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 17:28:52 +0000
Subject: [PATCH 44/77] =?UTF-8?q?=F0=9F=93=9A=20archive=20SOP.md,=20inline?=
=?UTF-8?q?=20chain-adding=20guide=20as=20code=20comment?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CONTRIBUTING.md | 2 +-
SOP.md => docs/archive/SOP.md | 0
src/constants/chains.consts.ts | 7 ++++++-
3 files changed, 7 insertions(+), 2 deletions(-)
rename SOP.md => docs/archive/SOP.md (100%)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5c3296b74..7eac9337d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -116,7 +116,7 @@ Button, Card (named export), BaseInput, BaseSelect, Checkbox, Divider, Title, To
## 📁 Documentation
-- **All docs go in `docs/`** (except root `README.md`, `SOP.md`, `CONTRIBUTING.md`).
+- **All docs go in `docs/`** (except root `README.md` and `CONTRIBUTING.md`).
- **Keep it concise** — AI tends to be verbose. No one reads that.
- **Check existing docs** before creating new ones — merge instead of duplicate.
- **Delete or edit outdated docs** instead of creating new ones.
diff --git a/SOP.md b/docs/archive/SOP.md
similarity index 100%
rename from SOP.md
rename to docs/archive/SOP.md
diff --git a/src/constants/chains.consts.ts b/src/constants/chains.consts.ts
index 4ff7b54e6..59352c55f 100644
--- a/src/constants/chains.consts.ts
+++ b/src/constants/chains.consts.ts
@@ -1,4 +1,9 @@
-// https://wagmi.sh/core/chains
+// How to add a new chain:
+// 1. If available in wagmi/chains (https://wagmi.sh/core/api/chains), import it directly.
+// 2. If NOT in wagmi/chains, create a custom chain object (see `milkomeda` below for reference).
+// 3. Add it to the `supportedPeanutChains` array at the bottom of this file.
+// Note: Every chain used in the SDK MUST be listed here, or wallet interactions will break.
+// Chains can be listed here even if not yet supported in the SDK.
import type { Chain } from 'viem'
import * as wagmiChains from 'wagmi/chains'
From 42b4c36c7adce07396dfedf530a5082cee415f24 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 18:12:17 +0000
Subject: [PATCH 45/77] feat: add PostHog reverse proxy to bypass ad blockers
Route analytics requests through /ingest/* rewrites instead of directly
to eu.i.posthog.com, so ad blockers don't strip ~30-40% of events.
Also sets skipTrailingSlashRedirect to prevent Next.js from breaking
proxy paths, and adds ui_host so PostHog toolbar still works.
---
instrumentation-client.ts | 3 ++-
next.config.js | 12 ++++++++++++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
index f91b7421c..4f3c5e367 100644
--- a/instrumentation-client.ts
+++ b/instrumentation-client.ts
@@ -2,7 +2,8 @@ import posthog from 'posthog-js'
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'development') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
- api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
+ api_host: '/ingest',
+ ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
person_profiles: 'identified_only',
capture_pageview: true,
capture_pageleave: true,
diff --git a/next.config.js b/next.config.js
index d37743d58..17cbdc77b 100644
--- a/next.config.js
+++ b/next.config.js
@@ -95,6 +95,7 @@ let nextConfig = {
return config
},
reactStrictMode: false,
+ skipTrailingSlashRedirect: true,
async rewrites() {
return {
beforeFiles: [
@@ -111,6 +112,17 @@ let nextConfig = {
destination: '/api/assetLinks',
},
],
+ afterFiles: [
+ // PostHog reverse proxy — bypasses ad blockers
+ {
+ source: '/ingest/static/:path*',
+ destination: 'https://eu-assets.i.posthog.com/static/:path*',
+ },
+ {
+ source: '/ingest/:path*',
+ destination: 'https://eu.i.posthog.com/:path*',
+ },
+ ],
}
},
async redirects() {
From 588f52b0ba67374725246c054062ef3c42999a67 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 18:44:25 +0000
Subject: [PATCH 46/77] fix: address PR review comments
- Prevent URL bypass: enforce email step when no email on file
- Split shared isLoading into isValidating/isAccepting to prevent
race condition between background validation and invite acceptance
- Add userId to waitlist-position queryKey for proper cache isolation
- Clear error state on logout
---
src/components/Invites/JoinWaitlistPage.tsx | 29 ++++++++++++---------
1 file changed, 17 insertions(+), 12 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index 74bf5a68f..f5f899930 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -51,24 +51,28 @@ const JoinWaitlistPage = () => {
const [inviteCode, setInviteCode] = useState(setupInviteCode)
const [isValid, setIsValid] = useState(false)
const [isChanging, setIsChanging] = useState(false)
- const [isLoading, setIsLoading] = useState(false)
+ const [isValidating, setIsValidating] = useState(false)
+ const [isAccepting, setIsAccepting] = useState(false)
const [error, setError] = useState('')
const [isLoggingOut, setIsLoggingOut] = useState(false)
const { data, isLoading: isLoadingWaitlistPosition } = useQuery({
- queryKey: ['waitlist-position'],
+ queryKey: ['waitlist-position', user?.user.userId],
queryFn: () => invitesApi.getWaitlistQueuePosition(),
enabled: !!user?.user.userId && step === 'jail',
})
- // Redirect completed steps when user/permission state changes
+ // Enforce step invariants: prevent URL bypass and fast-forward completed steps
useEffect(() => {
- if (step === 'email' && user?.user.email) {
+ if (isFetchingUser) return
+ if (step !== 'email' && !user?.user.email) {
+ setStep('email')
+ } else if (step === 'email' && user?.user.email) {
setStep(nextStepAfterEmail(isPermissionGranted))
} else if (step === 'notifications' && isPermissionGranted) {
setStep('jail')
}
- }, [user?.user.email, isPermissionGranted, step, setStep])
+ }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep])
// Step 1: Submit email via server action
const handleEmailSubmit = async () => {
@@ -103,19 +107,19 @@ const JoinWaitlistPage = () => {
setStep('jail')
}
- // Step 3: Validate and accept invite code
+ // Step 3: Validate and accept invite code (separate loading states to avoid race)
const validateInviteCode = async (code: string): Promise => {
- setIsLoading(true)
+ setIsValidating(true)
try {
const res = await invitesApi.validateInviteCode(code)
return res.success
} finally {
- setIsLoading(false)
+ setIsValidating(false)
}
}
const handleAcceptInvite = async () => {
- setIsLoading(true)
+ setIsAccepting(true)
try {
const res = await invitesApi.acceptInvite(inviteCode, inviteType)
if (res.success) {
@@ -127,7 +131,7 @@ const JoinWaitlistPage = () => {
} catch {
setError('Something went wrong. Please try again or contact support.')
} finally {
- setIsLoading(false)
+ setIsAccepting(false)
}
}
@@ -138,6 +142,7 @@ const JoinWaitlistPage = () => {
router.push('/setup')
} finally {
setIsLoggingOut(false)
+ setError('')
}
}
@@ -248,10 +253,10 @@ const JoinWaitlistPage = () => {
Next
From 8824d34c1643980857e71281c06ee7f1f7ff97a6 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 18:51:57 +0000
Subject: [PATCH 47/77] fix: guard handleEmailSubmit against duplicate calls
Add isSubmittingEmail check to the function itself so Enter key
can't bypass the loading guard and fire duplicate requests.
---
src/components/Invites/JoinWaitlistPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index f5f899930..6dc34bc5b 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -76,7 +76,7 @@ const JoinWaitlistPage = () => {
// Step 1: Submit email via server action
const handleEmailSubmit = async () => {
- if (!isValidEmail(emailValue) || !user?.user.userId) return
+ if (!isValidEmail(emailValue) || !user?.user.userId || isSubmittingEmail) return
setIsSubmittingEmail(true)
setEmailError('')
From b20d69fd66ded42e070e13e81fdd6be4cb5afa97 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 19:45:16 +0000
Subject: [PATCH 48/77] fix: add error handling and skip option to email step
The email submit had silent failure paths where no error was shown
to the user. Now every failure path sets an error message, and a
"Skip for now" link appears when the email step fails so users
aren't permanently stuck.
---
src/components/Invites/JoinWaitlistPage.tsx | 43 +++++++++++++++++----
1 file changed, 35 insertions(+), 8 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index 6dc34bc5b..cb884ed23 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -62,21 +62,29 @@ const JoinWaitlistPage = () => {
enabled: !!user?.user.userId && step === 'jail',
})
+ // Track whether user explicitly skipped the email step
+ const [emailSkipped, setEmailSkipped] = useState(false)
+
// Enforce step invariants: prevent URL bypass and fast-forward completed steps
useEffect(() => {
if (isFetchingUser) return
- if (step !== 'email' && !user?.user.email) {
+ if (step !== 'email' && !user?.user.email && !emailSkipped) {
setStep('email')
} else if (step === 'email' && user?.user.email) {
setStep(nextStepAfterEmail(isPermissionGranted))
} else if (step === 'notifications' && isPermissionGranted) {
setStep('jail')
}
- }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep])
+ }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailSkipped])
// Step 1: Submit email via server action
const handleEmailSubmit = async () => {
- if (!isValidEmail(emailValue) || !user?.user.userId || isSubmittingEmail) return
+ if (!isValidEmail(emailValue) || isSubmittingEmail) return
+
+ if (!user?.user.userId) {
+ setEmailError('Account not loaded yet. Please wait a moment and try again.')
+ return
+ }
setIsSubmittingEmail(true)
setEmailError('')
@@ -85,17 +93,30 @@ const JoinWaitlistPage = () => {
const result = await updateUserById({ userId: user.user.userId, email: emailValue })
if (result.error) {
setEmailError(result.error)
- } else {
- await fetchUser()
- setStep(nextStepAfterEmail(isPermissionGranted))
+ return
}
- } catch {
- setEmailError('Something went wrong. Please try again.')
+
+ const refreshedUser = await fetchUser()
+ if (!refreshedUser?.user.email) {
+ console.error('[JoinWaitlist] Email update succeeded but fetchUser did not return email')
+ setEmailError('Email saved, but we had trouble loading your profile. Please try again.')
+ return
+ }
+
+ setStep(nextStepAfterEmail(isPermissionGranted))
+ } catch (e) {
+ console.error('[JoinWaitlist] handleEmailSubmit failed:', e)
+ setEmailError('Something went wrong. Please try again or skip this step.')
} finally {
setIsSubmittingEmail(false)
}
}
+ const handleSkipEmail = () => {
+ setEmailSkipped(true)
+ setStep(nextStepAfterEmail(isPermissionGranted))
+ }
+
// Step 2: Enable notifications (always advances regardless of outcome)
const handleEnableNotifications = async () => {
try {
@@ -197,6 +218,12 @@ const JoinWaitlistPage = () => {
>
Continue
+
+ {emailError && (
+
+ Skip for now
+
+ )}
)}
From 77b68cd8f54d570b5e6a9de28acca828cfcf0742 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 19:58:51 +0000
Subject: [PATCH 49/77] fix: race condition between setStep and react-query
user state
After email submit succeeds, setStep('notifications') fires but the
useEffect sees stale user data (no email yet) and resets to 'email'.
Fix: track emailStepDone flag set synchronously before setStep, so
the useEffect doesn't override the step transition.
---
src/components/Invites/JoinWaitlistPage.tsx | 21 +++++++++++++++------
1 file changed, 15 insertions(+), 6 deletions(-)
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index cb884ed23..3df66a37a 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -62,20 +62,26 @@ const JoinWaitlistPage = () => {
enabled: !!user?.user.userId && step === 'jail',
})
- // Track whether user explicitly skipped the email step
- const [emailSkipped, setEmailSkipped] = useState(false)
+ // Track whether the email step has been completed or skipped this session,
+ // so the step invariant useEffect doesn't race with react-query state updates
+ const [emailStepDone, setEmailStepDone] = useState(!!user?.user.email)
// Enforce step invariants: prevent URL bypass and fast-forward completed steps
useEffect(() => {
if (isFetchingUser) return
- if (step !== 'email' && !user?.user.email && !emailSkipped) {
+ if (step !== 'email' && !user?.user.email && !emailStepDone) {
setStep('email')
- } else if (step === 'email' && user?.user.email) {
+ } else if (step === 'email' && (user?.user.email || emailStepDone)) {
setStep(nextStepAfterEmail(isPermissionGranted))
} else if (step === 'notifications' && isPermissionGranted) {
setStep('jail')
}
- }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailSkipped])
+ }, [user?.user.email, isPermissionGranted, isFetchingUser, step, setStep, emailStepDone])
+
+ // Sync emailStepDone when user data loads with an existing email
+ useEffect(() => {
+ if (user?.user.email) setEmailStepDone(true)
+ }, [user?.user.email])
// Step 1: Submit email via server action
const handleEmailSubmit = async () => {
@@ -103,6 +109,9 @@ const JoinWaitlistPage = () => {
return
}
+ // Mark email step as done BEFORE setStep to prevent the useEffect
+ // from racing and resetting the step back to 'email'
+ setEmailStepDone(true)
setStep(nextStepAfterEmail(isPermissionGranted))
} catch (e) {
console.error('[JoinWaitlist] handleEmailSubmit failed:', e)
@@ -113,7 +122,7 @@ const JoinWaitlistPage = () => {
}
const handleSkipEmail = () => {
- setEmailSkipped(true)
+ setEmailStepDone(true)
setStep(nextStepAfterEmail(isPermissionGranted))
}
From 490713c8154318b9a986ec94a1c30e1fa256b967 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 19:26:16 +0000
Subject: [PATCH 50/77] feat: instrument core money flows with PostHog
analytics
Add 17 new events across send, claim, deposit, and withdraw flows
to measure conversion funnels and identify drop-off points.
Send Link: created, failed, shared
Claim Link: viewed, recipient_selected, started, completed, failed
Deposit: method_selected, amount_entered, confirmed, completed, failed
Withdraw: method_selected, confirmed, completed, failed
---
.../add-money/[country]/bank/page.tsx | 16 ++++++++++
src/app/(mobile-ui)/add-money/crypto/page.tsx | 18 ++++++++---
.../withdraw/[country]/bank/page.tsx | 17 ++++++++++
src/app/(mobile-ui)/withdraw/crypto/page.tsx | 15 +++++++++
src/app/(mobile-ui)/withdraw/manteca/page.tsx | 22 +++++++++++++
.../AddMoney/components/MantecaAddMoney.tsx | 22 ++++++++++++-
.../AddWithdraw/AddWithdrawRouterView.tsx | 31 +++++++++++++++++++
src/components/Claim/Link/Initial.view.tsx | 17 ++++++++++
.../Claim/Link/Onchain/Confirm.view.tsx | 22 +++++++++++++
.../link/views/Initial.link.send.view.tsx | 13 ++++++++
.../link/views/Success.link.send.view.tsx | 12 ++++++-
src/constants/analytics.consts.ts | 23 ++++++++++++++
12 files changed, 222 insertions(+), 6 deletions(-)
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index f058b3361..5a52f6580 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -31,6 +31,8 @@ import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'
import { useExchangeRate } from '@/hooks/useExchangeRate'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// Step type for URL state
type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails'
@@ -216,6 +218,11 @@ export default function OnrampBankPage() {
const handleAmountContinue = () => {
if (validateAmount(rawTokenAmount)) {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
+ amount_usd: usdEquivalent,
+ method_type: 'bank',
+ country: selectedCountryPath,
+ })
setShowWarningModal(true)
}
}
@@ -238,6 +245,11 @@ export default function OnrampBankPage() {
setOnrampData(onrampDataResponse)
if (onrampDataResponse.transferId) {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, {
+ amount_usd: usdEquivalent,
+ method_type: 'bank',
+ country: selectedCountryPath,
+ })
setUrlState({ step: 'showDetails' })
} else {
setError({
@@ -247,6 +259,10 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
+ method_type: 'bank',
+ error_message: onrampError || 'Unknown error',
+ })
if (onrampError) {
setError({
showError: true,
diff --git a/src/app/(mobile-ui)/add-money/crypto/page.tsx b/src/app/(mobile-ui)/add-money/crypto/page.tsx
index 8d4481aad..dc868d9de 100644
--- a/src/app/(mobile-ui)/add-money/crypto/page.tsx
+++ b/src/app/(mobile-ui)/add-money/crypto/page.tsx
@@ -8,6 +8,8 @@ import type { RhinoChainType } from '@/services/services.types'
import { useQuery } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const AddMoneyCryptoPage = () => {
const { user } = useAuth()
@@ -25,10 +27,18 @@ const AddMoneyCryptoPage = () => {
staleTime: 1000 * 60 * 60 * 24, // 24 hours
})
- const handleSuccess = useCallback((amount: number) => {
- setDepositedAmount(amount)
- setShowSuccessView(true)
- }, [])
+ const handleSuccess = useCallback(
+ (amount: number) => {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_COMPLETED, {
+ amount,
+ chain_type: chainType,
+ method_type: 'crypto',
+ })
+ setDepositedAmount(amount)
+ setShowSuccessView(true)
+ },
+ [chainType]
+ )
if (showSuccessView) {
return
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 83d1e06c4..11248f01c 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -32,6 +32,8 @@ import { PointsAction } from '@/services/services.types'
import { usePointsCalculation } from '@/hooks/usePointsCalculation'
import { useSearchParams } from 'next/navigation'
import { parseUnits } from 'viem'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
type View = 'INITIAL' | 'SUCCESS'
@@ -153,6 +155,12 @@ export default function WithdrawBankPage() {
return
}
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, {
+ amount_usd: amountToWithdraw,
+ method_type: 'bridge',
+ country,
+ })
+
try {
// Step 1: create the transfer to get deposit instructions
const destination = destinationDetails(bankAccount)
@@ -213,8 +221,17 @@ export default function WithdrawBankPage() {
}
setView('SUCCESS')
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, {
+ amount_usd: amountToWithdraw,
+ method_type: 'bridge',
+ country,
+ })
} catch (e: any) {
const error = ErrorHandler(e)
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, {
+ method_type: 'bridge',
+ error_message: error,
+ })
if (error.includes('Something failed. Please try again.')) {
setError({ showError: true, errorMessage: e.message })
} else {
diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
index d97db5f86..3a502d9ec 100644
--- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx
@@ -31,6 +31,8 @@ import { useRouteCalculation } from '@/features/payments/shared/hooks/useRouteCa
import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
import { isTxReverted } from '@/utils/general.utils'
import { ErrorHandler } from '@/utils/sdkErrorHandler.utils'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
export default function WithdrawCryptoPage() {
const router = useRouter()
@@ -254,6 +256,11 @@ export default function WithdrawCryptoPage() {
clearErrors()
setIsSendingTx(true)
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, {
+ amount_usd: usdAmount,
+ method_type: 'crypto',
+ })
+
try {
// send transactions via peanut wallet
const txResult = await sendTransactions(transactions, PEANUT_WALLET_CHAIN.id.toString())
@@ -281,9 +288,17 @@ export default function WithdrawCryptoPage() {
setPaymentDetails(payment)
triggerHaptic()
setCurrentView('STATUS')
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, {
+ amount_usd: usdAmount,
+ method_type: 'crypto',
+ })
} catch (err) {
console.error('Withdrawal execution failed:', err)
const errMsg = ErrorHandler(err)
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, {
+ method_type: 'crypto',
+ error_message: errMsg,
+ })
setError(errMsg)
} finally {
setIsSendingTx(false)
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 8371f74f8..acc55369d 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -48,6 +48,8 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'
@@ -257,6 +259,12 @@ export default function MantecaWithdrawFlow() {
const handleWithdraw = async () => {
if (!destinationAddress || !usdAmount || !currencyCode || !priceLock) return
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, {
+ amount_usd: usdAmount,
+ method_type: 'manteca',
+ country: countryPath,
+ })
+
try {
setLoadingState('Preparing transaction')
@@ -311,6 +319,11 @@ export default function MantecaWithdrawFlow() {
})
if (result.error) {
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, {
+ method_type: 'manteca',
+ error_message: result.error,
+ })
+
// handle third-party account error with user-friendly message
if (result.error === 'TAX_ID_MISMATCH' || result.error === 'CUIT_MISMATCH') {
setErrorMessage('You can only withdraw to accounts under your name.')
@@ -324,8 +337,17 @@ export default function MantecaWithdrawFlow() {
}
setStep('success')
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, {
+ amount_usd: usdAmount,
+ method_type: 'manteca',
+ country: countryPath,
+ })
} catch (error) {
console.error('Manteca withdraw error:', error)
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, {
+ method_type: 'manteca',
+ error_message: 'Withdraw failed unexpectedly',
+ })
setErrorMessage('Withdraw failed unexpectedly. If problem persists contact support')
setStep('failure')
} finally {
diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx
index f7e902f18..cab1c3ff5 100644
--- a/src/components/AddMoney/components/MantecaAddMoney.tsx
+++ b/src/components/AddMoney/components/MantecaAddMoney.tsx
@@ -19,6 +19,8 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// Step type for URL state
type MantecaStep = 'inputAmount' | 'depositDetails'
@@ -170,6 +172,14 @@ const MantecaAddMoney: FC = () => {
try {
setError(null)
setIsCreatingDeposit(true)
+
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
+ amount_usd: usdAmount,
+ method_type: 'manteca',
+ country: selectedCountryPath,
+ denomination: currentDenomination,
+ })
+
const isUsdDenominated = currentDenomination === 'USD'
// Use the displayed amount for the API call
const amount = displayedAmount
@@ -183,11 +193,21 @@ const MantecaAddMoney: FC = () => {
return
}
setDepositDetails(depositData.data)
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_CONFIRMED, {
+ amount_usd: usdAmount,
+ method_type: 'manteca',
+ country: selectedCountryPath,
+ })
// Update URL state to show deposit details step
setUrlState({ step: 'depositDetails' })
} catch (error) {
console.log(error)
- setError(error instanceof Error ? error.message : String(error))
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
+ method_type: 'manteca',
+ error_message: errorMessage,
+ })
+ setError(errorMessage)
} finally {
setIsCreatingDeposit(false)
}
diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
index 375479a18..de4b9979a 100644
--- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx
+++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
@@ -21,6 +21,8 @@ import { CountryList } from '../Common/CountryList'
import PeanutLoading from '../Global/PeanutLoading'
import SavedAccountsView from '../Common/SavedAccountsView'
import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
interface AddWithdrawRouterViewProps {
flow: 'add' | 'withdraw'
@@ -126,6 +128,10 @@ export const AddWithdrawRouterView: FC = ({
(method: DepositMethod) => {
if (flow === 'add' && user) {
saveRecentMethod(user.user.userId, method)
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ method_type: method.type === 'crypto' ? 'crypto' : 'bank',
+ country: method.path,
+ })
}
// Handle "From Bank" specially for add flow
@@ -144,6 +150,11 @@ export const AddWithdrawRouterView: FC = ({
const methodType =
method.type === 'crypto' ? 'crypto' : isMantecaCountry(method.path) ? 'manteca' : 'bridge'
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, {
+ method_type: methodType,
+ country: method.path,
+ })
+
setSelectedMethod({
type: methodType,
countryPath: method.path,
@@ -299,6 +310,18 @@ export const AddWithdrawRouterView: FC = ({
inputTitle={mainHeading}
viewMode="add-withdraw"
onCountryClick={(country) => {
+ if (flow === 'add') {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ method_type: 'bank',
+ country: country.path,
+ })
+ } else {
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, {
+ method_type: isMantecaCountry(country.path) ? 'manteca' : 'bridge',
+ country: country.path,
+ })
+ }
+
// from send flow (bank): set method in context and stay on /withdraw?method=bank
if (flow === 'withdraw' && isBankFromSend) {
if (isMantecaCountry(country.path)) {
@@ -333,8 +356,16 @@ export const AddWithdrawRouterView: FC = ({
}}
onCryptoClick={() => {
if (flow === 'add') {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ method_type: 'crypto',
+ country: 'crypto',
+ })
setIsSupportedTokensModalOpen(true)
} else {
+ posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, {
+ method_type: 'crypto',
+ country: 'crypto',
+ })
// preserve method param if coming from send flow (though crypto shouldn't show this screen)
const queryParams = methodParam ? `?method=${methodParam}` : ''
const cryptoPath = `${baseRoute}/crypto${queryParams}`
diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx
index a1b0c0ea7..1d7a66a67 100644
--- a/src/components/Claim/Link/Initial.view.tsx
+++ b/src/components/Claim/Link/Initial.view.tsx
@@ -45,6 +45,8 @@ import { invitesApi } from '@/services/invites'
import { EInviteType } from '@/services/services.types'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
export const InitialClaimLinkView = (props: IClaimScreenProps) => {
// get campaign tag from claim link url
@@ -179,6 +181,16 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
prevUser.current = user
}, [user, resetClaimBankFlow])
+ useEffect(() => {
+ if (claimLinkData) {
+ posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_VIEWED, {
+ amount: formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals),
+ token_symbol: claimLinkData.tokenSymbol,
+ chain_id: claimLinkData.chainId,
+ })
+ }
+ }, [])
+
const resetSelectedToken = useCallback(() => {
if (isPeanutWallet) {
setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString())
@@ -950,6 +962,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
})
} else {
setRecipientType(update.type)
+ if (update.isValid && !update.isChanging) {
+ posthog.capture(ANALYTICS_EVENTS.CLAIM_RECIPIENT_SELECTED, {
+ recipient_type: update.type,
+ })
+ }
}
setIsValidRecipient(update.isValid)
setErrorState({
diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx
index 6475706aa..f71f5d43f 100644
--- a/src/components/Claim/Link/Onchain/Confirm.view.tsx
+++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx
@@ -19,6 +19,8 @@ import useClaimLink from '../../useClaimLink'
import { useAuth } from '@/context/authContext'
import { sendLinksApi } from '@/services/sendLinks'
import { useSearchParams } from 'next/navigation'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
export const ConfirmClaimLinkView = ({
onNext,
@@ -83,6 +85,15 @@ export const ConfirmClaimLinkView = ({
errorMessage: '',
})
+ const formattedAmount = formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals)
+
+ posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_STARTED, {
+ amount: formattedAmount,
+ token_symbol: claimLinkData.tokenSymbol,
+ chain_id: claimLinkData.chainId,
+ is_xchain: !!selectedRoute,
+ })
+
try {
let claimTxHash: string | undefined = ''
if (selectedRoute) {
@@ -119,6 +130,12 @@ export const ConfirmClaimLinkView = ({
}
}
setTransactionHash(claimTxHash)
+ posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_COMPLETED, {
+ amount: formattedAmount,
+ token_symbol: claimLinkData.tokenSymbol,
+ chain_id: claimLinkData.chainId,
+ is_xchain: !!selectedRoute,
+ })
onNext()
// Note: Balance/transaction refresh handled by mutation or SUCCESS view
} catch (error) {
@@ -127,6 +144,11 @@ export const ConfirmClaimLinkView = ({
showError: true,
errorMessage: errorString,
})
+ posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_FAILED, {
+ amount: formattedAmount,
+ error_message: errorString,
+ is_xchain: !!selectedRoute,
+ })
Sentry.captureException(error)
} finally {
setLoadingState('Idle')
diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx
index a742bc933..9d2a9b3d6 100644
--- a/src/components/Send/link/views/Initial.link.send.view.tsx
+++ b/src/components/Send/link/views/Initial.link.send.view.tsx
@@ -19,6 +19,8 @@ import { Button } from '@/components/0_Bruddle/Button'
import FileUploadInput from '../../../Global/FileUploadInput'
import AmountInput from '../../../Global/AmountInput'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LinkSendInitialView = () => {
const {
@@ -56,6 +58,13 @@ const LinkSendInitialView = () => {
const { link, pubKey, chainId, contractVersion, depositIdx, txHash, amount, tokenAddress } =
await createLink(parseUnits(tokenValue!, PEANUT_WALLET_TOKEN_DECIMALS))
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_CREATED, {
+ amount: tokenValue,
+ chain_id: chainId,
+ token_address: tokenAddress,
+ has_attachment: !!attachmentOptions?.rawFile,
+ })
+
setLink(link)
setView('SUCCESS')
fetchBalance()
@@ -89,6 +98,10 @@ const LinkSendInitialView = () => {
// handle errors
const errorString = ErrorHandler(error)
setErrorState({ showError: true, errorMessage: errorString })
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_FAILED, {
+ amount: tokenValue,
+ error_message: errorString,
+ })
captureException(error)
} finally {
setLoadingState('Idle')
diff --git a/src/components/Send/link/views/Success.link.send.view.tsx b/src/components/Send/link/views/Success.link.send.view.tsx
index e4003d405..d8b91d751 100644
--- a/src/components/Send/link/views/Success.link.send.view.tsx
+++ b/src/components/Send/link/views/Success.link.send.view.tsx
@@ -17,6 +17,8 @@ import { useEffect, useState } from 'react'
import useClaimLink from '@/components/Claim/useClaimLink'
import { useToast } from '@/components/0_Bruddle/Toast'
import { TRANSACTIONS } from '@/constants/query.consts'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
const LinkSendSuccessView = () => {
const router = useRouter()
@@ -62,7 +64,15 @@ const LinkSendSuccessView = () => {
{link && (
-
+ {
+ posthog.capture(ANALYTICS_EVENTS.SEND_LINK_SHARED, {
+ amount: tokenValue,
+ })
+ }}
+ >
Share link
Date: Tue, 10 Mar 2026 20:30:26 +0000
Subject: [PATCH 51/77] feat: instrument invite/referral and points flows with
PostHog analytics
Track invite virality (modal open/dismiss, link share/copy, page view,
claim click, accept/fail) and points engagement (page view, points earned).
Adds onCopy callback to CopyToClipboard for clean analytics integration.
---
src/app/(mobile-ui)/points/page.tsx | 7 +++++
src/components/Card/CardSuccessScreen.tsx | 1 +
.../Global/CopyToClipboard/index.tsx | 6 +++--
.../Global/InviteFriendsModal/index.tsx | 26 ++++++++++++++++---
src/components/Invites/InvitesPage.tsx | 19 +++++++++++++-
src/components/Invites/JoinWaitlistPage.tsx | 16 ++++++++++++
src/components/Profile/index.tsx | 1 +
src/constants/analytics.consts.ts | 14 ++++++++++
.../shared/components/PaymentSuccessView.tsx | 11 ++++++++
src/hooks/useZeroDev.ts | 18 ++++++++++++-
10 files changed, 112 insertions(+), 7 deletions(-)
diff --git a/src/app/(mobile-ui)/points/page.tsx b/src/app/(mobile-ui)/points/page.tsx
index 7690531a5..3ecbf8d4c 100644
--- a/src/app/(mobile-ui)/points/page.tsx
+++ b/src/app/(mobile-ui)/points/page.tsx
@@ -20,6 +20,8 @@ import { pointsApi } from '@/services/points'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import { type PointsInvite } from '@/services/services.types'
import { useEffect, useRef, useState } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import InvitesGraph from '@/components/Global/InvitesGraph'
import { CashCard } from '@/components/Points/CashCard'
import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
@@ -85,6 +87,10 @@ const PointsPage = () => {
enabled: !!tierInfo?.data,
})
+ useEffect(() => {
+ posthog.capture(ANALYTICS_EVENTS.POINTS_PAGE_VIEWED)
+ }, [])
+
useEffect(() => {
// re-fetch user to get the latest invitees list for showing heart icon
fetchUser()
@@ -303,6 +309,7 @@ const PointsPage = () => {
visible={isInviteModalOpen}
onClose={() => setIsInviteModalOpen(false)}
username={username ?? ''}
+ source="points_page"
/>
diff --git a/src/components/Card/CardSuccessScreen.tsx b/src/components/Card/CardSuccessScreen.tsx
index bf8c3bd3c..44d35e43a 100644
--- a/src/components/Card/CardSuccessScreen.tsx
+++ b/src/components/Card/CardSuccessScreen.tsx
@@ -136,6 +136,7 @@ const CardSuccessScreen = ({ onViewBadges }: CardSuccessScreenProps) => {
visible={isInviteModalOpen}
onClose={() => setIsInviteModalOpen(false)}
username={user?.user?.username ?? ''}
+ source="card_deposit_success"
/>
>
)
diff --git a/src/components/Global/CopyToClipboard/index.tsx b/src/components/Global/CopyToClipboard/index.tsx
index d5aaa8d8f..c01efb482 100644
--- a/src/components/Global/CopyToClipboard/index.tsx
+++ b/src/components/Global/CopyToClipboard/index.tsx
@@ -14,18 +14,20 @@ interface Props {
iconSize?: '2' | '3' | '4' | '6' | '8'
type?: 'button' | 'icon'
buttonSize?: ButtonSize
+ onCopy?: () => void
}
const CopyToClipboard = forwardRef(
- ({ textToCopy, fill = 'black', className, iconSize = '6', type = 'icon', buttonSize }, ref) => {
+ ({ textToCopy, fill = 'black', className, iconSize = '6', type = 'icon', buttonSize, onCopy }, ref) => {
const [copied, setCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
+ onCopy?.()
})
- }, [textToCopy])
+ }, [textToCopy, onCopy])
useImperativeHandle(ref, () => ({ copy }), [copy])
diff --git a/src/components/Global/InviteFriendsModal/index.tsx b/src/components/Global/InviteFriendsModal/index.tsx
index 85c45468c..8dba8566e 100644
--- a/src/components/Global/InviteFriendsModal/index.tsx
+++ b/src/components/Global/InviteFriendsModal/index.tsx
@@ -5,12 +5,16 @@ import Card from '@/components/Global/Card'
import CopyToClipboard from '@/components/Global/CopyToClipboard'
import ShareButton from '@/components/Global/ShareButton'
import { generateInviteCodeLink, generateInvitesShareText } from '@/utils/general.utils'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import posthog from 'posthog-js'
+import { useEffect } from 'react'
import QRCode from 'react-qr-code'
interface InviteFriendsModalProps {
visible: boolean
onClose: () => void
username: string
+ source?: string
}
/**
@@ -19,13 +23,24 @@ interface InviteFriendsModalProps {
*
* Used in: CardSuccessScreen, Profile, PointsPage
*/
-export default function InviteFriendsModal({ visible, onClose, username }: InviteFriendsModalProps) {
+export default function InviteFriendsModal({ visible, onClose, username, source }: InviteFriendsModalProps) {
const { inviteCode, inviteLink } = generateInviteCodeLink(username)
+ useEffect(() => {
+ if (visible) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_MODAL_OPENED, { source })
+ }
+ }, [visible, source])
+
+ const handleClose = () => {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_MODAL_DISMISSED, { source })
+ onClose()
+ }
+
return (
{inviteCode}
-
+ posthog.capture(ANALYTICS_EVENTS.INVITE_LINK_COPIED, { source })}
+ />
Promise.resolve(generateInvitesShareText(inviteLink))}
title="Share your invite link"
+ onSuccess={() => posthog.capture(ANALYTICS_EVENTS.INVITE_LINK_SHARED, { source })}
>
Share Invite Link
diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx
index 1f9a8bcc1..c3b87bb9d 100644
--- a/src/components/Invites/InvitesPage.tsx
+++ b/src/components/Invites/InvitesPage.tsx
@@ -1,5 +1,5 @@
'use client'
-import { Suspense, useEffect, useRef, useState } from 'react'
+import { Suspense, useEffect, useRef, useState, useCallback } from 'react'
import PeanutLoading from '../Global/PeanutLoading'
import ValidationErrorView from '../Payment/Views/Error.validation.view'
import InvitesPageLayout from './InvitesPageLayout'
@@ -16,6 +16,8 @@ import { EInviteType } from '@/services/services.types'
import { saveToCookie } from '@/utils/general.utils'
import { useLogin } from '@/hooks/useLogin'
import UnsupportedBrowserModal from '../Global/UnsupportedBrowserModal'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// mapping of special invite codes to their campaign tags
// when these invite codes are used, the corresponding campaign tag is automatically applied
@@ -55,6 +57,18 @@ function InvitePageContent() {
enabled: !!inviteCode,
})
+ // track invite page view (ref guard prevents duplicate fires when shouldShowContent toggles)
+ const hasTrackedPageView = useRef(false)
+ useEffect(() => {
+ if (shouldShowContent && inviteCodeData?.success && !hasTrackedPageView.current) {
+ hasTrackedPageView.current = true
+ posthog.capture(ANALYTICS_EVENTS.INVITE_PAGE_VIEWED, {
+ invite_code: inviteCode,
+ inviter_username: inviteCodeData.username,
+ })
+ }
+ }, [shouldShowContent, inviteCodeData, inviteCode])
+
// determine if we should show content based on user state
useEffect(() => {
// if still fetching user, don't show content yet
@@ -123,6 +137,9 @@ function InvitePageContent() {
const handleClaimInvite = async () => {
if (inviteCode) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, {
+ invite_code: inviteCode,
+ })
dispatch(setupActions.setInviteCode(inviteCode))
dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK))
saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code
diff --git a/src/components/Invites/JoinWaitlistPage.tsx b/src/components/Invites/JoinWaitlistPage.tsx
index 3df66a37a..d15d25c01 100644
--- a/src/components/Invites/JoinWaitlistPage.tsx
+++ b/src/components/Invites/JoinWaitlistPage.tsx
@@ -19,6 +19,8 @@ import { updateUserById } from '@/app/actions/users'
import { useQueryState, parseAsStringEnum } from 'nuqs'
import { isValidEmail } from '@/utils/format.utils'
import { BaseInput } from '@/components/0_Bruddle/BaseInput'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
type WaitlistStep = 'email' | 'notifications' | 'jail'
@@ -153,12 +155,26 @@ const JoinWaitlistPage = () => {
try {
const res = await invitesApi.acceptInvite(inviteCode, inviteType)
if (res.success) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, {
+ invite_code: inviteCode,
+ source: 'waitlist_page',
+ })
sessionStorage.setItem('showNoMoreJailModal', 'true')
fetchUser()
} else {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: inviteCode,
+ error_message: 'API returned unsuccessful',
+ source: 'waitlist_page',
+ })
setError('Something went wrong. Please try again or contact support.')
}
} catch {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: inviteCode,
+ error_message: 'Exception during invite acceptance',
+ source: 'waitlist_page',
+ })
setError('Something went wrong. Please try again or contact support.')
} finally {
setIsAccepting(false)
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 09086deed..a80a9e58b 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -145,6 +145,7 @@ export const Profile = () => {
visible={isInviteFriendsModalOpen}
onClose={() => setIsInviteFriendsModalOpen(false)}
username={user?.user.username ?? ''}
+ source="profile"
/>
)
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index f94dbd7bb..4f888472d 100644
--- a/src/constants/analytics.consts.ts
+++ b/src/constants/analytics.consts.ts
@@ -63,6 +63,20 @@ export const ANALYTICS_EVENTS = {
WITHDRAW_COMPLETED: 'withdraw_completed',
WITHDRAW_FAILED: 'withdraw_failed',
+ // ── Invites / Referrals ──
+ INVITE_LINK_SHARED: 'invite_link_shared',
+ INVITE_LINK_COPIED: 'invite_link_copied',
+ INVITE_PAGE_VIEWED: 'invite_page_viewed',
+ INVITE_CLAIM_CLICKED: 'invite_claim_clicked',
+ INVITE_ACCEPTED: 'invite_accepted',
+ INVITE_ACCEPT_FAILED: 'invite_accept_failed',
+ INVITE_MODAL_OPENED: 'invite_modal_opened',
+ INVITE_MODAL_DISMISSED: 'invite_modal_dismissed',
+
+ // ── Points / Cashback ──
+ POINTS_PAGE_VIEWED: 'points_page_viewed',
+ POINTS_EARNED: 'points_earned',
+
// ── QR ──
QR_SCANNED: 'qr_scanned',
diff --git a/src/features/payments/shared/components/PaymentSuccessView.tsx b/src/features/payments/shared/components/PaymentSuccessView.tsx
index 8789657d1..1b63524a4 100644
--- a/src/features/payments/shared/components/PaymentSuccessView.tsx
+++ b/src/features/payments/shared/components/PaymentSuccessView.tsx
@@ -37,6 +37,8 @@ import { type ReactNode, useEffect, useMemo, useRef } from 'react'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
import { useHaptic } from 'use-haptic'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import PointsCard from '@/components/Common/PointsCard'
import { BASE_URL } from '@/constants/general.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
@@ -194,6 +196,15 @@ const PaymentSuccessView = ({
const pointsDivRef = useRef
(null)
usePointsConfetti(points, pointsDivRef)
+ useEffect(() => {
+ if (points) {
+ posthog.capture(ANALYTICS_EVENTS.POINTS_EARNED, {
+ points,
+ flow_type: isWithdrawFlow ? 'withdraw' : type?.toLowerCase(),
+ })
+ }
+ }, [points, isWithdrawFlow, type])
+
useEffect(() => {
// invalidate queries to refetch history
queryClient?.invalidateQueries({ queryKey: [TRANSACTIONS] })
diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts
index c8518ecd6..adff7930d 100644
--- a/src/hooks/useZeroDev.ts
+++ b/src/hooks/useZeroDev.ts
@@ -13,6 +13,8 @@ import { useCallback, useContext } from 'react'
import type { TransactionReceipt, Hex, Hash } from 'viem'
import { captureException } from '@sentry/nextjs'
import { invitesApi } from '@/services/invites'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
// types
type UserOpEncodedParams = {
@@ -87,7 +89,17 @@ export const useZeroDev = () => {
if (userInviteCode?.trim().length > 0) {
try {
const result = await invitesApi.acceptInvite(userInviteCode, inviteType, campaignTag)
- if (!result.success) {
+ if (result.success) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, {
+ invite_code: userInviteCode,
+ invite_type: inviteType,
+ campaign_tag: campaignTag,
+ })
+ } else {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: userInviteCode,
+ error_message: 'API returned unsuccessful',
+ })
console.error('Error accepting invite', result)
}
if (inviteCodeFromCookie) {
@@ -97,6 +109,10 @@ export const useZeroDev = () => {
removeFromCookie('campaignTag')
}
} catch (e) {
+ posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPT_FAILED, {
+ invite_code: userInviteCode,
+ error_message: String(e),
+ })
console.error('Error accepting invite', e)
}
}
From 2d986863d11bb192dccc882ded9c0b7cc8c00cd2 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 20:36:03 +0000
Subject: [PATCH 52/77] fix: address coderabbit review feedback on analytics
events
- Use caught error instead of stale onrampError state in DEPOSIT_FAILED
- Normalize method.path to canonical slug for country in method_selected events
- Add ref guard + claimLinkData dependency for CLAIM_LINK_VIEWED
- Track DEPOSIT_FAILED for non-throwing depositData.error branch in Manteca
- Add missing usdAmount/selectedCountryPath to useCallback deps
---
.../add-money/[country]/bank/page.tsx | 3 ++-
.../AddMoney/components/MantecaAddMoney.tsx | 16 +++++++++++++++-
.../AddWithdraw/AddWithdrawRouterView.tsx | 4 ++--
src/components/Claim/Link/Initial.view.tsx | 6 ++++--
4 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 5a52f6580..38bce93a9 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -259,9 +259,10 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
method_type: 'bank',
- error_message: onrampError || 'Unknown error',
+ error_message: errorMessage,
})
if (onrampError) {
setError({
diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx
index cab1c3ff5..a1c4a34d2 100644
--- a/src/components/AddMoney/components/MantecaAddMoney.tsx
+++ b/src/components/AddMoney/components/MantecaAddMoney.tsx
@@ -189,6 +189,11 @@ const MantecaAddMoney: FC = () => {
currency: selectedCountry.currency,
})
if (depositData.error) {
+ posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
+ method_type: 'manteca',
+ country: selectedCountryPath,
+ error_message: depositData.error,
+ })
setError(depositData.error)
return
}
@@ -211,7 +216,16 @@ const MantecaAddMoney: FC = () => {
} finally {
setIsCreatingDeposit(false)
}
- }, [currentDenomination, selectedCountry, displayedAmount, isMantecaKycRequired, isCreatingDeposit, setUrlState])
+ }, [
+ currentDenomination,
+ selectedCountry,
+ displayedAmount,
+ isMantecaKycRequired,
+ isCreatingDeposit,
+ setUrlState,
+ usdAmount,
+ selectedCountryPath,
+ ])
// handle verification modal opening
useEffect(() => {
diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
index de4b9979a..34d7c8187 100644
--- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx
+++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx
@@ -130,7 +130,7 @@ export const AddWithdrawRouterView: FC = ({
saveRecentMethod(user.user.userId, method)
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, {
method_type: method.type === 'crypto' ? 'crypto' : 'bank',
- country: method.path,
+ country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1),
})
}
@@ -152,7 +152,7 @@ export const AddWithdrawRouterView: FC = ({
posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, {
method_type: methodType,
- country: method.path,
+ country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1),
})
setSelectedMethod({
diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx
index 1d7a66a67..4a94b1123 100644
--- a/src/components/Claim/Link/Initial.view.tsx
+++ b/src/components/Claim/Link/Initial.view.tsx
@@ -181,15 +181,17 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
prevUser.current = user
}, [user, resetClaimBankFlow])
+ const hasTrackedClaimView = useRef(false)
useEffect(() => {
- if (claimLinkData) {
+ if (claimLinkData && !hasTrackedClaimView.current) {
+ hasTrackedClaimView.current = true
posthog.capture(ANALYTICS_EVENTS.CLAIM_LINK_VIEWED, {
amount: formatUnits(claimLinkData.amount, claimLinkData.tokenDecimals),
token_symbol: claimLinkData.tokenSymbol,
chain_id: claimLinkData.chainId,
})
}
- }, [])
+ }, [claimLinkData])
const resetSelectedToken = useCallback(() => {
if (isPeanutWallet) {
From 03323d275896e62d0bff7ffe696ca27c317bb8e9 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 20:58:00 +0000
Subject: [PATCH 53/77] feat: add PostHog tracking to OneSignal notification
flow
Track notification permission modal shown/dismissed, permission
requested/granted/denied, and push subscription opt-in to measure
notification fatigue and conversion rates.
---
src/constants/analytics.consts.ts | 8 ++++++++
src/hooks/useNotifications.ts | 16 ++++++++++++++++
2 files changed, 24 insertions(+)
diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts
index 4f888472d..bc6015501 100644
--- a/src/constants/analytics.consts.ts
+++ b/src/constants/analytics.consts.ts
@@ -77,6 +77,14 @@ export const ANALYTICS_EVENTS = {
POINTS_PAGE_VIEWED: 'points_page_viewed',
POINTS_EARNED: 'points_earned',
+ // ── Notifications ──
+ NOTIFICATION_PERMISSION_REQUESTED: 'notification_permission_requested',
+ NOTIFICATION_PERMISSION_GRANTED: 'notification_permission_granted',
+ NOTIFICATION_PERMISSION_DENIED: 'notification_permission_denied',
+ NOTIFICATION_MODAL_SHOWN: 'notification_modal_shown',
+ NOTIFICATION_MODAL_DISMISSED: 'notification_modal_dismissed',
+ NOTIFICATION_SUBSCRIBED: 'notification_subscribed',
+
// ── QR ──
QR_SCANNED: 'qr_scanned',
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 6388138b0..5e228456f 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import OneSignal from 'react-onesignal'
import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
import { useUserStore } from '@/redux/hooks'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
export function useNotifications() {
const { user } = useUserStore()
@@ -102,6 +104,7 @@ export function useNotifications() {
// show modal only if user hasn't closed it yet
if (!modalClosed) {
setShowPermissionModal(true)
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_MODAL_SHOWN)
} else {
setShowPermissionModal(false)
}
@@ -186,6 +189,16 @@ export function useNotifications() {
// update local permission state and immediately re-evaluate ui visibility
refreshPermissionState()
evaluateVisibility()
+
+ // track the resulting permission state
+ if (typeof Notification !== 'undefined') {
+ const perm = Notification.permission
+ if (perm === 'granted') {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_GRANTED)
+ } else if (perm === 'denied') {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_DENIED)
+ }
+ }
})
type PushSubscriptionChangeEvent = { current?: { optedIn?: boolean } | null }
@@ -214,6 +227,7 @@ export function useNotifications() {
// hide modal when user opts in
if (event.current?.optedIn) {
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED)
setShowPermissionModal(false)
}
}
@@ -237,6 +251,7 @@ export function useNotifications() {
if (typeof window === 'undefined' || !oneSignalInitialized) return 'default'
setIsRequestingPermission(true)
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_PERMISSION_REQUESTED)
try {
// always use the native browser permission dialog, avoid onesignal slidedown ui
@@ -315,6 +330,7 @@ export function useNotifications() {
const closePermissionModal = useCallback(() => {
setShowPermissionModal(false)
updateUserPreferences(user?.user.userId, { notifModalClosed: true })
+ posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_MODAL_DISMISSED)
}, [user?.user.userId])
// update permission state after user interacts with permission prompt
From 18bf292394816fc823ef7cb3b1af0a11f3bdc3b5 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Tue, 10 Mar 2026 21:37:01 +0000
Subject: [PATCH 54/77] feat: add modal fatigue tracking with unified
MODAL_SHOWN/DISMISSED/CTA_CLICKED events
Track all 6 auto-shown modals (notifications, early_user, post_signup,
balance_warning, card_pioneer, kyc_completed) with generic modal events
and a modal_type property for dashboard filtering.
- Add MODAL_TYPES constants for compile-time safety on modal_type values
- Replace notification-specific modal events with generic ones in useNotifications
- Add useRef dedup guards to prevent duplicate MODAL_SHOWN fires
- Keep notification permission events (granted/denied/subscribed/requested)
as standalone events without modal_type to avoid conflating concerns
---
src/components/Card/CardPioneerModal.tsx | 8 +++++++
.../Global/BalanceWarningModal/index.tsx | 23 +++++++++++++++++--
.../Global/EarlyUserModal/index.tsx | 10 +++++++-
.../Global/NoMoreJailModal/index.tsx | 4 ++++
.../Home/KycCompletedModal/index.tsx | 20 ++++++++++++++--
.../Notifications/SetupNotificationsModal.tsx | 4 ++++
src/constants/analytics.consts.ts | 19 +++++++++++++--
src/hooks/useNotifications.ts | 10 +++++---
8 files changed, 88 insertions(+), 10 deletions(-)
diff --git a/src/components/Card/CardPioneerModal.tsx b/src/components/Card/CardPioneerModal.tsx
index 9e76f1952..a2c93dd8e 100644
--- a/src/components/Card/CardPioneerModal.tsx
+++ b/src/components/Card/CardPioneerModal.tsx
@@ -1,6 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/0_Bruddle/Button'
import BaseModal from '@/components/Global/Modal'
@@ -44,17 +46,23 @@ const CardPioneerModal = ({ hasPurchased }: CardPioneerModalProps) => {
// Show modal with a small delay for better UX
const timer = setTimeout(() => {
setIsVisible(true)
+ posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.CARD_PIONEER })
}, 1000)
return () => clearTimeout(timer)
}, [hasPurchased])
const handleDismiss = () => {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.CARD_PIONEER })
localStorage.setItem(STORAGE_KEY, new Date().toISOString())
setIsVisible(false)
}
const handleJoinNow = () => {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, {
+ modal_type: MODAL_TYPES.CARD_PIONEER,
+ cta: 'get_early_access',
+ })
setIsVisible(false)
router.push('/card')
}
diff --git a/src/components/Global/BalanceWarningModal/index.tsx b/src/components/Global/BalanceWarningModal/index.tsx
index 1a4456f72..4a56e803b 100644
--- a/src/components/Global/BalanceWarningModal/index.tsx
+++ b/src/components/Global/BalanceWarningModal/index.tsx
@@ -2,8 +2,10 @@
import { Icon } from '@/components/Global/Icons/Icon'
import Modal from '@/components/Global/Modal'
-import { useMemo } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import { Slider } from '@/components/Slider'
+import posthog from 'posthog-js'
+import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts'
enum Platform {
IOS = 'ios',
@@ -74,6 +76,14 @@ export default function BalanceWarningModal({ visible, onCloseAction }: BalanceW
const platform = detectPlatform()
return PLATFORM_INFO[platform]
}, [])
+
+ const hasTrackedShow = useRef(false)
+ useEffect(() => {
+ if (visible && !hasTrackedShow.current) {
+ hasTrackedShow.current = true
+ posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { modal_type: MODAL_TYPES.BALANCE_WARNING })
+ }
+ }, [visible])
return (
-
+ {
+ posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, {
+ modal_type: MODAL_TYPES.BALANCE_WARNING,
+ cta: 'slide_to_continue',
+ })
+ onCloseAction()
+ }}
+ title="Slide to Continue"
+ />