feat(mobile): improve pairing invite journey

This commit is contained in:
jjbrunton committed 2026-09-06 12:38:16 +01:00
1 parent 402c027b14
commit f9481054de
12 files changed
+302 -59

No files matched your search

+3 -1
View File
@@ -76,10 +76,11 @@ export default function AppLayout() {
const segmentStrings = segments as string[]; const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing"); const isOnPairing = segmentStrings.includes("pairing");
const isOnPaired = segmentStrings.includes("paired");
const isOnChat = segmentStrings.includes("chat"); const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw"); const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; 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 // Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => { useEffect(() => {
@@ -367,6 +368,7 @@ export default function AppLayout() {
href: null, // Hide from tab bar href: null, // Hide from tab bar
}} }}
/> />
<Tabs.Screen name="paired" options={{ href: null }} />
<Tabs.Screen <Tabs.Screen
name="chat/[id]" name="chat/[id]"
options={{ options={{
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useRef, useState } from "react";
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, typography } from "../../src/theme";
import { useAuthStore, useMatchStore } from "../../src/store";
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
import { Events } from "../../src/lib/analytics";
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
const bucket = (n: number) => 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<Text>(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 <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
}
return <GradientBackground><View style={styles.container}>
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} thing{newMatchesCount === 1 ? "" : "s"} you already agree on</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See what you agree on</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing to compare yet. Answer a few questions and anything you agree on shows up here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
</View></GradientBackground>;
}
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} });
+141 -31
View File
@@ -11,6 +11,7 @@ import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
TouchableOpacity, TouchableOpacity,
AccessibilityInfo,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
@@ -27,26 +28,42 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink"; import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode"; 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() { export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore(); const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>(); const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [wasPrefilled, setWasPrefilled] = useState(false); const [wasPrefilled, setWasPrefilled] = useState(false);
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null); const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false); const [prefillAttempted, setPrefillAttempted] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [isPollOffline, setIsPollOffline] = useState(false);
// Redirect if already paired. Pairing claims both members' sealed answers and // Redirect if already paired. Pairing claims both members' sealed answers and
// computes matches server-side, so the matches store must refetch here too: // computes matches server-side, so the matches store must refetch here too:
// whichever partner lands on this redirect first would otherwise show stale, // whichever partner lands on this redirect first would otherwise show stale,
// pre-pairing matches until some unrelated screen happened to refresh it. // pre-pairing matches until some unrelated screen happened to refresh it.
useEffect(() => { useEffect(() => {
if (couple && partner) { if (!couple || !partner || !user) return;
void useMatchStore.getState().fetchMatches(true); let cancelled = false;
router.replace("/(app)"); void (async () => {
} if (typeof params.incomingCode === "string") {
}, [couple, partner]); 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 // 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 // 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 // Poll every 5 seconds as fallback
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
fetchCouple(); void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
}, 5000); }, 5000);
return () => { return () => {
@@ -140,11 +157,12 @@ export default function PairingScreen() {
// Validate invite code format (8 alphanumeric characters) // Validate invite code format (8 alphanumeric characters)
const sanitizedCode = inviteCode.trim().toUpperCase(); const sanitizedCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) { if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code."); setJoinError("That code isn't the right shape. Codes are 8 letters and numbers.");
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
setJoinError(null);
try { try {
await coupleApi.join(sanitizedCode); await coupleApi.join(sanitizedCode);
@@ -152,14 +170,26 @@ export default function PairingScreen() {
await fetchCouple(); // Fetch couple data await fetchCouple(); // Fetch couple data
// Joining claims both members' sealed answers and computes matches // Joining claims both members' sealed answers and computes matches
// server-side, so the joiner needs a fresh matches list too. // server-side, so the joiner needs a fresh matches list too.
void useMatchStore.getState().fetchMatches(true); await useMatchStore.getState().fetchMatches(true);
Events.coupleJoined(); Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [ router.replace("/(app)/paired");
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
]);
} catch (error: any) { } catch (error: any) {
Alert.alert("Error", getPairingError(error)); 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -176,6 +206,7 @@ export default function PairingScreen() {
const handleInviteCodeChange = (value: string) => { const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false); setWasPrefilled(false);
setInviteCode(value); setInviteCode(value);
setJoinError(null);
}; };
const shareMessage = couple?.invite_code const shareMessage = couple?.invite_code
@@ -210,14 +241,20 @@ export default function PairingScreen() {
Events.codeShared(); 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( Alert.alert(
"Cancel Pairing", "Cancel this invite?",
"Are you sure you want to cancel? Your invite code will be deleted.", "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", style: "destructive",
onPress: async () => { onPress: async () => {
setIsSubmitting(true); setIsSubmitting(true);
@@ -238,6 +275,36 @@ 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) { if (isAuthLoading) {
return ( return (
<GradientBackground> <GradientBackground>
@@ -265,10 +332,22 @@ export default function PairingScreen() {
> >
<Ionicons name="arrow-back" size={24} color={colors.text} /> <Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.title}>Partner Code</Text> <Text style={styles.title}>Your invite</Text>
</Animated.View> </Animated.View>
<View style={styles.content}> <View style={styles.content}>
{incomingCode && (
<GlassCard variant="elevated">
<Text style={styles.conflictTitle}>Use this code instead?</Text>
<Text style={styles.conflictBody}>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.</Text>
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
Cancel mine and join
</GlassButton>
<TouchableOpacity onPress={() => void clearPendingInviteCode()} style={styles.cancelButton} testID="pairing-conflict-keep-own">
<Text style={styles.cancelButtonText}>Keep my invite</Text>
</TouchableOpacity>
</GlassCard>
)}
{/* Icon */} {/* Icon */}
<Animated.View <Animated.View
entering={FadeInDown.delay(200).duration(500)} entering={FadeInDown.delay(200).duration(500)}
@@ -283,7 +362,7 @@ export default function PairingScreen() {
<Ionicons name="heart" size={40} color={colors.text} /> <Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Share this code with your partner to link your accounts They unlock your answers when they join
</Text> </Text>
</Animated.View> </Animated.View>
@@ -295,8 +374,7 @@ export default function PairingScreen() {
> >
<GlassCard variant="elevated"> <GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count"> <Text style={styles.sealedCountText} testID="pairing-sealed-count">
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us. 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.
They will unlock the moment your partner joins.
</Text> </Text>
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
@@ -313,12 +391,12 @@ export default function PairingScreen() {
onPress={copyToClipboard} onPress={copyToClipboard}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text> <Text style={styles.code} accessibilityLabel={`Invite code ${couple.invite_code.toUpperCase().split("").join(" ")}`}>{couple.invite_code.toUpperCase()}</Text>
<View style={styles.copyIcon}> <View style={styles.copyIcon}>
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} /> <Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.tapToCopy}>Tap to copy</Text> <Text style={styles.tapToCopy}>Tap to copy your code</Text>
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
@@ -341,7 +419,7 @@ export default function PairingScreen() {
size="lg" size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />} icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
> >
Share Invite Code Share invite
</GlassButton> </GlassButton>
</Animated.View> </Animated.View>
@@ -358,6 +436,10 @@ export default function PairingScreen() {
<Ionicons name="logo-whatsapp" size={20} color={colors.text} /> <Ionicons name="logo-whatsapp" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>WhatsApp</Text> <Text style={styles.quickShareLabel}>WhatsApp</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={copyInviteLink} activeOpacity={0.7} testID="pairing-copy-link" accessibilityLabel="Copy invite link">
<Ionicons name="link-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Copy link</Text>
</TouchableOpacity>
</Animated.View> </Animated.View>
{/* Waiting indicator */} {/* Waiting indicator */}
@@ -367,8 +449,9 @@ export default function PairingScreen() {
> >
<View style={styles.waitingBadge}> <View style={styles.waitingBadge}>
<ActivityIndicator size="small" color={colors.primary} /> <ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.waitingText}>Waiting for your partner to join...</Text> <Text style={styles.waitingText}>Waiting for them to join</Text>
</View> </View>
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
</Animated.View> </Animated.View>
{/* Cancel Button */} {/* Cancel Button */}
@@ -382,7 +465,7 @@ export default function PairingScreen() {
style={styles.cancelButton} style={styles.cancelButton}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.cancelButtonText}>Cancel Pairing</Text> <Text style={styles.cancelButtonText}>Cancel invite</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</View> </View>
@@ -427,7 +510,7 @@ export default function PairingScreen() {
<Ionicons name="link" size={40} color={colors.text} /> <Ionicons name="link" size={40} color={colors.text} />
</LinearGradient> </LinearGradient>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Link with your partner to start matching Answer privately. See what you agree on together.
</Text> </Text>
</Animated.View> </Animated.View>
@@ -481,7 +564,12 @@ export default function PairingScreen() {
/> />
{wasPrefilled && ( {wasPrefilled && (
<Text style={styles.prefilledCaption}> <Text style={styles.prefilledCaption}>
Code applied from your invite link Code from your invite link
</Text>
)}
{joinError && (
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
{joinError}
</Text> </Text>
)} )}
<GlassButton <GlassButton
@@ -490,7 +578,7 @@ export default function PairingScreen() {
loading={isSubmitting} loading={isSubmitting}
fullWidth fullWidth
> >
Join Partner Join your partner
</GlassButton> </GlassButton>
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
@@ -516,7 +604,7 @@ export default function PairingScreen() {
disabled={isSubmitting} disabled={isSubmitting}
fullWidth fullWidth
> >
Create New Code Create invite code
</GlassButton> </GlassButton>
</Animated.View> </Animated.View>
</View> </View>
@@ -723,6 +811,28 @@ const styles = StyleSheet.create({
...typography.subhead, ...typography.subhead,
color: colors.textTertiary, 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: { cancelSection: {
alignItems: "center", alignItems: "center",
marginTop: spacing.xl, marginTop: spacing.xl,
+12 -4
View File
@@ -38,6 +38,7 @@ 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 { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
// GestureHandlerRootView is only needed on native platforms // GestureHandlerRootView is only needed on native platforms
@@ -130,12 +131,18 @@ export default function RootLayout() {
const inviteLink = parseInviteLinkCode(url); const inviteLink = parseInviteLinkCode(url);
if (inviteLink) { if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source); Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState(); const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (isAuthenticated && !onboardingPending && !user?.couple_id) { if (user?.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); 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; return;
} }
@@ -265,6 +272,7 @@ export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<ReducedMotionConfig mode={ReduceMotion.System} />
<StatusBar style="light" /> <StatusBar style="light" />
<Stack <Stack
screenOptions={{ screenOptions={{
@@ -1,22 +1,14 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy"; import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => { describe("buildInviteShareMessage", () => {
it("leads with a plain invite when there are no sealed answers", () => { it("keeps a recoverable bare code in a separate sentence", () => {
const message = buildInviteShareMessage("ABCD1234", 0); const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toBe( expect(message).toContain("https://sauci.app/join/AB12CD34");
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)" 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", () => { it("does not claim a sealed outcome before answers exist", () => {
const message = buildInviteShareMessage("ABCD1234", 1); expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
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.");
}); });
}); });
@@ -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");
});
});
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
onPress={onInvitePress} onPress={onInvitePress}
activeOpacity={0.8} activeOpacity={0.8}
testID="swipe-sealed-answers-banner" testID="swipe-sealed-answers-banner"
accessibilityLabel={`Invite your partner to unlock ${sealedCount} sealed answers`}
> >
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} /> <Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<Text style={styles.text}> <View style={styles.copy}>
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join. <Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
</Text> <Text style={styles.text}>You'll both see what you agree on the moment they join.</Text>
</View>
<Text style={styles.cta}>Invite</Text> <Text style={styles.cta}>Invite</Text>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
gap: spacing.sm, gap: spacing.sm,
marginHorizontal: spacing.md, marginHorizontal: spacing.md,
marginBottom: spacing.sm, marginBottom: spacing.sm,
paddingVertical: spacing.sm, paddingVertical: spacing.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
borderRadius: radius.full, borderRadius: radius.lg,
backgroundColor: colors.backgroundLight, backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
}, },
copy: { flex: 1, gap: 2 },
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
text: { text: {
...typography.caption1, ...typography.caption1,
color: colors.textSecondary, color: colors.textSecondary,
+12
View File
@@ -281,6 +281,18 @@ export const Events = {
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") => inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }), logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"), 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 // Quiz events
quizStarted: () => logEvent("quiz_started"), quizStarted: () => logEvent("quiz_started"),
+3 -3
View File
@@ -5,10 +5,10 @@
* the recipient a concrete reason to join right away. * the recipient a concrete reason to join right away.
*/ */
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string { 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) { if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions"; 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.`;
} }
+15
View File
@@ -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<boolean> {
return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true";
}
export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise<void> {
await AsyncStorage.setItem(keyFor(userId, coupleId), "true");
}
export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise<void> {
if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId));
}
+14
View File
@@ -201,6 +201,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
signOut: async () => { signOut: async () => {
Events.signOut(); 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 // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
user: null, user: null,
@@ -236,6 +245,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
setUser: (user) => { setUser: (user) => {
const priorUserId = user === null ? get().user?.id : undefined;
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
set({ set({
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
@@ -246,6 +257,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// Clear other stores when user signs out // Clear other stores when user signs out
if (user === null) { if (user === null) {
if (priorUserId) {
void import("../lib/pairedUnlockSeen").then(({ clearPairedUnlockSeen }) => clearPairedUnlockSeen(priorUserId, priorCoupleId));
}
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
+16
View File
@@ -178,6 +178,17 @@ This prevents race conditions where multiple users try to join simultaneously.
**Waiting State:** **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 │ │ ❤️ 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` `join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
(web, PostHog). (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 ## Cascade Behavior
When a couple is deleted, foreign key cascades clean up: When a couple is deleted, foreign key cascades clean up: