feat(quiz): couples quiz with shareable watermarked results card

Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 18:09:19 +01:00
1 parent 30aa7bc386
commit 6523ddeb1c
40 files changed
+2973 -237

No files matched your search

@@ -0,0 +1,106 @@
import { ActivityIndicator, View, StyleSheet } from "react-native";
import { GradientBackground } from "../../components/ui";
import { featureColors } from "../../theme";
import { SwipeNoPartnerState } from "../swipe/components/SwipeNoPartnerState";
import { useQuizScreen } from "./hooks/useQuizScreen";
import {
QuizIntro,
QuizQuestionCard,
QuizResults,
QuizShareModal,
QuizWaitingState,
} from "./components";
export function QuizScreen() {
const {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
} = useQuizScreen();
if (screenState === "not_paired") {
return <SwipeNoPartnerState hasCouple={false} onPairPress={handlePairPress} />;
}
if (screenState === "loading") {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
if (screenState === "intro") {
return <QuizIntro isStarting={isStarting} onStart={handleStart} />;
}
if (screenState === "waiting") {
return <QuizWaitingState onNudgePartner={handleNudgePartner} onRefresh={handleRefresh} />;
}
if (screenState === "results") {
return (
<>
<QuizResults
result={result}
isLoadingResult={isLoadingResult}
isStarting={isStarting}
onNewQuiz={handleNewQuiz}
onShare={openShareModal}
/>
<QuizShareModal
visible={shareModalVisible}
onClose={closeShareModal}
scorePercent={result?.score_percent ?? 0}
/>
</>
);
}
// screenState === "answering"
if (!session || !currentStep || isSubmitting) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
return (
<QuizQuestionCard
question={currentStep.question}
step={currentStep.step}
questionNumber={currentStep.questionNumber}
totalQuestions={totalQuestions}
onSelectOption={handleSelectOption}
/>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
export default QuizScreen;
@@ -0,0 +1,118 @@
import { StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, shadows, spacing, typography } from "../../../theme";
const ACCENT = featureColors.quiz.accent;
interface QuizIntroProps {
isStarting: boolean;
onStart: () => void;
}
export function QuizIntro({ isStarting, onStart }: QuizIntroProps) {
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View entering={FadeInDown.duration(600).springify()} style={styles.content}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="help-circle" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>How well do you know each other?</Text>
<Text style={styles.description}>
Answer a set of questions about yourself, then guess how your partner
answered. See how well you really know each other, and share your score.
</Text>
<View style={styles.badge}>
<Ionicons name="list-outline" size={14} color={ACCENT} />
<Text style={styles.badgeText}>10 questions</Text>
</View>
<GlassButton
onPress={onStart}
loading={isStarting}
disabled={isStarting}
fullWidth
size="lg"
style={styles.startButton}
testID="quiz-start"
>
Start the quiz
</GlassButton>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
content: {
width: "100%",
alignItems: "center",
},
iconGradient: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
...shadows.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: ACCENT,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.lg,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
badgeText: {
...typography.caption1,
fontWeight: "600",
color: ACCENT,
},
startButton: {
marginTop: spacing.md,
},
});
@@ -0,0 +1,118 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizQuestion } from "../types";
import type { QuizAnswerStep } from "../hooks/useQuizScreen";
const ACCENT = featureColors.quiz.accent;
interface QuizQuestionCardProps {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
totalQuestions: number;
onSelectOption: (index: number) => void;
}
export function QuizQuestionCard({
question,
step,
questionNumber,
totalQuestions,
onSelectOption,
}: QuizQuestionCardProps) {
const prompt = step === "self" ? question.prompt_self : question.prompt_guess;
return (
<GradientBackground>
<View style={styles.container}>
<Text style={styles.progress} testID="quiz-progress">
{questionNumber} of {totalQuestions}
</Text>
<View style={styles.stepBadge}>
<Text style={styles.stepBadgeText}>{step === "self" ? "YOU" : "YOUR PARTNER"}</Text>
</View>
<Animated.View entering={FadeInDown.duration(400).springify()} key={`${question.id}-${step}`}>
<GlassCard variant="elevated" style={styles.card}>
<Text style={styles.prompt}>{prompt}</Text>
</GlassCard>
<View style={styles.options}>
{question.options.map((option, index) => (
<Pressable
key={`${question.id}-${index}`}
onPress={() => onSelectOption(index)}
style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
testID={`quiz-option-${index}`}
>
<Text style={styles.optionText}>{option}</Text>
</Pressable>
))}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
},
progress: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
marginBottom: spacing.md,
},
stepBadge: {
alignSelf: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.lg,
},
stepBadgeText: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 2,
color: ACCENT,
},
card: {
marginBottom: spacing.xl,
},
prompt: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
options: {
gap: spacing.md,
},
option: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
},
optionPressed: {
borderColor: ACCENT,
backgroundColor: colors.background,
},
optionText: {
...typography.body,
color: colors.text,
textAlign: "center",
fontWeight: "600",
},
});
@@ -0,0 +1,173 @@
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizResult } from "../types";
interface QuizResultsProps {
result: QuizResult | null;
isLoadingResult: boolean;
isStarting: boolean;
onNewQuiz: () => void;
onShare: () => void;
}
export function QuizResults({ result, isLoadingResult, isStarting, onNewQuiz, onShare }: QuizResultsProps) {
if (!result) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
<Text style={styles.loadingText}>
{isLoadingResult ? "Tallying your answers..." : "Loading your results..."}
</Text>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInDown.duration(500).springify()}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.hero}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.heroLabel}>YOU MATCHED ON</Text>
<Text style={styles.heroScore} testID="quiz-score">
{result.score_percent}%
</Text>
<Text style={styles.heroTagline}>How well do you know each other?</Text>
</LinearGradient>
</Animated.View>
<View style={styles.breakdown}>
{result.questions.map((question) => (
<GlassCard key={question.question_id} style={styles.questionCard}>
<Text style={styles.questionPrompt}>{question.prompt_self}</Text>
<View style={styles.resultRow}>
<Ionicons
name={question.i_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.i_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.i_guessed_right ? "You guessed right" : "You guessed wrong"}
</Text>
</View>
<View style={styles.resultRow}>
<Ionicons
name={question.partner_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.partner_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.partner_guessed_right
? "Your partner guessed right"
: "Your partner guessed wrong"}
</Text>
</View>
</GlassCard>
))}
</View>
<GlassButton
onPress={onShare}
fullWidth
size="lg"
style={styles.shareButton}
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
testID="quiz-share"
>
Share your score
</GlassButton>
<GlassButton
variant="secondary"
onPress={onNewQuiz}
loading={isStarting}
disabled={isStarting}
fullWidth
style={styles.newQuizButton}
>
Take another quiz
</GlassButton>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
container: {
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: spacing.xxl,
},
hero: {
borderRadius: radius.xl,
paddingVertical: spacing.xl,
paddingHorizontal: spacing.lg,
alignItems: "center",
marginBottom: spacing.lg,
},
heroLabel: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
heroScore: {
...typography.largeTitle,
fontSize: 56,
lineHeight: 64,
color: colors.text,
fontWeight: "800",
},
heroTagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
marginTop: spacing.xs,
},
breakdown: {
gap: spacing.sm,
marginBottom: spacing.xl,
},
questionCard: {
gap: spacing.xs,
},
questionPrompt: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
},
resultRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
resultText: {
...typography.caption1,
color: colors.textSecondary,
},
shareButton: {
marginBottom: spacing.md,
},
newQuizButton: {},
});
@@ -0,0 +1,15 @@
import { render } from "@/test/test-utils";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
describe("QuizResultsShareCard", () => {
it("renders the score and Sauci watermark without any question content", () => {
const { getByText, queryByText } = render(
<QuizResultsShareCard scorePercent={84} cardWidth={300} />,
);
expect(getByText("84%")).toBeTruthy();
expect(getByText("How well do you know each other?")).toBeTruthy();
expect(getByText("sauci.app")).toBeTruthy();
expect(queryByText(/prompt/i)).toBeNull();
});
});
@@ -0,0 +1,98 @@
import { Image, StyleSheet, Text, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { featureColors, radius, spacing, typography } from "../../../theme";
const logo = require("../../../../assets/logo.png");
interface QuizResultsShareCardProps {
scorePercent: number;
cardWidth: number;
}
/**
* The card captured for sharing. Deliberately carries only the score, never the
* question text or answers, so a screenshot cannot leak either partner's content.
*/
export function QuizResultsShareCard({ scorePercent, cardWidth }: QuizResultsShareCardProps) {
return (
<View style={[styles.card, { width: cardWidth }]}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.overlay} />
<View style={styles.content}>
<Text style={styles.tagline}>How well do you know each other?</Text>
<Text style={styles.label}>WE MATCHED ON</Text>
<Text style={styles.score}>{scorePercent}%</Text>
</View>
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
card: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: "hidden",
},
gradient: {
flex: 1,
padding: spacing.lg,
justifyContent: "space-between",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.08)",
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
tagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
textAlign: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
score: {
...typography.largeTitle,
fontSize: 64,
lineHeight: 72,
fontWeight: "800",
color: "#ffffff",
},
branding: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: "contain",
},
brandingText: {
...typography.subhead,
color: "#ffffff",
fontWeight: "600",
},
});
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from "react";
import {
Linking,
Modal,
Platform,
Share,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from "react-native";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system/legacy";
import { colors, spacing, typography, blur } from "../../../theme";
import { Events } from "../../../lib/analytics";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
const MAX_CARD_WIDTH = 340;
interface QuizShareModalProps {
visible: boolean;
onClose: () => void;
scorePercent: number;
}
export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const shareMessage = `We matched on ${scorePercent}% on our couple quiz! Discover more on sauci.app`;
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) setCapturedUri(uri);
} catch (error) {
console.error("Error capturing quiz share image:", error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
useEffect(() => {
if (!visible) setCapturedUri(null);
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
const instagramUrl = "instagram://app";
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === "ios") {
await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app";
await Linking.openURL(instagramStoriesUrl);
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Instagram share error:", error);
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
if (Platform.OS === "ios") {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", UTI: "public.png" });
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Messages share error:", error);
await Share.share({ message: shareMessage });
Events.quizShared();
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", dialogTitle: "Share your quiz score" });
} else {
await Share.share({ message: shareMessage });
}
Events.quizShared();
} catch (error) {
console.error("Share error:", error);
}
};
const useBlur = Platform.OS === "ios";
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<View style={styles.overlay}>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView intensity={blur.heavy} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
testID="quiz-share-close"
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share your score</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.previewContainer}>
<ViewShot ref={shareViewRef} options={{ format: "png", quality: 1, result: "tmpfile" }}>
<QuizResultsShareCard scorePercent={scorePercent} cardWidth={cardWidth} />
</ViewShot>
</View>
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
onPress={handleMoreShare}
disabled={isCapturing}
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
onPress,
disabled,
}: {
icon: string;
label: string;
onPress: () => void;
disabled?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
testID={`quiz-share-${icon}`}
>
<View style={styles.actionIconContainer}>
<Ionicons name={icon as any} size={28} color={colors.text} />
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropFallback: {
flex: 1,
backgroundColor: "rgba(14, 14, 17, 0.95)",
},
content: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: "center",
alignItems: "center",
},
title: {
...typography.headline,
color: colors.text,
fontWeight: "600",
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: "center",
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: "500",
},
});
@@ -0,0 +1,27 @@
import { SwipeInfoStateLayout } from "../../swipe/components/SwipeInfoStateLayout";
import { colors } from "../../../theme";
interface QuizWaitingStateProps {
onNudgePartner: () => void;
onRefresh: () => void;
}
export function QuizWaitingState({ onNudgePartner, onRefresh }: QuizWaitingStateProps) {
return (
<SwipeInfoStateLayout
accentColor={colors.premium.rose}
icon="checkmark-circle"
label="NICE WORK"
title="Your answers are in"
badgeText="WAITING ON YOUR PARTNER"
description="Nudge your partner to finish the quiz so you can both see how well you know each other."
features={[
{ icon: "hourglass-outline", text: "Results unlock once you both finish" },
{ icon: "heart-outline", text: "Share your match score together" },
]}
teaser="Almost there"
action={{ label: "Nudge your partner", onPress: onNudgePartner }}
secondaryAction={{ label: "Refresh", onPress: onRefresh, icon: "refresh-outline" }}
/>
);
}
@@ -0,0 +1,6 @@
export { QuizIntro } from "./QuizIntro";
export { QuizQuestionCard } from "./QuizQuestionCard";
export { QuizWaitingState } from "./QuizWaitingState";
export { QuizResults } from "./QuizResults";
export { QuizResultsShareCard } from "./QuizResultsShareCard";
export { QuizShareModal } from "./QuizShareModal";
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useQuizScreen } from "./useQuizScreen";
import { useQuizStore } from "../../../store/quizStore";
import { quizApi } from "../services/quizApi";
import type { QuizSession } from "../types";
const mockRouterPush = jest.fn();
jest.mock("expo-router", () => {
const React = jest.requireActual("react");
return {
useRouter: () => ({ push: mockRouterPush }),
// Mirrors react-navigation's real semantics closely enough for this hook:
// runs once per stable callback identity, not on every render.
useFocusEffect: (callback: () => void) => React.useEffect(callback, [callback]),
};
});
let mockAuth = { user: { couple_id: null as string | null }, partner: null as { id: string } | null };
jest.mock("../../../store", () => ({
useAuthStore: () => mockAuth,
}));
jest.mock("../services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: () => null,
}));
function session(overrides: Partial<QuizSession> = {}): QuizSession {
return {
id: "session-1",
status: "active",
created_at: "2026-08-01T00:00:00.000Z",
completed_at: null,
score_percent: null,
questions: [
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
describe("useQuizScreen", () => {
beforeEach(() => {
jest.clearAllMocks();
useQuizStore.setState({
session: null, result: null, localAnswers: {},
isLoading: false, isStarting: false, isSubmitting: false, isLoadingResult: false,
errorCode: null, error: null, generation: 0,
});
mockAuth = { user: { couple_id: null }, partner: null };
});
it("shows the not_paired state when the user has no couple or partner", async () => {
const { result } = renderHook(() => useQuizScreen());
expect(result.current.screenState).toBe("not_paired");
act(() => result.current.handlePairPress());
expect(mockRouterPush).toHaveBeenCalledWith("/pairing");
});
it("moves to intro once paired and no active session exists", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: null });
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("intro"));
});
it("walks self then guess for each question and submits once all are answered", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: session() });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({
session: session({ i_completed: true }),
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("answering"));
expect(result.current.currentStep?.step).toBe("self");
await act(async () => {
await result.current.handleSelectOption(0);
});
expect(result.current.currentStep?.step).toBe("guess");
await act(async () => {
await result.current.handleSelectOption(1);
});
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
]);
await waitFor(() => expect(result.current.screenState).toBe("waiting"));
});
it("shows results once the session and both partners have completed", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({
session: session({ status: "completed", i_completed: true, partner_completed: true, score_percent: 80 }),
});
(quizApi.getResult as jest.Mock).mockResolvedValue({
score_percent: 80, completed_at: "now", questions: [],
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("results"));
await waitFor(() => expect(result.current.result?.score_percent).toBe(80));
});
});
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Share } from "react-native";
import { useFocusEffect, useRouter } from "expo-router";
import { useAuthStore } from "../../../store";
import { useQuizStore } from "../../../store/quizStore";
import { Events } from "../../../lib/analytics";
import type { QuizQuestion } from "../types";
export type QuizScreenState = "not_paired" | "loading" | "intro" | "answering" | "waiting" | "results";
export type QuizAnswerStep = "self" | "guess";
export interface CurrentQuizStep {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
}
export function useQuizScreen() {
const router = useRouter();
const { user, partner } = useAuthStore();
const {
session,
result,
localAnswers,
isLoading,
isStarting,
isSubmitting,
isLoadingResult,
errorCode,
load,
start,
setSelfAnswer,
setGuessAnswer,
submit,
loadResult,
} = useQuizStore();
const [shareModalVisible, setShareModalVisible] = useState(false);
const hasCouple = Boolean(user?.couple_id);
const hasPartner = Boolean(partner);
const paired = hasCouple && hasPartner;
useFocusEffect(
useCallback(() => {
if (paired) void load();
}, [paired, load]),
);
const isCompleted = session?.status === "completed";
const shouldShowResults = isCompleted || (session?.i_completed && session?.partner_completed);
useEffect(() => {
if (shouldShowResults && session && !result && !isLoadingResult) {
void loadResult();
}
}, [shouldShowResults, session, result, isLoadingResult, loadResult]);
const screenState: QuizScreenState = useMemo(() => {
if (!paired) return "not_paired";
if (isLoading && !session) return "loading";
if (!session) return "intro";
if (shouldShowResults) return "results";
if (session.i_completed && !session.partner_completed) return "waiting";
return "answering";
}, [paired, isLoading, session, shouldShowResults]);
// The first unanswered step across the question set, in order.
const currentStep: CurrentQuizStep | null = useMemo(() => {
if (!session) return null;
for (let i = 0; i < session.questions.length; i += 1) {
const question = session.questions[i];
const answer = localAnswers[question.id];
if (answer?.self_index === undefined) {
return { question, step: "self", questionNumber: i + 1 };
}
if (answer?.guess_index === undefined) {
return { question, step: "guess", questionNumber: i + 1 };
}
}
return null;
}, [session, localAnswers]);
const totalQuestions = session?.questions.length ?? 0;
const handleSelectOption = useCallback(
async (index: number) => {
if (!currentStep) return;
const { question, step } = currentStep;
if (step === "self") {
setSelfAnswer(question.id, index);
return;
}
setGuessAnswer(question.id, index);
// The guess just recorded may be the last one needed; submit as soon as
// every question has both a self and a guess answer.
const answers = useQuizStore.getState().localAnswers;
const isLastQuestion = totalQuestions > 0 && currentStep.questionNumber === totalQuestions;
if (isLastQuestion) {
const allAnswered = session!.questions.every((q) => {
const a = q.id === question.id ? { ...answers[q.id], guess_index: index } : answers[q.id];
return a?.self_index !== undefined && a?.guess_index !== undefined;
});
if (allAnswered) {
await submit();
}
}
},
[currentStep, setSelfAnswer, setGuessAnswer, submit, totalQuestions, session],
);
const handleStart = useCallback(() => {
void start();
}, [start]);
const handleNewQuiz = useCallback(() => {
void start();
}, [start]);
const handleRefresh = useCallback(() => {
void load();
}, [load]);
const handlePairPress = useCallback(() => {
router.push("/pairing");
}, [router]);
const handleNudgePartner = useCallback(async () => {
try {
await Share.share({
message: "I just finished our couple quiz on Sauci, your turn!",
});
Events.quizPartnerNudged();
} catch (err) {
console.error("Error nudging partner:", err);
}
}, []);
const openShareModal = useCallback(() => setShareModalVisible(true), []);
const closeShareModal = useCallback(() => setShareModalVisible(false), []);
return {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
isLoading,
errorCode,
hasCouple,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { QuizScreen, default } from "./QuizScreen";
export { useQuizScreen } from "./hooks/useQuizScreen";
export { quizApi, getQuizErrorCode } from "./services/quizApi";
export * from "./types";
@@ -0,0 +1,57 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import { getQuizErrorCode, quizApi } from "./quizApi";
jest.mock("../../../lib/apiClient", () => {
const actual = jest.requireActual("../../../lib/apiClient");
return {
...actual,
apiClient: { get: jest.fn(), post: jest.fn() },
};
});
const get = apiClient.get as jest.Mock;
const post = apiClient.post as jest.Mock;
describe("quizApi", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("starts a session against the couple quiz session endpoint", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
await quizApi.startSession();
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions");
});
it("reads the current session", async () => {
get.mockResolvedValue({ session: null });
await quizApi.getCurrentSession();
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/current");
});
it("submits the full answer set for a session", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
const answers = [{ question_id: "q1", self_index: 0, guess_index: 1 }];
await quizApi.submitAnswers("s1", answers);
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions/s1/answers", { answers });
});
it("encodes the session id when reading a result", async () => {
get.mockResolvedValue({ score_percent: 80, completed_at: "now", questions: [] });
await quizApi.getResult("s1/weird");
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/s1%2Fweird/result");
});
});
describe("getQuizErrorCode", () => {
it("reads the contract's { error: { code } } body off an ApiError", () => {
const error = new ApiError("no_couple", 409, { error: { code: "no_couple" } });
expect(getQuizErrorCode(error)).toBe("no_couple");
});
it("returns null for a non-ApiError or a body without a code", () => {
expect(getQuizErrorCode(new Error("boom"))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, {}))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, undefined))).toBeNull();
});
});
@@ -0,0 +1,23 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import type { QuizAnswer, QuizErrorCode, QuizResult, QuizSession } from "../types";
const sessionPath = (sessionId: string) => `/v1/quiz/sessions/${encodeURIComponent(sessionId)}`;
export const quizApi = {
startSession: () => apiClient.post<{ session: QuizSession }>("/v1/quiz/sessions"),
getCurrentSession: () => apiClient.get<{ session: QuizSession | null }>("/v1/quiz/sessions/current"),
submitAnswers: (sessionId: string, answers: QuizAnswer[]) =>
apiClient.post<{ session: QuizSession }>(`${sessionPath(sessionId)}/answers`, { answers }),
getResult: (sessionId: string) => apiClient.get<QuizResult>(`${sessionPath(sessionId)}/result`),
};
/**
* Reads the API's `{ error: { code } }` body out of an `ApiError`, so callers can
* branch on the contract's error codes instead of matching on message text.
*/
export function getQuizErrorCode(error: unknown): QuizErrorCode | null {
if (!(error instanceof ApiError)) return null;
const body = error.details as { error?: { code?: string } } | undefined;
const code = body?.error?.code;
return typeof code === "string" ? (code as QuizErrorCode) : null;
}
+58
View File
@@ -0,0 +1,58 @@
export interface QuizQuestion {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizAnswer {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizSession {
id: string;
status: "active" | "completed";
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestion[];
my_answers: QuizAnswer[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResult {
score_percent: number;
completed_at: string;
questions: QuizResultQuestion[];
}
/** Known error codes the quiz API can surface as `{ error: { code } }`. */
export type QuizErrorCode =
| "no_couple"
| "partner_required"
| "session_not_found"
| "session_completed"
| "invalid_answers"
| "session_not_completed";
/** In-progress local answer for a question, before the full set is submitted. */
export interface QuizLocalAnswer {
self_index?: number;
guess_index?: number;
}