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

+32 -4
View File
@@ -16,9 +16,10 @@ 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 } from "../../src/store";
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";
@@ -28,7 +29,7 @@ import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pend
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -36,9 +37,13 @@ export default function PairingScreen() {
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
// Redirect if already paired
// 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]);
@@ -145,6 +150,9 @@ export default function PairingScreen() {
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.
void useMatchStore.getState().fetchMatches(true);
Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [
@@ -171,7 +179,7 @@ export default function PairingScreen() {
};
const shareMessage = couple?.invite_code
? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
? buildInviteShareMessage(couple.invite_code, sealedCount)
: "";
const shareCode = async () => {
@@ -279,6 +287,21 @@ export default function PairingScreen() {
</Text>
</Animated.View>
{/* Earned value: sealed answers already banked while unpaired */}
{sealedCount > 0 && (
<Animated.View
entering={FadeInDown.delay(250).duration(500)}
style={styles.section}
>
<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.
</Text>
</GlassCard>
</Animated.View>
)}
{/* Code Card */}
<Animated.View
entering={FadeInDown.delay(300).duration(500)}
@@ -645,6 +668,11 @@ const styles = StyleSheet.create({
textAlign: "center",
maxWidth: 280,
},
sealedCountText: {
...typography.body,
color: colors.text,
textAlign: "center",
},
section: {
marginBottom: spacing.md,
},
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
import { ApiError, apiClient } from "@/lib/apiClient";
import { useAuthStore } from "@/store";
import * as swipeService from "@/features/swipe/services/swipeService";
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
@@ -15,12 +16,13 @@ jest.mock("expo-router", () => ({
// Stable identities: the hook's effects key off these, so fresh objects per render
// would retrigger the fetch effect forever and the screen would never settle.
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" }, sealedCount: 0 };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
useAuthStore.setState = jest.fn();
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
it("updates the sealed answer count optimistically when the server reports one", async () => {
// A solo answer has no partner to match against, so sealed_count is the
// only feedback the server gives back; the banner reads it from auth state.
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null, sealed_count: 3 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(useAuthStore.setState).toHaveBeenCalledWith({ sealedCount: 3 });
});
it("refreshes the shared streak once the answer lands", async () => {
const { result } = await renderReady();
mockFetchStreak.mockClear();
@@ -0,0 +1,22 @@
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("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.");
});
});
@@ -30,10 +30,15 @@ const profile = {
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
// fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
@@ -89,12 +94,12 @@ describe("authStore", () => {
max_intensity: 3,
};
useAuthStore.setState({ user: pairedProfile } as any);
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner, sealed_count: 3 });
await useAuthStore.getState().fetchCouple();
expect(stateSpy).toHaveBeenCalledWith();
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
expect(useAuthStore.getState()).toMatchObject({ couple, partner, sealedCount: 3 });
});
it("updates activity through the standalone API", async () => {
+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,
+2
View File
@@ -4,6 +4,8 @@ import { apiRequest } from "./apiClient";
export interface CoupleStateResponse {
couple: Couple | null;
partner: Profile | null;
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
sealed_count: number;
}
export interface CoupleMutationResponse {
+14
View File
@@ -0,0 +1,14 @@
/**
* Builds the message shared when inviting a partner to pair. Once the inviter has
* sealed answers banked (answered questions solo, before a partner joined), the
* copy leads with that earned value instead of a plain invite, because it gives
* 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)`;
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! Tap this link to pair up instantly: ${link}`;
}
+10 -14
View File
@@ -12,6 +12,8 @@ interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
/** This user's banked (sealed) answers with no couple yet. Zero once claimed at pairing. */
sealedCount: number;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
@@ -44,6 +46,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
@@ -77,12 +80,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isAuthenticated: true, isAnonymous });
}
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
await get().fetchCouple();
} else {
set({ couple: null, partner: null });
}
// Always fetch couple state: it reports the sealed answer count for a
// solo user even before any couple exists, not just once paired.
await get().fetchCouple();
set({ isLoading: false });
} catch (error) {
@@ -107,14 +107,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) {
set({ couple: null, partner: null });
return;
}
const { couple, partner } = await coupleApi.getState();
set({ couple, partner });
// Fetches unconditionally: getState() reports sealed_count for a solo user
// who has no couple yet too, not only once paired.
const { couple, partner, sealed_count } = await coupleApi.getState();
set({ couple, partner, sealedCount: sealed_count });
},
/**