mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge solo sealed answers that unlock on pairing into staging
This commit is contained in:
commit
7d392e756d
26 files changed
+510
-75
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user