diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e3b50b8..fda5b0d 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,6 +16,9 @@ "ios": { "supportsTablet": false, "bundleIdentifier": "com.sauci.app", + "associatedDomains": [ + "applinks:sauci.app" + ], "config": { "usesNonExemptEncryption": false }, @@ -194,6 +197,23 @@ "android.permission.USE_BIOMETRIC", "android.permission.USE_FINGERPRINT", "com.android.vending.BILLING" + ], + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": true, + "data": [ + { + "scheme": "https", + "host": "sauci.app", + "pathPrefix": "/join" + } + ], + "category": [ + "BROWSABLE", + "DEFAULT" + ] + } ] }, "web": { diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index a47c456..a2f3d78 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -17,17 +17,24 @@ import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeInDown } from "react-native-reanimated"; import * as Clipboard from "expo-clipboard"; import { useAuthStore } from "../../src/store"; -import { coupleApi } from "../../src/lib/coupleApi"; +import { coupleApi } from "../../src/lib/coupleApi"; import { getPairingError } from "../../src/lib/errors"; import { Events } from "../../src/lib/analytics"; -import { router } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; +import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink"; +import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode"; +import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer"; export default function PairingScreen() { - const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore(); + const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore(); + const params = useLocalSearchParams<{ code?: string }>(); const [inviteCode, setInviteCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [wasPrefilled, setWasPrefilled] = useState(false); + const [clipboardOfferCode, setClipboardOfferCode] = useState(null); + const [prefillAttempted, setPrefillAttempted] = useState(false); // Redirect if already paired useEffect(() => { @@ -36,25 +43,83 @@ export default function PairingScreen() { } }, [couple, partner]); - // Poll the standalone API while waiting for a partner. Realtime delivery can - // be added later without coupling product data back to Supabase. - useEffect(() => { - if (!couple || partner) return; + // One-time prefill: apply a code carried by a join link (route param), then + // a stashed code from before sign-in, and only otherwise offer a clipboard + // match. Never auto-submits; the user still taps "Join Partner". + useEffect(() => { + if (couple) return; // only relevant to the unpaired join flow + if (prefillAttempted) return; + + let cancelled = false; + + const applyPrefill = async (code: string) => { + if (cancelled) return; + setInviteCode(code); + setWasPrefilled(true); + Events.pairingCodePrefilled(); + }; + + (async () => { + const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : ""; + if (isValidInviteCode(routeCode)) { + await applyPrefill(routeCode); + await clearPendingInviteCode(); + if (!cancelled) setPrefillAttempted(true); + return; + } + + const stashedCode = await getPendingInviteCode(); + if (stashedCode) { + await applyPrefill(stashedCode); + await clearPendingInviteCode(); + if (!cancelled) setPrefillAttempted(true); + return; + } + + const clipboardCode = await checkClipboardForInviteCode(); + if (clipboardCode && !cancelled) { + setClipboardOfferCode(clipboardCode); + } + if (!cancelled) setPrefillAttempted(true); + })(); + + return () => { + cancelled = true; + }; + }, [couple, params.code, prefillAttempted]); + + const acceptClipboardOffer = () => { + if (!clipboardOfferCode) return; + setInviteCode(clipboardOfferCode); + setWasPrefilled(true); + Events.inviteLinkOpened("clipboard"); + Events.pairingCodePrefilled(); + setClipboardOfferCode(null); + }; + + const dismissClipboardOffer = () => { + setClipboardOfferCode(null); + }; + + // Poll the standalone API while waiting for a partner. Realtime delivery can + // be added later without coupling product data back to Supabase. + useEffect(() => { + if (!couple || partner) return; // Poll every 5 seconds as fallback const pollInterval = setInterval(() => { fetchCouple(); }, 5000); - return () => { - clearInterval(pollInterval); - }; - }, [couple, partner, fetchCouple]); + return () => { + clearInterval(pollInterval); + }; + }, [couple, partner, fetchCouple]); const handleCreateCouple = async () => { setIsSubmitting(true); try { - await coupleApi.create(); + await coupleApi.create(); await fetchUser(); // Refresh user to get couple_id await fetchCouple(); // Fetch couple data @@ -76,7 +141,7 @@ export default function PairingScreen() { setIsSubmitting(true); try { - await coupleApi.join(sanitizedCode); + await coupleApi.join(sanitizedCode); await fetchUser(); // Refresh user to get couple_id await fetchCouple(); // Fetch couple data @@ -100,8 +165,13 @@ export default function PairingScreen() { } }; + const handleInviteCodeChange = (value: string) => { + setWasPrefilled(false); + setInviteCode(value); + }; + const shareMessage = couple?.invite_code - ? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}` + ? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)` : ""; const shareCode = async () => { @@ -144,7 +214,7 @@ export default function PairingScreen() { onPress: async () => { setIsSubmitting(true); try { - await coupleApi.cancel(); + await coupleApi.cancel(); await fetchUser(); await fetchCouple(); @@ -338,6 +408,37 @@ export default function PairingScreen() { + {/* Clipboard invite code offer */} + {clipboardOfferCode && ( + + + + + Use code {clipboardOfferCode} from your clipboard? + + + + Not now + + + Use code + + + + + )} + {/* Join Card */} Have a code? + {wasPrefilled && ( + + Code applied from your invite link + + )} { console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id); if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") { diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 751d505..eb70f74 100755 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,10 +1,33 @@ +import { useEffect, useState } from "react"; import { Redirect } from "expo-router"; import { useAuthStore } from "../src/store"; import { View, ActivityIndicator, StyleSheet } from "react-native"; import { needsOnboarding } from "../src/constants/onboarding"; +import { getPendingInviteCode } from "../src/lib/pendingInviteCode"; export default function Index() { const { isAuthenticated, isLoading, user } = useAuthStore(); + const [pendingCodeChecked, setPendingCodeChecked] = useState(false); + const [hasPendingInvite, setHasPendingInvite] = useState(false); + + // Check once, after auth resolves, whether an invite code is waiting to be + // applied (e.g. the user tapped a join link before signing in). + useEffect(() => { + if (isLoading || !isAuthenticated || user?.couple_id) { + return; + } + + let cancelled = false; + getPendingInviteCode().then((code) => { + if (cancelled) return; + setHasPendingInvite(!!code); + setPendingCodeChecked(true); + }); + + return () => { + cancelled = true; + }; + }, [isLoading, isAuthenticated, user?.couple_id]); // Show loading state while auth is being determined if (isLoading) { @@ -25,6 +48,26 @@ export default function Index() { return ; } + // A couple already exists for this user; no invite hand-off is relevant. + if (user?.couple_id) { + return ; + } + + // Wait for the one-time pending invite code check before deciding the + // route, so a stashed code from a join link is applied without an extra + // trip through the home screen. + if (!pendingCodeChecked) { + return ( + + + + ); + } + + if (hasPendingInvite) { + return ; + } + // Go to home/dashboard - users can pair from within the app // No forced redirect to pairing for users without a couple return ; diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 3f5a938..6909af0 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -3804,8 +3804,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + boost: 1dca942403ed9342f98334bf4c3621f011aa7946 + DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d @@ -3851,8 +3851,8 @@ SPEC CHECKSUMS: FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41 FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd - glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 + glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d diff --git a/apps/mobile/src/__tests__/lib/clipboardInviteOffer.test.ts b/apps/mobile/src/__tests__/lib/clipboardInviteOffer.test.ts new file mode 100644 index 0000000..f802381 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/clipboardInviteOffer.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/inviteLink.test.ts b/apps/mobile/src/__tests__/lib/inviteLink.test.ts new file mode 100644 index 0000000..08bdd54 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/inviteLink.test.ts @@ -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(); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts b/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts new file mode 100644 index 0000000..1e227b3 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/pendingInviteCode.test.ts @@ -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(); + }); +}); diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 70a0980..be45d04 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -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"), // Quiz events quizStarted: () => logEvent("quiz_started"), diff --git a/apps/mobile/src/lib/clipboardInviteOffer.ts b/apps/mobile/src/lib/clipboardInviteOffer.ts new file mode 100644 index 0000000..77d340a --- /dev/null +++ b/apps/mobile/src/lib/clipboardInviteOffer.ts @@ -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 { + 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; +} diff --git a/apps/mobile/src/lib/inviteLink.ts b/apps/mobile/src/lib/inviteLink.ts new file mode 100644 index 0000000..5625d88 --- /dev/null +++ b/apps/mobile/src/lib/inviteLink.ts @@ -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 }; +} diff --git a/apps/mobile/src/lib/pendingInviteCode.ts b/apps/mobile/src/lib/pendingInviteCode.ts new file mode 100644 index 0000000..e697eff --- /dev/null +++ b/apps/mobile/src/lib/pendingInviteCode.ts @@ -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 { + try { + await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code)); + } catch (error) { + console.error("Error stashing pending invite code:", error); + } +} + +export async function getPendingInviteCode(): Promise { + 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 { + try { + await AsyncStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.error("Error clearing pending invite code:", error); + } +} diff --git a/apps/web/app/join/[code]/JoinClient.test.ts b/apps/web/app/join/[code]/JoinClient.test.ts new file mode 100644 index 0000000..bac41c3 --- /dev/null +++ b/apps/web/app/join/[code]/JoinClient.test.ts @@ -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') + }) +}) diff --git a/apps/web/app/join/[code]/JoinClient.tsx b/apps/web/app/join/[code]/JoinClient.tsx new file mode 100644 index 0000000..f7101c9 --- /dev/null +++ b/apps/web/app/join/[code]/JoinClient.tsx @@ -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 ( +
+
+

Invite link not recognized

+

+ This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app. +

+ + Back to Home + +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + Back to Home + +
+
+ + {/* Content */} +
+
+ +
+ +

You've been invited to Sauci

+

+ Open the app to pair up instantly. If it doesn't open automatically, copy your code below and + enter it after you install Sauci. +

+ +
+

Your invite code

+

+ {normalizedCode} +

+ +
+ +

Don't have Sauci yet? Get the app first:

+ + +
+ + {/* Footer */} +
+
+ Sauci +
+ Privacy + Terms + Home +
+
+
+
+ ) +} diff --git a/apps/web/app/join/[code]/page.tsx b/apps/web/app/join/[code]/page.tsx new file mode 100644 index 0000000..7b296ab --- /dev/null +++ b/apps/web/app/join/[code]/page.tsx @@ -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 +} diff --git a/apps/web/lib/appLinks.ts b/apps/web/lib/appLinks.ts new file mode 100644 index 0000000..0a20f91 --- /dev/null +++ b/apps/web/lib/appLinks.ts @@ -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)}` +} diff --git a/apps/web/lib/inviteCode.test.ts b/apps/web/lib/inviteCode.test.ts new file mode 100644 index 0000000..f6f9231 --- /dev/null +++ b/apps/web/lib/inviteCode.test.ts @@ -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') + }) +}) diff --git a/apps/web/lib/inviteCode.ts b/apps/web/lib/inviteCode.ts new file mode 100644 index 0000000..277e3b4 --- /dev/null +++ b/apps/web/lib/inviteCode.ts @@ -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() +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index cc3aab9..8251175 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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 diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..49fdd96 --- /dev/null +++ b/apps/web/public/.well-known/apple-app-site-association @@ -0,0 +1,11 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "BXVPBATDZY.com.sauci.app", + "paths": ["/join/*"] + } + ] + } +} diff --git a/apps/web/public/.well-known/assetlinks.json b/apps/web/public/.well-known/assetlinks.json new file mode 100644 index 0000000..bceffb8 --- /dev/null +++ b/apps/web/public/.well-known/assetlinks.json @@ -0,0 +1,12 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.sauci.app", + "sha256_cert_fingerprints": [ + "65:2F:E7:60:3F:2E:D2:54:8B:4A:EF:0E:F1:AC:35:3E:DD:13:13:EF:35:E7:90:A7:01:1F:89:0F:4F:71:E2:21" + ] + } + } +] diff --git a/apps/web/public/.well-known/well-known.test.ts b/apps/web/public/.well-known/well-known.test.ts new file mode 100644 index 0000000..5295685 --- /dev/null +++ b/apps/web/public/.well-known/well-known.test.ts @@ -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') + // Must be the Play App Signing key certificate fingerprint (Play Console > + // Test and release > Setup > App signing), colon-separated uppercase hex. + expect(data[0].target.sha256_cert_fingerprints[0]).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/) + }) +}) diff --git a/docs/couple-pairing.md b/docs/couple-pairing.md index 536d1b0..74d1348 100644 --- a/docs/couple-pairing.md +++ b/docs/couple-pairing.md @@ -263,6 +263,55 @@ DELETE delete-relationship Both users: Unpaired ``` +## Invite Funnel (Join Links) + +The invite code can also be delivered as a tappable link instead of requiring +the recipient to type it in manually. + +**Link forms:** +- Universal/App Link: `https://sauci.app/join/{code}` (served by `apps/web`, + opens the app directly on a device that has it installed and verified). +- Custom scheme: `app.sauci://join?code={code}` (used by the web join page's + automatic hand-off attempt, and remains supported for any existing client + behavior). + +**Web fallback (`apps/web/app/join/[code]/page.tsx`):** validates the code +shape client-side only (8-character alphanumeric; never calls a private API), +attempts the custom scheme link, and otherwise shows the code with a copy +button plus App Store / Play Store links. Copying only copies the raw code. + +**Association files:** `apps/web/public/.well-known/apple-app-site-association` +and `apps/web/public/.well-known/assetlinks.json` authorize `sauci.app` to open +the app for `/join/*`. The Apple Team ID (`BXVPBATDZY`, from +`apps/mobile/app.json` and `apps/mobile/eas.json`) and bundle/package id +(`com.sauci.app`) are real values already used elsewhere in the repo. The +SHA-256 fingerprint in `assetlinks.json` is the Play App Signing key +certificate fingerprint from the Play Console (Test and release > Setup > +App signing); if the app signing key ever rotates, this file must be updated +or Android App Links will stop verifying. + +**Mobile link handling:** `apps/mobile/app.json` declares +`associatedDomains: ["applinks:sauci.app"]` (iOS) and an intent filter for +`https://sauci.app/join/*` (Android), in addition to the existing `app.sauci` +scheme. `apps/mobile/app/_layout.tsx` parses incoming URLs +(`src/lib/inviteLink.ts`) and either routes a signed-in, onboarded user +straight to the pairing screen with the code pre-filled, or stashes the code +(`src/lib/pendingInviteCode.ts`) for signed-out/mid-onboarding users. The +stashed code is applied once the user reaches the pairing screen or the app's +initial route (`apps/mobile/app/index.tsx`) after sign-in/onboarding +completes. + +**Deferred clipboard hand-off:** if an unpaired user opens the pairing screen +with no code from a link or stash, the app checks the clipboard once per +session (`src/lib/clipboardInviteOffer.ts`) for a string matching the invite +code shape and offers to use it ("Use code XXXXXXXX from your clipboard?"). +The code is never applied automatically; the user must accept the offer. + +**Analytics:** `invite_link_opened` (`source`: `universal_link` | `scheme` | +`clipboard`), `pairing_code_prefilled` (mobile, Firebase Analytics), and +`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked` +(web, PostHog). + ## Cascade Behavior When a couple is deleted, foreign key cascades clean up: