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
34 changes: 34 additions & 0 deletions src/app/(mobile-ui)/support-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

/**
* MINIMAL TEST PAGE for iOS debugging
* This bypasses all custom hooks to test if basic rendering works
*/
export default function SupportTestPage() {
return (
<div
style={{
width: '100%',
height: '100vh',
background: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
padding: '20px',
}}
>
<h1 style={{ fontSize: '24px', marginBottom: '20px' }}>iOS Test Page</h1>
<p style={{ marginBottom: '10px' }}>If you see this, page rendering works!</p>
<p style={{ fontSize: '12px', color: '#666' }}>
User agent: {typeof navigator !== 'undefined' ? navigator.userAgent : 'Loading...'}
</p>
<div style={{ marginTop: '20px' }}>
<a href="/support" style={{ color: 'blue', textDecoration: 'underline' }}>
Try /support page
</a>
</div>
</div>
)
}
Comment on lines +1 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix formatting to pass CI checks.

The pipeline reports Prettier formatting issues that will block merge.

Run the following command to fix:

pnpm prettier --write .
🧰 Tools
🪛 GitHub Actions: Tests

[warning] 1-1: Code style issues found in the file. Run 'prettier --write' to fix.


[error] 1-1: Prettier formatting check failed. Command 'pnpm prettier --check .' exited with code 1. Run 'pnpm prettier --write .' to fix code style issues.

🤖 Prompt for AI Agents
In src/app/(mobile-ui)/support-test/page.tsx lines 1-33, Prettier formatting
errors are blocking CI; run the project's formatter to fix the file (execute:
pnpm prettier --write .), review the modified file to ensure 'use client' and
JSX remain unchanged, stage and commit the formatted changes, and push to the
branch so CI can re-run.


40 changes: 18 additions & 22 deletions src/app/(mobile-ui)/support/page.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,33 @@
'use client'

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

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

// Debug logging for iOS
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)
}, [])
console.log('[SupportPage] Mounted', {
crispProxyUrl,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'SSR',
windowHeight: typeof window !== 'undefined' ? window.innerHeight : 'SSR',
})
}, [crispProxyUrl])

return (
<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
className="relative w-full md:max-w-[90%] md:pl-24"
style={{
height: '100%',
minHeight: '100vh',
background: 'var(--background)',
}}
>
<CrispIframe crispProxyUrl={crispProxyUrl} />
</div>
)
}
Expand Down
87 changes: 45 additions & 42 deletions src/app/crisp-proxy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use client'

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

/**
Expand All @@ -14,40 +13,48 @@ import { CRISP_WEBSITE_ID } from '@/constants/crisp'
*
* User data flows via URL parameters and is set during Crisp initialization,
* following Crisp's recommended pattern for iframe embedding with JS SDK control.
*
* iOS FIXES:
* - Removed Suspense boundary (causes Safari streaming deadlock)
* - Manual URLSearchParams instead of useSearchParams() hook (hydration issue)
*/
function CrispProxyContent() {
const searchParams = useSearchParams()
export default function CrispProxyPage() {
const [searchParams, setSearchParams] = useState<URLSearchParams | null>(null)

// Parse URL params manually (iOS Safari issue with useSearchParams + Suspense)
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
}
setSearchParams(new URLSearchParams(window.location.search))
}
}, [])

// Memoize notify function to prevent recreation
const notifyParentReady = useCallback(() => {
if (typeof window !== 'undefined' && window.parent !== window) {
window.parent.postMessage({ type: 'CRISP_READY' }, window.location.origin)
}
}, [])

// Set runtime config before Crisp loads
useEffect(() => {
if (typeof window === 'undefined') return
;(window as any).CRISP_RUNTIME_CONFIG = {
lock_maximized: true,
lock_full_view: true,
cross_origin_cookies: true, // Essential for session persistence in iframes
}
}, [])

// Initialize Crisp with user data
useEffect(() => {
if (typeof window === 'undefined' || !searchParams) 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

Expand Down Expand Up @@ -103,19 +110,19 @@ function CrispProxyContent() {
return true
}

// Initialize data once Crisp loads
if (window.$crisp) {
setAllData()
} else {
const checkCrisp = setInterval(() => {
if (window.$crisp) {
setAllData()
clearInterval(checkCrisp)
}
}, 100)
// Poll for Crisp SDK availability
const checkCrisp = setInterval(() => {
if (window.$crisp && setAllData()) {
clearInterval(checkCrisp)
}
}, 100)

setTimeout(() => clearInterval(checkCrisp), 5000)
}
// Safety: Clear polling after 10 seconds
const timeoutId = setTimeout(() => {
clearInterval(checkCrisp)
// Notify parent even if Crisp failed to load to prevent infinite spinner
notifyParentReady()
}, 10000)

// Listen for reset messages from parent window
const handleMessage = (event: MessageEvent) => {
Expand All @@ -127,8 +134,12 @@ function CrispProxyContent() {
}

window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [searchParams])
return () => {
window.removeEventListener('message', handleMessage)
clearInterval(checkCrisp)
clearTimeout(timeoutId)
}
}, [searchParams, notifyParentReady])

return (
<div className="h-full w-full">
Expand All @@ -149,11 +160,3 @@ function CrispProxyContent() {
</div>
)
}

export default function CrispProxyPage() {
return (
<Suspense fallback={<div className="h-full w-full" />}>
<CrispProxyContent />
</Suspense>
)
}
106 changes: 106 additions & 0 deletions src/components/Global/CrispIframe/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client'

import { useState, useEffect } from 'react'
import { useCrispIframeReady } from '@/hooks/useCrispIframeReady'
import PeanutLoading from '../PeanutLoading'
import { Button } from '@/components/0_Bruddle'

interface CrispIframeProps {
crispProxyUrl: string
enabled?: boolean
}

/**
* Shared Crisp iframe component with loading and error states
* DRY component used by both SupportDrawer and SupportPage
*/
export const CrispIframe = ({ crispProxyUrl, enabled = true }: CrispIframeProps) => {
const [componentError, setComponentError] = useState<string | null>(null)

// Defensive: Catch any hook errors
let hookState
try {
hookState = useCrispIframeReady(enabled)
} catch (error) {
console.error('[CrispIframe] Hook error:', error)
hookState = { isReady: true, hasError: true, retry: () => window.location.reload() }
}
Comment on lines +20 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix unconditional hook call violation.

Wrapping useCrispIframeReady in a try/catch creates a conditional hook call, which violates the Rules of Hooks. React requires hooks to be called unconditionally at the top level of every render. If the hook throws, subsequent renders will have mismatched hook order, causing React errors.

Move error handling into the hook itself, or wrap this entire component in an error boundary:

-    // Defensive: Catch any hook errors
-    let hookState
-    try {
-        hookState = useCrispIframeReady(enabled)
-    } catch (error) {
-        console.error('[CrispIframe] Hook error:', error)
-        hookState = { isReady: true, hasError: true, retry: () => window.location.reload() }
-    }
-
-    const { isReady, hasError, retry } = hookState
+    const { isReady, hasError, retry } = useCrispIframeReady(enabled)

If you need error boundaries for the entire component, add one in the parent that renders <CrispIframe>.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Defensive: Catch any hook errors
let hookState
try {
hookState = useCrispIframeReady(enabled)
} catch (error) {
console.error('[CrispIframe] Hook error:', error)
hookState = { isReady: true, hasError: true, retry: () => window.location.reload() }
}
const { isReady, hasError, retry } = useCrispIframeReady(enabled)
🧰 Tools
🪛 Biome (2.1.2)

[error] 23-23: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
In src/components/Global/CrispIframe/index.tsx around lines 20 to 27, you
currently wrap the hook call in a try/catch which conditionally calls a hook and
violates the Rules of Hooks; instead, call useCrispIframeReady unconditionally
at the top of the component and move any try/catch/error handling into the hook
implementation (have the hook catch exceptions and return an error state like {
isReady:false, hasError:true, retry:() => ... }), or remove the try/catch here
and wrap the CrispIframe component in a React Error Boundary at the parent
level; update the component to use the error flags returned by the hook (or rely
on the boundary) and remove the local try/catch to ensure hooks are always
called in the same order.


const { isReady, hasError, retry } = hookState

// Debug logging for iOS
useEffect(() => {
console.log('[CrispIframe] Component mounted', {
enabled,
crispProxyUrl,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'SSR',
})
}, [enabled, crispProxyUrl])

useEffect(() => {
console.log('[CrispIframe] State:', { isReady, hasError })
}, [isReady, hasError])

// Catch render errors
if (componentError) {
return (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<div className="space-y-2">
<p className="text-base font-semibold">Something went wrong</p>
<p className="text-sm text-grey-1">
Please email us at{' '}
<a href="mailto:support@peanut.me" className="text-purple-1 underline">
support@peanut.me
</a>
</p>
{componentError && <p className="text-xs text-error">{componentError}</p>}
</div>
<Button
onClick={() => window.location.reload()}
shadowSize="4"
variant="purple"
icon="retry"
iconSize={16}
>
Reload page
</Button>
</div>
)
}

return (
<>
{!isReady && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background">
<PeanutLoading />
</div>
)}
{hasError && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<div className="space-y-2">
<p className="text-base font-semibold">Having trouble loading support chat</p>
<p className="text-sm text-grey-1">
Check your internet connection and try again. If the problem persists, you can email us at{' '}
<a href="mailto:support@peanut.me" className="text-purple-1 underline">
support@peanut.me
</a>
</p>
</div>
<Button onClick={retry} shadowSize="4" variant="purple" icon="retry" iconSize={16}>
Try again
</Button>
</div>
)}
<iframe
src={crispProxyUrl}
className="h-full w-full"
style={{
height: '100%',
width: '100%',
minHeight: '-webkit-fill-available',
}}
title="Support Chat"
/>
Comment on lines +79 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retry button never reloads the Crisp iframe.

retry only resets hook state; the <iframe> keeps its original instance, so iOS users stuck on a blank load stay stuck even after pressing “Try again.” Please force a remount (or cache-busting source) when retrying so the Crisp proxy actually reloads.

 'use client'
 
+import { useCallback, useState } from 'react'
 import { useCrispIframeReady } from '@/hooks/useCrispIframeReady'
 import PeanutLoading from '../PeanutLoading'
 import { Button } from '@/components/0_Bruddle'
@@
-export const CrispIframe = ({ crispProxyUrl, enabled = true }: CrispIframeProps) => {
-    const { isReady, hasError, retry } = useCrispIframeReady(enabled)
+export const CrispIframe = ({ crispProxyUrl, enabled = true }: CrispIframeProps) => {
+    const [reloadNonce, setReloadNonce] = useState(0)
+    const { isReady, hasError, retry } = useCrispIframeReady(enabled)
+    const handleRetry = useCallback(() => {
+        setReloadNonce((prev) => prev + 1)
+        retry()
+    }, [retry])
@@
-                    <Button onClick={retry} shadowSize="4" variant="purple" icon="retry" iconSize={16}>
+                    <Button onClick={handleRetry} shadowSize="4" variant="purple" icon="retry" iconSize={16}>
                         Try again
                     </Button>
                 </div>
             )}
             <iframe
+                key={reloadNonce}
                 src={crispProxyUrl}
                 className="h-full w-full"
🤖 Prompt for AI Agents
In src/components/Global/CrispIframe/index.tsx around lines 27 to 51, the Retry
button only resets hook state but does not cause the iframe to reload, so on iOS
the original iframe instance remains blank; update the retry handler to force a
remount or cache-bust the iframe src: add a local key or stamp state (e.g.
reloadCounter or srcTimestamp) that is incremented/updated in retry and pass it
as the iframe key or append it as a query param to crispProxyUrl so React
unmounts/remounts the iframe (or the browser treats the URL as new) and the
Crisp proxy actually reloads.

</>
)
}
33 changes: 2 additions & 31 deletions src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,22 @@
'use client'

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

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

const crispProxyUrl = useCrispProxyUrl(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 (
<Drawer open={isSupportModalOpen} onOpenChange={setIsSupportModalOpen}>
<DrawerContent className="z-[999999] max-h-[85vh] w-screen pt-4">
<DrawerTitle className="sr-only">Support</DrawerTitle>
<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" />
<CrispIframe crispProxyUrl={crispProxyUrl} enabled={isSupportModalOpen} />
</div>
</DrawerContent>
</Drawer>
Expand Down
Loading
Loading