mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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>
285 lines
8.7 KiB
TypeScript
Executable File
285 lines
8.7 KiB
TypeScript
Executable File
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
|
import { useFocusEffect, useRouter } from 'expo-router';
|
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { BlurView } from 'expo-blur';
|
|
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, QuizTile } from '../../src/components/discovery';
|
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
|
import { Events } from '../../src/lib/analytics';
|
|
import { colors, spacing, typography } from '../../src/theme';
|
|
import type { QuestionPack, Category } from '../../src/types';
|
|
|
|
const MAX_CONTENT_WIDTH = 600;
|
|
|
|
// Group packs by category
|
|
function groupPacksByCategory(
|
|
packs: QuestionPack[],
|
|
categories: Category[]
|
|
): { category: Category; packs: QuestionPack[] }[] {
|
|
const categoryMap = new Map<string, QuestionPack[]>();
|
|
const uncategorized: QuestionPack[] = [];
|
|
|
|
// Initialize map with empty arrays for each category
|
|
categories.forEach((cat) => {
|
|
categoryMap.set(cat.id, []);
|
|
});
|
|
|
|
// Group packs
|
|
packs.forEach((pack) => {
|
|
if (pack.category_id && categoryMap.has(pack.category_id)) {
|
|
categoryMap.get(pack.category_id)!.push(pack);
|
|
} else {
|
|
uncategorized.push(pack);
|
|
}
|
|
});
|
|
|
|
// Build result array maintaining category sort order
|
|
const result: { category: Category; packs: QuestionPack[] }[] = [];
|
|
|
|
categories.forEach((cat) => {
|
|
const categoryPacks = categoryMap.get(cat.id) || [];
|
|
if (categoryPacks.length > 0) {
|
|
result.push({ category: cat, packs: categoryPacks });
|
|
}
|
|
});
|
|
|
|
// Add uncategorized at the end if any
|
|
if (uncategorized.length > 0) {
|
|
result.push({
|
|
category: {
|
|
id: 'uncategorized',
|
|
name: 'More',
|
|
description: null,
|
|
icon: 'ellipsis-horizontal',
|
|
color: null,
|
|
sort_order: 999,
|
|
created_at: '',
|
|
is_public: true,
|
|
},
|
|
packs: uncategorized,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export default function DiscoveryScreen() {
|
|
const { user, partner, couple } = useAuthStore();
|
|
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
|
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
|
|
const { subscription } = useSubscriptionStore();
|
|
const router = useRouter();
|
|
const { width } = useWindowDimensions();
|
|
const [showPaywall, setShowPaywall] = useState(false);
|
|
const [headerHeight, setHeaderHeight] = useState(130);
|
|
const lastPromptImpression = useRef<string | null>(null);
|
|
|
|
// Check all sources of premium access: user flag, partner flag, or active subscription
|
|
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
|
const pendingQuestionIds = useMemo(
|
|
() => pendingQuestions.map(question => question.id).join(','),
|
|
[pendingQuestions],
|
|
);
|
|
|
|
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
|
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
|
// request here.
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
void ensurePacksLoaded();
|
|
void fetchPendingQuestions();
|
|
|
|
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
|
|
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
|
|
lastPromptImpression.current = pendingQuestionIds;
|
|
}
|
|
|
|
return () => {
|
|
lastPromptImpression.current = null;
|
|
};
|
|
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
|
|
);
|
|
|
|
// Group packs by category
|
|
const packsByCategory = useMemo(
|
|
() => groupPacksByCategory(packs, categories),
|
|
[packs, categories]
|
|
);
|
|
|
|
// Check if user has no packs enabled (empty state)
|
|
const hasNoPacks = packs.length === 0;
|
|
|
|
const handlePendingQuestionsPress = useCallback(() => {
|
|
const firstQuestion = pendingQuestions[0];
|
|
if (!firstQuestion) return;
|
|
|
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
|
|
router.push({
|
|
pathname: '/(app)/swipe',
|
|
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
|
|
});
|
|
}, [pendingQuestions, router]);
|
|
|
|
return (
|
|
<GradientBackground>
|
|
<View
|
|
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
|
|
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
|
|
>
|
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
|
|
<CompactHeader user={user} partner={partner} couple={couple} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.container}
|
|
contentContainerStyle={[
|
|
styles.contentContainer,
|
|
isWideScreen && styles.contentContainerWide,
|
|
{ paddingTop: headerHeight },
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
|
<PendingQuestionsCard
|
|
questions={pendingQuestions}
|
|
partnerName={partner?.name}
|
|
onPress={handlePendingQuestionsPress}
|
|
delay={50}
|
|
/>
|
|
|
|
{/* Shared streak - hidden entirely for couples with nothing running */}
|
|
<View style={styles.streakSlot}>
|
|
<StreakDisplay delay={100} />
|
|
</View>
|
|
|
|
{/* Live Draw Activity Tile */}
|
|
<LiveDrawTile delay={150} />
|
|
|
|
{/* Dares */}
|
|
<DaresTile delay={200} />
|
|
|
|
{/* Quiz */}
|
|
<QuizTile delay={225} />
|
|
|
|
{/* Content Rows by Category */}
|
|
{packsByCategory.map((group, index) => (
|
|
<ContentRow
|
|
key={group.category.id}
|
|
title={group.category.name}
|
|
packs={group.packs}
|
|
isPremiumUser={isPremiumUser}
|
|
categoryId={group.category.id}
|
|
delay={250 + index * 100}
|
|
getPackProgress={getPackProgress}
|
|
onShowPaywall={() => setShowPaywall(true)}
|
|
/>
|
|
))}
|
|
|
|
{/* Empty state when no packs */}
|
|
{hasNoPacks && (
|
|
<Animated.View
|
|
entering={FadeInDown.delay(300).duration(500).springify()}
|
|
style={styles.emptyState}
|
|
>
|
|
<View style={styles.emptyIcon}>
|
|
<Ionicons name="layers-outline" size={48} color={colors.primary} />
|
|
</View>
|
|
<Text style={styles.emptyTitle}>No packs available</Text>
|
|
<Text style={styles.emptySubtitle}>
|
|
Check back soon for new content
|
|
</Text>
|
|
</Animated.View>
|
|
)}
|
|
|
|
<View style={styles.bottomSpacer} />
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Paywall Modal */}
|
|
<Paywall
|
|
visible={showPaywall}
|
|
onClose={() => setShowPaywall(false)}
|
|
onSuccess={() => {
|
|
setShowPaywall(false);
|
|
fetchPacks(); // Refresh packs to update lock status
|
|
}}
|
|
/>
|
|
</GradientBackground>
|
|
);
|
|
}
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
contentContainer: {
|
|
flexGrow: 1,
|
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
|
},
|
|
contentContainerWide: {
|
|
alignItems: 'center',
|
|
},
|
|
innerContainer: {
|
|
flex: 1,
|
|
width: '100%',
|
|
},
|
|
innerContainerWide: {
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
},
|
|
emptyState: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.xl,
|
|
paddingVertical: spacing.xxl,
|
|
},
|
|
emptyIcon: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 40,
|
|
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: spacing.lg,
|
|
},
|
|
emptyTitle: {
|
|
...typography.title3,
|
|
color: colors.text,
|
|
marginBottom: spacing.sm,
|
|
},
|
|
emptySubtitle: {
|
|
...typography.body,
|
|
color: colors.textSecondary,
|
|
textAlign: 'center',
|
|
},
|
|
streakSlot: {
|
|
paddingHorizontal: spacing.lg,
|
|
},
|
|
bottomSpacer: {
|
|
height: spacing.lg,
|
|
},
|
|
stickyHeader: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 10,
|
|
overflow: 'hidden', // Ensures blur doesn't bleed if we rounded corners (optional)
|
|
},
|
|
stickyHeaderWide: {
|
|
left: '50%',
|
|
right: 'auto',
|
|
width: MAX_CONTENT_WIDTH,
|
|
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
|
|
},
|
|
});
|