mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
merge: improve mobile invite journey
This commit is contained in:
commit
21006cd041
12 files changed
+335
-92
No files matched your search
@@ -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={{
|
||||
|
||||
@@ -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} });
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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 () => {
|
||||
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();
|
||||
|
||||
@@ -178,6 +178,17 @@ This prevents race conditions where multiple users try to join simultaneously.
|
||||
|
||||
**Waiting State:**
|
||||
```
|
||||
|
||||
The invite journey leads with the shared outcome, then shows the recoverable
|
||||
eight-character code. It offers both a bare-code copy and a full
|
||||
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
|
||||
deletes answers made after the code was created. The five-second poll surfaces
|
||||
an inline offline state but continues retrying.
|
||||
|
||||
When pairing succeeds, both members are routed once per account and couple to
|
||||
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
|
||||
reveals unmatched or individual answers. Existing swipe remains the named
|
||||
post-pair question entry until Quick Spark is implemented.
|
||||
┌─────────────────────────────┐
|
||||
│ ❤️ Partner Code │
|
||||
│ │
|
||||
@@ -360,6 +371,11 @@ The code is never applied automatically; the user must accept the offer.
|
||||
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
|
||||
(web, PostHog).
|
||||
|
||||
The mobile invite funnel also records aggregate-only prompt, share, join,
|
||||
failure, completion, unlock, cancellation, clipboard, and conflict events.
|
||||
Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and
|
||||
answers are never sent as analytics properties.
|
||||
|
||||
## Cascade Behavior
|
||||
|
||||
When a couple is deleted, foreign key cascades clean up:
|
||||
|
||||
Reference in new issue
Block a user