mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(mobile): improve pairing invite journey
This commit is contained in:
1 parent
402c027b14
commit
f9481054de
12 files changed
+302
-59
No files matched your search
@@ -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={{
|
||||||
|
|||||||
@@ -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} });
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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.`;
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user