Skip to content
Merged
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
35 changes: 31 additions & 4 deletions src/app/(mobile-ui)/support/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
'use client'

import { useState, useEffect } from 'react'
import { useCrispUserData } from '@/hooks/useCrispUserData'
import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
import PeanutLoading from '@/components/Global/PeanutLoading'

const SupportPage = () => {
const userData = useCrispUserData()
const crispProxyUrl = useCrispProxyUrl(userData)
const [isLoading, setIsLoading] = useState(true)

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)
}, [])

return (
<iframe
src="https://go.crisp.chat/chat/embed/?website_id=916078be-a6af-4696-82cb-bc08d43d9125"
className="h-full w-full md:max-w-[90%] md:pl-24"
/>
<div className="relative h-full w-full md:max-w-[90%] md:pl-24">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
<PeanutLoading />
</div>
)}
<iframe src={crispProxyUrl} className="h-full w-full" title="Support Chat" />
</div>
)
}

Expand Down
159 changes: 159 additions & 0 deletions src/app/crisp-proxy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client'

import Script from 'next/script'
import { useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { CRISP_WEBSITE_ID } from '@/constants/crisp'

/**
* 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 (
<div className="h-full w-full">
<Script strategy="afterInteractive" id="crisp-proxy-widget">
{`
window.$crisp=[];
window.CRISP_WEBSITE_ID="${CRISP_WEBSITE_ID}";
(function(){
d=document;
s=d.createElement("script");
s.src="https://client.crisp.chat/l.js";
s.async=1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
window.$crisp.push(["safe", true]);
`}
</Script>
</div>
)
}

export default function CrispProxyPage() {
return (
<Suspense fallback={<div className="h-full w-full" />}>
<CrispProxyContent />
</Suspense>
)
}
63 changes: 7 additions & 56 deletions src/components/CrispChat.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
'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'

/**
* 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<HTMLButtonElement>) => {
const { setIsSupportModalOpen } = useSupportModalContext()

Expand All @@ -19,55 +22,3 @@ export const CrispButton = ({ children, ...rest }: React.HTMLAttributes<HTMLButt
</button>
)
}

export default function CrispChat() {
const userData = useCrispUserData()

useEffect(() => {
// Only set user data if we have a username
if (!userData.username || 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()

// Also listen for Crisp session loaded event
if (window.$crisp) {
window.$crisp.push(['on', 'session:loaded', setData])
}

// Fallback: try again after a delay
const timer = setTimeout(setData, 2000)

return () => {
clearTimeout(timer)
if (window.$crisp) {
window.$crisp.push(['off', 'session:loaded', setData])
}
}
}, [userData])

// thought: we need to version pin this script
return (
<Script strategy="afterInteractive">
{`
window.$crisp=[];
window.CRISP_WEBSITE_ID="916078be-a6af-4696-82cb-bc08d43d9125";
(function(){
d=document;
s=d.createElement("script");
s.src="https://client.crisp.chat/l.js";
s.async=1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
window.$crisp.push(["safe", true]);
`}
</Script>
)
}
69 changes: 27 additions & 42 deletions src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,52 @@
'use client'

import { useState, useEffect } from 'react'
import { useSupportModalContext } from '@/context/SupportModalContext'
import { useCrispUserData } from '@/hooks/useCrispUserData'
import { setCrispUserData } from '@/utils/crisp'
import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'
import { useEffect, useRef } from 'react'
import PeanutLoading from '../PeanutLoading'

const SupportDrawer = () => {
const { isSupportModalOpen, setIsSupportModalOpen, prefilledMessage } = useSupportModalContext()
const userData = useCrispUserData()
const iframeRef = useRef<HTMLIFrameElement>(null)
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
if (!isSupportModalOpen || !iframeRef.current || !userData.username) return

const iframe = iframeRef.current
const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage)

// 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
useEffect(() => {
// Listen for ready message from proxy iframe
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) 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)
if (event.data.type === 'CRISP_READY') {
setIsLoading(false)
}
}

const handleLoad = () => {
// Try immediately
setData()
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])

// 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)
// Reset loading state when drawer closes
useEffect(() => {
if (!isSupportModalOpen) {
setIsLoading(true)
}

iframe.addEventListener('load', handleLoad)
return () => iframe.removeEventListener('load', handleLoad)
}, [isSupportModalOpen, userData, prefilledMessage])
}, [isSupportModalOpen])

return (
<Drawer open={isSupportModalOpen} onOpenChange={setIsSupportModalOpen}>
<DrawerContent className="z-[999999] max-h-[85vh] w-screen pt-4">
<DrawerTitle className="sr-only">Support</DrawerTitle>
<iframe
ref={iframeRef}
src="https://go.crisp.chat/chat/embed/?website_id=916078be-a6af-4696-82cb-bc08d43d9125"
className="h-[80vh] w-full"
/>
<div className="relative h-[80vh] w-full">
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
<PeanutLoading />
</div>
)}
<iframe src={crispProxyUrl} className="h-full w-full" title="Support Chat" />
</div>
</DrawerContent>
</Drawer>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
6 changes: 6 additions & 0 deletions src/constants/crisp.ts
Original file line number Diff line number Diff line change
@@ -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'
10 changes: 10 additions & 0 deletions src/constants/support.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading