feat(invite-funnel): add web join page and mobile deep-link hand-off

Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 17:58:43 +01:00
1 parent 5564f9bbab
commit d48b44baa8
22 files changed
+950 -23

No files matched your search

@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const componentPath = new URL('./JoinClient.tsx', import.meta.url)
describe('/join/[code] JoinClient', () => {
it('validates the code shape client-side without calling a private API', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain("isValidInviteCode(code)")
expect(source).not.toMatch(/\/public\/v1\//)
expect(source).not.toMatch(/fetch\(/)
})
it('attempts the app scheme link and shows the raw code fallback', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('buildInviteSchemeUrl')
expect(source).toContain('invite-code-display')
expect(source).toContain('Copy code')
})
it('copies only the raw code, not a share message', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('navigator.clipboard.writeText(normalizedCode)')
})
it('links to both app stores', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('APP_STORE_URL')
expect(source).toContain('PLAY_STORE_URL')
})
it('instruments the funnel with PostHog page view, copy, and store-click events', async () => {
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_store_button_clicked'")
})
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')
})
})
+153
View File
@@ -0,0 +1,153 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Check, Copy, Heart } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
export function JoinClient({ code }: { code: string }) {
const posthog = usePostHog()
const [copied, setCopied] = useState(false)
const hasAttemptedOpen = 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 })
// 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 handleCopy = async () => {
try {
await navigator.clipboard.writeText(normalizedCode)
setCopied(true)
posthog?.capture('join_page_code_copied')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy invite code:', err)
}
}
const handleStoreClick = (store: 'app_store' | 'play_store') => {
posthog?.capture('join_page_store_button_clicked', { store })
}
if (!isValid) {
return (
<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">
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
<p className="text-white/60 mb-8">
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
</p>
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
</div>
</main>
)
}
return (
<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">
<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">
<ArrowLeft className="w-5 h-5" />
<span>Back to Home</span>
</Link>
</div>
</header>
{/* Content */}
<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">
<Heart className="w-10 h-10 text-primary" />
</div>
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to Sauci</h1>
<p className="text-white/60 mb-10">
Open the app to pair up instantly. If it doesn&apos;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">
<p className="text-white/60 text-sm mb-3">Your invite code</p>
<p
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
data-testid="invite-code-display"
>
{normalizedCode}
</p>
<button
type="button"
onClick={handleCopy}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{copied ? (
<>
<Check className="w-5 h-5" />
Copied
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy code
</>
)}
</button>
</div>
<p className="text-white/40 text-sm mb-6">Don&apos;t have Sauci yet? Get the app first:</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('app_store')}
className="btn-primary w-full sm:w-auto"
>
Download on the App Store
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('play_store')}
className="btn-secondary w-full sm:w-auto"
>
Get it on Google Play
</a>
</div>
</div>
{/* Footer */}
<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">
<span className="text-xl font-bold gradient-text">Sauci</span>
<div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link>
</div>
</div>
</footer>
</main>
)
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from 'next'
import { JoinClient } from './JoinClient'
export const metadata: Metadata = {
title: 'Join your partner on Sauci',
description: 'Open Sauci and pair up instantly with your invite code.',
}
export default function JoinPage({ params }: { params: { code: string } }) {
return <JoinClient code={params.code} />
}
+15
View File
@@ -0,0 +1,15 @@
// App store and deep-link destinations used by the invite join page
// (apps/web/app/join/[code]/page.tsx). These are the real, live listing URLs
// already used by components/Hero.tsx, components/CTA.tsx,
// components/Navbar.tsx, and components/Pricing.tsx.
export const APP_STORE_URL = 'https://apps.apple.com/gb/app/sauci-couple-games-romance/id6757159885'
export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
export function buildInviteSchemeUrl(code: string): string {
return `app.sauci://join?code=${encodeURIComponent(code)}`
}
export function buildInviteUniversalUrl(code: string): string {
return `https://sauci.app/join/${encodeURIComponent(code)}`
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isValidInviteCode, normalizeInviteCode } from './inviteCode'
describe('isValidInviteCode', () => {
it('accepts 8-character alphanumeric codes', () => {
expect(isValidInviteCode('ABCD1234')).toBe(true)
expect(isValidInviteCode('abcd1234')).toBe(true)
})
it('rejects codes with the wrong length or characters', () => {
expect(isValidInviteCode('ABC123')).toBe(false)
expect(isValidInviteCode('ABCD12345')).toBe(false)
expect(isValidInviteCode('ABCD-123')).toBe(false)
expect(isValidInviteCode('')).toBe(false)
expect(isValidInviteCode(null)).toBe(false)
expect(isValidInviteCode(undefined)).toBe(false)
})
})
describe('normalizeInviteCode', () => {
it('trims and uppercases', () => {
expect(normalizeInviteCode(' abcd1234 ')).toBe('ABCD1234')
})
})
+15
View File
@@ -0,0 +1,15 @@
// Shared invite code shape validation for the web join page. Mirrors the
// mobile app's invite code shape (apps/mobile/src/lib/inviteLink.ts) and the
// standalone API's invite_code column. This module only validates the shape
// client-side; it never calls a private API.
const INVITE_CODE_PATTERN = /^[A-Za-z0-9]{8}$/
export function isValidInviteCode(code: string | null | undefined): boolean {
if (!code) return false
return INVITE_CODE_PATTERN.test(code.trim())
}
export function normalizeInviteCode(code: string): string {
return code.trim().toUpperCase()
}
+18
View File
@@ -1,6 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
async headers() {
return [
{
// No file extension by design; iOS requires this exact path and
// rejects a response that isn't served as application/json.
source: '/.well-known/apple-app-site-association',
headers: [
{ key: 'Content-Type', value: 'application/json' },
],
},
{
source: '/.well-known/assetlinks.json',
headers: [
{ key: 'Content-Type', value: 'application/json' },
],
},
]
},
}
module.exports = nextConfig
@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "BXVPBATDZY.com.sauci.app",
"paths": ["/join/*"]
}
]
}
}
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.sauci.app",
"sha256_cert_fingerprints": [
"PLACEHOLDER_ANDROID_RELEASE_SHA256_CERT_FINGERPRINT"
]
}
}
]
@@ -0,0 +1,30 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const aasaPath = new URL('./apple-app-site-association', import.meta.url)
const assetlinksPath = new URL('./assetlinks.json', import.meta.url)
describe('apple-app-site-association', () => {
it('is valid JSON that covers /join/*', async () => {
const raw = await readFile(aasaPath, 'utf8')
const data = JSON.parse(raw)
expect(data.applinks.details).toHaveLength(1)
expect(data.applinks.details[0].paths).toContain('/join/*')
// com.sauci.app matches apps/mobile/app.json ios.bundleIdentifier.
expect(data.applinks.details[0].appID).toMatch(/\.com\.sauci\.app$/)
})
})
describe('assetlinks.json', () => {
it('is valid JSON that targets the Android package', async () => {
const raw = await readFile(assetlinksPath, 'utf8')
const data = JSON.parse(raw)
expect(data[0].target.package_name).toBe('com.sauci.app')
expect(data[0].target.namespace).toBe('android_app')
// The real release cert fingerprint is not present in this repository;
// this placeholder must be replaced before Android App Links will verify.
expect(data[0].target.sha256_cert_fingerprints[0]).toContain('PLACEHOLDER')
})
})