diff --git a/src/app/(mobile-ui)/support-test/page.tsx b/src/app/(mobile-ui)/support-test/page.tsx
new file mode 100644
index 000000000..9abfb8d65
--- /dev/null
+++ b/src/app/(mobile-ui)/support-test/page.tsx
@@ -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 (
+
+
iOS Test Page
+
If you see this, page rendering works!
+
+ User agent: {typeof navigator !== 'undefined' ? navigator.userAgent : 'Loading...'}
+
+
+
+ )
+}
+
diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx
index 2a0072c82..6e55209e6 100644
--- a/src/app/(mobile-ui)/support/page.tsx
+++ b/src/app/(mobile-ui)/support/page.tsx
@@ -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 (
-
- {isLoading && (
-
- )}
-
+
+
)
}
diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx
index 2fb31cf38..9b65385a2 100644
--- a/src/app/crisp-proxy/page.tsx
+++ b/src/app/crisp-proxy/page.tsx
@@ -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'
/**
@@ -14,22 +13,41 @@ 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
(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')
@@ -37,17 +55,6 @@ function CrispProxyContent() {
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
@@ -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) => {
@@ -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 (
@@ -149,11 +160,3 @@ function CrispProxyContent() {
)
}
-
-export default function CrispProxyPage() {
- return (
- }>
-
-
- )
-}
diff --git a/src/components/Global/CrispIframe/index.tsx b/src/components/Global/CrispIframe/index.tsx
new file mode 100644
index 000000000..368d2b777
--- /dev/null
+++ b/src/components/Global/CrispIframe/index.tsx
@@ -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(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() }
+ }
+
+ 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 (
+
+
+
Something went wrong
+
+ Please email us at{' '}
+
+ support@peanut.me
+
+
+ {componentError &&
{componentError}
}
+
+
window.location.reload()}
+ shadowSize="4"
+ variant="purple"
+ icon="retry"
+ iconSize={16}
+ >
+ Reload page
+
+
+ )
+ }
+
+ return (
+ <>
+ {!isReady && (
+
+ )}
+ {hasError && (
+
+
+
Having trouble loading support chat
+
+ Check your internet connection and try again. If the problem persists, you can email us at{' '}
+
+ support@peanut.me
+
+
+
+
+ Try again
+
+
+ )}
+
+ >
+ )
+}
diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx
index 55471c926..0ec131456 100644
--- a/src/components/Global/SupportDrawer/index.tsx
+++ b/src/components/Global/SupportDrawer/index.tsx
@@ -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 (
Support
- {isLoading && (
-
- )}
-
+
diff --git a/src/hooks/useCrispIframeReady.ts b/src/hooks/useCrispIframeReady.ts
new file mode 100644
index 000000000..5828b78d5
--- /dev/null
+++ b/src/hooks/useCrispIframeReady.ts
@@ -0,0 +1,76 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useDeviceType, DeviceType } from './useGetDeviceType'
+
+export interface CrispIframeState {
+ isReady: boolean
+ hasError: boolean
+ retry: () => void
+}
+
+/**
+ * Hook to manage Crisp iframe ready state with device-specific timeouts
+ *
+ * iOS devices get longer timeout due to stricter security policies and
+ * slower script execution in iframe contexts.
+ *
+ * @param enabled - Whether to listen for ready messages (useful for conditional rendering)
+ * @returns Object with isReady, hasError, and retry function
+ */
+export function useCrispIframeReady(enabled: boolean = true): CrispIframeState {
+ const [isReady, setIsReady] = useState(false)
+ const [hasError, setHasError] = useState(false)
+ const [attemptCount, setAttemptCount] = useState(0)
+ const { deviceType } = useDeviceType()
+
+ const retry = useCallback(() => {
+ setIsReady(false)
+ setHasError(false)
+ setAttemptCount((prev) => prev + 1)
+ }, [])
+
+ useEffect(() => {
+ if (!enabled) return
+
+ let receivedMessage = false
+
+ const handleMessage = (event: MessageEvent) => {
+ // Security: Only accept messages from same origin
+ if (event.origin !== window.location.origin) return
+
+ if (event.data.type === 'CRISP_READY') {
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('[Crisp] Iframe ready signal received')
+ }
+ receivedMessage = true
+ setIsReady(true)
+ setHasError(false)
+ }
+ }
+
+ // Device-specific timeouts:
+ // - iOS: 8s (stricter security, slower script execution in PWA)
+ // - Others: 3s (normal loading time)
+ const timeoutDuration = deviceType === DeviceType.IOS ? 8000 : 3000
+
+ const timeoutId = setTimeout(() => {
+ if (!receivedMessage) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ `[Crisp] Timeout reached after ${timeoutDuration}ms without CRISP_READY (device: ${deviceType})`
+ )
+ }
+ setHasError(true)
+ setIsReady(true) // Show iframe anyway for manual interaction
+ }
+ }, timeoutDuration)
+
+ window.addEventListener('message', handleMessage)
+
+ return () => {
+ window.removeEventListener('message', handleMessage)
+ clearTimeout(timeoutId)
+ }
+ }, [enabled, deviceType, attemptCount])
+
+ return { isReady, hasError, retry }
+}