diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3160095..c6fae32 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -62,13 +62,20 @@ body { border-radius: 16px; } -.glass-light { +.glass-light { background: rgba(22, 33, 62, 0.6); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px; -} +} + +/* Solid pairing surface. Join links must not rely on glass or blur. */ +.surface-solid { + background: #17171c; + border: 1px solid #303036; + border-radius: 16px; +} /* Gradient text */ .gradient-text { diff --git a/apps/web/app/join/[code]/JoinClient.test.ts b/apps/web/app/join/[code]/JoinClient.test.ts index bac41c3..e66186a 100644 --- a/apps/web/app/join/[code]/JoinClient.test.ts +++ b/apps/web/app/join/[code]/JoinClient.test.ts @@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => { expect(source).not.toMatch(/fetch\(/) }) - it('attempts the app scheme link and shows the raw code fallback', async () => { + it('only attempts the app scheme link after an explicit Open in Sauci action', async () => { const source = await readFile(componentPath, 'utf8') expect(source).toContain('buildInviteSchemeUrl') + expect(source).toContain('Open in Sauci') + expect(source.indexOf('const handleOpenInApp')).toBeLessThan(source.indexOf('window.location.href = buildInviteSchemeUrl')) expect(source).toContain('invite-code-display') - expect(source).toContain('Copy code') }) it('copies only the raw code, not a share message', async () => { @@ -37,13 +38,37 @@ describe('/join/[code] JoinClient', () => { const source = await readFile(componentPath, 'utf8') expect(source).toContain("posthog?.capture('join_page_viewed'") - expect(source).toContain("posthog?.capture('join_page_code_copied')") + expect(source).toContain("posthog?.capture('join_page_code_copied'") expect(source).toContain("posthog?.capture('join_page_store_button_clicked'") + expect(source).toContain("posthog?.capture('join_page_open_app_clicked'") + expect(source).toContain("posthog?.capture('join_page_open_app_fallback_shown'") + expect(source).toContain("posthog?.capture('join_page_offline_shown'") + }) + + it('does not put an invite code in a PostHog payload', async () => { + const source = await readFile(componentPath, 'utf8') + + expect(source).not.toMatch(/posthog\?\.capture\([^)]*normalizedCode/) + expect(source).not.toMatch(/posthog\?\.capture\([^)]*\bcode\s*:/) + }) + + it('uses the required recovery and accessibility states', async () => { + const source = await readFile(componentPath, 'utf8') + + expect(source).toContain('Copy this before you install') + expect(source).toContain('setTimeout(() =>') + expect(source).toContain('}, 1500)') + expect(source).toContain("document.visibilityState === 'visible'") + expect(source).toContain("aria-live=\"polite\"") + expect(source).toContain('navigator.onLine') + expect(source).toContain('surface-solid') + expect(source).not.toContain('glass-light') }) it('shows a clear fallback message for a malformed invite code', async () => { const source = await readFile(componentPath, 'utf8') expect(source).toContain('Invite link not recognized') + expect(source).not.toContain('has expired') }) }) diff --git a/apps/web/app/join/[code]/JoinClient.tsx b/apps/web/app/join/[code]/JoinClient.tsx index f7101c9..7826c1c 100644 --- a/apps/web/app/join/[code]/JoinClient.tsx +++ b/apps/web/app/join/[code]/JoinClient.tsx @@ -7,35 +7,66 @@ import { usePostHog } from 'posthog-js/react' import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode' import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks' +type Platform = 'ios' | 'android' | 'desktop' | 'other' + +function getPlatform(): Platform { + if (typeof navigator === 'undefined') return 'other' + + const userAgent = navigator.userAgent + + if (/iPad|iPhone|iPod/i.test(userAgent)) return 'ios' + if (/Android/i.test(userAgent)) return 'android' + if (/Macintosh|Windows|Linux/i.test(userAgent)) return 'desktop' + + return 'other' +} + export function JoinClient({ code }: { code: string }) { const posthog = usePostHog() const [copied, setCopied] = useState(false) - const hasAttemptedOpen = useRef(false) + const [isOnline, setIsOnline] = useState(true) + const [openAppFallbackVisible, setOpenAppFallbackVisible] = useState(false) + const [platform, setPlatform] = useState('other') + const fallbackTimer = useRef | null>(null) + const hasCopiedCode = useRef(false) const isValid = isValidInviteCode(code) const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code]) - // Track the page view once. Fires regardless of code validity so we can - // see broken/expired links in the funnel too. useEffect(() => { - posthog?.capture('join_page_viewed', { code_valid: isValid }) + const detectedPlatform = getPlatform() + setPlatform(detectedPlatform) + posthog?.capture('join_page_viewed', { code_valid: isValid, platform: detectedPlatform }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Attempt to open the app via the custom scheme once. If the app is - // installed and registered for the scheme, this hands off to it; if not, - // nothing visible happens and the fallback UI below remains in view. useEffect(() => { - if (!isValid || hasAttemptedOpen.current) return - hasAttemptedOpen.current = true - window.location.href = buildInviteSchemeUrl(normalizedCode) - }, [isValid, normalizedCode]) + const updateOnlineStatus = () => setIsOnline(navigator.onLine) - const handleCopy = async () => { + updateOnlineStatus() + window.addEventListener('online', updateOnlineStatus) + window.addEventListener('offline', updateOnlineStatus) + + return () => { + window.removeEventListener('online', updateOnlineStatus) + window.removeEventListener('offline', updateOnlineStatus) + } + }, []) + + useEffect(() => { + if (!isOnline) posthog?.capture('join_page_offline_shown') + }, [isOnline, posthog]) + + useEffect(() => () => { + if (fallbackTimer.current) clearTimeout(fallbackTimer.current) + }, []) + + const handleCopy = async (trigger: 'code_block' | 'fallback_note') => { try { await navigator.clipboard.writeText(normalizedCode) + hasCopiedCode.current = true setCopied(true) - posthog?.capture('join_page_code_copied') + posthog?.capture('join_page_code_copied', { trigger }) setTimeout(() => setCopied(false), 2000) } catch (err) { console.error('Failed to copy invite code:', err) @@ -43,60 +74,95 @@ export function JoinClient({ code }: { code: string }) { } const handleStoreClick = (store: 'app_store' | 'play_store') => { - posthog?.capture('join_page_store_button_clicked', { store }) + if (!isOnline) return + posthog?.capture('join_page_store_button_clicked', { store, code_copied_first: hasCopiedCode.current }) + } + + const handleOpenInApp = () => { + setOpenAppFallbackVisible(false) + posthog?.capture('join_page_open_app_clicked', { platform }) + window.location.href = buildInviteSchemeUrl(normalizedCode) + + if (fallbackTimer.current) clearTimeout(fallbackTimer.current) + fallbackTimer.current = setTimeout(() => { + if (document.visibilityState === 'visible') { + setOpenAppFallbackVisible(true) + posthog?.capture('join_page_open_app_fallback_shown', { platform }) + } + }, 1500) } if (!isValid) { + const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL + return (
-
+

Invite link not recognized

-

- This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app. +

+ This invite link looks incomplete. Ask your partner to send a new one from the Sauci app.

- - Back to Home - +
+ + Back to Home + + + Get Sauci + +
) } + const copyAnnouncement = copied ? 'Invite code copied.' : '' + const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.' + const desktopCopy = platform === 'desktop' + ? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.' + : 'They have already answered questions about you two. When you join, you will both see only the things you agree on. Neither of you sees the other’s individual answers.' + return (
- {/* Header */}
- + Back to Home
- {/* Content */}
-
- + {!isOnline && ( +

+ You're offline. Your code is below, and it still works. +

+ )} +
+
-

You've been invited to Sauci

-

- Open the app to pair up instantly. If it doesn't open automatically, copy your code below and - enter it after you install Sauci. -

+

You've been invited to compare answers

+

{desktopCopy}

-
-

Your invite code

+
+

Your invite code

{normalizedCode}

+

Copy this before you install. You'll enter it in the app.

-
+ {copyAnnouncement} + -

Don't have Sauci yet? Get the app first:

+ +

Already installed? This opens the app with your code.

- + {openAppFallbackVisible && ( +

+ Nothing opened? You probably don't have Sauci yet.{' '} + + , then install below. +

+ )} + +
+

Get Sauci

+ +
- {/* Footer */}