mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add discovery UI, hide NSFW setting, and pack progress
- New discovery components (ContentTile, ContentRow, CircularProgress) - Refactor home screen with compact header and content tiles - Add hide_nsfw profile setting for content filtering - Add color column to categories table - Track pack progress (answered/total questions) - Update related stores and tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc38e41572
commit
69ac0940d3
29 files changed
+2048
-1174
No files matched your search
+184
-625
@@ -1,641 +1,200 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeInRight,
|
||||
FadeIn,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore, usePacksStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow } from '../../src/components/discovery';
|
||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||
import type { QuestionPack, Category } from '../../src/types';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
const ACCENT_COLOR = colors.primary;
|
||||
const MAX_CONTENT_WIDTH = 600;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
// Group packs by category
|
||||
function groupPacksByCategory(
|
||||
packs: QuestionPack[],
|
||||
categories: Category[]
|
||||
): { category: Category; packs: QuestionPack[] }[] {
|
||||
const categoryMap = new Map<string, QuestionPack[]>();
|
||||
const uncategorized: QuestionPack[] = [];
|
||||
|
||||
// Refresh data when screen gains focus for freshness
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches();
|
||||
fetchPacks();
|
||||
}, [])
|
||||
);
|
||||
// Initialize map with empty arrays for each category
|
||||
categories.forEach((cat) => {
|
||||
categoryMap.set(cat.id, []);
|
||||
});
|
||||
|
||||
const topMatches = matches.slice(0, 3);
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const enabledPacksCount = enabledPackIds.length;
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Subtle pulsating animation for the CTA icon
|
||||
const pulseScale = useSharedValue(1);
|
||||
const glowOpacity = useSharedValue(0.3);
|
||||
// Build result array maintaining category sort order
|
||||
const result: { category: Category; packs: QuestionPack[] }[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
const startPulse = () => {
|
||||
pulseScale.value = 1;
|
||||
pulseScale.value = withRepeat(
|
||||
withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
glowOpacity.value = withRepeat(
|
||||
withTiming(0.6, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
};
|
||||
const timeout = setTimeout(startPulse, 100);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
categories.forEach((cat) => {
|
||||
const categoryPacks = categoryMap.get(cat.id) || [];
|
||||
if (categoryPacks.length > 0) {
|
||||
result.push({ category: cat, packs: categoryPacks });
|
||||
}
|
||||
});
|
||||
|
||||
const pulseAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: pulseScale.value }],
|
||||
}));
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
const glowAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: glowOpacity.value,
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
export default function DiscoveryScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
|
||||
const isPremiumUser = user?.is_premium ?? false;
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
// Refresh data when screen gains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPacks();
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
{/* Compact Header */}
|
||||
<CompactHeader user={user} partner={partner} couple={couple} />
|
||||
|
||||
{/* 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={200 + 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.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||
{/* Premium Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(100).duration(600).springify()}
|
||||
style={styles.header}
|
||||
>
|
||||
{/* Greeting section */}
|
||||
<Text style={styles.label}>WELCOME BACK</Text>
|
||||
<Text style={styles.greeting}>
|
||||
{user?.name || "Beautiful"}
|
||||
</Text>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Decorative separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
<View style={styles.separatorDiamond} />
|
||||
<View style={styles.separatorLine} />
|
||||
</View>
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Partner badge */}
|
||||
{partner ? (
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.partnerBadge}
|
||||
>
|
||||
<Ionicons name="heart" size={12} color={ACCENT_COLOR} />
|
||||
<Text style={styles.partnerText}>
|
||||
Connected with {partner.name || partner.email?.split('@')[0] || 'Partner'}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : couple ? (
|
||||
<TouchableOpacity
|
||||
style={styles.waitingBadge}
|
||||
onPress={() => router.push("/(app)/pairing")}
|
||||
>
|
||||
<Ionicons name="hourglass-outline" size={12} color={ACCENT_COLOR} />
|
||||
<Text style={styles.waitingText}>Waiting for partner</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.pairBadge}
|
||||
onPress={() => router.push("/(app)/pairing")}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.pairText}>Connect with your partner</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
</Animated.View>
|
||||
|
||||
{/* Stats Row */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
style={styles.statsContainer}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.statCard, matches.length > 0 && styles.statCardActive]}>
|
||||
<View style={[styles.statIcon, matches.length > 0 && styles.statIconActive]}>
|
||||
<Ionicons
|
||||
name="heart"
|
||||
size={18}
|
||||
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
|
||||
<Text style={styles.statLabel}>Matches</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/packs")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.statCard, enabledPacksCount > 0 && styles.statCardActive]}>
|
||||
<View style={[styles.statIcon, enabledPacksCount > 0 && styles.statIconActive]}>
|
||||
<Ionicons
|
||||
name="layers"
|
||||
size={18}
|
||||
color={enabledPacksCount > 0 ? colors.text : ACCENT_COLOR}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
|
||||
<Text style={styles.statLabel}>Packs</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.statCardWrapper}
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.statCard, newMatchesCount > 0 && styles.statCardActive]}>
|
||||
<View style={[styles.statIcon, newMatchesCount > 0 && styles.statIconActive]}>
|
||||
<Ionicons
|
||||
name="sparkles"
|
||||
size={18}
|
||||
color={newMatchesCount > 0 ? colors.text : ACCENT_COLOR}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
||||
<Text style={styles.statLabel}>New</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Play CTA */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
style={styles.ctaSection}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={styles.ctaCard}>
|
||||
{/* Glow effect */}
|
||||
<Animated.View style={[styles.ctaGlow, glowAnimatedStyle]} />
|
||||
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.ctaGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<View style={styles.ctaContent}>
|
||||
<Text style={styles.ctaLabel}>EXPLORE</Text>
|
||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
||||
<Text style={styles.ctaSubtitle}>
|
||||
Discover new experiences
|
||||
</Text>
|
||||
</View>
|
||||
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
|
||||
<Ionicons name="flame" size={28} color={colors.text} />
|
||||
</Animated.View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Top Matches (sorted by unread first, then recent) */}
|
||||
{topMatches.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionTitleRow}>
|
||||
<View style={styles.sectionIcon}>
|
||||
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>Top Matches</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
style={styles.seeAllButton}
|
||||
>
|
||||
<Text style={styles.seeAllText}>See all</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{topMatches.map((match, index) => (
|
||||
<Animated.View
|
||||
key={match.id}
|
||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/chat/${match.id}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.matchCard}>
|
||||
<View style={styles.matchRow}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.matchIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="heart" size={14} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.matchText} numberOfLines={1}>
|
||||
{(match as any).question?.text || "A new match!"}
|
||||
</Text>
|
||||
{match.is_new && <View style={styles.newBadge} />}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
))}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Tips when no matches */}
|
||||
{topMatches.length === 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<View style={styles.sectionTitleRow}>
|
||||
<View style={styles.sectionIcon}>
|
||||
<Ionicons name="compass" size={14} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>Getting Started</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tipCard}>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="layers-outline" size={18} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Enable question packs</Text>
|
||||
<Text style={styles.tipSubtext}>Choose topics to explore together</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.tipCard, styles.tipCardSpaced]}>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="swap-horizontal-outline" size={18} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Swipe on questions</Text>
|
||||
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.tipCard, styles.tipCardSpaced]}>
|
||||
<View style={styles.tipRow}>
|
||||
<View style={styles.tipIcon}>
|
||||
<Ionicons name="heart-outline" size={18} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipText}>Discover matches</Text>
|
||||
<Text style={styles.tipSubtext}>See where you both agree</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</GradientBackground>
|
||||
);
|
||||
{/* 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,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
label: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2.5,
|
||||
color: ACCENT_COLOR,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
greeting: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
separator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: spacing.md,
|
||||
width: 120,
|
||||
},
|
||||
separatorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.3)',
|
||||
},
|
||||
separatorDiamond: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
transform: [{ rotate: '45deg' }],
|
||||
marginHorizontal: spacing.sm,
|
||||
opacity: 0.6,
|
||||
},
|
||||
partnerBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
partnerText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
pairBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.glass.background,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
pairText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
waitingBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
waitingText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statCardWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
statCard: {
|
||||
alignItems: "center",
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
||||
},
|
||||
statCardActive: {
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
statIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
statIconActive: {
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
},
|
||||
statNumber: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
},
|
||||
statLabel: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
ctaSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
ctaCard: {
|
||||
position: 'relative',
|
||||
borderRadius: radius.xl,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
ctaGlow: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: -20,
|
||||
right: -20,
|
||||
bottom: -20,
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
borderRadius: radius.xl + 20,
|
||||
},
|
||||
ctaGradient: {
|
||||
borderRadius: radius.xl,
|
||||
padding: spacing.lg,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
...shadows.lg,
|
||||
},
|
||||
ctaContent: {
|
||||
flex: 1,
|
||||
},
|
||||
ctaLabel: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
ctaTitle: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
ctaSubtitle: {
|
||||
...typography.subhead,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
},
|
||||
ctaIconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
sectionIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
seeAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
seeAllText: {
|
||||
...typography.subhead,
|
||||
color: ACCENT_COLOR,
|
||||
},
|
||||
matchCard: {
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
||||
padding: spacing.md,
|
||||
},
|
||||
matchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
matchIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
matchText: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
},
|
||||
newBadge: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: ACCENT_COLOR,
|
||||
},
|
||||
tipCard: {
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
||||
padding: spacing.md,
|
||||
},
|
||||
tipCardSpaced: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
tipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tipIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
tipContent: {
|
||||
flex: 1,
|
||||
},
|
||||
tipText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tipSubtext: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
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',
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
||||
import { useMatchStore, useAuthStore } from "../../src/store";
|
||||
import { useMatchStore, useAuthStore, type PendingQuestion } from "../../src/store";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
@@ -49,13 +49,17 @@ export default function MatchesScreen() {
|
||||
// Archive state and methods
|
||||
archivedMatches,
|
||||
showArchived,
|
||||
toggleShowArchived,
|
||||
archiveMatch,
|
||||
unarchiveMatch,
|
||||
isLoadingArchived,
|
||||
fetchArchivedMatches,
|
||||
// Pending state and methods
|
||||
pendingQuestions,
|
||||
isLoadingPending,
|
||||
currentView,
|
||||
setCurrentView,
|
||||
fetchPendingQuestions,
|
||||
} = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const { user, couple, partner } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
@@ -132,6 +136,8 @@ export default function MatchesScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches(true);
|
||||
// Also refresh pending questions when focused
|
||||
fetchPendingQuestions();
|
||||
}, [])
|
||||
);
|
||||
|
||||
@@ -267,6 +273,96 @@ export default function MatchesScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingItem = ({ item, index }: { item: PendingQuestion; index: number }) => {
|
||||
if (!item.question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packName = item.question.pack?.name ?? 'Unknown Pack';
|
||||
const timeSince = getTimeSince(item.partnerAnsweredAt);
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({
|
||||
pathname: "/(app)/swipe",
|
||||
params: { mode: 'pending', startQuestionId: item.question.id }
|
||||
})}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.matchCardPremium}>
|
||||
{/* Subtle gradient background - slightly different hue for pending */}
|
||||
<LinearGradient
|
||||
colors={['rgba(33, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${ROSE_RGBA}0.06)`, 'transparent']}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.matchRow}>
|
||||
{/* Pending icon container - hourglass */}
|
||||
<View style={[styles.iconContainerPremium, styles.iconContainerPending]}>
|
||||
<Ionicons
|
||||
name="hourglass-outline"
|
||||
size={20}
|
||||
color={ROSE}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
||||
{item.question.text}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.tagPremium}>
|
||||
<Text style={styles.tagTextPremium}>
|
||||
{packName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.datePremium}>
|
||||
{timeSince}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightSection}>
|
||||
<View style={styles.chevronContainerPremium}>
|
||||
<Ionicons name="chevron-forward" size={16} color={`${ROSE_RGBA}0.6)`} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium border */}
|
||||
<View style={[styles.cardPremiumBorder, styles.cardPendingBorder]} pointerEvents="none" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to get time since partner answered
|
||||
const getTimeSince = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!isLoadingMore) return null;
|
||||
return (
|
||||
@@ -286,6 +382,91 @@ export default function MatchesScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// Gate matches behind pairing - user must have a partner to view matches
|
||||
if (!partner) {
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs */}
|
||||
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[colors.premium.goldGlow, 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
|
||||
<LinearGradient
|
||||
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.pairingGateContainer}>
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.pairingGateContent}
|
||||
>
|
||||
{/* Icon */}
|
||||
<View style={styles.pairingGateIconContainer}>
|
||||
<Ionicons name="heart" size={36} color={ROSE} />
|
||||
</View>
|
||||
|
||||
{/* Title section */}
|
||||
<Text style={styles.pairingGateLabel}>{couple ? "ALMOST THERE" : "CONNECT"}</Text>
|
||||
<Text style={styles.pairingGateTitle}>{couple ? "Waiting" : "Pair Up"}</Text>
|
||||
|
||||
<DecorativeSeparator variant="rose" />
|
||||
|
||||
{/* Status badge */}
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.pairingGateBadge}
|
||||
>
|
||||
<Text style={styles.pairingGateBadgeText}>{couple ? "INVITE SENT" : "MADE FOR TWO"}</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.pairingGateDescription}>
|
||||
{couple
|
||||
? "Share your invite code so they can join you. Once paired, you'll discover your matches here!"
|
||||
: "Sauci is made for two! Connect with your partner to start discovering what you agree on."
|
||||
}
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.pairingGateFeatures}>
|
||||
<View style={styles.pairingGateFeatureItem}>
|
||||
<Ionicons name="heart" size={16} color={ACCENT} />
|
||||
<Text style={styles.pairingGateFeatureText}>See when you both agree</Text>
|
||||
</View>
|
||||
<View style={styles.pairingGateFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
||||
<Text style={styles.pairingGateFeatureText}>Unlock hidden connections</Text>
|
||||
</View>
|
||||
<View style={styles.pairingGateFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
||||
<Text style={styles.pairingGateFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.pairingGateTeaser}>{couple ? "Your partner is just a code away" : "Begin your journey together"}</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => router.push("/pairing")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
{couple ? "View Invite Code" : "Pair Now"}
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs - Premium gold/rose */}
|
||||
@@ -331,8 +512,8 @@ export default function MatchesScreen() {
|
||||
</View>
|
||||
|
||||
<AnimatedFlatList
|
||||
data={showArchived ? archivedMatches : matches}
|
||||
renderItem={renderItem}
|
||||
data={currentView === 'archived' ? archivedMatches : currentView === 'pending' ? pendingQuestions : matches}
|
||||
renderItem={currentView === 'pending' ? renderPendingItem as any : renderItem}
|
||||
keyExtractor={(item: any) => item.id}
|
||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -340,16 +521,22 @@ export default function MatchesScreen() {
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => fetchMatches(true)}
|
||||
refreshing={currentView === 'pending' ? isLoadingPending : isLoading}
|
||||
onRefresh={() => {
|
||||
if (currentView === 'pending') {
|
||||
fetchPendingQuestions();
|
||||
} else {
|
||||
fetchMatches(true);
|
||||
}
|
||||
}}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReached={currentView === 'pending' ? undefined : handleLoadMore}
|
||||
onEndReachedThreshold={2}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListFooterComponent={currentView === 'pending' ? null : renderFooter}
|
||||
ListHeaderComponent={
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
@@ -366,26 +553,47 @@ export default function MatchesScreen() {
|
||||
{/* Filter toggle */}
|
||||
<View style={styles.filterContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
|
||||
onPress={() => showArchived && toggleShowArchived()}
|
||||
style={[styles.filterTab, currentView === 'pending' && styles.filterTabActive]}
|
||||
onPress={() => currentView !== 'pending' && setCurrentView('pending')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLoadingPending ? (
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name="hourglass-outline"
|
||||
size={14}
|
||||
color={currentView === 'pending' ? ACCENT : colors.textTertiary}
|
||||
/>
|
||||
<Text style={[styles.filterTabText, currentView === 'pending' && styles.filterTabTextActive]}>
|
||||
Your Turn
|
||||
</Text>
|
||||
{pendingQuestions.length > 0 && currentView !== 'pending' && (
|
||||
<View style={styles.pendingBadge}>
|
||||
<Text style={styles.pendingBadgeText}>{pendingQuestions.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterTab, currentView === 'active' && styles.filterTabActive]}
|
||||
onPress={() => currentView !== 'active' && setCurrentView('active')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="heart"
|
||||
size={14}
|
||||
color={!showArchived ? ACCENT : colors.textTertiary}
|
||||
color={currentView === 'active' ? ACCENT : colors.textTertiary}
|
||||
/>
|
||||
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
|
||||
Active
|
||||
<Text style={[styles.filterTabText, currentView === 'active' && styles.filterTabTextActive]}>
|
||||
Complete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterTab, showArchived && styles.filterTabActive]}
|
||||
onPress={() => {
|
||||
if (!showArchived) {
|
||||
toggleShowArchived();
|
||||
}
|
||||
}}
|
||||
style={[styles.filterTab, currentView === 'archived' && styles.filterTabActive]}
|
||||
onPress={() => currentView !== 'archived' && setCurrentView('archived')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLoadingArchived ? (
|
||||
@@ -395,9 +603,9 @@ export default function MatchesScreen() {
|
||||
<Ionicons
|
||||
name="archive-outline"
|
||||
size={14}
|
||||
color={showArchived ? ACCENT : colors.textTertiary}
|
||||
color={currentView === 'archived' ? ACCENT : colors.textTertiary}
|
||||
/>
|
||||
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
|
||||
<Text style={[styles.filterTabText, currentView === 'archived' && styles.filterTabTextActive]}>
|
||||
Archived
|
||||
</Text>
|
||||
</>
|
||||
@@ -407,11 +615,17 @@ export default function MatchesScreen() {
|
||||
|
||||
{/* Count badge */}
|
||||
<View style={styles.countBadgePremium}>
|
||||
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
|
||||
<Ionicons
|
||||
name={currentView === 'archived' ? "archive" : currentView === 'pending' ? "hourglass" : "heart"}
|
||||
size={12}
|
||||
color={ACCENT}
|
||||
/>
|
||||
<Text style={styles.countTextPremium}>
|
||||
{showArchived
|
||||
{currentView === 'archived'
|
||||
? `${archivedMatches.length} ARCHIVED`
|
||||
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
|
||||
: currentView === 'pending'
|
||||
? `${pendingQuestions.length} WAITING`
|
||||
: `${totalCount ?? matches.length} COMPLETE`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -419,7 +633,7 @@ export default function MatchesScreen() {
|
||||
</Animated.View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
showArchived ? (
|
||||
currentView === 'archived' ? (
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
@@ -436,12 +650,35 @@ export default function MatchesScreen() {
|
||||
</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => toggleShowArchived()}
|
||||
onPress={() => setCurrentView('active')}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
View Active Matches
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
) : currentView === 'pending' ? (
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
{/* Pending icon */}
|
||||
<View style={[styles.emptyIconContainer, { backgroundColor: `${ROSE_RGBA}0.1)`, borderColor: `${ROSE_RGBA}0.2)` }]}>
|
||||
<Ionicons name="checkmark-circle-outline" size={48} color={ROSE} />
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.emptyTitle}>All Caught Up!</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
You've answered all the questions your partner has swiped on. Keep swiping to discover more together!
|
||||
</Text>
|
||||
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Keep Swiping
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
@@ -615,6 +852,22 @@ const styles = StyleSheet.create({
|
||||
color: ACCENT,
|
||||
fontWeight: '600',
|
||||
},
|
||||
pendingBadge: {
|
||||
backgroundColor: ROSE,
|
||||
borderRadius: 10,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: 5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 2,
|
||||
},
|
||||
pendingBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
fontSize: 10,
|
||||
},
|
||||
countBadgePremium: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -668,6 +921,9 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.15)`,
|
||||
},
|
||||
cardPendingBorder: {
|
||||
borderColor: `${ROSE_RGBA}0.2)`,
|
||||
},
|
||||
matchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -687,6 +943,10 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
},
|
||||
iconContainerPending: {
|
||||
backgroundColor: `${ROSE_RGBA}0.15)`,
|
||||
borderColor: `${ROSE_RGBA}0.25)`,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -813,4 +1073,83 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
// Pairing gate styles
|
||||
pairingGateContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
pairingGateContent: {
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
pairingGateIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: `${ROSE_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ROSE_RGBA}0.2)`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
pairingGateLabel: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 3,
|
||||
color: ROSE,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
pairingGateTitle: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
pairingGateBadge: {
|
||||
backgroundColor: `${ROSE_RGBA}0.1)`,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ROSE_RGBA}0.2)`,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
pairingGateBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
color: ROSE,
|
||||
},
|
||||
pairingGateDescription: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
pairingGateFeatures: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
pairingGateFeatureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
pairingGateFeatureText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
pairingGateTeaser: {
|
||||
...typography.footnote,
|
||||
fontStyle: 'italic',
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import Animated, {
|
||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||
import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||
import { IntensitySlider } from '../../src/components/ui/IntensitySlider';
|
||||
import { GlassToggle } from '../../src/components/ui/GlassToggle';
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||
import { useAuthStore } from '../../src/store';
|
||||
import { supabase } from '../../src/lib/supabase';
|
||||
@@ -32,7 +32,7 @@ import { getProfileError } from '../../src/lib/errors';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||
import type { Gender, IntensityLevel } from '../../src/types';
|
||||
import type { Gender } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
{ value: 'male', label: 'Male', icon: 'male' },
|
||||
@@ -51,20 +51,13 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||
];
|
||||
|
||||
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
||||
// Intensity thresholds for backwards compatibility
|
||||
// When hide_nsfw=true, max_intensity=2 (mild content only)
|
||||
// When hide_nsfw=false, max_intensity=5 (all content)
|
||||
const NSFW_OFF_INTENSITY = 2;
|
||||
const NSFW_ON_INTENSITY = 5;
|
||||
|
||||
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
||||
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getInitialIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
||||
const normalized = normalizeIntensity(profile?.max_intensity);
|
||||
if (normalized) return normalized;
|
||||
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
||||
};
|
||||
|
||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
@@ -73,7 +66,7 @@ export default function OnboardingScreen() {
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getInitialIntensity(user));
|
||||
const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
@@ -132,13 +125,13 @@ export default function OnboardingScreen() {
|
||||
setError('Please select what brings you to Sauci');
|
||||
return;
|
||||
}
|
||||
setStage('explicit');
|
||||
setStage('content');
|
||||
Events.onboardingStageComplete('purpose');
|
||||
};
|
||||
|
||||
const handleIntensityContinue = () => {
|
||||
const handleContentContinue = () => {
|
||||
setStage('notifications');
|
||||
Events.onboardingStageComplete('explicit');
|
||||
Events.onboardingStageComplete('content');
|
||||
};
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
@@ -177,8 +170,10 @@ export default function OnboardingScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
const showExplicitContent = maxIntensity >= 3;
|
||||
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
|
||||
// Derive intensity from hide_nsfw for backwards compatibility
|
||||
const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
|
||||
const showExplicitContent = !hideNsfw;
|
||||
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
name: name.trim(),
|
||||
@@ -186,6 +181,7 @@ export default function OnboardingScreen() {
|
||||
usage_reason: usageReason,
|
||||
max_intensity: maxIntensity,
|
||||
show_explicit_content: showExplicitContent,
|
||||
hide_nsfw: hideNsfw,
|
||||
onboarding_completed: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -476,10 +472,10 @@ export default function OnboardingScreen() {
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
case 'explicit':
|
||||
case 'content':
|
||||
return (
|
||||
<Animated.View
|
||||
key="explicit"
|
||||
key="content"
|
||||
entering={SlideInRight.duration(400)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={styles.stageContainer}
|
||||
@@ -489,22 +485,40 @@ export default function OnboardingScreen() {
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="heart-circle" size={44} color={colors.text} />
|
||||
<Ionicons name="flame" size={44} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.title}>Set Your Comfort Zone</Text>
|
||||
<Text style={styles.title}>Content Preferences</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Choose the intimacy level you're comfortable with
|
||||
Choose what type of content you'd like to see
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<GlassCard style={styles.card}>
|
||||
<IntensitySlider
|
||||
value={maxIntensity}
|
||||
onValueChange={setMaxIntensity}
|
||||
/>
|
||||
<Text style={styles.intensityNote}>
|
||||
Start where you're comfortable — you can always adjust later
|
||||
<View style={styles.contentToggleRow}>
|
||||
<View style={styles.contentToggleLeft}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.contentToggleIcon}
|
||||
>
|
||||
<Ionicons name="eye-off" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<View style={styles.contentToggleText}>
|
||||
<Text style={styles.contentToggleLabel}>Hide Adult Content</Text>
|
||||
<Text style={styles.contentToggleDescription}>
|
||||
Only show mild, family-friendly question packs
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<GlassToggle
|
||||
value={hideNsfw}
|
||||
onValueChange={setHideNsfw}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.contentNote}>
|
||||
You can change this anytime in settings
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||
@@ -515,7 +529,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
<View style={styles.footer}>
|
||||
<GlassButton
|
||||
onPress={handleIntensityContinue}
|
||||
onPress={handleContentContinue}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
@@ -613,8 +627,8 @@ export default function OnboardingScreen() {
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
setError(null);
|
||||
if (stage === 'notifications') setStage('explicit');
|
||||
else if (stage === 'explicit') setStage('purpose');
|
||||
if (stage === 'notifications') setStage('content');
|
||||
else if (stage === 'content') setStage('purpose');
|
||||
else if (stage === 'purpose') setStage('gender');
|
||||
else if (stage === 'gender') setStage('name');
|
||||
else if (stage === 'name') setStage('avatar');
|
||||
@@ -630,7 +644,7 @@ export default function OnboardingScreen() {
|
||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'content' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
||||
</View>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
@@ -866,11 +880,42 @@ const styles = StyleSheet.create({
|
||||
color: colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
intensityNote: {
|
||||
contentToggleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentToggleLeft: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
contentToggleIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
contentToggleText: {
|
||||
flex: 1,
|
||||
},
|
||||
contentToggleLabel: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
contentToggleDescription: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
contentNote: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
radioOuter: {
|
||||
width: 24,
|
||||
|
||||
@@ -496,6 +496,7 @@ export default function QuestionPacksScreen() {
|
||||
name: "Other",
|
||||
description: null,
|
||||
icon: "📁",
|
||||
color: null,
|
||||
sort_order: 999,
|
||||
created_at: "",
|
||||
is_public: true
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function PairingScreen() {
|
||||
if (couple?.invite_code) {
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`,
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
});
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
|
||||
+300
-65
@@ -40,6 +40,10 @@ export default function SwipeScreen() {
|
||||
const params = useLocalSearchParams();
|
||||
// Normalize packId - useLocalSearchParams can return string | string[]
|
||||
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
|
||||
// Mode can be 'pending' to show only questions partner has answered
|
||||
const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode;
|
||||
// Start question ID - when user taps a specific pending question, start there
|
||||
const startQuestionId = Array.isArray(params.startQuestionId) ? params.startQuestionId[0] : params.startQuestionId;
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -49,10 +53,12 @@ export default function SwipeScreen() {
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
const fetchRequestId = useRef(0);
|
||||
const lastFetchedMode = useRef<string | undefined>(undefined);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
@@ -112,16 +118,42 @@ export default function SwipeScreen() {
|
||||
const isFirstMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset state when pack changes
|
||||
// Reset state when pack or mode changes
|
||||
setCurrentIndex(0);
|
||||
setIsLoading(true);
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
hasTrackedExhausted.current = false;
|
||||
lastFetchedMode.current = mode;
|
||||
|
||||
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
|
||||
ensureEnabledPacksLoaded().then(() => fetchQuestions());
|
||||
ensureEnabledPacksLoaded().then(() => {
|
||||
if (mode === 'pending') {
|
||||
fetchPendingQuestions();
|
||||
} else {
|
||||
fetchQuestions();
|
||||
}
|
||||
});
|
||||
checkTutorial();
|
||||
}, [packId, mode]);
|
||||
|
||||
// Fetch pack context when packId is provided
|
||||
useEffect(() => {
|
||||
if (packId) {
|
||||
const fetchPackContext = async () => {
|
||||
const { data } = await supabase
|
||||
.from('question_packs')
|
||||
.select('name, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
if (data) {
|
||||
setPackContext({ name: data.name, icon: data.icon || 'layers' });
|
||||
}
|
||||
};
|
||||
fetchPackContext();
|
||||
} else {
|
||||
setPackContext(null);
|
||||
}
|
||||
}, [packId]);
|
||||
|
||||
// Refresh questions when screen gains focus (handles partner answering while away)
|
||||
@@ -132,9 +164,17 @@ export default function SwipeScreen() {
|
||||
isFirstMount.current = false;
|
||||
return;
|
||||
}
|
||||
// Silently refetch to get updated partner_answered status
|
||||
fetchQuestions();
|
||||
}, [packId])
|
||||
// Skip if mode just changed (useEffect handles that)
|
||||
if (lastFetchedMode.current !== mode) {
|
||||
return;
|
||||
}
|
||||
// Silently refetch to get updated status
|
||||
if (mode === 'pending') {
|
||||
fetchPendingQuestions();
|
||||
} else {
|
||||
fetchQuestions();
|
||||
}
|
||||
}, [packId, mode])
|
||||
);
|
||||
|
||||
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||
@@ -249,8 +289,9 @@ export default function SwipeScreen() {
|
||||
};
|
||||
|
||||
// Filter out questions from disabled packs immediately when enabledPackIds changes
|
||||
// Skip this filter in pending mode - user should answer ALL questions partner swiped on
|
||||
useEffect(() => {
|
||||
if (!packId && enabledPackIds.length > 0 && questions.length > 0) {
|
||||
if (!packId && mode !== 'pending' && enabledPackIds.length > 0 && questions.length > 0) {
|
||||
const enabledSet = new Set(enabledPackIds);
|
||||
setQuestions(prev => {
|
||||
const filtered = prev.filter(q => enabledSet.has(q.pack_id));
|
||||
@@ -261,7 +302,7 @@ export default function SwipeScreen() {
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
}, [enabledPackIds]);
|
||||
}, [enabledPackIds, mode]);
|
||||
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
@@ -315,6 +356,119 @@ export default function SwipeScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch pending questions (partner answered, user hasn't) for "Your Turn" mode
|
||||
const fetchPendingQuestions = async () => {
|
||||
const currentRequestId = ++fetchRequestId.current;
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
if (!coupleId || !userId) {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setQuestions([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get partner's user_id
|
||||
const { data: coupleProfiles } = await supabase
|
||||
.from("profiles")
|
||||
.select("id")
|
||||
.eq("couple_id", coupleId)
|
||||
.neq("id", userId);
|
||||
|
||||
const partnerId = coupleProfiles?.[0]?.id;
|
||||
if (!partnerId) {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setQuestions([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get questions the current user has already answered
|
||||
const { data: userResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("user_id", userId)
|
||||
.eq("couple_id", coupleId);
|
||||
|
||||
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
|
||||
|
||||
// Get partner's responses (questions they answered that user hasn't)
|
||||
// Order by oldest first so user burns down the backlog
|
||||
const { data: partnerResponses, error } = await supabase
|
||||
.from("responses")
|
||||
.select(`
|
||||
id,
|
||||
question_id,
|
||||
created_at,
|
||||
question:questions(
|
||||
*,
|
||||
pack:question_packs(id, name, icon)
|
||||
)
|
||||
`)
|
||||
.eq("user_id", partnerId)
|
||||
.eq("couple_id", coupleId)
|
||||
.order("created_at", { ascending: true }); // Oldest first
|
||||
|
||||
if (currentRequestId !== fetchRequestId.current) return;
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching pending questions:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Filter to only questions user hasn't answered, excluding deleted questions
|
||||
const pendingQuestions = (partnerResponses ?? [])
|
||||
.filter(r => {
|
||||
const question = r.question as any;
|
||||
return !answeredQuestionIds.has(r.question_id) && question && !question.deleted_at;
|
||||
})
|
||||
.map(r => {
|
||||
const question = r.question as any;
|
||||
return {
|
||||
...question,
|
||||
pack_id: question.pack?.id,
|
||||
pack_name: question.pack?.name,
|
||||
pack_icon: question.pack?.icon,
|
||||
};
|
||||
});
|
||||
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
// If user tapped a specific question, reorder so it's first
|
||||
let orderedQuestions = pendingQuestions;
|
||||
if (startQuestionId && pendingQuestions.length > 0) {
|
||||
const startIndex = pendingQuestions.findIndex(q => q.id === startQuestionId);
|
||||
if (startIndex !== -1) {
|
||||
// Put tapped question first, then the rest in original order
|
||||
const tappedQuestion = pendingQuestions[startIndex];
|
||||
const rest = pendingQuestions.filter((_, i) => i !== startIndex);
|
||||
orderedQuestions = [tappedQuestion, ...rest];
|
||||
}
|
||||
}
|
||||
|
||||
setQuestions(orderedQuestions);
|
||||
setCurrentIndex(0); // Always start at 0 now since we reordered
|
||||
|
||||
// Don't check gap/daily limits in pending mode - user is just catching up
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
setDailyLimitInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
console.error("Failed to fetch pending questions:", error);
|
||||
setQuestions([]);
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
||||
const question = questions[currentIndex];
|
||||
|
||||
@@ -342,18 +496,21 @@ export default function SwipeScreen() {
|
||||
} else {
|
||||
Events.questionAnswered(answer, question.pack_id);
|
||||
|
||||
// Increment local daily count if we have limit info
|
||||
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
|
||||
setDailyLimitInfo(prev => prev ? {
|
||||
...prev,
|
||||
responses_today: prev.responses_today + 1,
|
||||
remaining: Math.max(0, prev.remaining - 1),
|
||||
is_blocked: prev.responses_today + 1 >= prev.limit_value
|
||||
} : null);
|
||||
}
|
||||
// Skip limit checks in pending mode - user is just catching up
|
||||
if (mode !== 'pending') {
|
||||
// Increment local daily count if we have limit info
|
||||
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
|
||||
setDailyLimitInfo(prev => prev ? {
|
||||
...prev,
|
||||
responses_today: prev.responses_today + 1,
|
||||
remaining: Math.max(0, prev.remaining - 1),
|
||||
is_blocked: prev.responses_today + 1 >= prev.limit_value
|
||||
} : null);
|
||||
}
|
||||
|
||||
// Check both gap and daily limit
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
// Check both gap and daily limit
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit response", error);
|
||||
@@ -682,6 +839,8 @@ export default function SwipeScreen() {
|
||||
|
||||
if (currentIndex >= questions.length) {
|
||||
const CAUGHT_UP_ACCENT = colors.premium.rose;
|
||||
const isPackMode = !!packId;
|
||||
const isPendingMode = mode === 'pending';
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.centerContainer}>
|
||||
@@ -695,8 +854,12 @@ export default function SwipeScreen() {
|
||||
</View>
|
||||
|
||||
{/* Title section */}
|
||||
<Text style={styles.caughtUpLabel}>COMPLETE</Text>
|
||||
<Text style={styles.waitingTitle}>All Caught Up</Text>
|
||||
<Text style={styles.caughtUpLabel}>
|
||||
{isPendingMode ? 'QUEUE COMPLETE' : isPackMode ? 'PACK COMPLETE' : 'COMPLETE'}
|
||||
</Text>
|
||||
<Text style={styles.waitingTitle}>
|
||||
{isPendingMode ? 'All Caught Up!' : isPackMode ? packContext?.name || 'Pack Complete' : 'All Caught Up'}
|
||||
</Text>
|
||||
|
||||
<DecorativeSeparator variant="rose" />
|
||||
|
||||
@@ -705,40 +868,88 @@ export default function SwipeScreen() {
|
||||
entering={FadeIn.delay(300).duration(400)}
|
||||
style={styles.waitingBadge}
|
||||
>
|
||||
<Text style={styles.waitingBadgeText}>YOU'RE AHEAD</Text>
|
||||
<Text style={styles.waitingBadgeText}>
|
||||
{isPendingMode ? 'YOUR TURN COMPLETE' : isPackMode ? 'ALL QUESTIONS ANSWERED' : "YOU'RE AHEAD"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Description */}
|
||||
<Text style={styles.waitingDescription}>
|
||||
You've answered all available questions. New questions are added regularly, or explore different packs.
|
||||
{isPendingMode
|
||||
? "You've answered all the questions your partner swiped on. Nice work! Check back later for more."
|
||||
: isPackMode
|
||||
? "You've answered all questions in this pack. Explore other packs to discover more!"
|
||||
: "You've answered all available questions. New questions are added regularly, or explore different packs."
|
||||
}
|
||||
</Text>
|
||||
|
||||
{/* Feature hints */}
|
||||
<View style={styles.waitingFeatures}>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Try different packs</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
{isPendingMode ? (
|
||||
<>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="heart" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Check your matches</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Chat about discoveries</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Keep exploring together</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Try different packs</Text>
|
||||
</View>
|
||||
<View style={styles.waitingFeatureItem}>
|
||||
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom teaser */}
|
||||
<Text style={styles.waitingTeaser}>More ways to connect are on the way</Text>
|
||||
<Text style={styles.waitingTeaser}>
|
||||
{isPendingMode
|
||||
? 'Your partner will love that you caught up'
|
||||
: isPackMode
|
||||
? 'Discover more ways to connect'
|
||||
: 'More ways to connect are on the way'}
|
||||
</Text>
|
||||
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={fetchQuestions}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Refresh Questions
|
||||
</GlassButton>
|
||||
{isPendingMode ? (
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
View Matches
|
||||
</GlassButton>
|
||||
) : isPackMode ? (
|
||||
<GlassButton
|
||||
onPress={() => router.back()}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Explore More Packs
|
||||
</GlassButton>
|
||||
) : (
|
||||
<GlassButton
|
||||
variant="secondary"
|
||||
onPress={fetchQuestions}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Refresh Questions
|
||||
</GlassButton>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GradientBackground>
|
||||
@@ -771,31 +982,43 @@ export default function SwipeScreen() {
|
||||
entering={FadeIn.duration(400)}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={styles.progressContainer}>
|
||||
{/* Premium label */}
|
||||
<Text style={styles.progressLabel}>EXPLORE</Text>
|
||||
<Text style={styles.progressText}>
|
||||
{currentIndex + 1} of {effectiveTotal || questions.length}
|
||||
</Text>
|
||||
<View style={[styles.progressBar, styles.progressBarPremium]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
styles.progressFillPremium,
|
||||
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
|
||||
]}
|
||||
>
|
||||
{/* Shimmer sweep */}
|
||||
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(212, 175, 55, 0.5)', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.headerRow}>
|
||||
{/* Back button when playing specific pack or pending mode */}
|
||||
{(packId || mode === 'pending') && (
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<View style={styles.progressContainer}>
|
||||
{/* Premium label */}
|
||||
<Text style={styles.progressLabel}>
|
||||
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
|
||||
</Text>
|
||||
<Text style={styles.progressText}>
|
||||
{currentIndex + 1} of {effectiveTotal || questions.length}
|
||||
</Text>
|
||||
<View style={[styles.progressBar, styles.progressBarPremium]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
styles.progressFillPremium,
|
||||
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
|
||||
]}
|
||||
>
|
||||
{/* Shimmer sweep */}
|
||||
<Animated.View style={[styles.progressShimmer, shimmerStyle]}>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(212, 175, 55, 0.5)', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
{/* Spacer to balance header when back button is shown */}
|
||||
{(packId || mode === 'pending') && <View style={styles.headerSpacer} />}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -904,6 +1127,18 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backButton: {
|
||||
padding: spacing.sm,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40, // Match back button width for centering
|
||||
},
|
||||
progressContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user