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: