feat(pairing): solo sealed answers that unlock on pairing

Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:34:42 +01:00
1 parent f22d4a3649
commit 09255da7db
26 files changed
+510 -75

No files matched your search

+13 -11
View File
@@ -10,7 +10,7 @@ import { SwipeCardStack } from "./components/SwipeCardStack";
import { SwipeHeader } from "./components/SwipeHeader";
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
import { SwipeLoadingState } from "./components/SwipeLoadingState";
import { SwipeNoPartnerState } from "./components/SwipeNoPartnerState";
import { SwipeSealedAnswersBanner } from "./components/SwipeSealedAnswersBanner";
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
import { SwipeBlockedState } from "./components/SwipeBlockedState";
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
@@ -36,6 +36,7 @@ const SwipeScreen = () => {
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,
@@ -51,15 +52,6 @@ const SwipeScreen = () => {
return <SwipeLoadingState />;
}
if (!partner) {
return (
<SwipeNoPartnerState
hasCouple={!!couple}
onPairPress={() => router.push("/pairing")}
/>
);
}
if (dailyLimitInfo?.is_blocked) {
return (
<SwipeDailyLimitState
@@ -85,7 +77,10 @@ const SwipeScreen = () => {
);
}
if (!packId && enabledPackIds.length === 0) {
// Pack selection is stored per couple, so it only exists once paired. A solo
// user cannot have configured packs yet; recommended() already falls back to
// every public pack for them, so this gate would otherwise be a dead end.
if (couple && !packId && enabledPackIds.length === 0) {
return (
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
);
@@ -118,6 +113,13 @@ const SwipeScreen = () => {
dailyLimitInfo={dailyLimitInfo}
/>
{!partner && (
<SwipeSealedAnswersBanner
sealedCount={sealedCount}
onInvitePress={() => router.push("/pairing")}
/>
)}
<SwipeCardStack
questions={filteredQuestions}
currentIndex={currentIndex}
@@ -2,6 +2,7 @@ import { Share } from "react-native";
import { colors } from "../../../theme";
import { useAuthStore } from "../../../store";
import { Events } from "../../../lib/analytics";
import { buildInviteShareMessage } from "../../../lib/inviteShareCopy";
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
interface SwipeNoPartnerStateProps {
@@ -11,13 +12,13 @@ interface SwipeNoPartnerStateProps {
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
const accent = colors.premium.rose;
const { couple } = useAuthStore();
const { couple, sealedCount } = useAuthStore();
const handleShareCode = async () => {
if (!couple?.invite_code) return;
try {
await Share.share({
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
message: buildInviteShareMessage(couple.invite_code, sealedCount),
});
Events.codeShared();
} catch (error) {
@@ -0,0 +1,56 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { colors, radius, spacing, typography } from "../../../theme";
interface SwipeSealedAnswersBannerProps {
sealedCount: number;
onInvitePress: () => void;
}
/**
* An unpaired user answers straight into the swipe flow now instead of hitting a
* dead-end waiting room. This banner keeps the invite ask visible without blocking
* play, and grows more concrete as the sealed count climbs.
*/
export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSealedAnswersBannerProps) => {
if (sealedCount <= 0) return null;
return (
<TouchableOpacity
style={styles.container}
onPress={onInvitePress}
activeOpacity={0.8}
testID="swipe-sealed-answers-banner"
>
<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>
<Text style={styles.cta}>Invite</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
backgroundColor: colors.backgroundLight,
},
text: {
...typography.caption1,
color: colors.textSecondary,
flex: 1,
},
cta: {
...typography.caption1,
color: colors.premium.rose,
fontWeight: "700",
},
});
@@ -48,7 +48,7 @@ export const useSwipeScreen = () => {
const [countdown, setCountdown] = useState<string>("");
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
const { user, partner, couple, sealedCount } = useAuthStore();
const filteredQuestions = filterSupportedQuestions(questions);
@@ -128,20 +128,15 @@ export const useSwipeScreen = () => {
filtered = data;
}
if (partner) {
// The daily limit applies to pack browsing too, so it is read in both
// modes; the answer gap is deliberately only enforced outside packs.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} else {
// The daily limit applies solo too, since a sealed answer still counts
// against it; the answer gap only makes sense once there is a partner
// to be ahead of, and checkAnswerGap already no-ops without one.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
if (currentRequestId === fetchRequestId.current) {
@@ -156,7 +151,7 @@ export const useSwipeScreen = () => {
setIsLoading(false);
}
}
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
}, [packId, checkAnswerGap, checkDailyLimit]);
const fetchPending = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
@@ -315,7 +310,7 @@ export const useSwipeScreen = () => {
uploadResponseMedia,
});
const data = await apiClient.post<{ match: unknown | null }>("/v1/responses", {
const data = await apiClient.post<{ match: unknown | null; sealed_count?: number }>("/v1/responses", {
question_id: questionId,
answer,
response_data: finalResponseData,
@@ -323,6 +318,13 @@ export const useSwipeScreen = () => {
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
// A solo answer has no partner to match against; the sealed count is
// this answer's only visible feedback, so the banner updates instantly
// instead of waiting for the couple state to refresh next time round.
if (typeof data?.sealed_count === 'number') {
useAuthStore.setState({ sealedCount: data.sealed_count });
}
const hasMatch = data?.match != null;
if (hasMatch) {
@@ -423,6 +425,7 @@ export const useSwipeScreen = () => {
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,