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,54 @@
import * as Clipboard from "expo-clipboard";
import {
checkClipboardForInviteCode,
__resetClipboardInviteOfferForTests,
} from "../../lib/clipboardInviteOffer";
jest.mock("expo-clipboard", () => ({
hasStringAsync: jest.fn(),
getStringAsync: jest.fn(),
}));
const hasStringAsync = Clipboard.hasStringAsync as jest.Mock;
const getStringAsync = Clipboard.getStringAsync as jest.Mock;
describe("checkClipboardForInviteCode", () => {
beforeEach(() => {
__resetClipboardInviteOfferForTests();
hasStringAsync.mockReset();
getStringAsync.mockReset();
});
it("returns a normalized code when the clipboard holds a validly-shaped code", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("abcd1234");
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
});
it("returns null when the clipboard is empty", async () => {
hasStringAsync.mockResolvedValue(false);
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
expect(getStringAsync).not.toHaveBeenCalled();
});
it("returns null when the clipboard string does not match the invite code shape", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("hello world");
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
});
it("only checks the clipboard once per session", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("abcd1234");
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
expect(hasStringAsync).toHaveBeenCalledTimes(1);
// A second call in the same session should not re-check the clipboard.
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
expect(hasStringAsync).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,69 @@
import { isValidInviteCode, normalizeInviteCode, parseInviteLinkCode } from "../../lib/inviteLink";
describe("isValidInviteCode", () => {
it("accepts 8-character alphanumeric codes case-insensitively", () => {
expect(isValidInviteCode("ABCD1234")).toBe(true);
expect(isValidInviteCode("abcd1234")).toBe(true);
});
it("rejects codes with the wrong length or characters", () => {
expect(isValidInviteCode("ABC123")).toBe(false); // too short
expect(isValidInviteCode("ABCD12345")).toBe(false); // too long
expect(isValidInviteCode("ABCD-123")).toBe(false); // invalid character
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");
});
});
describe("parseInviteLinkCode", () => {
it("parses a universal link", () => {
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234")).toEqual({
code: "ABCD1234",
source: "universal_link",
});
});
it("parses a universal link with a trailing slash", () => {
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234/")).toEqual({
code: "ABCD1234",
source: "universal_link",
});
});
it("parses a custom scheme link", () => {
expect(parseInviteLinkCode("app.sauci://join?code=abcd1234")).toEqual({
code: "ABCD1234",
source: "scheme",
});
});
it("returns null for an unrelated https URL", () => {
expect(parseInviteLinkCode("https://sauci.app/redeem")).toBeNull();
});
it("returns null for an unrelated host", () => {
expect(parseInviteLinkCode("https://example.com/join/abcd1234")).toBeNull();
});
it("returns null for a custom scheme link with a badly-shaped code", () => {
expect(parseInviteLinkCode("app.sauci://join?code=123")).toBeNull();
});
it("returns null for a custom scheme link missing the join host", () => {
expect(parseInviteLinkCode("app.sauci://login?token=abc")).toBeNull();
});
it("returns null for garbage input", () => {
expect(parseInviteLinkCode("not a url")).toBeNull();
expect(parseInviteLinkCode(null)).toBeNull();
expect(parseInviteLinkCode(undefined)).toBeNull();
expect(parseInviteLinkCode("")).toBeNull();
});
});
@@ -0,0 +1,29 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
stashPendingInviteCode,
getPendingInviteCode,
clearPendingInviteCode,
} from "../../lib/pendingInviteCode";
describe("pendingInviteCode", () => {
beforeEach(async () => {
await AsyncStorage.clear();
});
it("stashes, retrieves, and clears a valid code", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
await clearPendingInviteCode();
await expect(getPendingInviteCode()).resolves.toBeNull();
});
it("returns null when nothing has been stashed", async () => {
await expect(getPendingInviteCode()).resolves.toBeNull();
});
it("ignores a stashed value that no longer matches the invite code shape", async () => {
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
await expect(getPendingInviteCode()).resolves.toBeNull();
});
});
+3
View File
@@ -272,6 +272,9 @@ export const Events = {
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
// Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") =>
@@ -0,0 +1,39 @@
// Deferred hand-off fallback: when a user copies an invite code on another
// device (e.g. from the web join page) then opens the app, offer to use the
// code found on the clipboard. Never auto-applies the code; the caller must
// present a confirmation before using the result. Only checks once per app
// session so the user is not repeatedly prompted.
import * as Clipboard from "expo-clipboard";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
let hasCheckedThisSession = false;
/**
* Checks the clipboard once per session for a string matching the invite
* code shape. Returns the normalized code, or null when there is nothing
* usable (or the check already ran this session).
*/
export async function checkClipboardForInviteCode(): Promise<string | null> {
if (hasCheckedThisSession) return null;
hasCheckedThisSession = true;
try {
const hasString = await Clipboard.hasStringAsync();
if (!hasString) return null;
const value = await Clipboard.getStringAsync();
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
}
} catch (error) {
console.error("Error checking clipboard for invite code:", error);
}
return null;
}
/** Test-only helper to reset the per-session gating between test cases. */
export function __resetClipboardInviteOfferForTests(): void {
hasCheckedThisSession = false;
}
+76
View File
@@ -0,0 +1,76 @@
// Shared invite code shape and deep-link parsing for the invite funnel.
//
// Two link forms carry an invite code into the app:
// - Universal/App Link: https://sauci.app/join/{code}
// - Custom scheme link: app.sauci://join?code={code}
//
// Both forms are handled by the root layout's deep link listener and by the
// pairing screen when it receives a `code` route param.
export const INVITE_CODE_LENGTH = 8;
const INVITE_CODE_PATTERN = /^[A-Z0-9]{8}$/;
export type InviteLinkSource = "universal_link" | "scheme";
export interface ParsedInviteLink {
code: string;
source: InviteLinkSource;
}
/**
* Returns true when `code` (case-insensitive) matches the 8-character
* alphanumeric invite code shape used across the app.
*/
export function isValidInviteCode(code: string | null | undefined): boolean {
if (!code) return false;
return INVITE_CODE_PATTERN.test(code.trim().toUpperCase());
}
/**
* Normalizes a raw invite code string to the canonical uppercase form used
* for display, storage, and API calls.
*/
export function normalizeInviteCode(code: string): string {
return code.trim().toUpperCase();
}
/**
* Parses an incoming URL for an invite code, supporting both the universal
* link form (https://sauci.app/join/{code}) and the custom scheme form
* (app.sauci://join?code={code}). Returns null when the URL does not carry a
* recognizable, validly-shaped invite code.
*/
export function parseInviteLinkCode(url: string | null | undefined): ParsedInviteLink | null {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
let rawCode: string | null = null;
let source: InviteLinkSource | null = null;
if (parsed.protocol === "https:" && parsed.hostname === "sauci.app") {
const match = parsed.pathname.match(/^\/join\/([^/]+)\/?$/);
if (match) {
rawCode = match[1];
source = "universal_link";
}
} else if (parsed.protocol === "app.sauci:") {
const host = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
if (host === "join") {
rawCode = parsed.searchParams.get("code");
source = "scheme";
}
}
if (!rawCode || !source) return null;
const code = normalizeInviteCode(rawCode);
if (!isValidInviteCode(code)) return null;
return { code, source };
}
+37
View File
@@ -0,0 +1,37 @@
// Deferred hand-off storage for an invite code received before the user is
// signed in or has finished onboarding. The code is applied once the user
// reaches the pairing screen (see app/(app)/pairing.tsx and app/index.tsx).
import AsyncStorage from "@react-native-async-storage/async-storage";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
const STORAGE_KEY = "pending_invite_code";
export async function stashPendingInviteCode(code: string): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
} catch (error) {
console.error("Error stashing pending invite code:", error);
}
}
export async function getPendingInviteCode(): Promise<string | null> {
try {
const value = await AsyncStorage.getItem(STORAGE_KEY);
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
}
return null;
} catch (error) {
console.error("Error reading pending invite code:", error);
return null;
}
}
export async function clearPendingInviteCode(): Promise<void> {
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error("Error clearing pending invite code:", error);
}
}