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,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')
})
})