mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user