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

+4 -1
View File
@@ -8,7 +8,7 @@ import * as Haptics from 'expo-haptics';
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay';
import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme';
@@ -166,6 +166,9 @@ export default function DiscoveryScreen() {
{/* Dares */}
<DaresTile delay={200} />
{/* Quiz */}
<QuizTile delay={225} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
+5 -232
View File
@@ -1,232 +1,5 @@
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { colors, spacing, radius, typography, animations } from "../../src/theme";
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.rose;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export default function QuizScreen() {
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
useFeatureInterest("quiz");
const scale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Learn what makes each other tick</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Deepen your bond</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Track your journey</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
import { QuizScreen } from "../../src/features/quiz";
export default function QuizRoute() {
return <QuizScreen />;
}
@@ -0,0 +1,126 @@
import { ApiError } from "@/lib/apiClient";
import { quizApi } from "@/features/quiz/services/quizApi";
import { useQuizStore } from "@/store/quizStore";
import type { QuizSession } from "@/features/quiz/types";
jest.mock("@/features/quiz/services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: (error: unknown) => {
const { ApiError: RealApiError } = jest.requireActual("@/lib/apiClient");
if (!(error instanceof RealApiError)) return null;
const details = (error as { details?: unknown }).details as { error?: { code?: string } } | undefined;
return details?.error?.code ?? 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"] },
{ id: "q2", prompt_self: "Self 2", prompt_guess: "Guess 2", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
const resetStore = () =>
useQuizStore.setState({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
});
describe("quizStore", () => {
beforeEach(() => {
jest.clearAllMocks();
resetStore();
});
it("starts a new session and resets local answers/result", async () => {
(quizApi.startSession as jest.Mock).mockResolvedValue({ session: session() });
await useQuizStore.getState().start();
expect(useQuizStore.getState().session?.id).toBe("session-1");
expect(useQuizStore.getState().localAnswers).toEqual({});
expect(useQuizStore.getState().isStarting).toBe(false);
});
it("records local self and guess answers per question without submitting early", () => {
useQuizStore.setState({ session: session() });
useQuizStore.getState().setSelfAnswer("q1", 0);
useQuizStore.getState().setGuessAnswer("q1", 1);
expect(useQuizStore.getState().localAnswers.q1).toEqual({ self_index: 0, guess_index: 1 });
expect(quizApi.submitAnswers).not.toHaveBeenCalled();
});
it("submits the full answer set built from local answers and stores the returned session", async () => {
useQuizStore.setState({
session: session(),
localAnswers: {
q1: { self_index: 0, guess_index: 1 },
q2: { self_index: 1, guess_index: 0 },
},
});
const updated = session({ i_completed: true });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({ session: updated });
const ok = await useQuizStore.getState().submit();
expect(ok).toBe(true);
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
{ question_id: "q2", self_index: 1, guess_index: 0 },
]);
expect(useQuizStore.getState().session?.i_completed).toBe(true);
expect(useQuizStore.getState().localAnswers).toEqual({});
});
it("surfaces a pairing error code instead of a generic message", async () => {
(quizApi.getCurrentSession as jest.Mock).mockRejectedValue(
new ApiError("no_couple", 409, { error: { code: "no_couple" } }),
);
await useQuizStore.getState().load();
expect(useQuizStore.getState().errorCode).toBe("no_couple");
expect(useQuizStore.getState().error).toBeNull();
});
it("clearQuiz resets all state and invalidates any in-flight request (sign-out)", async () => {
let resolve!: (value: unknown) => void;
(quizApi.getCurrentSession as jest.Mock).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const staleLoad = useQuizStore.getState().load();
useQuizStore.getState().clearQuiz();
resolve({ session: session({ id: "stale-session" }) });
await staleLoad;
expect(useQuizStore.getState().session).toBeNull();
expect(useQuizStore.getState().generation).toBe(1);
});
});
@@ -0,0 +1,98 @@
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Haptics from 'expo-haptics';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, radius, typography, shadows, featureColors } from '../../theme';
import { useAuthStore } from '../../store';
const HORIZONTAL_PADDING = spacing.lg;
const TILE_HEIGHT = 100;
const ACCENT = featureColors.quiz.accent;
interface QuizTileProps {
delay?: number;
}
export function QuizTile({ delay = 0 }: QuizTileProps) {
const { user } = useAuthStore();
const paired = Boolean(user?.couple_id);
if (!paired) return null;
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.push('/(app)/quiz' as never);
};
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
<View style={[styles.tile, shadows.md]}>
<View style={styles.iconContainer}>
<Ionicons name="help-circle" size={24} color="rgba(255, 255, 255, 0.9)" />
</View>
<View style={styles.content}>
<Text style={styles.title}>Quiz</Text>
<Text style={styles.subtitle}>How well do you know each other?</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
<View style={styles.overlay} />
</View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
tileWrapper: {
paddingHorizontal: HORIZONTAL_PADDING,
},
tile: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: ACCENT,
borderRadius: radius.lg,
padding: spacing.md,
height: TILE_HEIGHT,
overflow: 'hidden',
position: 'relative',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
content: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '700',
},
subtitle: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: radius.lg,
pointerEvents: 'none',
},
});
@@ -4,4 +4,5 @@ export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
export { QuizTile } from './QuizTile';
export { PendingQuestionsCard } from './PendingQuestionsCard';
@@ -30,6 +30,7 @@ interface GlassButtonProps {
textStyle?: TextStyle;
haptic?: boolean;
icon?: React.ReactNode;
testID?: string;
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
@@ -46,6 +47,7 @@ export function GlassButton({
textStyle,
haptic = true,
icon,
testID,
}: GlassButtonProps) {
const scale = useSharedValue(1);
@@ -104,6 +106,7 @@ export function GlassButton({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || loading}
testID={testID}
style={[
animatedStyle,
fullWidth && styles.fullWidth,
@@ -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;
}
+6
View File
@@ -273,6 +273,12 @@ export const Events = {
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
quizShared: () => logEvent("quiz_shared"),
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
// Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_started", { tutorial }),
+8 -4
View File
@@ -35,7 +35,8 @@ const getOtherStores = () => {
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
const { useQuizStore } = require("./quizStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore };
};
@@ -88,7 +89,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
await authClient.auth.signOut();
return;
}
@@ -144,7 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -152,6 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
@@ -176,7 +179,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -184,6 +187,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
+1
View File
@@ -11,3 +11,4 @@ export { useNotificationPreferencesStore } from "./notificationPreferencesStore"
export type { NotificationPreferences } from "./notificationPreferencesStore";
export { useStreakStore } from "./streakStore";
export type { CoupleStreak } from "./streakStore";
export { useQuizStore } from "./quizStore";
+164
View File
@@ -0,0 +1,164 @@
import { create } from "zustand";
import { Events } from "../lib/analytics";
import { getQuizErrorCode, quizApi } from "../features/quiz/services/quizApi";
import type { QuizAnswer, QuizErrorCode, QuizLocalAnswer, QuizResult, QuizSession } from "../features/quiz/types";
interface QuizState {
session: QuizSession | null;
result: QuizResult | null;
/** In-progress answers for the active session, keyed by question id, not yet submitted. */
localAnswers: Record<string, QuizLocalAnswer>;
isLoading: boolean;
isStarting: boolean;
isSubmitting: boolean;
isLoadingResult: boolean;
/** Set when the couple/partner state blocks the quiz (`no_couple`, `partner_required`). */
errorCode: QuizErrorCode | null;
error: string | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
load: () => Promise<void>;
start: () => Promise<void>;
setSelfAnswer: (questionId: string, index: number) => void;
setGuessAnswer: (questionId: string, index: number) => void;
submit: () => Promise<boolean>;
loadResult: () => Promise<void>;
reset: () => void;
clearQuiz: () => void;
}
export const useQuizStore = create<QuizState>((set, get) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
load: async () => {
const myGeneration = get().generation;
set({ isLoading: true, error: null, errorCode: null });
try {
const { session } = await quizApi.getCurrentSession();
if (get().generation !== myGeneration) return;
set({ session, isLoading: false, localAnswers: {} });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz session:", cause);
set({ isLoading: false, errorCode: code, error: code ? null : "Could not load your quiz" });
}
},
start: async () => {
const myGeneration = get().generation;
set({ isStarting: true, error: null, errorCode: null });
try {
const { session } = await quizApi.startSession();
if (get().generation !== myGeneration) return;
set({ session, result: null, localAnswers: {}, isStarting: false });
Events.quizStarted();
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error starting quiz:", cause);
set({ isStarting: false, errorCode: code, error: code ? null : "Could not start the quiz" });
}
},
setSelfAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], self_index: index },
},
}));
},
setGuessAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], guess_index: index },
},
}));
},
submit: async () => {
const state = get();
const session = state.session;
if (!session) return false;
const answers: QuizAnswer[] = session.questions.map((question) => {
const local = state.localAnswers[question.id] ?? {};
return {
question_id: question.id,
self_index: local.self_index ?? -1,
guess_index: local.guess_index ?? -1,
};
});
const myGeneration = state.generation;
set({ isSubmitting: true, error: null, errorCode: null });
try {
const { session: updated } = await quizApi.submitAnswers(session.id, answers);
if (get().generation !== myGeneration) return false;
set({ session: updated, isSubmitting: false, localAnswers: {} });
if (updated.score_percent !== null) {
Events.quizCompleted(updated.score_percent);
}
return true;
} catch (cause) {
if (get().generation !== myGeneration) return false;
const code = getQuizErrorCode(cause);
console.error("Error submitting quiz answers:", cause);
set({ isSubmitting: false, errorCode: code, error: code ? null : "Could not submit your answers" });
return false;
}
},
loadResult: async () => {
const session = get().session;
if (!session) return;
const myGeneration = get().generation;
set({ isLoadingResult: true, error: null });
try {
const result = await quizApi.getResult(session.id);
if (get().generation !== myGeneration) return;
set({ result, isLoadingResult: false });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz result:", cause);
set({ isLoadingResult: false, errorCode: code, error: code ? null : "Could not load your results" });
}
},
/** Clears the in-memory quiz state without bumping the generation, e.g. after starting a new quiz. */
reset: () => {
set({ session: null, result: null, localAnswers: {}, error: null, errorCode: null });
},
clearQuiz: () => {
set((state) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
},
}));