mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
merge: improve web invite journey
This commit is contained in:
commit
922066f492
4 files changed
+168
-44
No files matched your search
@@ -70,6 +70,13 @@ body {
|
|||||||
border-radius: 16px;
|
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 */
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(135deg, #e94560 0%, #9b59b6 100%);
|
background: linear-gradient(135deg, #e94560 0%, #9b59b6 100%);
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => {
|
|||||||
expect(source).not.toMatch(/fetch\(/)
|
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')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain('buildInviteSchemeUrl')
|
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('invite-code-display')
|
||||||
expect(source).toContain('Copy code')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies only the raw code, not a share message', async () => {
|
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')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain("posthog?.capture('join_page_viewed'")
|
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_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 () => {
|
it('shows a clear fallback message for a malformed invite code', async () => {
|
||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain('Invite link not recognized')
|
expect(source).toContain('Invite link not recognized')
|
||||||
|
expect(source).not.toContain('has expired')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -7,35 +7,66 @@ import { usePostHog } from 'posthog-js/react'
|
|||||||
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
|
||||||
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
|
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 }) {
|
export function JoinClient({ code }: { code: string }) {
|
||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const hasAttemptedOpen = useRef(false)
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [openAppFallbackVisible, setOpenAppFallbackVisible] = useState(false)
|
||||||
|
const [platform, setPlatform] = useState<Platform>('other')
|
||||||
|
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const hasCopiedCode = useRef(false)
|
||||||
|
|
||||||
const isValid = isValidInviteCode(code)
|
const isValid = isValidInviteCode(code)
|
||||||
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, 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(() => {
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!isValid || hasAttemptedOpen.current) return
|
const updateOnlineStatus = () => setIsOnline(navigator.onLine)
|
||||||
hasAttemptedOpen.current = true
|
|
||||||
window.location.href = buildInviteSchemeUrl(normalizedCode)
|
|
||||||
}, [isValid, normalizedCode])
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(normalizedCode)
|
await navigator.clipboard.writeText(normalizedCode)
|
||||||
|
hasCopiedCode.current = true
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
posthog?.capture('join_page_code_copied')
|
posthog?.capture('join_page_code_copied', { trigger })
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy invite code:', 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') => {
|
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) {
|
if (!isValid) {
|
||||||
|
const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
|
||||||
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
|
<div className="surface-solid p-8 sm:p-12 max-w-md w-full text-center">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
|
||||||
<p className="text-white/60 mb-8">
|
<p className="text-white/70 mb-8">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
<Link href="/" className="btn-primary inline-block">
|
<Link href="/" className="btn-primary inline-block">
|
||||||
Back to Home
|
Back to Home
|
||||||
</Link>
|
</Link>
|
||||||
|
<a
|
||||||
|
href={malformedStoreUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary inline-block"
|
||||||
|
>
|
||||||
|
Get Sauci
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
||||||
{/* Header */}
|
|
||||||
<header className="py-6 px-6 border-b border-white/5">
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||||
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
|
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
<span>Back to Home</span>
|
<span>Back to Home</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
|
||||||
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
|
{!isOnline && (
|
||||||
<Heart className="w-10 h-10 text-primary" />
|
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status">
|
||||||
|
You're offline. Your code is below, and it still works.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center mx-auto mb-8 shadow-[0_8px_28px_rgba(233,69,96,0.28)]">
|
||||||
|
<Heart className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to Sauci</h1>
|
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
||||||
<p className="text-white/60 mb-10">
|
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
||||||
Open the app to pair up instantly. If it doesn't open automatically, copy your code below and
|
|
||||||
enter it after you install Sauci.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="glass-light p-6 sm:p-8 mb-8">
|
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading">
|
||||||
<p className="text-white/60 text-sm mb-3">Your invite code</p>
|
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
||||||
<p
|
<p
|
||||||
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||||
data-testid="invite-code-display"
|
data-testid="invite-code-display"
|
||||||
|
aria-label={normalizedCode.split('').join(' ')}
|
||||||
>
|
>
|
||||||
{normalizedCode}
|
{normalizedCode}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-white/70 text-sm mb-5">Copy this before you install. You'll enter it in the app.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={() => void handleCopy('code_block')}
|
||||||
className="btn-secondary w-full flex items-center justify-center gap-2"
|
className="btn-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
@@ -111,33 +177,59 @@ export function JoinClient({ code }: { code: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span className="sr-only" role="status" aria-live="polite">{copyAnnouncement}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="text-white/40 text-sm mb-6">Don't have Sauci yet? Get the app first:</p>
|
<button type="button" onClick={handleOpenInApp} className="btn-primary w-full mb-2">
|
||||||
|
Open in Sauci
|
||||||
|
</button>
|
||||||
|
<p className="text-white/60 text-sm mb-6">Already installed? This opens the app with your code.</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
{openAppFallbackVisible && (
|
||||||
|
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status" aria-live="polite">
|
||||||
|
Nothing opened? You probably don't have Sauci yet.{' '}
|
||||||
|
<button type="button" className="underline font-medium text-white" onClick={() => void handleCopy('fallback_note')}>
|
||||||
|
Copy your code
|
||||||
|
</button>
|
||||||
|
, then install below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section aria-labelledby="install-heading">
|
||||||
|
<h2 id="install-heading" className="text-white/70 text-sm font-medium mb-4">Get Sauci</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<a
|
<a
|
||||||
href={APP_STORE_URL}
|
href={APP_STORE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => handleStoreClick('app_store')}
|
onClick={(event) => {
|
||||||
className="btn-primary w-full sm:w-auto"
|
if (!isOnline) event.preventDefault()
|
||||||
|
handleStoreClick('app_store')
|
||||||
|
}}
|
||||||
|
aria-disabled={!isOnline}
|
||||||
|
title={storeUnavailableTitle}
|
||||||
|
className={`${platform === 'ios' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Download on the App Store
|
Get it on the App Store
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={PLAY_STORE_URL}
|
href={PLAY_STORE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => handleStoreClick('play_store')}
|
onClick={(event) => {
|
||||||
className="btn-secondary w-full sm:w-auto"
|
if (!isOnline) event.preventDefault()
|
||||||
|
handleStoreClick('play_store')
|
||||||
|
}}
|
||||||
|
aria-disabled={!isOnline}
|
||||||
|
title={storeUnavailableTitle}
|
||||||
|
className={`${platform === 'android' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Get it on Google Play
|
Get it on Google Play
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
|
||||||
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<span className="text-xl font-bold gradient-text">Sauci</span>
|
<span className="text-xl font-bold gradient-text">Sauci</span>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
|
|||||||
import { JoinClient } from './JoinClient'
|
import { JoinClient } from './JoinClient'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Join your partner on Sauci',
|
title: 'Compare answers together on Sauci',
|
||||||
description: 'Open Sauci and pair up instantly with your invite code.',
|
description: 'Copy your invite code, then join Sauci to discover what you both agree on.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function JoinPage({
|
export default async function JoinPage({
|
||||||
|
|||||||
Reference in new issue
Block a user