diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx index 37e9c8c22..eea0814c0 100644 --- a/src/app/crisp-proxy/page.tsx +++ b/src/app/crisp-proxy/page.tsx @@ -18,16 +18,6 @@ import { CRISP_WEBSITE_ID } from '@/constants/crisp' 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 @@ -82,6 +72,7 @@ function CrispProxyContent() { ['wallet_address', data.wallet_address || ''], ['bridge_user_id', data.bridge_user_id || ''], ['manteca_user_id', data.manteca_user_id || ''], + ['posthog_person', data.posthog_person || ''], ], ] window.$crisp.push(['set', 'session:data', sessionDataArray]) @@ -122,6 +113,7 @@ function CrispProxyContent() { if (event.origin !== window.location.origin) return if (event.data.type === 'CRISP_RESET_SESSION' && window.$crisp) { + window.CRISP_TOKEN_ID = null window.$crisp.push(['do', 'session:reset']) } } @@ -136,9 +128,14 @@ function CrispProxyContent() { {` window.$crisp=[]; window.CRISP_WEBSITE_ID="${CRISP_WEBSITE_ID}"; + window.CRISP_RUNTIME_CONFIG={lock_maximized:true,lock_full_view:true,cross_origin_cookies:true,session_merge:true}; + (function(){ + var t=new URLSearchParams(window.location.search).get("crisp_token_id"); + if(t) window.CRISP_TOKEN_ID=t; + })(); (function(){ - d=document; - s=d.createElement("script"); + var d=document; + var s=d.createElement("script"); s.src="https://client.crisp.chat/l.js"; s.async=1; d.getElementsByTagName("head")[0].appendChild(s); diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 74e2eb5a7..bc4aeb6ec 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useModalsContext } from '@/context/ModalsContext' import { useCrispUserData } from '@/hooks/useCrispUserData' +import { useCrispTokenId } from '@/hooks/useCrispTokenId' import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl' import { Drawer, DrawerContent, DrawerTitle } from '../Drawer' import PeanutLoading from '../PeanutLoading' @@ -10,9 +11,10 @@ import PeanutLoading from '../PeanutLoading' const SupportDrawer = () => { const { isSupportModalOpen, setIsSupportModalOpen, supportPrefilledMessage: prefilledMessage } = useModalsContext() const userData = useCrispUserData() + const crispTokenId = useCrispTokenId() const [isLoading, setIsLoading] = useState(true) - const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage) + const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage, crispTokenId) useEffect(() => { // Listen for ready message from proxy iframe diff --git a/src/constants/support.ts b/src/constants/support.ts index f7cb4afef..c3613cb06 100644 --- a/src/constants/support.ts +++ b/src/constants/support.ts @@ -8,3 +8,6 @@ export const GRAFANA_DASHBOARD_BASE_URL = /** Arbiscan block explorer for viewing wallet addresses on Arbitrum */ export const ARBISCAN_ADDRESS_BASE_URL = 'https://arbiscan.io/address' + +/** PostHog person page for viewing session recordings and events */ +export const POSTHOG_PERSON_BASE_URL = 'https://eu.posthog.com/project/138913/person' diff --git a/src/hooks/useCrispProxyUrl.ts b/src/hooks/useCrispProxyUrl.ts index 29b9b739f..af79a4bec 100644 --- a/src/hooks/useCrispProxyUrl.ts +++ b/src/hooks/useCrispProxyUrl.ts @@ -10,12 +10,17 @@ import { type CrispUserData } from '@/hooks/useCrispUserData' * * @param userData - User data to encode in URL * @param prefilledMessage - Optional message to prefill in chat + * @param crispTokenId - Stable token for Crisp session continuity (prevents duplicate conversations) * @returns URL path to crisp-proxy page with encoded parameters */ -export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: string): string { +export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: string, crispTokenId?: string): string { return useMemo(() => { const params = new URLSearchParams() + if (crispTokenId) { + params.append('crisp_token_id', crispTokenId) + } + if (userData.email) { params.append('user_email', userData.email) } @@ -34,7 +39,8 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str userData.grafanaLink || userData.walletAddressLink || userData.bridgeUserId || - userData.mantecaUserId + userData.mantecaUserId || + userData.posthogPersonLink ) { const sessionData = JSON.stringify({ username: userData.username || '', @@ -44,6 +50,7 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str wallet_address: userData.walletAddressLink || '', bridge_user_id: userData.bridgeUserId || '', manteca_user_id: userData.mantecaUserId || '', + posthog_person: userData.posthogPersonLink || '', }) params.append('session_data', sessionData) } @@ -55,6 +62,7 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str const queryString = params.toString() return queryString ? `/crisp-proxy?${queryString}` : '/crisp-proxy' }, [ + crispTokenId, userData.email, userData.fullName, userData.username, @@ -64,6 +72,7 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str userData.walletAddressLink, userData.bridgeUserId, userData.mantecaUserId, + userData.posthogPersonLink, prefilledMessage, ]) } diff --git a/src/hooks/useCrispTokenId.ts b/src/hooks/useCrispTokenId.ts new file mode 100644 index 000000000..f4815d8a1 --- /dev/null +++ b/src/hooks/useCrispTokenId.ts @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react' +import { useAuth } from '@/context/authContext' + +const CRISP_TOKEN_SALT = 'peanut-crisp-session-v1' + +/** + * Generates a deterministic Crisp session token from a userId using SHA-256. + * Formatted as UUID-like string for Crisp compatibility. + * + * @see https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ + */ +async function generateCrispToken(userId: string): Promise { + const data = new TextEncoder().encode(`${CRISP_TOKEN_SALT}:${userId}`) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + + return [ + hashHex.slice(0, 8), + hashHex.slice(8, 12), + hashHex.slice(12, 16), + hashHex.slice(16, 20), + hashHex.slice(20, 32), + ].join('-') +} + +// In-memory cache so the token is available synchronously after first computation, +// preventing an undefined→resolved state change that would cause iframe reloads. +const tokenCache = new Map() + +/** + * Hook that returns a stable Crisp token ID derived from the current user's ID. + * Returns undefined when not authenticated. + */ +export function useCrispTokenId(): string | undefined { + const { userId } = useAuth() + const [tokenId, setTokenId] = useState(userId ? tokenCache.get(userId) : undefined) + + useEffect(() => { + if (!userId) { + setTokenId(undefined) + return + } + + const cached = tokenCache.get(userId) + if (cached) { + setTokenId(cached) + return + } + + generateCrispToken(userId) + .then((token) => { + tokenCache.set(userId, token) + setTokenId(token) + }) + .catch(() => setTokenId(undefined)) + }, [userId]) + + return tokenId +} diff --git a/src/hooks/useCrispUserData.ts b/src/hooks/useCrispUserData.ts index 938f8424b..d1ae22532 100644 --- a/src/hooks/useCrispUserData.ts +++ b/src/hooks/useCrispUserData.ts @@ -1,7 +1,7 @@ import { useAuth } from '@/context/authContext' import { AccountType } from '@/interfaces' import { useMemo } from 'react' -import { GRAFANA_DASHBOARD_BASE_URL, ARBISCAN_ADDRESS_BASE_URL } from '@/constants/support' +import { GRAFANA_DASHBOARD_BASE_URL, ARBISCAN_ADDRESS_BASE_URL, POSTHOG_PERSON_BASE_URL } from '@/constants/support' export interface CrispUserData { username: string | undefined @@ -14,6 +14,7 @@ export interface CrispUserData { walletAddressLink: string | undefined bridgeUserId: string | undefined mantecaUserId: string | undefined + posthogPersonLink: string | undefined } /** @@ -40,6 +41,8 @@ export function useCrispUserData(): CrispUserData { const mantecaUserId = user?.user?.kycVerifications?.find((kyc) => kyc.provider === 'MANTECA')?.providerUserId || undefined + const posthogPersonLink = userId ? `${POSTHOG_PERSON_BASE_URL}/${userId}` : undefined + return { username, userId, @@ -51,6 +54,7 @@ export function useCrispUserData(): CrispUserData { walletAddressLink, bridgeUserId, mantecaUserId, + posthogPersonLink, } }, [username, userId, user]) } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 790eb5e5b..9b6bf210b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,4 +1,6 @@ interface Window { gtag?: (command: string, ...args: unknown[]) => void $crisp?: Array + CRISP_TOKEN_ID?: string | null + CRISP_WEBSITE_ID?: string } diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts index 289fd3ba2..83da6056c 100644 --- a/src/utils/crisp.ts +++ b/src/utils/crisp.ts @@ -14,8 +14,18 @@ import { type CrispUserData } from '@/hooks/useCrispUserData' export function setCrispUserData(crispInstance: any, userData: CrispUserData, prefilledMessage?: string): void { if (!crispInstance) return - const { username, userId, email, fullName, avatar, grafanaLink, walletAddressLink, bridgeUserId, mantecaUserId } = - userData + const { + username, + userId, + email, + fullName, + avatar, + grafanaLink, + walletAddressLink, + bridgeUserId, + mantecaUserId, + posthogPersonLink, + } = userData if (email) { crispInstance.push(['set', 'user:email', [email]]) @@ -43,6 +53,7 @@ export function setCrispUserData(crispInstance: any, userData: CrispUserData, pr ['wallet_address', walletAddressLink || ''], ['bridge_user_id', bridgeUserId || ''], ['manteca_user_id', mantecaUserId || ''], + ['posthog_person', posthogPersonLink || ''], ], ], ]) @@ -61,6 +72,11 @@ export function resetCrispSession(crispInstance: any): void { if (!crispInstance || typeof window === 'undefined') return try { + // Clear CRISP_TOKEN_ID before resetting session to fully unbind the user. + // This prevents the next anonymous session from inheriting the previous user's conversation. + // @see https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ + window.CRISP_TOKEN_ID = null + crispInstance.push(['do', 'session:reset']) } catch (e) { console.debug('[Crisp] Could not reset session:', e)