Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions src/app/crisp-proxy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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'])
}
}
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
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'

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
Expand Down
3 changes: 3 additions & 0 deletions src/constants/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
13 changes: 11 additions & 2 deletions src/hooks/useCrispProxyUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 || '',
Expand All @@ -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)
}
Expand All @@ -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,
Expand All @@ -64,6 +72,7 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str
userData.walletAddressLink,
userData.bridgeUserId,
userData.mantecaUserId,
userData.posthogPersonLink,
prefilledMessage,
])
}
60 changes: 60 additions & 0 deletions src/hooks/useCrispTokenId.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string>()

/**
* 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<string | undefined>(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
}
6 changes: 5 additions & 1 deletion src/hooks/useCrispUserData.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +14,7 @@ export interface CrispUserData {
walletAddressLink: string | undefined
bridgeUserId: string | undefined
mantecaUserId: string | undefined
posthogPersonLink: string | undefined
}

/**
Expand All @@ -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,
Expand All @@ -51,6 +54,7 @@ export function useCrispUserData(): CrispUserData {
walletAddressLink,
bridgeUserId,
mantecaUserId,
posthogPersonLink,
}
}, [username, userId, user])
}
2 changes: 2 additions & 0 deletions src/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
interface Window {
gtag?: (command: string, ...args: unknown[]) => void
$crisp?: Array<unknown[]>
CRISP_TOKEN_ID?: string | null
CRISP_WEBSITE_ID?: string
}
20 changes: 18 additions & 2 deletions src/utils/crisp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]])
Expand Down Expand Up @@ -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 || ''],
],
],
])
Expand All @@ -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)
Expand Down
Loading