merge: improve mobile invite journey

This commit is contained in:
jjbrunton committed 2026-09-06 12:38:57 +01:00
commit 21006cd041
12 files changed
+335 -92

No files matched your search

+3 -1
View File
@@ -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
}}
/>
<Tabs.Screen name="paired" options={{ href: null }} />
<Tabs.Screen
name="chat/[id]"
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} });
+168 -58
View File
@@ -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<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
// 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() {
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Partner Code</Text>
<Text style={styles.title}>Your invite</Text>
</Animated.View>
<View style={styles.content}>
{/* Icon */}
<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 */}
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.iconSection}
@@ -283,7 +362,7 @@ export default function PairingScreen() {
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Share this code with your partner to link your accounts
They unlock your answers when they join
</Text>
</Animated.View>
@@ -295,8 +374,7 @@ export default function PairingScreen() {
>
<GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
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.
</Text>
</GlassCard>
</Animated.View>
@@ -313,12 +391,12 @@ export default function PairingScreen() {
onPress={copyToClipboard}
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}>
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<Text style={styles.tapToCopy}>Tap to copy</Text>
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
</GlassCard>
</Animated.View>
@@ -341,7 +419,7 @@ export default function PairingScreen() {
size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
>
Share Invite Code
Share invite
</GlassButton>
</Animated.View>
@@ -354,10 +432,14 @@ export default function PairingScreen() {
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Messages</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<Ionicons name="logo-whatsapp" size={20} color={colors.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>
{/* Waiting indicator */}
@@ -367,8 +449,9 @@ export default function PairingScreen() {
>
<View style={styles.waitingBadge}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
</View>
<Text style={styles.waitingText}>Waiting for them to join</Text>
</View>
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
</Animated.View>
{/* Cancel Button */}
@@ -382,7 +465,7 @@ export default function PairingScreen() {
style={styles.cancelButton}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
<Text style={styles.cancelButtonText}>Cancel invite</Text>
</TouchableOpacity>
</Animated.View>
</View>
@@ -427,7 +510,7 @@ export default function PairingScreen() {
<Ionicons name="link" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Link with your partner to start matching
Answer privately. See what you agree on together.
</Text>
</Animated.View>
@@ -481,16 +564,21 @@ export default function PairingScreen() {
/>
{wasPrefilled && (
<Text style={styles.prefilledCaption}>
Code applied from your invite link
</Text>
)}
Code from your invite link
</Text>
)}
{joinError && (
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
{joinError}
</Text>
)}
<GlassButton
onPress={handleJoinCouple}
disabled={isSubmitting}
loading={isSubmitting}
fullWidth
>
Join Partner
Join your partner
</GlassButton>
</GlassCard>
</Animated.View>
@@ -516,7 +604,7 @@ export default function PairingScreen() {
disabled={isSubmitting}
fullWidth
>
Create New Code
Create invite code
</GlassButton>
</Animated.View>
</View>
@@ -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,
+18 -10
View File
@@ -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 (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{ flex: 1 }}>
<ReducedMotionConfig mode={ReduceMotion.System} />
<StatusBar style="light" />
<Stack
screenOptions={{
@@ -1,22 +1,14 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => {
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");
});
});
@@ -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}
activeOpacity={0.8}
testID="swipe-sealed-answers-banner"
accessibilityLabel={`Invite your partner to unlock ${sealedCount} sealed answers`}
>
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<Text style={styles.text}>
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
</Text>
<View style={styles.copy}>
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</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>
</TouchableOpacity>
);
@@ -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,
+12
View File
@@ -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"),
+3 -3
View File
@@ -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.`;
}
+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 () => {
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<AuthState>((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<AuthState>((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();