mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
1 parent
f22d4a3649
commit
09255da7db
26 files changed
+510
-75
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user