import { useState, useEffect } from "react"; import { View, Text, TextInput, StyleSheet, Alert, ActivityIndicator, Share, Linking, KeyboardAvoidingView, Platform, TouchableOpacity, AccessibilityInfo, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeInDown } from "react-native-reanimated"; import * as Clipboard from "expo-clipboard"; import { useAuthStore, useMatchStore } from "../../src/store"; import { coupleApi } from "../../src/lib/coupleApi"; import { getPairingError } from "../../src/lib/errors"; import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy"; import { Events } from "../../src/lib/analytics"; import { router, useLocalSearchParams } from "expo-router"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; 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 { ApiError } from "../../src/lib/apiClient"; import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen"; export default function PairingScreen() { const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore(); const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>(); const [inviteCode, setInviteCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [wasPrefilled, setWasPrefilled] = useState(false); const [clipboardOfferCode, setClipboardOfferCode] = useState(null); const [prefillAttempted, setPrefillAttempted] = useState(false); const [joinError, setJoinError] = useState(null); const [isPollOffline, setIsPollOffline] = useState(false); const [isIncomingConflictDismissed, setIsIncomingConflictDismissed] = 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 || !user) return; let cancelled = false; void (async () => { if (typeof params.incomingCode === "string") { await clearPendingInviteCode(user.id); 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" as any); } })(); 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 // match. Never auto-submits; the user still taps "Join Partner". useEffect(() => { if (couple) return; // only relevant to the unpaired join flow if (prefillAttempted) return; let cancelled = false; const applyPrefill = async (code: string) => { if (cancelled) return; setInviteCode(code); setWasPrefilled(true); Events.pairingCodePrefilled(); }; (async () => { const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : ""; if (isValidInviteCode(routeCode)) { await applyPrefill(routeCode); await clearPendingInviteCode(user?.id); if (!cancelled) setPrefillAttempted(true); return; } const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null; if (stashedCode) { await applyPrefill(stashedCode); await clearPendingInviteCode(user?.id); if (!cancelled) setPrefillAttempted(true); return; } const clipboardCode = await checkClipboardForInviteCode(); if (clipboardCode && !cancelled) { setClipboardOfferCode(clipboardCode); } if (!cancelled) setPrefillAttempted(true); })(); return () => { cancelled = true; }; }, [couple, params.code, prefillAttempted, user?.id]); const acceptClipboardOffer = () => { if (!clipboardOfferCode) return; setInviteCode(clipboardOfferCode); setWasPrefilled(true); Events.inviteLinkOpened("clipboard"); Events.pairingCodePrefilled(); setClipboardOfferCode(null); }; const dismissClipboardOffer = () => { setClipboardOfferCode(null); }; // Poll the standalone API while waiting for a partner. Realtime delivery can // be added later without coupling product data back to Supabase. useEffect(() => { if (!couple || partner) return; // Poll every 5 seconds as fallback const pollInterval = setInterval(() => { void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true)); }, 5000); return () => { clearInterval(pollInterval); }; }, [couple, partner, fetchCouple]); const handleCreateCouple = async () => { setIsSubmitting(true); try { await coupleApi.create(); await fetchUser(); // Refresh user to get couple_id await fetchCouple(); // Fetch couple data Events.coupleCreated(); } catch (error: any) { Alert.alert("Error", getPairingError(error)); } finally { setIsSubmitting(false); } }; const handleJoinCouple = async () => { // Validate invite code format (8 alphanumeric characters) const sanitizedCode = inviteCode.trim().toUpperCase(); if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) { setJoinError("That code isn't the right shape. Codes are 8 letters and numbers."); return; } setIsSubmitting(true); setJoinError(null); try { await coupleApi.join(sanitizedCode); await fetchUser(); // Refresh user to get couple_id 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. await useMatchStore.getState().fetchMatches(true); Events.coupleJoined(); router.replace("/(app)/paired" as any); } 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); } }; const copyToClipboard = async () => { if (couple?.invite_code) { await Clipboard.setStringAsync(couple.invite_code); Events.inviteCodeCopied(); Alert.alert("Copied", "Invite code copied to clipboard"); } }; const handleInviteCodeChange = (value: string) => { setWasPrefilled(false); setInviteCode(value); setJoinError(null); }; const shareMessage = couple?.invite_code ? buildInviteShareMessage(couple.invite_code, sealedCount) : ""; const shareCode = async () => { if (couple?.invite_code) { try { await Share.share({ message: shareMessage }); Events.codeShared(); } catch (error) { console.error(error); } } }; const shareViaWhatsApp = async () => { const url = `whatsapp://send?text=${encodeURIComponent(shareMessage)}`; const canOpen = await Linking.canOpenURL(url); if (canOpen) { await Linking.openURL(url); Events.codeShared(); } else { shareCode(); } }; const shareViaSMS = async () => { const url = `sms:&body=${encodeURIComponent(shareMessage)}`; await Linking.openURL(url); Events.codeShared(); }; const completeJoinedInvite = async () => { await fetchUser(); await fetchCouple(); await useMatchStore.getState().fetchMatches(true); router.replace("/(app)/paired" as any); }; const cancelInvite = async (afterCancel?: () => Promise) => { try { await coupleApi.cancelInvite(); await fetchUser(); await fetchCouple(); if (afterCancel) await afterCancel(); Events.pairingCancelled(); return true; } catch (error: any) { const apiCode = error instanceof ApiError ? (error.details as { error?: { code?: string } } | undefined)?.error?.code : undefined; if (apiCode === "invite_already_joined") { await completeJoinedInvite(); return false; } Alert.alert( "Couldn't cancel invite", "Your invite may still be active. Check your connection and try again.", [{ text: "Not now", style: "cancel" }, { text: "Try again", onPress: () => { void cancelInvite(afterCancel); } }], ); return false; } }; const copyInviteLink = async () => { if (couple?.invite_code) { await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`); } }; const handleCancelPairing = async () => { 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 invite", style: "cancel" }, { text: "Cancel invite", style: "destructive", onPress: async () => { setIsSubmitting(true); try { await cancelInvite(); } finally { setIsSubmitting(false); } }, }, ] ); }; 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(user?.id) }, { text: "Cancel mine and join", style: "destructive", onPress: async () => { setIsSubmitting(true); try { await cancelInvite(async () => { setInviteCode(incomingCode); setWasPrefilled(true); await clearPendingInviteCode(user?.id); }); } finally { setIsSubmitting(false); } }, }, ], ); }; if (isAuthLoading) { return ( ); } // If user has a couple but no partner, they are waiting if (couple && !partner) { return ( {/* Header */} router.back()} activeOpacity={0.7} > Your invite {incomingCode && !isIncomingConflictDismissed && ( Use this code instead? You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted. Cancel mine and join { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own"> Keep my invite )} {/* Icon */} Your answers are ready to compare when you both answer {/* Earned value: sealed answers already banked while unpaired */} {sealedCount > 0 && ( You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer. )} {/* Code Card */} {couple.invite_code.toUpperCase()} Tap to copy your code {/* CTA Text */} Send this to your partner to get started {/* Primary Share Button */} } > Share invite {/* Quick share buttons */} Messages WhatsApp Copy link {/* Waiting indicator */} Waiting for them to join {isPollOffline && Can't reach Sauci right now. Your code still works.} {/* Cancel Button */} Cancel invite ); } return ( {/* Header */} router.back()} activeOpacity={0.7} > Pair Up {/* Icon */} Answer privately. Compare your shared discoveries together. {/* Clipboard invite code offer */} {clipboardOfferCode && ( Use code {clipboardOfferCode} from your clipboard? Not now Use code )} {/* Join Card */} Have a code? {wasPrefilled && ( Code from your invite link )} {joinError && ( {joinError} )} Join your partner {/* Divider */} or {/* Create Code Button */} Create invite code ); } const styles = StyleSheet.create({ container: { flex: 1, }, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, header: { paddingTop: 60, paddingHorizontal: spacing.lg, paddingBottom: spacing.lg, flexDirection: "row", alignItems: "center", }, backButton: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.backgroundLight, justifyContent: "center", alignItems: "center", marginRight: spacing.md, borderWidth: 1, borderColor: colors.border, }, // ... input: { backgroundColor: colors.background, // Flat background borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, padding: spacing.md, ...typography.headline, color: colors.text, textAlign: "center", letterSpacing: 4, fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", marginBottom: spacing.md, }, inputPrefilled: { borderColor: colors.primary, }, prefilledCaption: { ...typography.caption1, color: colors.primary, textAlign: "center", marginTop: -spacing.sm, marginBottom: spacing.md, }, clipboardOffer: { backgroundColor: colors.backgroundLight, borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, padding: spacing.md, gap: spacing.sm, }, clipboardOfferText: { ...typography.subhead, color: colors.text, }, clipboardOfferActions: { flexDirection: "row", justifyContent: "flex-end", gap: spacing.sm, }, clipboardOfferButton: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md, borderRadius: radius.sm, }, clipboardOfferButtonPrimary: { backgroundColor: colors.primaryLight, }, clipboardOfferDismissText: { ...typography.subhead, color: colors.textTertiary, }, clipboardOfferAcceptText: { ...typography.subhead, color: colors.primary, fontWeight: "600", }, // ... dividerLine: { flex: 1, height: 1, backgroundColor: colors.border, }, // ... copyIcon: { width: 36, height: 36, borderRadius: 18, backgroundColor: colors.background, justifyContent: "center", alignItems: "center", marginLeft: spacing.md, borderWidth: 1, borderColor: colors.border, }, // ... waitingBadge: { flexDirection: "row", alignItems: "center", backgroundColor: colors.backgroundLight, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: radius.full, gap: spacing.sm, borderWidth: 1, borderColor: colors.border, }, title: { ...typography.title1, color: colors.text, }, content: { flex: 1, paddingHorizontal: spacing.lg, justifyContent: "center", }, iconSection: { alignItems: "center", marginBottom: spacing.xl, }, iconGradient: { width: 88, height: 88, borderRadius: 44, justifyContent: "center", alignItems: "center", marginBottom: spacing.lg, ...shadows.lg, }, subtitle: { ...typography.body, color: colors.textSecondary, textAlign: "center", maxWidth: 280, }, sealedCountText: { ...typography.body, color: colors.text, textAlign: "center", }, section: { marginBottom: spacing.md, }, sectionTitle: { ...typography.caption1, color: colors.textTertiary, textTransform: "uppercase", letterSpacing: 1.5, marginBottom: spacing.sm, marginLeft: spacing.xs, }, divider: { flexDirection: "row", alignItems: "center", marginVertical: spacing.lg, }, dividerText: { ...typography.subhead, color: colors.textTertiary, paddingHorizontal: spacing.md, }, codeContainer: { flexDirection: "row", alignItems: "center", justifyContent: "center", }, code: { ...typography.largeTitle, color: colors.text, letterSpacing: 6, fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", }, tapToCopy: { ...typography.caption1, color: colors.textTertiary, textAlign: "center", marginTop: spacing.sm, }, waitingSection: { alignItems: "center", marginTop: spacing.xl, }, 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, }, cancelButton: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md, }, cancelButtonText: { ...typography.subhead, color: colors.error, }, ctaSection: { alignItems: "center", marginBottom: spacing.md, }, ctaText: { ...typography.callout, color: colors.textSecondary, textAlign: "center", }, quickShareRow: { flexDirection: "row", justifyContent: "center", gap: spacing.md, marginBottom: spacing.md, }, quickShareButton: { flexDirection: "row", alignItems: "center", gap: spacing.sm, backgroundColor: colors.backgroundLight, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderRadius: radius.full, borderWidth: 1, borderColor: colors.border, }, quickShareLabel: { ...typography.subhead, color: colors.text, }, });