Merge solo sealed answers that unlock on pairing into staging

This commit is contained in:
jjbrunton committed 2026-08-30 19:49:51 +01:00
commit 7d392e756d
26 files changed
+510 -75

No files matched your search

+13 -11
View File
@@ -11,7 +11,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";
@@ -38,6 +38,7 @@ const SwipeScreen = () => {
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,
@@ -55,15 +56,6 @@ const SwipeScreen = () => {
return <SwipeLoadingState />;
}
if (!partner) {
return (
<SwipeNoPartnerState
hasCouple={!!couple}
onPairPress={() => router.push("/pairing")}
/>
);
}
if (dailyLimitInfo?.is_blocked) {
return (
<SwipeDailyLimitState
@@ -89,7 +81,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("/")} />
);
@@ -122,6 +117,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",
},
});
@@ -51,7 +51,7 @@ export const useSwipeScreen = () => {
const hasEvaluatedAvatarPrompt = useRef(false);
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
const { user, partner, couple, sealedCount } = useAuthStore();
const filteredQuestions = filterSupportedQuestions(questions);
@@ -131,20 +131,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) {
@@ -159,7 +154,7 @@ export const useSwipeScreen = () => {
setIsLoading(false);
}
}
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
}, [packId, checkAnswerGap, checkDailyLimit]);
const fetchPending = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
@@ -318,7 +313,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,
@@ -326,6 +321,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) {
@@ -458,6 +460,7 @@ export const useSwipeScreen = () => {
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,