From 332f17997efeef3b4741a7938801fe58dad22e1f Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Fri, 31 Oct 2025 20:43:25 -0300
Subject: [PATCH 01/17] various fixes
---
src/app/(mobile-ui)/home/page.tsx | 15 +-
src/app/(mobile-ui)/qr-pay/page.tsx | 80 +++++++++-
src/app/(mobile-ui)/withdraw/manteca/page.tsx | 39 ++++-
src/components/Global/Slider/index.tsx | 2 +-
.../Home/HomeBanners/BannerCard.tsx | 51 -------
src/components/Home/HomeBanners/index.tsx | 51 -------
.../Home/HomeCarouselCTA/CarouselCTA.tsx | 143 ++++++++++++++++++
src/components/Home/HomeCarouselCTA/index.tsx | 41 +++++
.../Profile/components/ProfileHeader.tsx | 22 +--
src/constants/carousel.consts.ts | 4 +
src/constants/index.ts | 1 +
...useBanners.tsx => useHomeCarouselCTAs.tsx} | 46 +++---
src/utils/general.utils.ts | 20 ++-
src/utils/qr-payment.utils.ts | 49 ++++++
14 files changed, 419 insertions(+), 145 deletions(-)
delete mode 100644 src/components/Home/HomeBanners/BannerCard.tsx
delete mode 100644 src/components/Home/HomeBanners/index.tsx
create mode 100644 src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
create mode 100644 src/components/Home/HomeCarouselCTA/index.tsx
create mode 100644 src/constants/carousel.consts.ts
rename src/hooks/{useBanners.tsx => useHomeCarouselCTAs.tsx} (68%)
create mode 100644 src/utils/qr-payment.utils.ts
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index ec161d207..9d446f190 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -33,7 +33,7 @@ import { useDeviceType, DeviceType } from '@/hooks/useGetDeviceType'
import SetupNotificationsModal from '@/components/Notifications/SetupNotificationsModal'
import { useNotifications } from '@/hooks/useNotifications'
import useKycStatus from '@/hooks/useKycStatus'
-import HomeBanners from '@/components/Home/HomeBanners'
+import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA'
import NoMoreJailModal from '@/components/Global/NoMoreJailModal'
import EarlyUserModal from '@/components/Global/EarlyUserModal'
import { STAR_STRAIGHT_ICON } from '@/assets'
@@ -199,9 +199,9 @@ export default function Home() {
showNoMoreJailModal !== 'true' &&
!user?.showEarlyUserModal // Give Early User and No more jail modal precedence, showing two modals together isn't ideal and it messes up their functionality
- if (shouldShow) {
+ if (shouldShow && !showAddMoneyPromptModal) {
+ // Only set state, don't set sessionStorage here to avoid race conditions
setShowAddMoneyPromptModal(true)
- sessionStorage.setItem('hasSeenAddMoneyPromptThisSession', 'true')
} else if (showAddMoneyPromptModal && showPermissionModal) {
// priority enforcement: hide add money modal if notification modal appears
// this handles race conditions where both modals try to show simultaneously
@@ -219,6 +219,13 @@ export default function Home() {
address,
])
+ // Set sessionStorage flag when modal becomes visible to prevent showing again
+ useEffect(() => {
+ if (showAddMoneyPromptModal) {
+ sessionStorage.setItem('hasSeenAddMoneyPromptThisSession', 'true')
+ }
+ }, [showAddMoneyPromptModal])
+
if (isLoading) {
return
}
@@ -260,7 +267,7 @@ export default function Home() {
-
+
{showPermissionModal && }
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index daddc3cf6..351defd9a 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -18,6 +18,7 @@ import TokenAmountInput from '@/components/Global/TokenAmountInput'
import { useWallet } from '@/hooks/wallet/useWallet'
import { clearRedirectUrl, getRedirectUrl, isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils'
import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils'
+import { calculateSavingsInCents, isArgentinaMantecaQrPayment, getSavingsMessage } from '@/utils/qr-payment.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { PEANUT_WALLET_TOKEN_DECIMALS, TRANSACTIONS, PERK_HOLD_DURATION_MS } from '@/constants'
import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts'
@@ -37,10 +38,13 @@ import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate'
import ActionModal from '@/components/Global/ActionModal'
import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
-import { useQueryClient } from '@tanstack/react-query'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import { shootDoubleStarConfetti } from '@/utils/confetti'
import { STAR_STRAIGHT_ICON } from '@/assets'
import { useAuth } from '@/context/authContext'
+import { pointsApi } from '@/services/points'
+import { PointsAction } from '@/services/services.types'
+import { usePointsConfetti } from '@/hooks/usePointsConfetti'
import { useWebSocket } from '@/hooks/useWebSocket'
import type { HistoryEntry } from '@/hooks/useTransactionHistory'
import { completeHistoryEntry } from '@/utils/history.utils'
@@ -92,6 +96,7 @@ export default function QRPayPage() {
const { setIsSupportModalOpen } = useSupportModalContext()
const [waitingForMerchantAmount, setWaitingForMerchantAmount] = useState(false)
const retryCount = useRef(0)
+ const pointsDivRef = useRef(null)
const paymentProcessor: PaymentProcessor | null = useMemo(() => {
switch (qrType) {
@@ -313,6 +318,20 @@ export default function QRPayPage() {
}
}, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])
+ // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
+ // This way points are cached by the time success view shows
+ const { data: pointsData } = useQuery({
+ queryKey: ['calculate-points', 'qr-payment', paymentProcessor, usdAmount],
+ queryFn: () =>
+ pointsApi.calculatePoints({
+ actionType: PointsAction.MANTECA_QR_PAYMENT, // Both Manteca and SimpleFi QR payments use this type
+ usdAmount: Number(usdAmount),
+ }),
+ enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && paymentProcessor),
+ refetchOnWindowFocus: false,
+ staleTime: 5 * 60 * 1000, // Cache for 5 minutes
+ })
+
const methodIcon = useMemo(() => {
switch (qrType) {
case EQrType.MERCADO_PAGO:
@@ -735,11 +754,14 @@ export default function QRPayPage() {
}
}, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket])
+ // Use points confetti hook for animation - must be called unconditionally
+ usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef)
+
useEffect(() => {
if (isSuccess) {
queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
}
- }, [isSuccess])
+ }, [isSuccess, queryClient])
const handleSimplefiRetry = useCallback(async () => {
setShowOrderNotReadyModal(false)
@@ -947,6 +969,11 @@ export default function QRPayPage() {
if (isSuccess && paymentProcessor === 'MANTECA' && !qrPayment) {
return null
} else if (isSuccess && paymentProcessor === 'MANTECA' && qrPayment) {
+ // Calculate savings for Argentina Manteca QR payments only
+ const savingsInCents = calculateSavingsInCents(usdAmount)
+ const showSavingsMessage = savingsInCents > 0 && isArgentinaMantecaQrPayment(qrType, paymentProcessor)
+ const savingsMessage = showSavingsMessage ? getSavingsMessage(savingsInCents) : ''
+
return (
@@ -980,6 +1007,22 @@ export default function QRPayPage() {
)}
+ {/* Savings Message - Show after payment card (Argentina Manteca only) */}
+ {showSavingsMessage && savingsMessage && (
+
{savingsMessage}
+ )}
+
+ {/* Points Display - Show after payment card */}
+ {pointsData?.estimatedPoints && (
+
+
+
+ You've earned {pointsData.estimatedPoints}{' '}
+ {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
+
+
+ )}
+
{/* Perk Eligibility Card - Show before claiming */}
{qrPayment?.perk?.eligible && !perkClaimed && !qrPayment.perk.claimed && (
@@ -1028,6 +1071,27 @@ export default function QRPayPage() {
)}
+ {/* Savings Message and Points - Show after perk banner if perk claimed */}
+ {(perkClaimed || qrPayment?.perk?.claimed) && (
+ <>
+ {/* Savings Message (Argentina Manteca only) */}
+ {showSavingsMessage && savingsMessage && (
+
{savingsMessage}
+ )}
+
+ {/* Points Display */}
+ {pointsData?.estimatedPoints && (
+
+
+
+ You've earned {pointsData.estimatedPoints}{' '}
+ {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
+
+
+ )}
+ >
+ )}
+
{/* Show Claim Perk button if eligible and not claimed yet */}
{qrPayment?.perk?.eligible && !perkClaimed && !qrPayment.perk.claimed ? (
@@ -1153,6 +1217,18 @@ export default function QRPayPage() {
+
+ {/* Points Display */}
+ {pointsData?.estimatedPoints && (
+
+
+
+ You've earned {pointsData.estimatedPoints}{' '}
+ {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
+
+
+ )}
+
{
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index 3a7634b42..f34eabe7e 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useWallet } from '@/hooks/wallet/useWallet'
-import { useState, useMemo, useContext, useEffect, useCallback } from 'react'
+import { useState, useMemo, useContext, useEffect, useCallback, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
@@ -37,10 +37,14 @@ import {
} from '@/constants'
import Select from '@/components/Global/Select'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
-import { useQueryClient } from '@tanstack/react-query'
+import { useQueryClient, useQuery } from '@tanstack/react-query'
import { captureException } from '@sentry/nextjs'
import useKycStatus from '@/hooks/useKycStatus'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
+import { pointsApi } from '@/services/points'
+import { PointsAction } from '@/services/services.types'
+import { usePointsConfetti } from '@/hooks/usePointsConfetti'
+import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'
@@ -70,6 +74,7 @@ export default function MantecaWithdrawFlow() {
const queryClient = useQueryClient()
const { isUserBridgeKycApproved } = useKycStatus()
const { hasPendingTransactions } = usePendingTransactions()
+ const pointsDivRef = useRef(null)
// Get method and country from URL parameters
const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc.
@@ -290,11 +295,27 @@ export default function MantecaWithdrawFlow() {
}
}, [usdAmount, balance, hasPendingTransactions])
+ // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
+ const { data: pointsData } = useQuery({
+ queryKey: ['calculate-points', 'manteca-withdraw', usdAmount],
+ queryFn: () =>
+ pointsApi.calculatePoints({
+ actionType: PointsAction.MANTECA_TRANSFER,
+ usdAmount: Number(usdAmount),
+ }),
+ enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0),
+ refetchOnWindowFocus: false,
+ staleTime: 5 * 60 * 1000, // Cache for 5 minutes
+ })
+
+ // Use points confetti hook for animation - must be called unconditionally
+ usePointsConfetti(step === 'success' ? pointsData?.estimatedPoints : undefined, pointsDivRef)
+
useEffect(() => {
if (step === 'success') {
queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
}
- }, [step])
+ }, [step, queryClient])
if (isCurrencyLoading || !currencyPrice || !selectedCountry) {
return
@@ -323,6 +344,18 @@ export default function MantecaWithdrawFlow() {
to {destinationAddress}
+
+ {/* Points Display */}
+ {pointsData?.estimatedPoints && (
+
+
+
+ You've earned {pointsData.estimatedPoints}{' '}
+ {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
+
+
+ )}
+
{
diff --git a/src/components/Global/Slider/index.tsx b/src/components/Global/Slider/index.tsx
index ca4445da9..0dc48a0bc 100644
--- a/src/components/Global/Slider/index.tsx
+++ b/src/components/Global/Slider/index.tsx
@@ -4,7 +4,7 @@ import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { twMerge } from 'tailwind-merge'
-const SNAP_POINTS = [25, 33, 50, 100]
+const SNAP_POINTS = [25, 100 / 3, 50, 100] // 100/3 = 33.333...% for equal 3-person splits
const SNAP_THRESHOLD = 5 // ±5% proximity to trigger snap
function Slider({
diff --git a/src/components/Home/HomeBanners/BannerCard.tsx b/src/components/Home/HomeBanners/BannerCard.tsx
deleted file mode 100644
index 4a9d9a5c4..000000000
--- a/src/components/Home/HomeBanners/BannerCard.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import { Card } from '@/components/0_Bruddle'
-import { Icon, type IconName } from '@/components/Global/Icons/Icon'
-import type { StaticImageData } from 'next/image'
-import Image from 'next/image'
-import React from 'react'
-import { twMerge } from 'tailwind-merge'
-
-interface BannerCardProps {
- icon: IconName
- title: string | React.ReactNode
- description: string | React.ReactNode
- logo?: StaticImageData
- onClose: () => void
- onClick?: () => void
- iconContainerClassName?: string
-}
-
-const BannerCard = ({ title, description, icon, onClose, onClick, logo, iconContainerClassName }: BannerCardProps) => {
- const handleClose = (e: React.MouseEvent) => {
- e.stopPropagation()
- onClose()
- }
-
- return (
-
-
-
-
-
-
- {/* Show icon only if logo isnt provided. Logo takes precedence over icon. */}
- {!logo && }
- {logo && }
-
-
-
{title}
-
{description}
-
-
- )
-}
-
-export default BannerCard
diff --git a/src/components/Home/HomeBanners/index.tsx b/src/components/Home/HomeBanners/index.tsx
deleted file mode 100644
index 3b8335576..000000000
--- a/src/components/Home/HomeBanners/index.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import Carousel from '@/components/Global/Carousel'
-import BannerCard from './BannerCard'
-import NotificationBanner from '@/components/Notifications/NotificationBanner'
-import { type IconName } from '@/components/Global/Icons/Icon'
-import { useBanners } from '@/hooks/useBanners'
-
-const HomeBanners = () => {
- const { banners, setBanners } = useBanners()
-
- // don't render carousel if there are no banners
- if (!banners.length) return null
-
- return (
-
- {banners.map((banner) => {
- // use the existing NotificationBanner component for notification banners
- if (banner.id === 'notification-banner') {
- return (
-
-
-
- )
- }
-
- // use BannerCard for all other banners
- return (
- {
- setBanners(banners.filter((b) => b.id !== banner.id))
- }}
- onClick={banner.onClick}
- logo={banner.logo}
- iconContainerClassName={banner.iconContainerClassName}
- />
- )
- })}
-
- )
-}
-
-export default HomeBanners
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
new file mode 100644
index 000000000..cc9979ffb
--- /dev/null
+++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
@@ -0,0 +1,143 @@
+'use client'
+
+import { Card } from '@/components/0_Bruddle'
+import { Icon, type IconName } from '@/components/Global/Icons/Icon'
+import type { StaticImageData } from 'next/image'
+import Image from 'next/image'
+import React, { useState } from 'react'
+import { twMerge } from 'tailwind-merge'
+import ActionModal from '@/components/Global/ActionModal'
+import { CAROUSEL_CLOSE_BUTTON_POSITION, CAROUSEL_CLOSE_ICON_SIZE } from '@/constants/carousel.consts'
+
+interface CarouselCTAProps {
+ icon: IconName
+ title: string | React.ReactNode
+ description: string | React.ReactNode
+ logo?: StaticImageData
+ onClose: () => void
+ onClick?: () => void | Promise
+ iconContainerClassName?: string
+ // Notification-specific props
+ isPermissionDenied?: boolean
+}
+
+const CarouselCTA = ({
+ title,
+ description,
+ icon,
+ onClose,
+ onClick,
+ logo,
+ iconContainerClassName,
+ isPermissionDenied,
+}: CarouselCTAProps) => {
+ const [showPermissionDeniedModal, setShowPermissionDeniedModal] = useState(false)
+
+ const handleClose = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onClose()
+ }
+
+ const handleClick = async () => {
+ try {
+ if (isPermissionDenied) {
+ setShowPermissionDeniedModal(true)
+ } else if (onClick) {
+ await onClick()
+ }
+ } catch (error) {
+ console.error('Error handling CTA click:', error)
+ }
+ }
+
+ // Get descriptive title for accessibility
+ const getAriaLabel = () => {
+ if (typeof title === 'string') {
+ return `Close ${title}`
+ }
+ // For React nodes (e.g., "Unlock QR code payments"), use icon as hint
+ if (icon === 'shield') {
+ return 'Close verification prompt'
+ }
+ if (icon === 'bell' || isPermissionDenied) {
+ return 'Close notification prompt'
+ }
+ return 'Close prompt'
+ }
+
+ return (
+ <>
+
+ {/* Close button - consistent positioning and size */}
+
+
+
+
+ {/* Icon container */}
+
+ {/* Show icon only if logo isn't provided. Logo takes precedence over icon. */}
+ {!logo && }
+ {logo && }
+
+
+ {/* Content */}
+
+
{title}
+
{description}
+
+
+
+ {/* Permission denied modal for notification CTA */}
+ {isPermissionDenied && showPermissionDeniedModal && (
+
+ To keep getting updates, you'll need to reinstall the app on your home screen.
+
+ Don't worry, your account and data are safe. It only takes a minute.
+
+ }
+ icon="bell"
+ onClose={() => {
+ setShowPermissionDeniedModal(false)
+ }}
+ ctaClassName="md:flex-col gap-4"
+ ctas={[
+ {
+ text: 'Got it!',
+ onClick: () => {
+ setShowPermissionDeniedModal(false)
+ },
+ shadowSize: '4',
+ variant: 'purple',
+ },
+ {
+ text: 'Reinstall later',
+ onClick: () => {
+ setShowPermissionDeniedModal(false)
+ },
+ variant: 'transparent',
+ className: 'underline h-6',
+ },
+ ]}
+ />
+ )}
+ >
+ )
+}
+
+export default CarouselCTA
+
diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx
new file mode 100644
index 000000000..15e8a5751
--- /dev/null
+++ b/src/components/Home/HomeCarouselCTA/index.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import Carousel from '@/components/Global/Carousel'
+import CarouselCTA from './CarouselCTA'
+import { type IconName } from '@/components/Global/Icons/Icon'
+import { useHomeCarouselCTAs } from '@/hooks/useHomeCarouselCTAs'
+
+const HomeCarouselCTA = () => {
+ const { carouselCTAs, setCarouselCTAs } = useHomeCarouselCTAs()
+
+ // don't render carousel if there are no CTAs
+ if (!carouselCTAs.length) return null
+
+ return (
+
+ {carouselCTAs.map((cta) => (
+ {
+ // Use cta.onClose if provided (for notification prompt), otherwise filter from list
+ if (cta.onClose) {
+ cta.onClose()
+ } else {
+ setCarouselCTAs((prev) => prev.filter((c) => c.id !== cta.id))
+ }
+ }}
+ onClick={cta.onClick}
+ logo={cta.logo}
+ iconContainerClassName={cta.iconContainerClassName}
+ isPermissionDenied={cta.isPermissionDenied}
+ />
+ ))}
+
+ )
+}
+
+export default HomeCarouselCTA
+
diff --git a/src/components/Profile/components/ProfileHeader.tsx b/src/components/Profile/components/ProfileHeader.tsx
index 333f6a366..b89d385b8 100644
--- a/src/components/Profile/components/ProfileHeader.tsx
+++ b/src/components/Profile/components/ProfileHeader.tsx
@@ -11,6 +11,7 @@ import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer'
import { VerifiedUserLabel } from '@/components/UserHeader'
import { useAuth } from '@/context/authContext'
import useKycStatus from '@/hooks/useKycStatus'
+import CopyToClipboard from '@/components/Global/CopyToClipboard'
interface ProfileHeaderProps {
name: string
@@ -44,15 +45,18 @@ const ProfileHeader: React.FC = ({
{/* Name */}
-
+
+
+
+
{/* Username with share drawer */}
{showShareButton && (
void | Promise
onClose?: () => void
isPermissionDenied?: boolean
iconContainerClassName?: string
}
-export const useBanners = () => {
- const [banners, setBanners] = useState([])
+export const useHomeCarouselCTAs = () => {
+ const [carouselCTAs, setCarouselCTAs] = useState([])
const { user } = useAuth()
const { showReminderBanner, requestPermission, snoozeReminderBanner, afterPermissionAttempt, isPermissionDenied } =
useNotifications()
const router = useRouter()
const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus()
- const generateBanners = () => {
- const _banners: Banner[] = []
+ const generateCarouselCTAs = useCallback(() => {
+ const _carouselCTAs: CarouselCTA[] = []
- // add notification banner as first item if it should be shown
+ // add notification prompt as first item if it should be shown
if (showReminderBanner) {
- _banners.push({
- id: 'notification-banner',
+ _carouselCTAs.push({
+ id: 'notification-prompt',
title: 'Stay in the loop!',
description: 'Turn on notifications and get alerts for all your wallet activity.',
icon: 'bell',
@@ -52,8 +51,8 @@ export const useBanners = () => {
}
if (!isUserKycApproved && !isUserBridgeKycUnderReview) {
- _banners.push({
- id: 'kyc-banner',
+ _carouselCTAs.push({
+ id: 'kyc-prompt',
title: (
Unlock QR code payments
@@ -72,17 +71,26 @@ export const useBanners = () => {
})
}
- setBanners(_banners)
- }
+ setCarouselCTAs(_carouselCTAs)
+ }, [
+ showReminderBanner,
+ isPermissionDenied,
+ isUserKycApproved,
+ isUserBridgeKycUnderReview,
+ router,
+ requestPermission,
+ afterPermissionAttempt,
+ snoozeReminderBanner,
+ ])
useEffect(() => {
if (!user) {
- setBanners([])
+ setCarouselCTAs([])
return
}
- generateBanners()
- }, [user, showReminderBanner, isPermissionDenied])
+ generateCarouselCTAs()
+ }, [user, generateCarouselCTAs])
- return { banners, setBanners }
+ return { carouselCTAs, setCarouselCTAs }
}
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index ca8b20092..5e8f80106 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -214,11 +214,21 @@ export const syncLocalStorageToCookie = (key: string) => {
console.log('Data synced successfully')
}
}
-
-// Helper function to format numbers with locale-specific (en-US) thousands separators for display.
-// The caller is responsible for prepending the correct currency symbol.
-// @dev todo: For true internationalization of read-only amounts, consider a dedicated service or util
-// that uses specific locales (e.g., 'es-AR' for '1.234,56'). This function standardizes on en-US for parsable input display.
+/**
+ * Helper function to format numbers with locale-specific (en-US) thousands separators for display.
+ * The caller is responsible for prepending the correct currency symbol.
+ *
+ * @remarks
+ * For true internationalization of read-only amounts, consider a dedicated service or util
+ * that uses specific locales (e.g., 'es-AR' for '1.234,56').
+ * This function standardizes on en-US for parsable input display.
+ *
+ * @param {string | undefined} valueStr - The numeric string value to format.
+ * @param {object} [options] - Optional formatting options.
+ * @param {number} [options.maxDecimals] - The maximum number of decimals to display.
+ * @param {number} [options.minDecimals] - The minimum number of decimals to display.
+ * @returns {string} The formatted string for display with thousands separators (en-US).
+ */
export const formatNumberForDisplay = (
valueStr: string | undefined,
options?: { maxDecimals?: number; minDecimals?: number }
diff --git a/src/utils/qr-payment.utils.ts b/src/utils/qr-payment.utils.ts
new file mode 100644
index 000000000..56c59e5bb
--- /dev/null
+++ b/src/utils/qr-payment.utils.ts
@@ -0,0 +1,49 @@
+import { formatNumberForDisplay } from './general.utils'
+import { EQrType } from '@/components/Global/DirectSendQR/utils'
+
+/**
+ * Calculate savings amount (5% of transaction value) in cents
+ * @param usdAmount Transaction amount in USD
+ * @returns Savings amount in cents, or 0 if invalid
+ */
+export function calculateSavingsInCents(usdAmount: string | null | undefined): number {
+ if (!usdAmount) return 0
+ const savingsAmount = parseFloat(usdAmount) * 0.05
+ return Math.round(savingsAmount * 100)
+}
+
+/**
+ * Check if QR payment is for Argentina (Manteca only)
+ * @param qrType QR code type from URL parameter
+ * @param paymentProcessor Payment processor ('MANTECA' | 'SIMPLEFI')
+ * @returns true if this is a Manteca QR payment in Argentina
+ */
+export function isArgentinaMantecaQrPayment(
+ qrType: string | null,
+ paymentProcessor: 'MANTECA' | 'SIMPLEFI' | null
+): boolean {
+ if (paymentProcessor !== 'MANTECA') return false
+ return qrType === EQrType.MERCADO_PAGO || qrType === EQrType.ARGENTINA_QR3
+}
+
+/**
+ * Get savings message text with proper formatting
+ * Shows dollars for amounts >= $1, cents for amounts < $1
+ * @param savingsInCents Savings amount in cents
+ * @returns Formatted message string
+ */
+export function getSavingsMessage(savingsInCents: number): string {
+ if (savingsInCents <= 0) return ''
+
+ const savingsInDollars = savingsInCents / 100
+
+ // If savings is less than $1, show in cents
+ if (savingsInDollars < 1) {
+ const centsText = savingsInCents === 1 ? 'cent' : 'cents'
+ return `If you had paid with card, it'd have cost you ~${savingsInCents} ${centsText} more!`
+ }
+
+ // If savings is $1 or more, show in dollars
+ const formattedDollars = formatNumberForDisplay(savingsInDollars.toString(), { maxDecimals: 2 })
+ return `If you had paid with card, it'd have cost you ~$${formattedDollars} more!`
+}
From ea09a1f4f71943578dfb670793c0ad3b323988d1 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Fri, 31 Oct 2025 21:03:08 -0300
Subject: [PATCH 02/17] format
---
src/components/Home/HomeCarouselCTA/CarouselCTA.tsx | 10 +++++++---
src/components/Home/HomeCarouselCTA/index.tsx | 1 -
src/constants/carousel.consts.ts | 1 -
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
index cc9979ffb..685977162 100644
--- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
+++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
@@ -73,7 +73,10 @@ const CarouselCTA = ({
type="button"
aria-label={getAriaLabel()}
onClick={handleClose}
- className={twMerge(CAROUSEL_CLOSE_BUTTON_POSITION, 'z-10 cursor-pointer p-0 text-black outline-none')}
+ className={twMerge(
+ CAROUSEL_CLOSE_BUTTON_POSITION,
+ 'z-10 cursor-pointer p-0 text-black outline-none'
+ )}
>
@@ -88,7 +91,9 @@ const CarouselCTA = ({
>
{/* Show icon only if logo isn't provided. Logo takes precedence over icon. */}
{!logo && }
- {logo && }
+ {logo && (
+
+ )}
{/* Content */}
@@ -140,4 +145,3 @@ const CarouselCTA = ({
}
export default CarouselCTA
-
diff --git a/src/components/Home/HomeCarouselCTA/index.tsx b/src/components/Home/HomeCarouselCTA/index.tsx
index 15e8a5751..e7ad1bf00 100644
--- a/src/components/Home/HomeCarouselCTA/index.tsx
+++ b/src/components/Home/HomeCarouselCTA/index.tsx
@@ -38,4 +38,3 @@ const HomeCarouselCTA = () => {
}
export default HomeCarouselCTA
-
diff --git a/src/constants/carousel.consts.ts b/src/constants/carousel.consts.ts
index 42681e06c..2d4807569 100644
--- a/src/constants/carousel.consts.ts
+++ b/src/constants/carousel.consts.ts
@@ -1,4 +1,3 @@
// Carousel CTA styling constants
export const CAROUSEL_CLOSE_BUTTON_POSITION = 'absolute right-2 top-2'
export const CAROUSEL_CLOSE_ICON_SIZE = 10
-
From db501818fa899f3bfea0ac7bcfd5fbd04550cdcb Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Fri, 31 Oct 2025 21:22:22 -0300
Subject: [PATCH 03/17] added support cta
---
src/components/Common/ActionList.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx
index 6ec704230..4b419b754 100644
--- a/src/components/Common/ActionList.tsx
+++ b/src/components/Common/ActionList.tsx
@@ -33,6 +33,7 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { ActionListCard } from '../ActionListCard'
import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions'
import { tokenSelectorContext } from '@/context'
+import SupportCTA from '../Global/SupportCTA'
interface IActionListProps {
flow: 'claim' | 'request'
@@ -314,6 +315,7 @@ export default function ActionList({
)
})}
+
setShowMinAmountError(false)}
From c767d751dfea6b6bed6b39bfe4da7673878b3141 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Fri, 31 Oct 2025 21:24:15 -0300
Subject: [PATCH 04/17] fix
---
src/components/Common/ActionList.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx
index 4b419b754..bb891a05d 100644
--- a/src/components/Common/ActionList.tsx
+++ b/src/components/Common/ActionList.tsx
@@ -315,7 +315,7 @@ export default function ActionList({
)
})}
-
+ {flow === 'claim' && !isLoggedIn && }
setShowMinAmountError(false)}
From fae636ee80cc7ddd09378be7f2586d1d947c5794 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 00:47:49 -0300
Subject: [PATCH 05/17] fix crisp
---
src/app/(mobile-ui)/support/page.tsx | 11 ++++
src/components/CrispChat.tsx | 19 +++---
src/components/Global/SupportDrawer/index.tsx | 47 ++-------------
src/hooks/useCrispIframeInitialization.ts | 58 +++++++++++++++++++
src/hooks/useCrispInitialization.ts | 45 ++++++++++++++
src/hooks/useCrispUserData.ts | 13 ++++-
src/utils/crisp.ts | 22 +++++--
7 files changed, 156 insertions(+), 59 deletions(-)
create mode 100644 src/hooks/useCrispIframeInitialization.ts
create mode 100644 src/hooks/useCrispInitialization.ts
diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx
index d9ec886cf..b273b29ea 100644
--- a/src/app/(mobile-ui)/support/page.tsx
+++ b/src/app/(mobile-ui)/support/page.tsx
@@ -1,8 +1,19 @@
'use client'
+import { useCrispUserData } from '@/hooks/useCrispUserData'
+import { useCrispIframeInitialization } from '@/hooks/useCrispIframeInitialization'
+import { useRef } from 'react'
+
const SupportPage = () => {
+ const userData = useCrispUserData()
+ const iframeRef = useRef(null)
+
+ // Initialize Crisp user data in iframe
+ useCrispIframeInitialization(iframeRef, userData, undefined, !!userData.userId)
+
return (
diff --git a/src/components/CrispChat.tsx b/src/components/CrispChat.tsx
index f2084d91b..0a68f72d3 100644
--- a/src/components/CrispChat.tsx
+++ b/src/components/CrispChat.tsx
@@ -24,29 +24,30 @@ export default function CrispChat() {
const userData = useCrispUserData()
useEffect(() => {
- // Only set user data if we have a username
- if (!userData.username || typeof window === 'undefined') return
+ if (!userData.userId || typeof window === 'undefined') return
- // Wait for Crisp to be fully loaded
const setData = () => {
if (window.$crisp) {
setCrispUserData(window.$crisp, userData)
}
}
- // Try to set immediately
- setData()
+ // Set data immediately if Crisp is already loaded
+ if (window.$crisp) {
+ setData()
+ }
- // Also listen for Crisp session loaded event
+ // Listen for session loaded event - primary event Crisp fires when ready
+ // This ensures data persists across sessions and is set when Crisp initializes
if (window.$crisp) {
window.$crisp.push(['on', 'session:loaded', setData])
}
- // Fallback: try again after a delay
- const timer = setTimeout(setData, 2000)
+ // Fallback: try once after a delay to catch cases where Crisp loads quickly
+ const fallbackTimer = setTimeout(setData, 1000)
return () => {
- clearTimeout(timer)
+ clearTimeout(fallbackTimer)
if (window.$crisp) {
window.$crisp.push(['off', 'session:loaded', setData])
}
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index ce3c2ecb5..75f98bb35 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -2,56 +2,17 @@
import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { setCrispUserData } from '@/utils/crisp'
+import { useCrispIframeInitialization } from '@/hooks/useCrispIframeInitialization'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
-import { useEffect, useRef } from 'react'
+import { useRef } from 'react'
const SupportDrawer = () => {
const { isSupportModalOpen, setIsSupportModalOpen, prefilledMessage } = useSupportModalContext()
const userData = useCrispUserData()
const iframeRef = useRef(null)
- useEffect(() => {
- if (!isSupportModalOpen || !iframeRef.current || !userData.username) return
-
- const iframe = iframeRef.current
-
- // Try to set Crisp data in iframe (same logic as CrispChat.tsx)
- const setData = () => {
- try {
- const iframeWindow = iframe.contentWindow as any
- if (!iframeWindow?.$crisp) return
-
- setCrispUserData(iframeWindow.$crisp, userData, prefilledMessage)
- } catch (e) {
- // Silently fail if CORS blocks access - no harm done
- console.debug('Could not set Crisp data in iframe (expected if CORS-blocked):', e)
- }
- }
-
- const handleLoad = () => {
- // Try immediately
- setData()
-
- // Listen for Crisp loaded event in iframe
- try {
- const iframeWindow = iframe.contentWindow as any
- if (iframeWindow?.$crisp) {
- iframeWindow.$crisp.push(['on', 'session:loaded', setData])
- }
- } catch (e) {
- // Ignore CORS errors
- }
-
- // Fallback: try again after delays
- setTimeout(setData, 500)
- setTimeout(setData, 1000)
- setTimeout(setData, 2000)
- }
-
- iframe.addEventListener('load', handleLoad)
- return () => iframe.removeEventListener('load', handleLoad)
- }, [isSupportModalOpen, userData, prefilledMessage])
+ // Initialize Crisp user data in iframe
+ useCrispIframeInitialization(iframeRef, userData, prefilledMessage, isSupportModalOpen && !!userData.userId)
return (
diff --git a/src/hooks/useCrispIframeInitialization.ts b/src/hooks/useCrispIframeInitialization.ts
new file mode 100644
index 000000000..1fd6b6cfc
--- /dev/null
+++ b/src/hooks/useCrispIframeInitialization.ts
@@ -0,0 +1,58 @@
+import { useEffect, useRef } from 'react'
+import { setCrispUserData } from '@/utils/crisp'
+import { type CrispUserData } from '@/hooks/useCrispUserData'
+
+/**
+ * Hook to initialize Crisp user data in an iframe (for embedded Crisp chat)
+ * Handles CORS-safe initialization and event listeners
+ * @param iframeRef - Ref to the iframe element
+ * @param userData - User data to set
+ * @param prefilledMessage - Optional prefilled message
+ * @param enabled - Whether initialization is enabled
+ */
+export function useCrispIframeInitialization(
+ iframeRef: React.RefObject,
+ userData: CrispUserData,
+ prefilledMessage?: string,
+ enabled: boolean = true
+) {
+ useEffect(() => {
+ if (!enabled || !iframeRef.current || !userData.userId) return
+
+ const iframe = iframeRef.current
+
+ const setData = () => {
+ try {
+ const iframeWindow = iframe.contentWindow as any
+ if (!iframeWindow?.$crisp) return
+
+ setCrispUserData(iframeWindow.$crisp, userData, prefilledMessage)
+ } catch (e) {
+ // Silently fail if CORS blocks access - no harm done
+ console.debug('Could not set Crisp data in iframe (expected if CORS-blocked):', e)
+ }
+ }
+
+ const handleLoad = () => {
+ // Try immediately
+ setData()
+
+ // Listen for Crisp loaded event in iframe
+ try {
+ const iframeWindow = iframe.contentWindow as any
+ if (iframeWindow?.$crisp) {
+ iframeWindow.$crisp.push(['on', 'session:loaded', setData])
+ }
+ } catch (e) {
+ // Ignore CORS errors
+ }
+
+ // Fallback: try once after a delay (simplified from multiple timeouts)
+ setTimeout(setData, 1000)
+ }
+
+ iframe.addEventListener('load', handleLoad)
+ return () => iframe.removeEventListener('load', handleLoad)
+ }, [iframeRef, userData, prefilledMessage, enabled])
+}
+
diff --git a/src/hooks/useCrispInitialization.ts b/src/hooks/useCrispInitialization.ts
new file mode 100644
index 000000000..b0d59f3ef
--- /dev/null
+++ b/src/hooks/useCrispInitialization.ts
@@ -0,0 +1,45 @@
+import { useEffect } from 'react'
+import { setCrispUserData } from '@/utils/crisp'
+import { type CrispUserData } from './useCrispUserData'
+
+/**
+ * Hook to initialize Crisp user data on a given $crisp instance
+ * Handles timing, event listeners, and cleanup according to Crisp SDK best practices
+ * @param crispInstance - The $crisp object (window.$crisp or iframe.contentWindow.$crisp)
+ * @param userData - User data to set
+ * @param prefilledMessage - Optional prefilled message
+ * @param enabled - Whether initialization is enabled (default: true)
+ */
+export function useCrispInitialization(
+ crispInstance: any,
+ userData: CrispUserData,
+ prefilledMessage?: string,
+ enabled: boolean = true
+) {
+ useEffect(() => {
+ if (!enabled || !userData.userId || !crispInstance) return
+
+ const setData = () => {
+ if (crispInstance) {
+ setCrispUserData(crispInstance, userData, prefilledMessage)
+ }
+ }
+
+ // Set data immediately if Crisp is already loaded
+ setData()
+
+ // Listen for session loaded event - primary event Crisp fires when ready
+ // This ensures data persists across sessions and is set when Crisp initializes
+ crispInstance.push(['on', 'session:loaded', setData])
+
+ // Fallback: try once after a delay to catch cases where Crisp loads quickly
+ const fallbackTimer = setTimeout(setData, 1000)
+
+ return () => {
+ clearTimeout(fallbackTimer)
+ if (crispInstance) {
+ crispInstance.push(['off', 'session:loaded', setData])
+ }
+ }
+ }, [crispInstance, userData, prefilledMessage, enabled])
+}
diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts
index 633f6368a..e5ffbbc74 100644
--- a/src/hooks/useCrispUserData.ts
+++ b/src/hooks/useCrispUserData.ts
@@ -5,22 +5,29 @@ export interface CrispUserData {
username: string | undefined
userId: string | undefined
email: string | undefined
+ fullName: string | undefined
+ avatar: string | undefined
grafanaLink: string | undefined
}
export function useCrispUserData(): CrispUserData {
- const { username, userId } = useAuth()
+ const { username, userId, user } = useAuth()
return useMemo(() => {
const grafanaLink = username
? `https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user?orgId=1&var-GRAFANA_VAR_Username=${encodeURIComponent(username)}`
: undefined
+ // Use actual email from user data, or userId as fallback identifier for Crisp
+ const email = user?.user?.email || (userId ? `${userId}@peanut.internal` : undefined)
+
return {
username,
userId,
- email: undefined,
+ email,
+ fullName: user?.user?.fullName,
+ avatar: user?.user?.profile_picture || undefined,
grafanaLink,
}
- }, [username, userId])
+ }, [username, userId, user])
}
diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts
index 90d1d79c5..217b78874 100644
--- a/src/utils/crisp.ts
+++ b/src/utils/crisp.ts
@@ -9,13 +9,26 @@ import { type CrispUserData } from '@/hooks/useCrispUserData'
export function setCrispUserData(crispInstance: any, userData: CrispUserData, prefilledMessage?: string): void {
if (!crispInstance) return
- const { username, userId, email, grafanaLink } = userData
+ const { username, userId, email, fullName, avatar, grafanaLink } = userData
- // Set user nickname and email
- crispInstance.push(['set', 'user:nickname', [username || '']])
- crispInstance.push(['set', 'user:email', [email || '']])
+ // Set user email - this is critical for session persistence across devices/browsers
+ if (email) {
+ crispInstance.push(['set', 'user:email', [email]])
+ }
+
+ // Set user nickname - prefer fullName, fallback to username
+ const nickname = fullName || username || ''
+ if (nickname) {
+ crispInstance.push(['set', 'user:nickname', [nickname]])
+ }
+
+ // Set user avatar if available
+ if (avatar) {
+ crispInstance.push(['set', 'user:avatar', [avatar]])
+ }
// Set session data - EXACT STRUCTURE (3 nested arrays!)
+ // This metadata appears in Crisp dashboard for support agents
crispInstance.push([
'set',
'session:data',
@@ -23,6 +36,7 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr
[
['username', username || ''],
['user_id', userId || ''],
+ ['full_name', fullName || ''],
['grafana_dashboard', grafanaLink || ''],
],
],
From 89b1c920ff1ecd717bed6dfdc2bc1a5fa06f278a Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 00:49:39 -0300
Subject: [PATCH 06/17] fix formatting
---
src/hooks/useCrispIframeInitialization.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/hooks/useCrispIframeInitialization.ts b/src/hooks/useCrispIframeInitialization.ts
index 1fd6b6cfc..d89d08fb9 100644
--- a/src/hooks/useCrispIframeInitialization.ts
+++ b/src/hooks/useCrispIframeInitialization.ts
@@ -55,4 +55,3 @@ export function useCrispIframeInitialization(
return () => iframe.removeEventListener('load', handleLoad)
}, [iframeRef, userData, prefilledMessage, enabled])
}
-
From 7bd9f3098caa924292a64e22f27a0b7d33da50b8 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 00:57:27 -0300
Subject: [PATCH 07/17] delete unused code
---
.../Notifications/NotificationBanner.tsx | 107 ------------------
1 file changed, 107 deletions(-)
delete mode 100644 src/components/Notifications/NotificationBanner.tsx
diff --git a/src/components/Notifications/NotificationBanner.tsx b/src/components/Notifications/NotificationBanner.tsx
deleted file mode 100644
index ca9af11e2..000000000
--- a/src/components/Notifications/NotificationBanner.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-'use client'
-import Card from '@/components/Global/Card'
-import { Icon } from '@/components/Global/Icons/Icon'
-import { useState } from 'react'
-import { twMerge } from 'tailwind-merge'
-import ActionModal from '../Global/ActionModal'
-
-const NotificationBanner = ({
- onClick,
- onClose,
- isPermissionDenied,
-}: {
- onClick: () => void
- onClose: () => void
- isPermissionDenied: boolean
-}) => {
- const [showBanner, setShowBanner] = useState(true)
- const [showPermissionDeniedModal, setShowPermissionDeniedModal] = useState(false)
-
- const handleClick = () => {
- onClick()
- }
-
- const handleHideBanner = (e?: React.MouseEvent) => {
- if (e) e.stopPropagation()
- setShowBanner(false)
- onClose()
- }
-
- if (!showBanner) return null
-
- return (
- <>
- {
- if (isPermissionDenied) {
- setShowPermissionDeniedModal(true)
- } else {
- handleClick()
- }
- }}
- >
-
-
-
-
-
-
-
Stay in the loop!
-
-
- Turn on notifications and get alerts for all your wallet activity.
-
-
-
-
-
-
-
-
- {showPermissionDeniedModal && (
-
- To keep getting updates, you'll need to reinstall the app on your home screen.
-
- Don't worry, your account and data are safe. It only takes a minute.
-
- }
- icon="bell"
- onClose={() => {
- setShowPermissionDeniedModal(false)
- }}
- ctaClassName="md:flex-col gap-4"
- ctas={[
- {
- text: 'Got it!',
- onClick: () => {
- setShowPermissionDeniedModal(false)
- },
- shadowSize: '4',
- variant: 'purple',
- },
- {
- text: 'Reinstall later',
- onClick: () => {
- setShowPermissionDeniedModal(false)
- },
- variant: 'transparent',
- className: 'underline h-6',
- },
- ]}
- />
- )}
- >
- )
-}
-
-export default NotificationBanner
From 1d807a1a07e5718a4aea466f2b870971d1418a19 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 00:58:55 -0300
Subject: [PATCH 08/17] fix padding
---
src/components/Home/HomeCarouselCTA/CarouselCTA.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
index 685977162..060cc51a0 100644
--- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
+++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx
@@ -67,7 +67,10 @@ const CarouselCTA = ({
return (
<>
-
+
{/* Close button - consistent positioning and size */}
Date: Sat, 1 Nov 2025 01:12:07 -0300
Subject: [PATCH 09/17] fix
---
src/app/(mobile-ui)/qr-pay/page.tsx | 58 +++++++++++------------------
1 file changed, 22 insertions(+), 36 deletions(-)
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 351defd9a..5dea0e24b 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -735,6 +735,12 @@ export default function QRPayPage() {
// Check user balance
useEffect(() => {
+ // Skip balance check on success screen (balance may not have updated yet)
+ if (isSuccess) {
+ setBalanceErrorMessage(null)
+ return
+ }
+
// Skip balance check if transaction is being processed
if (hasPendingTransactions || isWaitingForWebSocket) {
return
@@ -752,7 +758,7 @@ export default function QRPayPage() {
} else {
setBalanceErrorMessage(null)
}
- }, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket])
+ }, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket, isSuccess])
// Use points confetti hook for animation - must be called unconditionally
usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef)
@@ -1007,22 +1013,6 @@ export default function QRPayPage() {
)}
- {/* Savings Message - Show after payment card (Argentina Manteca only) */}
- {showSavingsMessage && savingsMessage && (
- {savingsMessage}
- )}
-
- {/* Points Display - Show after payment card */}
- {pointsData?.estimatedPoints && (
-
-
-
- You've earned {pointsData.estimatedPoints}{' '}
- {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
-
-
- )}
-
{/* Perk Eligibility Card - Show before claiming */}
{qrPayment?.perk?.eligible && !perkClaimed && !qrPayment.perk.claimed && (
@@ -1071,25 +1061,21 @@ export default function QRPayPage() {
)}
- {/* Savings Message and Points - Show after perk banner if perk claimed */}
- {(perkClaimed || qrPayment?.perk?.claimed) && (
- <>
- {/* Savings Message (Argentina Manteca only) */}
- {showSavingsMessage && savingsMessage && (
- {savingsMessage}
- )}
-
- {/* Points Display */}
- {pointsData?.estimatedPoints && (
-
-
-
- You've earned {pointsData.estimatedPoints}{' '}
- {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
-
-
- )}
- >
+ {/* Savings Message and Points - Show after payment card OR after perk banner */}
+ {/* Savings Message (Argentina Manteca only) */}
+ {showSavingsMessage && savingsMessage && (
+ {savingsMessage}
+ )}
+
+ {/* Points Display */}
+ {pointsData?.estimatedPoints && (
+
+
+
+ You've earned {pointsData.estimatedPoints}{' '}
+ {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
+
+
)}
From 141d76f1f41db886a5f2a0bf582d9d0a84186021 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 01:14:35 -0300
Subject: [PATCH 10/17] fix crisp metadata
---
src/components/Global/SupportDrawer/index.tsx | 3 ++-
src/context/authContext.tsx | 6 ++++++
src/utils/crisp.ts | 21 +++++++++++++++++++
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 75f98bb35..eabd54431 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -12,7 +12,8 @@ const SupportDrawer = () => {
const iframeRef = useRef(null)
// Initialize Crisp user data in iframe
- useCrispIframeInitialization(iframeRef, userData, prefilledMessage, isSupportModalOpen && !!userData.userId)
+ // Don't wait for drawer to open - iframe loads immediately and we need listener attached before load event
+ useCrispIframeInitialization(iframeRef, userData, prefilledMessage, !!userData.userId)
return (
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index c330fb724..741dc5680 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -11,6 +11,7 @@ import {
clearRedirectUrl,
updateUserPreferences,
} from '@/utils'
+import { resetCrispSession } from '@/utils/crisp'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react'
@@ -148,6 +149,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
sessionStorage.removeItem('hasSeenIOSPWAPromptThisSession')
}
+ // Reset Crisp session to prevent session merging with next user
+ if (typeof window !== 'undefined' && window.$crisp) {
+ resetCrispSession(window.$crisp)
+ }
+
await fetchUser()
dispatch(setupActions.resetSetup())
router.replace('/setup')
diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts
index 217b78874..e80658d8e 100644
--- a/src/utils/crisp.ts
+++ b/src/utils/crisp.ts
@@ -11,6 +11,12 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr
const { username, userId, email, fullName, avatar, grafanaLink } = userData
+ // Set session identifier (tokenId) for cross-device/cross-session persistence
+ // This ensures sessions persist across devices and cookie clears
+ if (userId) {
+ crispInstance.push(['set', 'session:identifier', [userId]])
+ }
+
// Set user email - this is critical for session persistence across devices/browsers
if (email) {
crispInstance.push(['set', 'user:email', [email]])
@@ -47,3 +53,18 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr
crispInstance.push(['set', 'message:text', [prefilledMessage]])
}
}
+
+/**
+ * Resets Crisp session - call this on logout to prevent session merging
+ * @param crispInstance - The $crisp object (window.$crisp or iframe.contentWindow.$crisp)
+ */
+export function resetCrispSession(crispInstance: any): void {
+ if (!crispInstance || typeof window === 'undefined') return
+
+ try {
+ // Reset the session to prevent merging with next user's session
+ crispInstance.push(['do', 'session:reset'])
+ } catch (e) {
+ console.debug('Could not reset Crisp session:', e)
+ }
+}
From 11e210583fd053e103444b411c2beea19a198ff1 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 01:20:34 -0300
Subject: [PATCH 11/17] fix simplefi pts
---
src/app/(mobile-ui)/qr-pay/page.tsx | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 5dea0e24b..9e5f78caa 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -320,14 +320,15 @@ export default function QRPayPage() {
// Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
// This way points are cached by the time success view shows
+ // Only Manteca QR payments give points (SimpleFi does not)
const { data: pointsData } = useQuery({
queryKey: ['calculate-points', 'qr-payment', paymentProcessor, usdAmount],
queryFn: () =>
pointsApi.calculatePoints({
- actionType: PointsAction.MANTECA_QR_PAYMENT, // Both Manteca and SimpleFi QR payments use this type
+ actionType: PointsAction.MANTECA_QR_PAYMENT,
usdAmount: Number(usdAmount),
}),
- enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && paymentProcessor),
+ enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && paymentProcessor === 'MANTECA'),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
@@ -1204,17 +1205,6 @@ export default function QRPayPage() {
- {/* Points Display */}
- {pointsData?.estimatedPoints && (
-
-
-
- You've earned {pointsData.estimatedPoints}{' '}
- {pointsData.estimatedPoints === 1 ? 'point' : 'points'}!
-
-
- )}
-
{
From e16c0324e7f1ca4647f39010ffc79086804223a3 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 01:41:08 -0300
Subject: [PATCH 12/17] fixes
---
src/app/(mobile-ui)/support/page.tsx | 18 +++---
src/components/CrispChat.tsx | 41 +++---------
src/components/Global/SupportDrawer/index.tsx | 17 +++--
src/hooks/useCrispEmbedUrl.ts | 33 ++++++++++
src/hooks/useCrispIframeInitialization.ts | 57 -----------------
src/hooks/useCrispIframeSessionData.ts | 64 +++++++++++++++++++
src/utils/crisp.ts | 7 +-
7 files changed, 123 insertions(+), 114 deletions(-)
create mode 100644 src/hooks/useCrispEmbedUrl.ts
delete mode 100644 src/hooks/useCrispIframeInitialization.ts
create mode 100644 src/hooks/useCrispIframeSessionData.ts
diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx
index b273b29ea..4c7d62609 100644
--- a/src/app/(mobile-ui)/support/page.tsx
+++ b/src/app/(mobile-ui)/support/page.tsx
@@ -1,23 +1,21 @@
'use client'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { useCrispIframeInitialization } from '@/hooks/useCrispIframeInitialization'
+import { useCrispEmbedUrl } from '@/hooks/useCrispEmbedUrl'
+import { useCrispIframeSessionData } from '@/hooks/useCrispIframeSessionData'
import { useRef } from 'react'
const SupportPage = () => {
const userData = useCrispUserData()
const iframeRef = useRef(null)
- // Initialize Crisp user data in iframe
- useCrispIframeInitialization(iframeRef, userData, undefined, !!userData.userId)
+ // Build Crisp embed URL with user data as URL parameters (bypasses CORS)
+ const crispEmbedUrl = useCrispEmbedUrl(userData)
- return (
-
- )
+ // Try to set session:data via JavaScript if CORS allows (for metadata like grafana link)
+ useCrispIframeSessionData(iframeRef, userData)
+
+ return
}
export default SupportPage
diff --git a/src/components/CrispChat.tsx b/src/components/CrispChat.tsx
index 0a68f72d3..9a1c24048 100644
--- a/src/components/CrispChat.tsx
+++ b/src/components/CrispChat.tsx
@@ -1,10 +1,9 @@
'use client'
import Script from 'next/script'
-import { useEffect } from 'react'
import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { setCrispUserData } from '@/utils/crisp'
+import { useCrispInitialization } from '@/hooks/useCrispInitialization'
export const CrispButton = ({ children, ...rest }: React.HTMLAttributes) => {
const { setIsSupportModalOpen } = useSupportModalContext()
@@ -23,36 +22,14 @@ export const CrispButton = ({ children, ...rest }: React.HTMLAttributes {
- if (!userData.userId || typeof window === 'undefined') return
-
- const setData = () => {
- if (window.$crisp) {
- setCrispUserData(window.$crisp, userData)
- }
- }
-
- // Set data immediately if Crisp is already loaded
- if (window.$crisp) {
- setData()
- }
-
- // Listen for session loaded event - primary event Crisp fires when ready
- // This ensures data persists across sessions and is set when Crisp initializes
- if (window.$crisp) {
- window.$crisp.push(['on', 'session:loaded', setData])
- }
-
- // Fallback: try once after a delay to catch cases where Crisp loads quickly
- const fallbackTimer = setTimeout(setData, 1000)
-
- return () => {
- clearTimeout(fallbackTimer)
- if (window.$crisp) {
- window.$crisp.push(['off', 'session:loaded', setData])
- }
- }
- }, [userData])
+ // Initialize Crisp user data using the centralized hook
+ // typeof window check ensures SSR safety
+ useCrispInitialization(
+ typeof window !== 'undefined' ? window.$crisp : null,
+ userData,
+ undefined,
+ !!userData.userId && typeof window !== 'undefined'
+ )
// thought: we need to version pin this script
return (
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index eabd54431..5836f12ef 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -2,7 +2,8 @@
import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { useCrispIframeInitialization } from '@/hooks/useCrispIframeInitialization'
+import { useCrispEmbedUrl } from '@/hooks/useCrispEmbedUrl'
+import { useCrispIframeSessionData } from '@/hooks/useCrispIframeSessionData'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
import { useRef } from 'react'
@@ -11,19 +12,17 @@ const SupportDrawer = () => {
const userData = useCrispUserData()
const iframeRef = useRef(null)
- // Initialize Crisp user data in iframe
- // Don't wait for drawer to open - iframe loads immediately and we need listener attached before load event
- useCrispIframeInitialization(iframeRef, userData, prefilledMessage, !!userData.userId)
+ // Build Crisp embed URL with user data as URL parameters (bypasses CORS)
+ const crispEmbedUrl = useCrispEmbedUrl(userData)
+
+ // Try to set session:data via JavaScript if CORS allows (for metadata like grafana link)
+ useCrispIframeSessionData(iframeRef, userData, prefilledMessage)
return (
Support
-
+
)
diff --git a/src/hooks/useCrispEmbedUrl.ts b/src/hooks/useCrispEmbedUrl.ts
new file mode 100644
index 000000000..56b1b727e
--- /dev/null
+++ b/src/hooks/useCrispEmbedUrl.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react'
+import { type CrispUserData } from '@/hooks/useCrispUserData'
+
+const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
+const CRISP_EMBED_BASE_URL = `https://go.crisp.chat/chat/embed/?website_id=${CRISP_WEBSITE_ID}`
+
+/**
+ * Hook to build Crisp embed URL with user data as URL parameters
+ * This bypasses CORS completely - Crisp supports these URL params:
+ * - user_email: User's email address (CRITICAL for session persistence)
+ * - user_nickname: User's nickname
+ * - user_phone: User's phone number
+ * - user_avatar: URL to the user's avatar image
+ */
+export function useCrispEmbedUrl(userData: CrispUserData): string {
+ return useMemo(() => {
+ const params = new URLSearchParams()
+
+ if (userData.email) {
+ params.append('user_email', userData.email)
+ }
+ if (userData.fullName || userData.username) {
+ params.append('user_nickname', userData.fullName || userData.username || '')
+ }
+ if (userData.avatar) {
+ params.append('user_avatar', userData.avatar)
+ }
+
+ const queryString = params.toString()
+ return queryString ? `${CRISP_EMBED_BASE_URL}&${queryString}` : CRISP_EMBED_BASE_URL
+ }, [userData.email, userData.fullName, userData.username, userData.avatar])
+}
+
diff --git a/src/hooks/useCrispIframeInitialization.ts b/src/hooks/useCrispIframeInitialization.ts
deleted file mode 100644
index d89d08fb9..000000000
--- a/src/hooks/useCrispIframeInitialization.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useEffect, useRef } from 'react'
-import { setCrispUserData } from '@/utils/crisp'
-import { type CrispUserData } from '@/hooks/useCrispUserData'
-
-/**
- * Hook to initialize Crisp user data in an iframe (for embedded Crisp chat)
- * Handles CORS-safe initialization and event listeners
- * @param iframeRef - Ref to the iframe element
- * @param userData - User data to set
- * @param prefilledMessage - Optional prefilled message
- * @param enabled - Whether initialization is enabled
- */
-export function useCrispIframeInitialization(
- iframeRef: React.RefObject,
- userData: CrispUserData,
- prefilledMessage?: string,
- enabled: boolean = true
-) {
- useEffect(() => {
- if (!enabled || !iframeRef.current || !userData.userId) return
-
- const iframe = iframeRef.current
-
- const setData = () => {
- try {
- const iframeWindow = iframe.contentWindow as any
- if (!iframeWindow?.$crisp) return
-
- setCrispUserData(iframeWindow.$crisp, userData, prefilledMessage)
- } catch (e) {
- // Silently fail if CORS blocks access - no harm done
- console.debug('Could not set Crisp data in iframe (expected if CORS-blocked):', e)
- }
- }
-
- const handleLoad = () => {
- // Try immediately
- setData()
-
- // Listen for Crisp loaded event in iframe
- try {
- const iframeWindow = iframe.contentWindow as any
- if (iframeWindow?.$crisp) {
- iframeWindow.$crisp.push(['on', 'session:loaded', setData])
- }
- } catch (e) {
- // Ignore CORS errors
- }
-
- // Fallback: try once after a delay (simplified from multiple timeouts)
- setTimeout(setData, 1000)
- }
-
- iframe.addEventListener('load', handleLoad)
- return () => iframe.removeEventListener('load', handleLoad)
- }, [iframeRef, userData, prefilledMessage, enabled])
-}
diff --git a/src/hooks/useCrispIframeSessionData.ts b/src/hooks/useCrispIframeSessionData.ts
new file mode 100644
index 000000000..0b78e0a33
--- /dev/null
+++ b/src/hooks/useCrispIframeSessionData.ts
@@ -0,0 +1,64 @@
+import { useEffect, useRef } from 'react'
+import { type CrispUserData } from '@/hooks/useCrispUserData'
+
+/**
+ * Hook to attempt setting session:data in Crisp iframe via JavaScript
+ * This is a best-effort attempt - URL params handle user identification
+ * Session metadata (grafana_dashboard, user_id, etc.) cannot be passed via URL params
+ * and must be set via JavaScript (which may fail due to CORS)
+ */
+export function useCrispIframeSessionData(
+ iframeRef: React.RefObject,
+ userData: CrispUserData,
+ prefilledMessage?: string
+) {
+ useEffect(() => {
+ if (!iframeRef.current || !userData.userId) return
+
+ const iframe = iframeRef.current
+ const setSessionData = () => {
+ try {
+ const iframeWindow = iframe.contentWindow as any
+ if (iframeWindow?.$crisp) {
+ // Set session:data (metadata) - this appears in Crisp dashboard for support agents
+ // Note: User identification (email, nickname) is already set via URL params
+ iframeWindow.$crisp.push([
+ 'set',
+ 'session:data',
+ [
+ [
+ ['username', userData.username || ''],
+ ['user_id', userData.userId || ''],
+ ['full_name', userData.fullName || ''],
+ ['grafana_dashboard', userData.grafanaLink || ''],
+ ],
+ ],
+ ])
+ if (prefilledMessage) {
+ iframeWindow.$crisp.push(['set', 'message:text', [prefilledMessage]])
+ }
+ console.log('[Crisp] ✅ Successfully set session metadata via JavaScript')
+ }
+ } catch (e: any) {
+ // CORS error expected - URL params already handle user identification
+ if (e.message?.includes('cross-origin') || e.name === 'SecurityError') {
+ console.debug(
+ '[Crisp] Session metadata cannot be set (CORS) - user identification via URL params is sufficient'
+ )
+ }
+ }
+ }
+
+ const handleLoad = () => {
+ // Try immediately
+ setSessionData()
+ // Retry after delays
+ setTimeout(setSessionData, 500)
+ setTimeout(setSessionData, 2000)
+ }
+
+ iframe.addEventListener('load', handleLoad)
+ return () => iframe.removeEventListener('load', handleLoad)
+ }, [iframeRef, userData, prefilledMessage])
+}
+
diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts
index e80658d8e..5f919ec49 100644
--- a/src/utils/crisp.ts
+++ b/src/utils/crisp.ts
@@ -11,13 +11,8 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr
const { username, userId, email, fullName, avatar, grafanaLink } = userData
- // Set session identifier (tokenId) for cross-device/cross-session persistence
- // This ensures sessions persist across devices and cookie clears
- if (userId) {
- crispInstance.push(['set', 'session:identifier', [userId]])
- }
-
// Set user email - this is critical for session persistence across devices/browsers
+ // According to Crisp docs, user:email is the primary identifier for session persistence
if (email) {
crispInstance.push(['set', 'user:email', [email]])
}
From 413d7213ac43aef8145742d1a8a9b57bc66ebd58 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 02:20:38 -0300
Subject: [PATCH 13/17] FINALLY
---
src/app/(mobile-ui)/support/page.tsx | 36 +++-
src/app/crisp-proxy/page.tsx | 160 ++++++++++++++++++
src/components/CrispChat.tsx | 38 +++--
src/components/Global/SupportDrawer/index.tsx | 42 ++++-
src/context/authContext.tsx | 7 +-
src/hooks/useCrispIframeSessionData.ts | 64 -------
src/hooks/useCrispInitialization.ts | 16 +-
src/hooks/useCrispProxyUrl.ts | 69 ++++++++
src/hooks/useCrispUserData.ts | 30 +++-
src/utils/crisp.ts | 72 ++++++--
10 files changed, 406 insertions(+), 128 deletions(-)
create mode 100644 src/app/crisp-proxy/page.tsx
delete mode 100644 src/hooks/useCrispIframeSessionData.ts
create mode 100644 src/hooks/useCrispProxyUrl.ts
diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx
index 4c7d62609..2a0072c82 100644
--- a/src/app/(mobile-ui)/support/page.tsx
+++ b/src/app/(mobile-ui)/support/page.tsx
@@ -1,21 +1,39 @@
'use client'
+import { useState, useEffect } from 'react'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { useCrispEmbedUrl } from '@/hooks/useCrispEmbedUrl'
-import { useCrispIframeSessionData } from '@/hooks/useCrispIframeSessionData'
-import { useRef } from 'react'
+import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
+import PeanutLoading from '@/components/Global/PeanutLoading'
const SupportPage = () => {
const userData = useCrispUserData()
- const iframeRef = useRef(null)
+ const crispProxyUrl = useCrispProxyUrl(userData)
+ const [isLoading, setIsLoading] = useState(true)
- // Build Crisp embed URL with user data as URL parameters (bypasses CORS)
- const crispEmbedUrl = useCrispEmbedUrl(userData)
+ useEffect(() => {
+ // Listen for ready message from proxy iframe
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return
- // Try to set session:data via JavaScript if CORS allows (for metadata like grafana link)
- useCrispIframeSessionData(iframeRef, userData)
+ if (event.data.type === 'CRISP_READY') {
+ setIsLoading(false)
+ }
+ }
- return
+ window.addEventListener('message', handleMessage)
+ return () => window.removeEventListener('message', handleMessage)
+ }, [])
+
+ return (
+
+ {isLoading && (
+
+ )}
+
+
+ )
}
export default SupportPage
diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx
new file mode 100644
index 000000000..e1518559c
--- /dev/null
+++ b/src/app/crisp-proxy/page.tsx
@@ -0,0 +1,160 @@
+'use client'
+
+import Script from 'next/script'
+import { useEffect, Suspense } from 'react'
+import { useSearchParams } from 'next/navigation'
+
+const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
+
+/**
+ * Crisp Proxy Page - Same-origin iframe solution for embedded Crisp chat
+ *
+ * This page loads the Crisp widget in full-screen mode and is embedded as an iframe
+ * from SupportDrawer and SupportPage. By being same-origin, we avoid CORS issues
+ * and can fully control the Crisp instance via JavaScript.
+ *
+ * User data flows via URL parameters and is set during Crisp initialization,
+ * following Crisp's recommended pattern for iframe embedding with JS SDK control.
+ */
+function CrispProxyContent() {
+ const searchParams = useSearchParams()
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ ;(window as any).CRISP_RUNTIME_CONFIG = {
+ lock_maximized: true,
+ lock_full_view: true,
+ cross_origin_cookies: true, // Essential for session persistence in iframes
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+
+ const email = searchParams.get('user_email')
+ const nickname = searchParams.get('user_nickname')
+ const avatar = searchParams.get('user_avatar')
+ const sessionDataJson = searchParams.get('session_data')
+ const prefilledMessage = searchParams.get('prefilled_message')
+
+ const notifyParentReady = () => {
+ if (window.parent !== window) {
+ window.parent.postMessage(
+ {
+ type: 'CRISP_READY',
+ },
+ window.location.origin
+ )
+ }
+ }
+
+ const setAllData = () => {
+ if (!window.$crisp) return false
+
+ // Check sessionStorage for reset flag (set during logout)
+ const needsReset = sessionStorage.getItem('crisp_needs_reset')
+ if (needsReset === 'true') {
+ window.$crisp.push(['do', 'session:reset'])
+ sessionStorage.removeItem('crisp_needs_reset')
+ }
+
+ // Set user identification
+ if (email) {
+ window.$crisp.push(['set', 'user:email', [email]])
+ }
+ if (nickname) {
+ window.$crisp.push(['set', 'user:nickname', [nickname]])
+ }
+ if (avatar) {
+ window.$crisp.push(['set', 'user:avatar', [avatar]])
+ }
+
+ // Set session metadata for support agents
+ if (sessionDataJson) {
+ try {
+ const data = JSON.parse(sessionDataJson)
+ const sessionDataArray = [
+ [
+ ['username', data.username || ''],
+ ['user_id', data.user_id || ''],
+ ['full_name', data.full_name || ''],
+ ['grafana_dashboard', data.grafana_dashboard || ''],
+ ['wallet_address', data.wallet_address || ''],
+ ['bridge_user_id', data.bridge_user_id || ''],
+ ['manteca_user_id', data.manteca_user_id || ''],
+ ],
+ ]
+ window.$crisp.push(['set', 'session:data', sessionDataArray])
+ } catch (e) {
+ console.error('[Crisp] Failed to parse session_data:', e)
+ }
+ }
+
+ if (prefilledMessage) {
+ window.$crisp.push(['set', 'message:text', [prefilledMessage]])
+ }
+
+ // Wait for Crisp to be fully ready (session loaded and UI rendered)
+ window.$crisp.push(['on', 'session:loaded', notifyParentReady])
+
+ // Fallback: notify after a delay if session:loaded doesn't fire
+ setTimeout(notifyParentReady, 1500)
+
+ return true
+ }
+
+ // Initialize data once Crisp loads
+ if (window.$crisp) {
+ setAllData()
+ } else {
+ const checkCrisp = setInterval(() => {
+ if (window.$crisp) {
+ setAllData()
+ clearInterval(checkCrisp)
+ }
+ }, 100)
+
+ setTimeout(() => clearInterval(checkCrisp), 5000)
+ }
+
+ // Listen for reset messages from parent window
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return
+
+ if (event.data.type === 'CRISP_RESET_SESSION' && window.$crisp) {
+ window.$crisp.push(['do', 'session:reset'])
+ }
+ }
+
+ window.addEventListener('message', handleMessage)
+ return () => window.removeEventListener('message', handleMessage)
+ }, [searchParams])
+
+ return (
+
+
+
+ )
+}
+
+export default function CrispProxyPage() {
+ return (
+ }>
+
+
+ )
+}
diff --git a/src/components/CrispChat.tsx b/src/components/CrispChat.tsx
index 9a1c24048..61b799446 100644
--- a/src/components/CrispChat.tsx
+++ b/src/components/CrispChat.tsx
@@ -5,6 +5,11 @@ import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
import { useCrispInitialization } from '@/hooks/useCrispInitialization'
+const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
+
+/**
+ * Button component that opens the support drawer
+ */
export const CrispButton = ({ children, ...rest }: React.HTMLAttributes) => {
const { setIsSupportModalOpen } = useSupportModalContext()
@@ -19,11 +24,15 @@ export const CrispButton = ({ children, ...rest }: React.HTMLAttributes
+
)
}
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 5836f12ef..55471c926 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -1,28 +1,52 @@
'use client'
+import { useState, useEffect } from 'react'
import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { useCrispEmbedUrl } from '@/hooks/useCrispEmbedUrl'
-import { useCrispIframeSessionData } from '@/hooks/useCrispIframeSessionData'
+import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
-import { useRef } from 'react'
+import PeanutLoading from '../PeanutLoading'
const SupportDrawer = () => {
const { isSupportModalOpen, setIsSupportModalOpen, prefilledMessage } = useSupportModalContext()
const userData = useCrispUserData()
- const iframeRef = useRef(null)
+ const [isLoading, setIsLoading] = useState(true)
- // Build Crisp embed URL with user data as URL parameters (bypasses CORS)
- const crispEmbedUrl = useCrispEmbedUrl(userData)
+ const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage)
- // Try to set session:data via JavaScript if CORS allows (for metadata like grafana link)
- useCrispIframeSessionData(iframeRef, userData, prefilledMessage)
+ useEffect(() => {
+ // Listen for ready message from proxy iframe
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return
+
+ if (event.data.type === 'CRISP_READY') {
+ setIsLoading(false)
+ }
+ }
+
+ window.addEventListener('message', handleMessage)
+ return () => window.removeEventListener('message', handleMessage)
+ }, [])
+
+ // Reset loading state when drawer closes
+ useEffect(() => {
+ if (!isSupportModalOpen) {
+ setIsLoading(true)
+ }
+ }, [isSupportModalOpen])
return (
Support
-
+
+ {isLoading && (
+
+ )}
+
+
)
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index 741dc5680..abd1b8589 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -11,7 +11,7 @@ import {
clearRedirectUrl,
updateUserPreferences,
} from '@/utils'
-import { resetCrispSession } from '@/utils/crisp'
+import { resetCrispSession, resetCrispProxySessions } from '@/utils/crisp'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react'
@@ -150,8 +150,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}
// Reset Crisp session to prevent session merging with next user
- if (typeof window !== 'undefined' && window.$crisp) {
- resetCrispSession(window.$crisp)
+ // This resets both main window Crisp instance and any proxy page instances
+ if (typeof window !== 'undefined') {
+ resetCrispProxySessions()
}
await fetchUser()
diff --git a/src/hooks/useCrispIframeSessionData.ts b/src/hooks/useCrispIframeSessionData.ts
deleted file mode 100644
index 0b78e0a33..000000000
--- a/src/hooks/useCrispIframeSessionData.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useEffect, useRef } from 'react'
-import { type CrispUserData } from '@/hooks/useCrispUserData'
-
-/**
- * Hook to attempt setting session:data in Crisp iframe via JavaScript
- * This is a best-effort attempt - URL params handle user identification
- * Session metadata (grafana_dashboard, user_id, etc.) cannot be passed via URL params
- * and must be set via JavaScript (which may fail due to CORS)
- */
-export function useCrispIframeSessionData(
- iframeRef: React.RefObject,
- userData: CrispUserData,
- prefilledMessage?: string
-) {
- useEffect(() => {
- if (!iframeRef.current || !userData.userId) return
-
- const iframe = iframeRef.current
- const setSessionData = () => {
- try {
- const iframeWindow = iframe.contentWindow as any
- if (iframeWindow?.$crisp) {
- // Set session:data (metadata) - this appears in Crisp dashboard for support agents
- // Note: User identification (email, nickname) is already set via URL params
- iframeWindow.$crisp.push([
- 'set',
- 'session:data',
- [
- [
- ['username', userData.username || ''],
- ['user_id', userData.userId || ''],
- ['full_name', userData.fullName || ''],
- ['grafana_dashboard', userData.grafanaLink || ''],
- ],
- ],
- ])
- if (prefilledMessage) {
- iframeWindow.$crisp.push(['set', 'message:text', [prefilledMessage]])
- }
- console.log('[Crisp] ✅ Successfully set session metadata via JavaScript')
- }
- } catch (e: any) {
- // CORS error expected - URL params already handle user identification
- if (e.message?.includes('cross-origin') || e.name === 'SecurityError') {
- console.debug(
- '[Crisp] Session metadata cannot be set (CORS) - user identification via URL params is sufficient'
- )
- }
- }
- }
-
- const handleLoad = () => {
- // Try immediately
- setSessionData()
- // Retry after delays
- setTimeout(setSessionData, 500)
- setTimeout(setSessionData, 2000)
- }
-
- iframe.addEventListener('load', handleLoad)
- return () => iframe.removeEventListener('load', handleLoad)
- }, [iframeRef, userData, prefilledMessage])
-}
-
diff --git a/src/hooks/useCrispInitialization.ts b/src/hooks/useCrispInitialization.ts
index b0d59f3ef..d1e08fa8a 100644
--- a/src/hooks/useCrispInitialization.ts
+++ b/src/hooks/useCrispInitialization.ts
@@ -3,12 +3,15 @@ import { setCrispUserData } from '@/utils/crisp'
import { type CrispUserData } from './useCrispUserData'
/**
- * Hook to initialize Crisp user data on a given $crisp instance
- * Handles timing, event listeners, and cleanup according to Crisp SDK best practices
- * @param crispInstance - The $crisp object (window.$crisp or iframe.contentWindow.$crisp)
+ * Initializes Crisp user data on the main window $crisp instance
+ *
+ * Used for the main Crisp widget (not iframe). Sets user identification and metadata
+ * using event listeners for proper timing.
+ *
+ * @param crispInstance - The $crisp object (window.$crisp)
* @param userData - User data to set
* @param prefilledMessage - Optional prefilled message
- * @param enabled - Whether initialization is enabled (default: true)
+ * @param enabled - Whether initialization is enabled
*/
export function useCrispInitialization(
crispInstance: any,
@@ -25,14 +28,9 @@ export function useCrispInitialization(
}
}
- // Set data immediately if Crisp is already loaded
setData()
-
- // Listen for session loaded event - primary event Crisp fires when ready
- // This ensures data persists across sessions and is set when Crisp initializes
crispInstance.push(['on', 'session:loaded', setData])
- // Fallback: try once after a delay to catch cases where Crisp loads quickly
const fallbackTimer = setTimeout(setData, 1000)
return () => {
diff --git a/src/hooks/useCrispProxyUrl.ts b/src/hooks/useCrispProxyUrl.ts
new file mode 100644
index 000000000..29b9b739f
--- /dev/null
+++ b/src/hooks/useCrispProxyUrl.ts
@@ -0,0 +1,69 @@
+import { useMemo } from 'react'
+import { type CrispUserData } from '@/hooks/useCrispUserData'
+
+/**
+ * Builds URL for Crisp proxy page with user data as query parameters
+ *
+ * This follows Crisp's recommended pattern for iframe embedding with JS SDK control.
+ * All data is passed via URL params so the proxy page can set it during Crisp initialization,
+ * avoiding timing issues with async postMessage approaches.
+ *
+ * @param userData - User data to encode in URL
+ * @param prefilledMessage - Optional message to prefill in chat
+ * @returns URL path to crisp-proxy page with encoded parameters
+ */
+export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: string): string {
+ return useMemo(() => {
+ const params = new URLSearchParams()
+
+ if (userData.email) {
+ params.append('user_email', userData.email)
+ }
+ if (userData.fullName || userData.username) {
+ params.append('user_nickname', userData.fullName || userData.username || '')
+ }
+ if (userData.avatar) {
+ params.append('user_avatar', userData.avatar)
+ }
+
+ // Session metadata as JSON for support agents
+ if (
+ userData.username ||
+ userData.userId ||
+ userData.fullName ||
+ userData.grafanaLink ||
+ userData.walletAddressLink ||
+ userData.bridgeUserId ||
+ userData.mantecaUserId
+ ) {
+ const sessionData = JSON.stringify({
+ username: userData.username || '',
+ user_id: userData.userId || '',
+ full_name: userData.fullName || '',
+ grafana_dashboard: userData.grafanaLink || '',
+ wallet_address: userData.walletAddressLink || '',
+ bridge_user_id: userData.bridgeUserId || '',
+ manteca_user_id: userData.mantecaUserId || '',
+ })
+ params.append('session_data', sessionData)
+ }
+
+ if (prefilledMessage) {
+ params.append('prefilled_message', prefilledMessage)
+ }
+
+ const queryString = params.toString()
+ return queryString ? `/crisp-proxy?${queryString}` : '/crisp-proxy'
+ }, [
+ userData.email,
+ userData.fullName,
+ userData.username,
+ userData.avatar,
+ userData.userId,
+ userData.grafanaLink,
+ userData.walletAddressLink,
+ userData.bridgeUserId,
+ userData.mantecaUserId,
+ prefilledMessage,
+ ])
+}
diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts
index e5ffbbc74..a85a47053 100644
--- a/src/hooks/useCrispUserData.ts
+++ b/src/hooks/useCrispUserData.ts
@@ -1,6 +1,11 @@
import { useAuth } from '@/context/authContext'
+import { AccountType } from '@/interfaces'
import { useMemo } from 'react'
+const GRAFANA_DASHBOARD_BASE_URL =
+ 'https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user'
+const ARBISCAN_ADDRESS_BASE_URL = 'https://arbiscan.io/address'
+
export interface CrispUserData {
username: string | undefined
userId: string | undefined
@@ -8,26 +13,43 @@ export interface CrispUserData {
fullName: string | undefined
avatar: string | undefined
grafanaLink: string | undefined
+ walletAddress: string | undefined
+ walletAddressLink: string | undefined
+ bridgeUserId: string | undefined
+ mantecaUserId: string | undefined
}
+/**
+ * Prepares user data for Crisp chat integration
+ * Extracts user information from auth context and formats it for Crisp
+ */
export function useCrispUserData(): CrispUserData {
const { username, userId, user } = useAuth()
return useMemo(() => {
const grafanaLink = username
- ? `https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user?orgId=1&var-GRAFANA_VAR_Username=${encodeURIComponent(username)}`
+ ? `${GRAFANA_DASHBOARD_BASE_URL}?orgId=1&var-GRAFANA_VAR_Username=${encodeURIComponent(username)}&from=now-30d&to=now&timezone=browser`
: undefined
- // Use actual email from user data, or userId as fallback identifier for Crisp
- const email = user?.user?.email || (userId ? `${userId}@peanut.internal` : undefined)
+ const walletAddress =
+ user?.accounts?.find((account) => account.type === AccountType.PEANUT_WALLET)?.identifier || undefined
+ const walletAddressLink = walletAddress ? `${ARBISCAN_ADDRESS_BASE_URL}/${walletAddress}` : undefined
+
+ const bridgeUserId = user?.user?.bridgeCustomerId || undefined
+ const mantecaUserId =
+ user?.user?.kycVerifications?.find((kyc) => kyc.provider === 'MANTECA')?.providerUserId || undefined
return {
username,
userId,
- email,
+ email: user?.user?.email || undefined,
fullName: user?.user?.fullName,
avatar: user?.user?.profile_picture || undefined,
grafanaLink,
+ walletAddress,
+ walletAddressLink,
+ bridgeUserId,
+ mantecaUserId,
}
}, [username, userId, user])
}
diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts
index 5f919ec49..289fd3ba2 100644
--- a/src/utils/crisp.ts
+++ b/src/utils/crisp.ts
@@ -1,35 +1,36 @@
import { type CrispUserData } from '@/hooks/useCrispUserData'
/**
- * Sets Crisp user data on a given $crisp instance
- * @param crispInstance - The $crisp object (either window.$crisp or iframe.contentWindow.$crisp)
+ * Sets Crisp user identification and session metadata on a $crisp instance
+ *
+ * This is used for the main window Crisp widget (not iframe).
+ * Sets user email (critical for session persistence), nickname, avatar,
+ * and session metadata visible to support agents.
+ *
+ * @param crispInstance - The $crisp object (window.$crisp)
* @param userData - User data to set
- * @param prefilledMessage - Optional prefilled message
+ * @param prefilledMessage - Optional message to prefill in chat
*/
export function setCrispUserData(crispInstance: any, userData: CrispUserData, prefilledMessage?: string): void {
if (!crispInstance) return
- const { username, userId, email, fullName, avatar, grafanaLink } = userData
+ const { username, userId, email, fullName, avatar, grafanaLink, walletAddressLink, bridgeUserId, mantecaUserId } =
+ userData
- // Set user email - this is critical for session persistence across devices/browsers
- // According to Crisp docs, user:email is the primary identifier for session persistence
if (email) {
crispInstance.push(['set', 'user:email', [email]])
}
- // Set user nickname - prefer fullName, fallback to username
const nickname = fullName || username || ''
if (nickname) {
crispInstance.push(['set', 'user:nickname', [nickname]])
}
- // Set user avatar if available
if (avatar) {
crispInstance.push(['set', 'user:avatar', [avatar]])
}
- // Set session data - EXACT STRUCTURE (3 nested arrays!)
- // This metadata appears in Crisp dashboard for support agents
+ // Session metadata for support agents - must be 3 levels of nested arrays
crispInstance.push([
'set',
'session:data',
@@ -39,27 +40,68 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr
['user_id', userId || ''],
['full_name', fullName || ''],
['grafana_dashboard', grafanaLink || ''],
+ ['wallet_address', walletAddressLink || ''],
+ ['bridge_user_id', bridgeUserId || ''],
+ ['manteca_user_id', mantecaUserId || ''],
],
],
])
- // Set prefilled message if exists
if (prefilledMessage) {
crispInstance.push(['set', 'message:text', [prefilledMessage]])
}
}
/**
- * Resets Crisp session - call this on logout to prevent session merging
- * @param crispInstance - The $crisp object (window.$crisp or iframe.contentWindow.$crisp)
+ * Resets Crisp session to prevent session merging between users
+ *
+ * @param crispInstance - The $crisp object
*/
export function resetCrispSession(crispInstance: any): void {
if (!crispInstance || typeof window === 'undefined') return
try {
- // Reset the session to prevent merging with next user's session
crispInstance.push(['do', 'session:reset'])
} catch (e) {
- console.debug('Could not reset Crisp session:', e)
+ console.debug('[Crisp] Could not reset session:', e)
+ }
+}
+
+/**
+ * Resets all Crisp sessions on logout (main window + proxy iframes)
+ *
+ * Attempts to reset currently mounted proxy iframes via postMessage,
+ * and sets a sessionStorage flag for proxy pages that aren't currently mounted.
+ */
+export function resetCrispProxySessions(): void {
+ if (typeof window === 'undefined') return
+
+ try {
+ const iframes = document.querySelectorAll('iframe[src*="crisp-proxy"]')
+
+ iframes.forEach((iframe) => {
+ try {
+ const iframeWindow = (iframe as HTMLIFrameElement).contentWindow
+ if (iframeWindow) {
+ iframeWindow.postMessage(
+ {
+ type: 'CRISP_RESET_SESSION',
+ },
+ window.location.origin
+ )
+ }
+ } catch (e) {
+ console.debug('[Crisp] Could not reset proxy iframe:', e)
+ }
+ })
+
+ if (window.$crisp) {
+ resetCrispSession(window.$crisp)
+ }
+
+ // Flag for proxy pages that aren't currently mounted
+ sessionStorage.setItem('crisp_needs_reset', 'true')
+ } catch (e) {
+ console.debug('[Crisp] Could not reset proxy sessions:', e)
}
}
From c2fffb39c178064d446122d9fbe58064f6d9d147 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sat, 1 Nov 2025 02:21:57 -0300
Subject: [PATCH 14/17] format
---
src/hooks/useCrispEmbedUrl.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/hooks/useCrispEmbedUrl.ts b/src/hooks/useCrispEmbedUrl.ts
index 56b1b727e..f8ddca460 100644
--- a/src/hooks/useCrispEmbedUrl.ts
+++ b/src/hooks/useCrispEmbedUrl.ts
@@ -30,4 +30,3 @@ export function useCrispEmbedUrl(userData: CrispUserData): string {
return queryString ? `${CRISP_EMBED_BASE_URL}&${queryString}` : CRISP_EMBED_BASE_URL
}, [userData.email, userData.fullName, userData.username, userData.avatar])
}
-
From 54cbee4ebfbda8e1e6bf731efa3b747577ebddfa Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Sun, 2 Nov 2025 23:10:17 -0300
Subject: [PATCH 15/17] fixes
---
src/app/(mobile-ui)/home/page.tsx | 9 ++---
src/app/(mobile-ui)/qr-pay/page.tsx | 35 +++++++-----------
src/app/(mobile-ui)/withdraw/manteca/page.tsx | 23 ++++--------
src/components/Home/InvitesIcon.tsx | 2 +-
src/components/UserHeader/index.tsx | 2 +-
src/hooks/usePointsCalculation.ts | 37 +++++++++++++++++++
src/utils/qr-payment.utils.ts | 4 +-
tailwind.config.js | 22 +++++++++++
8 files changed, 88 insertions(+), 46 deletions(-)
create mode 100644 src/hooks/usePointsCalculation.ts
diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx
index 9d446f190..5cca971d8 100644
--- a/src/app/(mobile-ui)/home/page.tsx
+++ b/src/app/(mobile-ui)/home/page.tsx
@@ -36,8 +36,7 @@ import useKycStatus from '@/hooks/useKycStatus'
import HomeCarouselCTA from '@/components/Home/HomeCarouselCTA'
import NoMoreJailModal from '@/components/Global/NoMoreJailModal'
import EarlyUserModal from '@/components/Global/EarlyUserModal'
-import { STAR_STRAIGHT_ICON } from '@/assets'
-import Image from 'next/image'
+import InvitesIcon from '@/components/Home/InvitesIcon'
import NavigationArrow from '@/components/Global/NavigationArrow'
const BALANCE_WARNING_THRESHOLD = parseInt(process.env.NEXT_PUBLIC_BALANCE_WARNING_THRESHOLD ?? '500')
@@ -235,9 +234,9 @@ export default function Home() {
-
-
-
Points
+
+
+
Points
{/*
*/}
diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index 9e5f78caa..e94e288ba 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -38,13 +38,13 @@ import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate'
import ActionModal from '@/components/Global/ActionModal'
import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useQueryClient } from '@tanstack/react-query'
import { shootDoubleStarConfetti } from '@/utils/confetti'
import { STAR_STRAIGHT_ICON } from '@/assets'
import { useAuth } from '@/context/authContext'
-import { pointsApi } from '@/services/points'
import { PointsAction } from '@/services/services.types'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
import { useWebSocket } from '@/hooks/useWebSocket'
import type { HistoryEntry } from '@/hooks/useTransactionHistory'
import { completeHistoryEntry } from '@/utils/history.utils'
@@ -96,7 +96,6 @@ export default function QRPayPage() {
const { setIsSupportModalOpen } = useSupportModalContext()
const [waitingForMerchantAmount, setWaitingForMerchantAmount] = useState(false)
const retryCount = useRef(0)
- const pointsDivRef = useRef
(null)
const paymentProcessor: PaymentProcessor | null = useMemo(() => {
switch (qrType) {
@@ -321,17 +320,13 @@ export default function QRPayPage() {
// Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
// This way points are cached by the time success view shows
// Only Manteca QR payments give points (SimpleFi does not)
- const { data: pointsData } = useQuery({
- queryKey: ['calculate-points', 'qr-payment', paymentProcessor, usdAmount],
- queryFn: () =>
- pointsApi.calculatePoints({
- actionType: PointsAction.MANTECA_QR_PAYMENT,
- usdAmount: Number(usdAmount),
- }),
- enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && paymentProcessor === 'MANTECA'),
- refetchOnWindowFocus: false,
- staleTime: 5 * 60 * 1000, // Cache for 5 minutes
- })
+ // Use timestamp as uniqueId to prevent cache collisions between different QR scans
+ const { pointsData, pointsDivRef } = usePointsCalculation(
+ PointsAction.MANTECA_QR_PAYMENT,
+ usdAmount,
+ paymentProcessor === 'MANTECA',
+ timestamp || undefined
+ )
const methodIcon = useMemo(() => {
switch (qrType) {
@@ -1010,6 +1005,10 @@ export default function QRPayPage() {
≈ {formatNumberForDisplay(usdAmount ?? undefined, { maxDecimals: 2 })} USD
+ {/* Savings Message (Argentina Manteca only) */}
+ {showSavingsMessage && savingsMessage && (
+ {savingsMessage}
+ )}
)}
@@ -1062,13 +1061,7 @@ export default function QRPayPage() {
)}
- {/* Savings Message and Points - Show after payment card OR after perk banner */}
- {/* Savings Message (Argentina Manteca only) */}
- {showSavingsMessage && savingsMessage && (
-
{savingsMessage}
- )}
-
- {/* Points Display */}
+ {/* Points Display - ref used for confetti origin point */}
{pointsData?.estimatedPoints && (
diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
index f34eabe7e..703a83f65 100644
--- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useWallet } from '@/hooks/wallet/useWallet'
-import { useState, useMemo, useContext, useEffect, useCallback, useRef } from 'react'
+import { useState, useMemo, useContext, useEffect, useCallback, useRef, useId } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/0_Bruddle/Button'
import { Card } from '@/components/0_Bruddle/Card'
@@ -37,13 +37,13 @@ import {
} from '@/constants'
import Select from '@/components/Global/Select'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
-import { useQueryClient, useQuery } from '@tanstack/react-query'
+import { useQueryClient } from '@tanstack/react-query'
import { captureException } from '@sentry/nextjs'
import useKycStatus from '@/hooks/useKycStatus'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
-import { pointsApi } from '@/services/points'
import { PointsAction } from '@/services/services.types'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'
@@ -52,6 +52,7 @@ const MAX_WITHDRAW_AMOUNT = '2000'
const MIN_WITHDRAW_AMOUNT = '1'
export default function MantecaWithdrawFlow() {
+ const flowId = useId() // Unique ID per flow instance to prevent cache collisions
const [amount, setAmount] = useState(undefined)
const [currencyAmount, setCurrencyAmount] = useState(undefined)
const [usdAmount, setUsdAmount] = useState(undefined)
@@ -74,7 +75,6 @@ export default function MantecaWithdrawFlow() {
const queryClient = useQueryClient()
const { isUserBridgeKycApproved } = useKycStatus()
const { hasPendingTransactions } = usePendingTransactions()
- const pointsDivRef = useRef(null)
// Get method and country from URL parameters
const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc.
@@ -296,17 +296,8 @@ export default function MantecaWithdrawFlow() {
}, [usdAmount, balance, hasPendingTransactions])
// Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
- const { data: pointsData } = useQuery({
- queryKey: ['calculate-points', 'manteca-withdraw', usdAmount],
- queryFn: () =>
- pointsApi.calculatePoints({
- actionType: PointsAction.MANTECA_TRANSFER,
- usdAmount: Number(usdAmount),
- }),
- enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0),
- refetchOnWindowFocus: false,
- staleTime: 5 * 60 * 1000, // Cache for 5 minutes
- })
+ // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows
+ const { pointsData, pointsDivRef } = usePointsCalculation(PointsAction.MANTECA_TRANSFER, usdAmount, true, flowId)
// Use points confetti hook for animation - must be called unconditionally
usePointsConfetti(step === 'success' ? pointsData?.estimatedPoints : undefined, pointsDivRef)
@@ -345,7 +336,7 @@ export default function MantecaWithdrawFlow() {
- {/* Points Display */}
+ {/* Points Display - ref used for confetti origin point */}
{pointsData?.estimatedPoints && (
diff --git a/src/components/Home/InvitesIcon.tsx b/src/components/Home/InvitesIcon.tsx
index 3062edf13..b7a8815bb 100644
--- a/src/components/Home/InvitesIcon.tsx
+++ b/src/components/Home/InvitesIcon.tsx
@@ -11,7 +11,7 @@ const InvitesIcon = ({
}) => {
return (
{
+ const { user } = useAuth()
+ const pointsDivRef = useRef(null)
+
+ const { data: pointsData } = useQuery({
+ queryKey: ['calculate-points', actionType, usdAmount, uniqueId],
+ queryFn: () =>
+ pointsApi.calculatePoints({
+ actionType,
+ usdAmount: Number(usdAmount),
+ }),
+ enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && additionalEnabled),
+ refetchOnWindowFocus: false,
+ staleTime: 5 * 60 * 1000, // Cache for 5 minutes
+ })
+
+ return { pointsData, pointsDivRef }
+}
diff --git a/src/utils/qr-payment.utils.ts b/src/utils/qr-payment.utils.ts
index 56c59e5bb..083bf5568 100644
--- a/src/utils/qr-payment.utils.ts
+++ b/src/utils/qr-payment.utils.ts
@@ -40,10 +40,10 @@ export function getSavingsMessage(savingsInCents: number): string {
// If savings is less than $1, show in cents
if (savingsInDollars < 1) {
const centsText = savingsInCents === 1 ? 'cent' : 'cents'
- return `If you had paid with card, it'd have cost you ~${savingsInCents} ${centsText} more!`
+ return `saved ~${savingsInCents} ${centsText} compared to card!`
}
// If savings is $1 or more, show in dollars
const formattedDollars = formatNumberForDisplay(savingsInDollars.toString(), { maxDecimals: 2 })
- return `If you had paid with card, it'd have cost you ~$${formattedDollars} more!`
+ return `saved ~$${formattedDollars} compared to card!`
}
diff --git a/tailwind.config.js b/tailwind.config.js
index badc2c542..904c97c7b 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -197,6 +197,27 @@ module.exports = {
'0%, 50%': { opacity: '1' },
'50.01%, 100%': { opacity: '0' },
},
+ starPulsateWiggle: {
+ // Gentle pulsate 3 times
+ '0%': { transform: 'scale(1) rotate(0deg)' },
+ '6%': { transform: 'scale(1.12) rotate(0deg)' },
+ '12%': { transform: 'scale(1) rotate(0deg)' },
+ '18%': { transform: 'scale(1.12) rotate(0deg)' },
+ '24%': { transform: 'scale(1) rotate(0deg)' },
+ '30%': { transform: 'scale(1.12) rotate(0deg)' },
+ '36%': { transform: 'scale(1) rotate(0deg)' },
+ // Pause for ~1 second
+ '50%': { transform: 'scale(1) rotate(0deg)' },
+ // Fast aggressive wiggle
+ '52%': { transform: 'scale(1) rotate(-15deg)' },
+ '54%': { transform: 'scale(1) rotate(15deg)' },
+ '56%': { transform: 'scale(1) rotate(-15deg)' },
+ '58%': { transform: 'scale(1) rotate(15deg)' },
+ '60%': { transform: 'scale(1) rotate(0deg)' },
+ // Short pause before loop
+ '75%': { transform: 'scale(1) rotate(0deg)' },
+ '100%': { transform: 'scale(1) rotate(0deg)' },
+ },
},
animation: {
colorPulse: 'colorPulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
@@ -205,6 +226,7 @@ module.exports = {
'pulsate-slow': 'pulsateDeep 4s ease-in-out infinite',
'pulse-strong': 'pulse-strong 1s ease-in-out infinite',
blink: 'blink 1.5s step-end infinite',
+ 'star-pulsate-wiggle': 'starPulsateWiggle 10s ease-in-out infinite',
},
opacity: {
85: '.85',
From fb3f5484d5497300e71c42a60d8176b0b58b3f18 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 3 Nov 2025 00:07:54 -0300
Subject: [PATCH 16/17] use points hook
---
.../withdraw/[country]/bank/page.tsx | 19 ++++++----------
src/app/[...recipient]/client.tsx | 22 +++++++------------
src/hooks/usePointsCalculation.ts | 17 ++++++++++----
3 files changed, 28 insertions(+), 30 deletions(-)
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 022831137..9cfac792a 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -22,9 +22,8 @@ import { createOfframp, confirmOfframp } from '@/app/actions/offramp'
import { useAuth } from '@/context/authContext'
import ExchangeRate from '@/components/ExchangeRate'
import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
-import { useQuery } from '@tanstack/react-query'
-import { pointsApi } from '@/services/points'
import { PointsAction } from '@/services/services.types'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
import { useSearchParams } from 'next/navigation'
import { parseUnits } from 'viem'
@@ -61,16 +60,12 @@ export default function WithdrawBankPage() {
)?.currencyCode
// Calculate points API call
- const { data: pointsData } = useQuery({
- queryKey: ['calculate-points', 'withdraw', bankAccount?.id, amountToWithdraw],
- queryFn: () =>
- pointsApi.calculatePoints({
- actionType: PointsAction.BRIDGE_TRANSFER,
- usdAmount: Number(amountToWithdraw),
- }),
- enabled: !!(user?.user.userId && amountToWithdraw && bankAccount),
- refetchOnWindowFocus: false,
- })
+ const { pointsData } = usePointsCalculation(
+ PointsAction.BRIDGE_TRANSFER,
+ amountToWithdraw,
+ !!(amountToWithdraw && bankAccount),
+ bankAccount?.id
+ )
// non-eur sepa countries that are currently experiencing issues
const isNonEuroSepaCountry = !!(
diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx
index 6981d257e..af94bb4a5 100644
--- a/src/app/[...recipient]/client.tsx
+++ b/src/app/[...recipient]/client.tsx
@@ -33,9 +33,8 @@ import NavHeader from '@/components/Global/NavHeader'
import { ReqFulfillBankFlowManager } from '@/components/Request/views/ReqFulfillBankFlowManager'
import SupportCTA from '@/components/Global/SupportCTA'
import { BankRequestType, useDetermineBankRequestType } from '@/hooks/useDetermineBankRequestType'
-import { useQuery } from '@tanstack/react-query'
-import { pointsApi } from '@/services/points'
import { PointsAction } from '@/services/services.types'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
export type PaymentFlow = 'request_pay' | 'external_wallet' | 'direct_pay' | 'withdraw'
interface Props {
@@ -81,22 +80,17 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props)
// For direct_pay: calculate on STATUS (after payment completes)
const shouldFetchPoints =
- user?.user.userId &&
usdAmount &&
chargeDetails?.uuid &&
((flow === 'request_pay' && currentView === 'CONFIRM') || (flow === 'direct_pay' && currentView === 'STATUS'))
- const { data: pointsData } = useQuery({
- queryKey: ['calculate-points', chargeDetails?.uuid, flow],
- queryFn: () =>
- pointsApi.calculatePoints({
- actionType: PointsAction.P2P_REQUEST_PAYMENT,
- usdAmount: Number(usdAmount),
- otherUserId: chargeDetails?.requestLink.recipientAccount.userId,
- }),
- enabled: !!shouldFetchPoints,
- refetchOnWindowFocus: false,
- })
+ const { pointsData } = usePointsCalculation(
+ PointsAction.P2P_REQUEST_PAYMENT,
+ usdAmount,
+ !!shouldFetchPoints,
+ chargeDetails?.uuid,
+ chargeDetails?.requestLink.recipientAccount.userId
+ )
// determine if the current user is the recipient of the transaction
const isCurrentUserRecipient = chargeDetails?.requestLink.recipientAccount?.userId === user?.user.userId
diff --git a/src/hooks/usePointsCalculation.ts b/src/hooks/usePointsCalculation.ts
index 7646f3460..eb270c25a 100644
--- a/src/hooks/usePointsCalculation.ts
+++ b/src/hooks/usePointsCalculation.ts
@@ -10,25 +10,34 @@ import { useAuth } from '@/context/authContext'
* @param usdAmount - The USD amount for the transaction
* @param additionalEnabled - Additional condition to enable the query (e.g., payment processor check)
* @param uniqueId - Optional unique identifier to prevent cache collisions between different transactions (e.g., payment ID, timestamp)
+ * @param otherUserId - Optional user ID for P2P transactions (used for handshake bonus calculation)
* @returns Points data and a ref for the points display element (used for confetti animation)
*/
export const usePointsCalculation = (
actionType: PointsAction,
usdAmount: string | null | undefined,
additionalEnabled: boolean = true,
- uniqueId?: string | number
+ uniqueId?: string | number,
+ otherUserId?: string
) => {
const { user } = useAuth()
const pointsDivRef = useRef(null)
+ // Normalize usdAmount by removing commas (e.g., "1,200.50" -> "1200.50")
+ // This handles formatted amounts from inputs that may include thousand separators
+ const normalizedUsdAmount = usdAmount?.toString().replace(/,/g, '')
+ const parsedUsdAmount = normalizedUsdAmount ? Number(normalizedUsdAmount) : undefined
+ const hasValidUsdAmount = parsedUsdAmount !== undefined && !Number.isNaN(parsedUsdAmount) && parsedUsdAmount > 0
+
const { data: pointsData } = useQuery({
- queryKey: ['calculate-points', actionType, usdAmount, uniqueId],
+ queryKey: ['calculate-points', actionType, normalizedUsdAmount, uniqueId, otherUserId],
queryFn: () =>
pointsApi.calculatePoints({
actionType,
- usdAmount: Number(usdAmount),
+ usdAmount: parsedUsdAmount!,
+ ...(otherUserId && { otherUserId }),
}),
- enabled: !!(user?.user.userId && usdAmount && Number(usdAmount) > 0 && additionalEnabled),
+ enabled: Boolean(user?.user.userId && hasValidUsdAmount && additionalEnabled),
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
})
From 60dedbdca948a8d59934ebf6452cef488eeb6cc9 Mon Sep 17 00:00:00 2001
From: Hugo Montenegro
Date: Mon, 3 Nov 2025 14:56:47 -0300
Subject: [PATCH 17/17] refactor: address PR review comments for Crisp
integration
- Extract hardcoded URLs and IDs to constants files (DRY)
- Remove unused useCrispInitialization hook and CrispChat default component
- Clean up unused imports (resetCrispSession)
- Add clarifying comments for wallet address source
- Move Grafana/Arbiscan URLs to constants/support.ts
- Move CRISP_WEBSITE_ID to constants/crisp.ts (was duplicated 3x)
---
src/app/crisp-proxy/page.tsx | 3 +-
src/components/CrispChat.tsx | 43 +++--------------------------
src/components/index.ts | 2 +-
src/constants/crisp.ts | 6 ++++
src/constants/support.ts | 10 +++++++
src/context/authContext.tsx | 2 +-
src/hooks/useCrispEmbedUrl.ts | 2 +-
src/hooks/useCrispInitialization.ts | 43 -----------------------------
src/hooks/useCrispUserData.ts | 9 +++---
9 files changed, 29 insertions(+), 91 deletions(-)
create mode 100644 src/constants/crisp.ts
create mode 100644 src/constants/support.ts
delete mode 100644 src/hooks/useCrispInitialization.ts
diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx
index e1518559c..2fb31cf38 100644
--- a/src/app/crisp-proxy/page.tsx
+++ b/src/app/crisp-proxy/page.tsx
@@ -3,8 +3,7 @@
import Script from 'next/script'
import { useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
-
-const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
+import { CRISP_WEBSITE_ID } from '@/constants/crisp'
/**
* Crisp Proxy Page - Same-origin iframe solution for embedded Crisp chat
diff --git a/src/components/CrispChat.tsx b/src/components/CrispChat.tsx
index 61b799446..2dd6106b2 100644
--- a/src/components/CrispChat.tsx
+++ b/src/components/CrispChat.tsx
@@ -1,14 +1,13 @@
'use client'
-import Script from 'next/script'
import { useSupportModalContext } from '@/context/SupportModalContext'
-import { useCrispUserData } from '@/hooks/useCrispUserData'
-import { useCrispInitialization } from '@/hooks/useCrispInitialization'
-
-const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
/**
* Button component that opens the support drawer
+ *
+ * The support UI is rendered via SupportDrawer component (iframe proxy approach)
+ * rather than the native Crisp widget to maintain better UX control and avoid
+ * page layout interference.
*/
export const CrispButton = ({ children, ...rest }: React.HTMLAttributes) => {
const { setIsSupportModalOpen } = useSupportModalContext()
@@ -23,37 +22,3 @@ export const CrispButton = ({ children, ...rest }: React.HTMLAttributes
)
}
-
-/**
- * Loads the main Crisp chat widget script and initializes user data
- *
- * This creates the main window $crisp instance. The actual chat UI is rendered
- * via SupportDrawer as an iframe to avoid page layout interference.
- */
-export default function CrispChat() {
- const userData = useCrispUserData()
-
- useCrispInitialization(
- typeof window !== 'undefined' ? window.$crisp : null,
- userData,
- undefined,
- !!userData.userId && typeof window !== 'undefined'
- )
-
- return (
-
- )
-}
diff --git a/src/components/index.ts b/src/components/index.ts
index 057eb6dc9..139dbd478 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,6 +1,6 @@
export * from './Blog'
export * from './Claim'
-export * from './CrispChat'
+export * from './CrispChat' // Only exports CrispButton
export * from './Global/DaimoPayButton'
export * from './Jobs'
export * from './Privacy'
diff --git a/src/constants/crisp.ts b/src/constants/crisp.ts
new file mode 100644
index 000000000..e4707341e
--- /dev/null
+++ b/src/constants/crisp.ts
@@ -0,0 +1,6 @@
+/**
+ * Crisp chat integration configuration
+ */
+
+/** Crisp website ID for Peanut's support chat */
+export const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
diff --git a/src/constants/support.ts b/src/constants/support.ts
new file mode 100644
index 000000000..f7cb4afef
--- /dev/null
+++ b/src/constants/support.ts
@@ -0,0 +1,10 @@
+/**
+ * Support and debugging tool URLs
+ */
+
+/** Grafana dashboard for exploring user wallet activity */
+export const GRAFANA_DASHBOARD_BASE_URL =
+ 'https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user'
+
+/** Arbiscan block explorer for viewing wallet addresses on Arbitrum */
+export const ARBISCAN_ADDRESS_BASE_URL = 'https://arbiscan.io/address'
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index abd1b8589..d6f3d5e89 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -11,7 +11,7 @@ import {
clearRedirectUrl,
updateUserPreferences,
} from '@/utils'
-import { resetCrispSession, resetCrispProxySessions } from '@/utils/crisp'
+import { resetCrispProxySessions } from '@/utils/crisp'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react'
diff --git a/src/hooks/useCrispEmbedUrl.ts b/src/hooks/useCrispEmbedUrl.ts
index f8ddca460..28b5311cc 100644
--- a/src/hooks/useCrispEmbedUrl.ts
+++ b/src/hooks/useCrispEmbedUrl.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { type CrispUserData } from '@/hooks/useCrispUserData'
+import { CRISP_WEBSITE_ID } from '@/constants/crisp'
-const CRISP_WEBSITE_ID = '916078be-a6af-4696-82cb-bc08d43d9125'
const CRISP_EMBED_BASE_URL = `https://go.crisp.chat/chat/embed/?website_id=${CRISP_WEBSITE_ID}`
/**
diff --git a/src/hooks/useCrispInitialization.ts b/src/hooks/useCrispInitialization.ts
deleted file mode 100644
index d1e08fa8a..000000000
--- a/src/hooks/useCrispInitialization.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useEffect } from 'react'
-import { setCrispUserData } from '@/utils/crisp'
-import { type CrispUserData } from './useCrispUserData'
-
-/**
- * Initializes Crisp user data on the main window $crisp instance
- *
- * Used for the main Crisp widget (not iframe). Sets user identification and metadata
- * using event listeners for proper timing.
- *
- * @param crispInstance - The $crisp object (window.$crisp)
- * @param userData - User data to set
- * @param prefilledMessage - Optional prefilled message
- * @param enabled - Whether initialization is enabled
- */
-export function useCrispInitialization(
- crispInstance: any,
- userData: CrispUserData,
- prefilledMessage?: string,
- enabled: boolean = true
-) {
- useEffect(() => {
- if (!enabled || !userData.userId || !crispInstance) return
-
- const setData = () => {
- if (crispInstance) {
- setCrispUserData(crispInstance, userData, prefilledMessage)
- }
- }
-
- setData()
- crispInstance.push(['on', 'session:loaded', setData])
-
- const fallbackTimer = setTimeout(setData, 1000)
-
- return () => {
- clearTimeout(fallbackTimer)
- if (crispInstance) {
- crispInstance.push(['off', 'session:loaded', setData])
- }
- }
- }, [crispInstance, userData, prefilledMessage, enabled])
-}
diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts
index a85a47053..938f8424b 100644
--- a/src/hooks/useCrispUserData.ts
+++ b/src/hooks/useCrispUserData.ts
@@ -1,10 +1,7 @@
import { useAuth } from '@/context/authContext'
import { AccountType } from '@/interfaces'
import { useMemo } from 'react'
-
-const GRAFANA_DASHBOARD_BASE_URL =
- 'https://teampeanut.grafana.net/d/ad31f645-81ca-4779-bfb2-bff8e03d9057/explore-peanut-wallet-user'
-const ARBISCAN_ADDRESS_BASE_URL = 'https://arbiscan.io/address'
+import { GRAFANA_DASHBOARD_BASE_URL, ARBISCAN_ADDRESS_BASE_URL } from '@/constants/support'
export interface CrispUserData {
username: string | undefined
@@ -31,6 +28,10 @@ export function useCrispUserData(): CrispUserData {
? `${GRAFANA_DASHBOARD_BASE_URL}?orgId=1&var-GRAFANA_VAR_Username=${encodeURIComponent(username)}&from=now-30d&to=now&timezone=browser`
: undefined
+ // Use address from user.accounts (database) rather than useWallet hook
+ // This ensures we always show the user's wallet address in support metadata,
+ // even if ZeroDev client isn't initialized yet. useWallet().address could be
+ // undefined during initialization, but we want persistent data for support agents.
const walletAddress =
user?.accounts?.find((account) => account.type === AccountType.PEANUT_WALLET)?.identifier || undefined
const walletAddressLink = walletAddress ? `${ARBISCAN_ADDRESS_BASE_URL}/${walletAddress}` : undefined