From f9481054dead4e19659df71974d4e00d422f05c1 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 6 Sep 2026 12:34:41 +0100 Subject: [PATCH] feat(mobile): improve pairing invite journey --- apps/mobile/app/(app)/_layout.tsx | 4 +- apps/mobile/app/(app)/paired.tsx | 50 ++++ apps/mobile/app/(app)/pairing.tsx | 226 +++++++++++++----- apps/mobile/app/_layout.tsx | 28 ++- .../src/__tests__/lib/inviteShareCopy.test.ts | 22 +- .../__tests__/lib/pairedUnlockSeen.test.ts | 18 ++ .../components/SwipeSealedAnswersBanner.tsx | 16 +- apps/mobile/src/lib/analytics.ts | 12 + apps/mobile/src/lib/inviteShareCopy.ts | 6 +- apps/mobile/src/lib/pairedUnlockSeen.ts | 15 ++ apps/mobile/src/store/authStore.ts | 14 ++ docs/couple-pairing.md | 16 ++ 12 files changed, 335 insertions(+), 92 deletions(-) create mode 100644 apps/mobile/app/(app)/paired.tsx create mode 100644 apps/mobile/src/__tests__/lib/pairedUnlockSeen.test.ts create mode 100644 apps/mobile/src/lib/pairedUnlockSeen.ts diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index f583d17..be2c93e 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -76,10 +76,11 @@ export default function AppLayout() { const segmentStrings = segments as string[]; const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnPairing = segmentStrings.includes("pairing"); + const isOnPaired = segmentStrings.includes("paired"); const isOnChat = segmentStrings.includes("chat"); const isOnLiveDraw = segmentStrings.includes("live-draw"); const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; - const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; + const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; // Redirect to login when not authenticated, or to onboarding if not completed/outdated useEffect(() => { @@ -367,6 +368,7 @@ export default function AppLayout() { href: null, // Hide from tab bar }} /> + n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+"; + +export default function PairedScreen() { + const { user, couple, partner } = useAuthStore(); + const { newMatchesCount, fetchMatches } = useMatchStore(); + const [ready, setReady] = useState(false); + const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>(); + const heading = useRef(null); + const role = rawRole === "inviter" ? "inviter" : "joiner"; + + useEffect(() => { + void fetchMatches(true).finally(() => setReady(true)); + }, [fetchMatches]); + useEffect(() => { + if (!ready || !user || !couple) return; + void markPairedUnlockSeen(user.id, couple.id); + Events.pairingUnlockViewed(role, bucket(newMatchesCount)); + const timer = setTimeout(() => { + if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never); + }, 250); + return () => clearTimeout(timer); + }, [ready, user, couple, newMatchesCount]); + + const partnerName = partner?.name?.split(" ")[0] || "your partner"; + const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); }; + const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); }; + const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); }; + if (!ready) { + return ; + } + return + + You're paired with {partnerName} + {newMatchesCount > 0 ? <>{newMatchesCount} thing{newMatchesCount === 1 ? "" : "s"} you already agree onFrom answers you both gave before now.See what you agree onStart a session : <>Nothing to compare yet. Answer a few questions and anything you agree on shows up here.Answer your first questions} + Not now + ; +} +const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} }); diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index b0ce9e6..fecde7e 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -10,7 +10,8 @@ import { Linking, KeyboardAvoidingView, Platform, - TouchableOpacity, + TouchableOpacity, + AccessibilityInfo, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; @@ -26,27 +27,43 @@ import { GradientBackground, GlassCard, GlassButton } from "../../src/components 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"; +import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer"; +import { ApiError } from "../../src/lib/apiClient"; +import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen"; export default function PairingScreen() { - const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore(); - const params = useLocalSearchParams<{ code?: string }>(); + const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore(); + const params = useLocalSearchParams<{ code?: string; incomingCode?: 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); + const [prefillAttempted, setPrefillAttempted] = useState(false); + const [joinError, setJoinError] = useState(null); + const [isPollOffline, setIsPollOffline] = useState(false); // Redirect if already paired. Pairing claims both members' sealed answers and // computes matches server-side, so the matches store must refetch here too: // whichever partner lands on this redirect first would otherwise show stale, // pre-pairing matches until some unrelated screen happened to refresh it. - useEffect(() => { - if (couple && partner) { - void useMatchStore.getState().fetchMatches(true); - router.replace("/(app)"); - } - }, [couple, partner]); + useEffect(() => { + if (!couple || !partner || !user) return; + let cancelled = false; + void (async () => { + if (typeof params.incomingCode === "string") { + await clearPendingInviteCode(); + if (!cancelled) { + Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]); + } + return; + } + const seen = await hasSeenPairedUnlock(user.id, couple.id); + if (!cancelled) { + router.replace(seen ? "/(app)" : "/(app)/paired"); + } + })(); + return () => { cancelled = true; }; + }, [couple, partner, user, params.incomingCode]); // 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 @@ -113,7 +130,7 @@ export default function PairingScreen() { // Poll every 5 seconds as fallback const pollInterval = setInterval(() => { - fetchCouple(); + void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true)); }, 5000); return () => { @@ -140,11 +157,12 @@ export default function PairingScreen() { // Validate invite code format (8 alphanumeric characters) const sanitizedCode = inviteCode.trim().toUpperCase(); if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) { - Alert.alert("Invalid Code", "Please enter a valid 8-character invite code."); - return; + setJoinError("That code isn't the right shape. Codes are 8 letters and numbers."); + return; } - setIsSubmitting(true); + setIsSubmitting(true); + setJoinError(null); try { await coupleApi.join(sanitizedCode); @@ -152,14 +170,26 @@ export default function PairingScreen() { await fetchCouple(); // Fetch couple data // Joining claims both members' sealed answers and computes matches // server-side, so the joiner needs a fresh matches list too. - void useMatchStore.getState().fetchMatches(true); + await useMatchStore.getState().fetchMatches(true); Events.coupleJoined(); - Alert.alert("Success", "You are now paired!", [ - { text: "Let's Go", onPress: () => router.replace("/(app)") } - ]); - } catch (error: any) { - Alert.alert("Error", getPairingError(error)); + router.replace("/(app)/paired"); + } catch (error: any) { + const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined; + const code = details?.error?.code; + const message = code === "couple_full" + ? "That code is already in use by two people." + : code === "already_paired" + ? "You're already paired." + : code === "invalid_invite_code" && error.status === 404 + ? "We can't find that code. It may have been cancelled. Ask them to send a new invite." + : code === "invalid_invite_code" + ? "That code isn't the right shape. Codes are 8 letters and numbers." + : /network|fetch|timeout/i.test(error?.message ?? "") + ? "Can't reach Sauci. Check your connection and try again." + : getPairingError(error); + setJoinError(message); + AccessibilityInfo.announceForAccessibility(message); } finally { setIsSubmitting(false); } @@ -169,13 +199,14 @@ export default function PairingScreen() { if (couple?.invite_code) { await Clipboard.setStringAsync(couple.invite_code); Events.inviteCodeCopied(); - Alert.alert("Copied", "Invite code copied to clipboard"); + Alert.alert("Copied", "Invite code copied to clipboard"); } }; - const handleInviteCodeChange = (value: string) => { - setWasPrefilled(false); - setInviteCode(value); + const handleInviteCodeChange = (value: string) => { + setWasPrefilled(false); + setInviteCode(value); + setJoinError(null); }; const shareMessage = couple?.invite_code @@ -204,20 +235,26 @@ export default function PairingScreen() { } }; - const shareViaSMS = async () => { + const shareViaSMS = async () => { const url = `sms:&body=${encodeURIComponent(shareMessage)}`; await Linking.openURL(url); Events.codeShared(); - }; + }; + + const copyInviteLink = async () => { + if (couple?.invite_code) { + await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`); + } + }; - const handleCancelPairing = async () => { + const handleCancelPairing = async () => { Alert.alert( - "Cancel Pairing", - "Are you sure you want to cancel? Your invite code will be deleted.", + "Cancel this invite?", + "Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.", [ - { text: "Keep Waiting", style: "cancel" }, + { text: "Keep invite", style: "cancel" }, { - text: "Cancel Pairing", + text: "Cancel invite", style: "destructive", onPress: async () => { setIsSubmitting(true); @@ -236,7 +273,37 @@ export default function PairingScreen() { }, ] ); - }; + }; + + const incomingCode = typeof params.incomingCode === "string" && isValidInviteCode(normalizeInviteCode(params.incomingCode)) + ? normalizeInviteCode(params.incomingCode) + : null; + + const handleCancelOwnAndPrefill = () => { + if (!incomingCode) return; + Alert.alert( + "Cancel this invite?", + "Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.", + [ + { text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode() }, + { + text: "Cancel mine and join", style: "destructive", onPress: async () => { + setIsSubmitting(true); + try { + await coupleApi.cancel(); + await fetchUser(); + await fetchCouple(); + setInviteCode(incomingCode); + setWasPrefilled(true); + await clearPendingInviteCode(); + } finally { + setIsSubmitting(false); + } + }, + }, + ], + ); + }; if (isAuthLoading) { return ( @@ -265,11 +332,23 @@ export default function PairingScreen() { > - Partner Code + Your invite - - {/* Icon */} + + {incomingCode && ( + + Use this code instead? + You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted. + + Cancel mine and join + + void clearPendingInviteCode()} style={styles.cancelButton} testID="pairing-conflict-keep-own"> + Keep my invite + + + )} + {/* Icon */} - Share this code with your partner to link your accounts + They unlock your answers when they join @@ -295,8 +374,7 @@ export default function PairingScreen() { > - You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us. - They will unlock the moment your partner joins. + You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. As soon as they join, everything you both agree on appears for both of you. @@ -313,12 +391,12 @@ export default function PairingScreen() { onPress={copyToClipboard} activeOpacity={0.7} > - {couple.invite_code.toUpperCase()} + {couple.invite_code.toUpperCase()} - Tap to copy + Tap to copy your code @@ -341,7 +419,7 @@ export default function PairingScreen() { size="lg" icon={} > - Share Invite Code + Share invite @@ -354,10 +432,14 @@ export default function PairingScreen() { Messages - + WhatsApp - + + + + Copy link + {/* Waiting indicator */} @@ -367,8 +449,9 @@ export default function PairingScreen() { > - Waiting for your partner to join... - + Waiting for them to join + + {isPollOffline && Can't reach Sauci right now. Your code still works.} {/* Cancel Button */} @@ -382,7 +465,7 @@ export default function PairingScreen() { style={styles.cancelButton} activeOpacity={0.7} > - Cancel Pairing + Cancel invite @@ -427,7 +510,7 @@ export default function PairingScreen() { - Link with your partner to start matching + Answer privately. See what you agree on together. @@ -481,16 +564,21 @@ export default function PairingScreen() { /> {wasPrefilled && ( - Code applied from your invite link - - )} + Code from your invite link + + )} + {joinError && ( + + {joinError} + + )} - Join Partner + Join your partner @@ -516,7 +604,7 @@ export default function PairingScreen() { disabled={isSubmitting} fullWidth > - Create New Code + Create invite code @@ -668,7 +756,7 @@ const styles = StyleSheet.create({ textAlign: "center", maxWidth: 280, }, - sealedCountText: { + sealedCountText: { ...typography.body, color: colors.text, textAlign: "center", @@ -719,10 +807,32 @@ const styles = StyleSheet.create({ marginTop: spacing.xl, }, - waitingText: { - ...typography.subhead, - color: colors.textTertiary, - }, + waitingText: { + ...typography.subhead, + color: colors.textTertiary, + }, + conflictTitle: { + ...typography.title3, + color: colors.text, + marginBottom: spacing.sm, + }, + conflictBody: { + ...typography.callout, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + warningText: { + ...typography.caption1, + color: colors.warning, + textAlign: "center", + marginTop: spacing.sm, + }, + joinError: { + ...typography.callout, + color: colors.error, + marginBottom: spacing.md, + textAlign: "center", + }, cancelSection: { alignItems: "center", marginTop: spacing.xl, diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index fa47adc..15e74fc 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -37,7 +37,8 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") { }; } -import { Stack, router, usePathname } from "expo-router"; +import { Stack, router, usePathname } from "expo-router"; +import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated"; import { StatusBar } from "expo-status-bar"; import { Ionicons } from "@expo/vector-icons"; // GestureHandlerRootView is only needed on native platforms @@ -129,14 +130,20 @@ export default function RootLayout() { // are handled separately from the Supabase auth deep link flow below. const inviteLink = parseInviteLinkCode(url); if (inviteLink) { - Events.inviteLinkOpened(inviteLink.source); - await stashPendingInviteCode(inviteLink.code); - - const { isAuthenticated, user } = useAuthStore.getState(); - const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); - if (isAuthenticated && !onboardingPending && !user?.couple_id) { - router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); - } + Events.inviteLinkOpened(inviteLink.source); + const { isAuthenticated, user } = useAuthStore.getState(); + const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); + if (user?.couple_id) { + await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode()); + if (user.couple_id) { + router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } }); + } + } else { + await stashPendingInviteCode(inviteLink.code); + if (isAuthenticated && !onboardingPending) { + router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); + } + } return; } @@ -264,7 +271,8 @@ export default function RootLayout() { return ( - + + { - it("leads with a plain invite when there are no sealed answers", () => { - const message = buildInviteShareMessage("ABCD1234", 0); - expect(message).toBe( - "Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)" - ); + it("keeps a recoverable bare code in a separate sentence", () => { + const message = buildInviteShareMessage("AB12CD34", 3); + expect(message).toContain("https://sauci.app/join/AB12CD34"); + expect(message).toContain("enter code AB12CD34 in Sauci"); + expect(message).toContain("we'll both see what we agree on"); }); - it("leads with earned value and singular wording for exactly one sealed answer", () => { - const message = buildInviteShareMessage("ABCD1234", 1); - expect(message).toBe( - "Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)" - ); - }); - - it("pluralises for more than one sealed answer", () => { - const message = buildInviteShareMessage("ABCD1234", 5); - expect(message).toContain("I have already answered 5 questions about us."); + it("does not claim a sealed outcome before answers exist", () => { + expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock"); }); }); diff --git a/apps/mobile/src/__tests__/lib/pairedUnlockSeen.test.ts b/apps/mobile/src/__tests__/lib/pairedUnlockSeen.test.ts new file mode 100644 index 0000000..3121b0b --- /dev/null +++ b/apps/mobile/src/__tests__/lib/pairedUnlockSeen.test.ts @@ -0,0 +1,18 @@ +jest.mock("@react-native-async-storage/async-storage", () => ({ + getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), +})); + +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { clearPairedUnlockSeen, hasSeenPairedUnlock, markPairedUnlockSeen } from "../../lib/pairedUnlockSeen"; + +describe("paired unlock state", () => { + it("is scoped to both account and couple", async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue("true"); + await hasSeenPairedUnlock("user-a", "couple-a"); + await markPairedUnlockSeen("user-a", "couple-a"); + await clearPairedUnlockSeen("user-a", "couple-a"); + expect(AsyncStorage.getItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a"); + expect(AsyncStorage.setItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a", "true"); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a"); + }); +}); diff --git a/apps/mobile/src/features/swipe/components/SwipeSealedAnswersBanner.tsx b/apps/mobile/src/features/swipe/components/SwipeSealedAnswersBanner.tsx index d836736..9327c46 100644 --- a/apps/mobile/src/features/swipe/components/SwipeSealedAnswersBanner.tsx +++ b/apps/mobile/src/features/swipe/components/SwipeSealedAnswersBanner.tsx @@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe onPress={onInvitePress} activeOpacity={0.8} testID="swipe-sealed-answers-banner" + accessibilityLabel={`Invite your partner to unlock ${sealedCount} sealed answers`} > - - {sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join. - + + {sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner + You'll both see what you agree on the moment they join. + Invite ); @@ -38,11 +40,15 @@ const styles = StyleSheet.create({ gap: spacing.sm, marginHorizontal: spacing.md, marginBottom: spacing.sm, - paddingVertical: spacing.sm, + paddingVertical: spacing.md, paddingHorizontal: spacing.md, - borderRadius: radius.full, + borderRadius: radius.lg, backgroundColor: colors.backgroundLight, + borderWidth: 1, + borderColor: colors.border, }, + copy: { flex: 1, gap: 2 }, + title: { ...typography.subhead, color: colors.text, fontWeight: "700" }, text: { ...typography.caption1, color: colors.textSecondary, diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 3e41b6e..1a9c190 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -281,6 +281,18 @@ export const Events = { inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") => logEvent("invite_link_opened", { source }), pairingCodePrefilled: () => logEvent("pairing_code_prefilled"), + invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }), + inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }), + pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }), + pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }), + pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }), + pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }), + pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }), + pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }), + clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"), + clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }), + inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }), + inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }), // Quiz events quizStarted: () => logEvent("quiz_started"), diff --git a/apps/mobile/src/lib/inviteShareCopy.ts b/apps/mobile/src/lib/inviteShareCopy.ts index 2809c46..0dea9a8 100644 --- a/apps/mobile/src/lib/inviteShareCopy.ts +++ b/apps/mobile/src/lib/inviteShareCopy.ts @@ -5,10 +5,10 @@ * the recipient a concrete reason to join right away. */ export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string { - const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`; + const link = `https://sauci.app/join/${inviteCode}`; if (sealedCount > 0) { const noun = sealedCount === 1 ? "question" : "questions"; - return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`; + return `Join me on Sauci! I've answered ${sealedCount} ${noun} about us. Join and we'll both see what we agree on. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; } - return `Join me on Sauci! Tap this link to pair up instantly: ${link}`; + return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; } diff --git a/apps/mobile/src/lib/pairedUnlockSeen.ts b/apps/mobile/src/lib/pairedUnlockSeen.ts new file mode 100644 index 0000000..4fa5959 --- /dev/null +++ b/apps/mobile/src/lib/pairedUnlockSeen.ts @@ -0,0 +1,15 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const keyFor = (userId: string, coupleId: string) => `paired_unlock_seen_${userId}_${coupleId}`; + +export async function hasSeenPairedUnlock(userId: string, coupleId: string): Promise { + return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true"; +} + +export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise { + await AsyncStorage.setItem(keyFor(userId, coupleId), "true"); +} + +export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise { + if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId)); +} diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index ce50485..3770059 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -201,6 +201,15 @@ export const useAuthStore = create((set, get) => ({ signOut: async () => { Events.signOut(); + // The paired-unlock presentation is scoped to this account and current + // relationship. Remove it before local identity state is cleared. + const unlockUserId = get().user?.id; + const unlockCoupleId = get().couple?.id ?? get().user?.couple_id ?? null; + if (unlockUserId) { + const { clearPairedUnlockSeen } = await import("../lib/pairedUnlockSeen"); + await clearPairedUnlockSeen(unlockUserId, unlockCoupleId); + } + // Clear local state FIRST to ensure UI updates even if Supabase call fails set({ user: null, @@ -236,6 +245,8 @@ export const useAuthStore = create((set, get) => ({ }, setUser: (user) => { + const priorUserId = user === null ? get().user?.id : undefined; + const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null; set({ user, isAuthenticated: !!user, @@ -246,6 +257,9 @@ export const useAuthStore = create((set, get) => ({ }); // Clear other stores when user signs out if (user === null) { + if (priorUserId) { + void import("../lib/pairedUnlockSeen").then(({ clearPairedUnlockSeen }) => clearPairedUnlockSeen(priorUserId, priorCoupleId)); + } const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); diff --git a/docs/couple-pairing.md b/docs/couple-pairing.md index 8a2ef3c..0bef798 100644 --- a/docs/couple-pairing.md +++ b/docs/couple-pairing.md @@ -178,6 +178,17 @@ This prevents race conditions where multiple users try to join simultaneously. **Waiting State:** ``` + +The invite journey leads with the shared outcome, then shows the recoverable +eight-character code. It offers both a bare-code copy and a full +`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it +deletes answers made after the code was created. The five-second poll surfaces +an inline offline state but continues retrying. + +When pairing succeeds, both members are routed once per account and couple to +the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never +reveals unmatched or individual answers. Existing swipe remains the named +post-pair question entry until Quick Spark is implemented. ┌─────────────────────────────┐ │ ❤️ Partner Code │ │ │ @@ -360,6 +371,11 @@ The code is never applied automatically; the user must accept the offer. `join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked` (web, PostHog). +The mobile invite funnel also records aggregate-only prompt, share, join, +failure, completion, unlock, cancellation, clipboard, and conflict events. +Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and +answers are never sent as analytics properties. + ## Cascade Behavior When a couple is deleted, foreign key cascades clean up: