diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx
index 18c0838..6ec5f4c 100755
--- a/apps/mobile/app/(app)/matches.tsx
+++ b/apps/mobile/app/(app)/matches.tsx
@@ -1,539 +1,663 @@
-import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
-import { useMatchStore, useAuthStore } from "../../src/store";
-import { useEffect, useCallback, useState } from "react";
-import { useFocusEffect } from "expo-router";
-import { Ionicons } from "@expo/vector-icons";
-import { useRouter } from "expo-router";
-import { LinearGradient } from "expo-linear-gradient";
-import Animated, {
- FadeIn,
- FadeInDown,
- FadeInRight,
- FadeInUp,
-} from "react-native-reanimated";
-import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
-import { useAmbientOrbAnimation } from "../../src/hooks";
-import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
-import { MatchesTutorial } from "../../src/components/tutorials";
-import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
-
-// Premium color palette
-const ACCENT = colors.premium.gold;
-const ACCENT_RGBA = 'rgba(212, 175, 55, ';
-const ROSE = colors.premium.rose;
-const ROSE_RGBA = 'rgba(232, 164, 174, ';
-
-const MAX_CONTENT_WIDTH = 500;
-
-export default function MatchesScreen() {
- const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
- const { user } = useAuthStore();
- const router = useRouter();
- const { width } = useWindowDimensions();
- const isWideScreen = width > MAX_CONTENT_WIDTH;
- const [showTutorial, setShowTutorial] = useState(false);
-
- // Ambient orb breathing animations
- const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
-
- // Check if tutorial should be shown when screen is focused or matches change
- useFocusEffect(
- useCallback(() => {
- const checkTutorial = async () => {
- if (matches.length > 0) {
- const seen = await hasSeenMatchesTutorial();
- if (!seen) {
- setShowTutorial(true);
- }
- }
- };
- checkTutorial();
- }, [matches.length])
- );
-
- const handleTutorialComplete = async () => {
- await markMatchesTutorialSeen();
- setShowTutorial(false);
- };
-
- useFocusEffect(
- useCallback(() => {
- fetchMatches();
- }, [])
- );
-
- useEffect(() => {
- if (matches.length > 0) {
- markAllAsSeen();
- }
- }, [matches.length]);
-
- const renderItem = ({ item, index }: { item: any; index: number }) => {
- const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
- const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
-
- let userText = item.question.text;
- let partnerText = item.question.partner_text;
-
- if (item.question.partner_text && userResponse && partnerResponse) {
- const userTime = new Date(userResponse.created_at).getTime();
- const partnerTime = new Date(partnerResponse.created_at).getTime();
-
- if (userTime > partnerTime) {
- userText = item.question.partner_text;
- partnerText = item.question.text;
- }
- }
-
- const isYesYes = item.match_type === "yes_yes";
-
- return (
-
- router.push(`/chat/${item.id}`)}
- activeOpacity={0.8}
- >
-
- {/* Subtle gradient background */}
-
- {/* Top silk highlight */}
-
-
-
- {/* Premium icon container */}
-
-
-
-
-
-
- {userText}
-
- {item.question.partner_text && (
-
- Partner: {partnerText}
-
- )}
-
-
-
- {isYesYes ? "YES + YES" : "YES + MAYBE"}
-
-
-
- {new Date(item.created_at).toLocaleDateString()}
-
-
-
-
-
- {item.unreadCount > 0 && (
-
-
-
- )}
-
-
-
-
-
-
- {/* Premium border */}
-
-
-
-
- );
- };
-
- if (!user) {
- return (
-
-
-
-
-
- );
- }
-
- return (
-
- {/* Ambient Orbs - Premium gold/rose */}
-
-
-
-
-
-
-
-
- {/* Premium Header */}
-
- {/* My Answers button */}
- router.push("/(app)/my-answers")}
- >
-
-
-
-
- {/* Premium label */}
- DISCOVER
- Matches
-
- {/* Decorative separator */}
-
-
- {/* Count badge */}
-
-
- {matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}
-
-
-
-
- item.id}
- contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
- showsVerticalScrollIndicator={false}
- refreshControl={
-
- }
- ListEmptyComponent={
-
- {/* Description */}
-
- Answer questions together to discover what you both enjoy. Matches appear when you agree!
-
-
- {/* Feature hints */}
-
-
-
- Swipe right for yes
-
-
-
- Match when you both agree
-
-
-
- Chat about your matches
-
-
-
- {/* Bottom teaser */}
- Your first match is just a swipe away
-
- router.push("/(app)/swipe")}
- style={{ marginTop: spacing.lg }}
- >
- Start Swiping
-
-
- }
- />
-
-
- {/* Matches Tutorial Overlay */}
- {showTutorial && (
-
- )}
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- loadingContainer: {
- flex: 1,
- justifyContent: "center",
- alignItems: "center",
- },
- // Ambient orbs
- ambientOrb: {
- position: 'absolute',
- width: 300,
- height: 300,
- borderRadius: 150,
- },
- orbTopRight: {
- top: 60,
- right: -40,
- },
- orbBottomLeft: {
- bottom: 180,
- left: -40,
- },
- orbGradient: {
- width: '100%',
- height: '100%',
- borderRadius: 150,
- },
- // Premium Header
- header: {
- paddingTop: 60,
- paddingHorizontal: spacing.lg,
- paddingBottom: spacing.md,
- },
- headerWide: {
- alignSelf: 'center',
- width: '100%',
- maxWidth: MAX_CONTENT_WIDTH,
- },
- myAnswersButton: {
- position: 'absolute',
- top: 60,
- right: spacing.md,
- zIndex: 10,
- width: 40,
- height: 40,
- borderRadius: 20,
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- borderWidth: 1,
- borderColor: `${ACCENT_RGBA}0.2)`,
- justifyContent: 'center',
- alignItems: 'center',
- },
- headerContent: {
- alignItems: 'center',
- },
- headerLabel: {
- ...typography.caption2,
- fontWeight: '600',
- letterSpacing: 3,
- color: ACCENT,
- marginBottom: spacing.xs,
- },
- headerTitle: {
- ...typography.largeTitle,
- color: colors.text,
- textAlign: 'center',
- },
- countBadgePremium: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radius.full,
- borderWidth: 1,
- borderColor: `${ACCENT_RGBA}0.2)`,
- gap: spacing.xs,
- },
- countTextPremium: {
- ...typography.caption2,
- fontWeight: '600',
- letterSpacing: 2,
- color: ACCENT,
- },
- // List
- list: {
- padding: spacing.lg,
- paddingTop: spacing.sm,
- paddingBottom: Platform.OS === 'ios' ? 120 : 100,
- },
- listWide: {
- alignSelf: 'center',
- width: '100%',
- maxWidth: MAX_CONTENT_WIDTH,
- },
- // Premium Match Card
- matchCardPremium: {
- marginBottom: spacing.sm,
- borderRadius: radius.lg,
- overflow: 'hidden',
- padding: spacing.md,
- },
- cardSilkHighlight: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- height: 60,
- },
- cardPremiumBorder: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- borderRadius: radius.lg,
- borderWidth: 1,
- borderColor: `${ACCENT_RGBA}0.15)`,
- },
- matchRow: {
- flexDirection: "row",
- alignItems: "center",
- },
- iconContainerPremium: {
- width: 44,
- height: 44,
- borderRadius: 22,
- backgroundColor: `${ROSE_RGBA}0.1)`,
- borderWidth: 1,
- borderColor: `${ROSE_RGBA}0.2)`,
- justifyContent: "center",
- alignItems: "center",
- marginRight: spacing.md,
- },
- iconContainerYesYes: {
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- borderColor: `${ACCENT_RGBA}0.2)`,
- },
- content: {
- flex: 1,
- },
- questionTextPremium: {
- ...typography.subhead,
- color: colors.text,
- fontWeight: "600",
- marginBottom: spacing.xs,
- lineHeight: 20,
- },
- partnerTextPremium: {
- ...typography.caption1,
- color: colors.textSecondary,
- fontStyle: "italic",
- marginBottom: spacing.sm,
- },
- metaRow: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "space-between",
- },
- tagPremium: {
- backgroundColor: `${ROSE_RGBA}0.1)`,
- paddingHorizontal: spacing.sm,
- paddingVertical: 3,
- borderRadius: radius.sm,
- borderWidth: 1,
- borderColor: `${ROSE_RGBA}0.15)`,
- },
- tagPremiumHighlight: {
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- borderColor: `${ACCENT_RGBA}0.2)`,
- },
- tagTextPremium: {
- ...typography.caption2,
- color: ROSE,
- fontWeight: "600",
- letterSpacing: 1,
- },
- tagTextPremiumHighlight: {
- color: ACCENT,
- },
- datePremium: {
- ...typography.caption2,
- color: colors.textTertiary,
- fontStyle: 'italic',
- },
- rightSection: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm,
- marginLeft: spacing.sm,
- },
- unreadBadgePremium: {
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: ACCENT,
- justifyContent: 'center',
- alignItems: 'center',
- },
- chevronContainerPremium: {
- width: 28,
- height: 28,
- borderRadius: 14,
- backgroundColor: `${ACCENT_RGBA}0.08)`,
- borderWidth: 1,
- borderColor: `${ACCENT_RGBA}0.15)`,
- justifyContent: 'center',
- alignItems: 'center',
- },
- // Premium Empty State
- emptyContent: {
- width: '100%',
- alignItems: 'center',
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xl,
- },
- emptyDescription: {
- ...typography.body,
- color: colors.textSecondary,
- textAlign: 'center',
- marginBottom: spacing.xl,
- paddingHorizontal: spacing.md,
- },
- emptyFeatures: {
- marginBottom: spacing.xl,
- },
- emptyFeatureItem: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: spacing.sm,
- },
- emptyFeatureText: {
- ...typography.subhead,
- color: colors.text,
- marginLeft: spacing.sm,
- },
- emptyTeaser: {
- ...typography.footnote,
- fontStyle: 'italic',
- color: colors.textTertiary,
- textAlign: 'center',
- },
-});
+import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
+import { useMatchStore, useAuthStore } from "../../src/store";
+import { useEffect, useCallback, useState } from "react";
+import { useFocusEffect } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { LinearGradient } from "expo-linear-gradient";
+import Animated, {
+ FadeIn,
+ FadeInDown,
+ FadeInRight,
+ FadeInUp,
+ useSharedValue,
+ useAnimatedScrollHandler,
+ useAnimatedStyle,
+ interpolate,
+ Extrapolation,
+} from "react-native-reanimated";
+import { BlurView } from "expo-blur";
+import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
+import { useAmbientOrbAnimation } from "../../src/hooks";
+import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
+import { MatchesTutorial } from "../../src/components/tutorials";
+import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
+
+// Premium color palette
+const ACCENT = colors.premium.gold;
+const ACCENT_RGBA = 'rgba(212, 175, 55, ';
+const ROSE = colors.premium.rose;
+const ROSE_RGBA = 'rgba(232, 164, 174, ';
+
+const MAX_CONTENT_WIDTH = 500;
+const NAV_BAR_HEIGHT = 44;
+const STATUS_BAR_HEIGHT = 60;
+const HEADER_SCROLL_DISTANCE = 100;
+
+const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
+
+export default function MatchesScreen() {
+ const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
+ const { user } = useAuthStore();
+ const router = useRouter();
+ const { width } = useWindowDimensions();
+ const isWideScreen = width > MAX_CONTENT_WIDTH;
+ const [showTutorial, setShowTutorial] = useState(false);
+
+ const scrollY = useSharedValue(0);
+
+ const scrollHandler = useAnimatedScrollHandler({
+ onScroll: (event) => {
+ scrollY.value = event.contentOffset.y;
+ },
+ });
+
+ // Animated styles for collapsing header
+ const heroStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.7],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ const scale = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE],
+ [1, 0.95],
+ Extrapolation.CLAMP
+ );
+ return { opacity, transform: [{ scale }] };
+ });
+
+ const compactHeaderStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
+ const navBarBackgroundStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.8],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
+ // Ambient orb breathing animations
+ const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
+
+ // Check if tutorial should be shown when screen is focused or matches change
+ useFocusEffect(
+ useCallback(() => {
+ const checkTutorial = async () => {
+ if (matches.length > 0) {
+ const seen = await hasSeenMatchesTutorial();
+ if (!seen) {
+ setShowTutorial(true);
+ }
+ }
+ };
+ checkTutorial();
+ }, [matches.length])
+ );
+
+ const handleTutorialComplete = async () => {
+ await markMatchesTutorialSeen();
+ setShowTutorial(false);
+ };
+
+ useFocusEffect(
+ useCallback(() => {
+ fetchMatches(true);
+ }, [])
+ );
+
+ const handleLoadMore = useCallback(() => {
+ console.log('[Matches] onEndReached called', { isLoading, isLoadingMore, hasMore, matchesCount: matches.length });
+ if (!isLoading && !isLoadingMore && hasMore) {
+ fetchMatches(false);
+ }
+ }, [isLoading, isLoadingMore, hasMore, matches.length]);
+
+ useEffect(() => {
+ if (matches.length > 0) {
+ markAllAsSeen();
+ }
+ }, [matches.length]);
+
+ const renderItem = ({ item, index }: { item: any; index: number }) => {
+ const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
+ const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
+
+ let userText = item.question.text;
+ let partnerText = item.question.partner_text;
+
+ if (item.question.partner_text && userResponse && partnerResponse) {
+ const userTime = new Date(userResponse.created_at).getTime();
+ const partnerTime = new Date(partnerResponse.created_at).getTime();
+
+ if (userTime > partnerTime) {
+ userText = item.question.partner_text;
+ partnerText = item.question.text;
+ }
+ }
+
+ const isYesYes = item.match_type === "yes_yes";
+
+ return (
+
+ router.push(`/chat/${item.id}`)}
+ activeOpacity={0.8}
+ >
+
+ {/* Subtle gradient background */}
+
+ {/* Top silk highlight */}
+
+
+
+ {/* Premium icon container */}
+
+
+
+
+
+
+ {userText}
+
+ {item.question.partner_text && (
+
+ Partner: {partnerText}
+
+ )}
+
+
+
+ {isYesYes ? "YES + YES" : "YES + MAYBE"}
+
+
+
+ {new Date(item.created_at).toLocaleDateString()}
+
+
+
+
+
+ {item.unreadCount > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+ {/* Premium border */}
+
+
+
+
+ );
+ };
+
+ const renderFooter = () => {
+ if (!isLoadingMore) return null;
+ return (
+
+
+
+ );
+ };
+
+ if (!user) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Ambient Orbs - Premium gold/rose */}
+
+
+
+
+
+
+
+
+ {/* Fixed Nav Bar */}
+
+
+ {Platform.OS === "ios" ? (
+
+ ) : (
+
+ )}
+
+
+
+ Matches
+
+
+ {/* My Answers button moved to Nav Bar */}
+ router.push({ pathname: "/(app)/my-answers", params: { returnTo: "/(app)/matches" } })}
+ >
+
+
+
+
+ item.id}
+ contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
+ showsVerticalScrollIndicator={false}
+ onScroll={scrollHandler}
+ scrollEventThrottle={16}
+ refreshControl={
+ fetchMatches(true)}
+ tintColor={colors.primary}
+ colors={[colors.primary]}
+ progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
+ />
+ }
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={2}
+ ListFooterComponent={renderFooter}
+ ListHeaderComponent={
+
+
+ {/* Premium label */}
+ DISCOVER
+ Matches
+
+ {/* Decorative separator */}
+
+
+ {/* Count badge */}
+
+
+ {totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}
+
+
+
+ }
+ ListEmptyComponent={
+
+ {/* Description */}
+
+ Answer questions together to discover what you both enjoy. Matches appear when you agree!
+
+
+ {/* Feature hints */}
+
+
+
+ Swipe right for yes
+
+
+
+ Match when you both agree
+
+
+
+ Chat about your matches
+
+
+
+ {/* Bottom teaser */}
+ Your first match is just a swipe away
+
+ router.push("/(app)/swipe")}
+ style={{ marginTop: spacing.lg }}
+ >
+ Start Swiping
+
+
+ }
+ />
+
+
+ {/* Matches Tutorial Overlay */}
+ {showTutorial && (
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ // Ambient orbs
+ ambientOrb: {
+ position: 'absolute',
+ width: 300,
+ height: 300,
+ borderRadius: 150,
+ },
+ orbTopRight: {
+ top: 60,
+ right: -40,
+ },
+ orbBottomLeft: {
+ bottom: 180,
+ left: -40,
+ },
+ orbGradient: {
+ width: '100%',
+ height: '100%',
+ borderRadius: 150,
+ },
+ // Fixed Nav Bar
+ navBar: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: STATUS_BAR_HEIGHT - 10,
+ paddingHorizontal: spacing.md,
+ zIndex: 100,
+ },
+ navBarBackground: {
+ ...StyleSheet.absoluteFillObject,
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint for matches
+ overflow: "hidden",
+ },
+ navBarTitle: {
+ ...typography.headline,
+ color: colors.text,
+ textAlign: "center",
+ },
+ navBarButton: {
+ position: 'absolute',
+ right: spacing.md,
+ top: STATUS_BAR_HEIGHT - 5,
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(255, 255, 255, 0.1)',
+ },
+ // Header (Hero)
+ header: {
+ paddingTop: STATUS_BAR_HEIGHT + spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.lg,
+ alignItems: 'center',
+ },
+ headerWide: {
+ alignSelf: 'center',
+ width: '100%',
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ headerContent: {
+ alignItems: 'center',
+ },
+ headerLabel: {
+ ...typography.caption2,
+ fontWeight: '600',
+ letterSpacing: 3,
+ color: ACCENT,
+ marginBottom: spacing.xs,
+ },
+ headerTitle: {
+ ...typography.largeTitle,
+ color: colors.text,
+ textAlign: 'center',
+ },
+ countBadgePremium: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: `${ACCENT_RGBA}0.2)`,
+ gap: spacing.xs,
+ },
+ countTextPremium: {
+ ...typography.caption2,
+ fontWeight: '600',
+ letterSpacing: 2,
+ color: ACCENT,
+ },
+ // List
+ list: {
+ padding: spacing.lg,
+ // Remove top padding as header handles it
+ paddingTop: 0,
+ paddingBottom: Platform.OS === 'ios' ? 120 : 100,
+ },
+ listWide: {
+ alignSelf: 'center',
+ width: '100%',
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ // Premium Match Card
+ matchCardPremium: {
+ marginBottom: spacing.sm,
+ borderRadius: radius.lg,
+ overflow: 'hidden',
+ padding: spacing.md,
+ },
+ cardSilkHighlight: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ height: 60,
+ },
+ cardPremiumBorder: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: radius.lg,
+ borderWidth: 1,
+ borderColor: `${ACCENT_RGBA}0.15)`,
+ },
+ matchRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ iconContainerPremium: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ backgroundColor: `${ROSE_RGBA}0.1)`,
+ borderWidth: 1,
+ borderColor: `${ROSE_RGBA}0.2)`,
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: spacing.md,
+ },
+ iconContainerYesYes: {
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ borderColor: `${ACCENT_RGBA}0.2)`,
+ },
+ content: {
+ flex: 1,
+ },
+ questionTextPremium: {
+ ...typography.subhead,
+ color: colors.text,
+ fontWeight: "600",
+ marginBottom: spacing.xs,
+ lineHeight: 20,
+ },
+ partnerTextPremium: {
+ ...typography.caption1,
+ color: colors.textSecondary,
+ fontStyle: "italic",
+ marginBottom: spacing.sm,
+ },
+ metaRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ tagPremium: {
+ backgroundColor: `${ROSE_RGBA}0.1)`,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: 3,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: `${ROSE_RGBA}0.15)`,
+ },
+ tagPremiumHighlight: {
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ borderColor: `${ACCENT_RGBA}0.2)`,
+ },
+ tagTextPremium: {
+ ...typography.caption2,
+ color: ROSE,
+ fontWeight: "600",
+ letterSpacing: 1,
+ },
+ tagTextPremiumHighlight: {
+ color: ACCENT,
+ },
+ datePremium: {
+ ...typography.caption2,
+ color: colors.textTertiary,
+ fontStyle: 'italic',
+ },
+ rightSection: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ marginLeft: spacing.sm,
+ },
+ unreadBadgePremium: {
+ width: 22,
+ height: 22,
+ borderRadius: 11,
+ backgroundColor: ACCENT,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ chevronContainerPremium: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: `${ACCENT_RGBA}0.08)`,
+ borderWidth: 1,
+ borderColor: `${ACCENT_RGBA}0.15)`,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ // Premium Empty State
+ emptyContent: {
+ width: '100%',
+ alignItems: 'center',
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xl,
+ },
+ emptyDescription: {
+ ...typography.body,
+ color: colors.textSecondary,
+ textAlign: 'center',
+ marginBottom: spacing.xl,
+ paddingHorizontal: spacing.md,
+ },
+ emptyFeatures: {
+ marginBottom: spacing.xl,
+ },
+ emptyFeatureItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: spacing.sm,
+ },
+ emptyFeatureText: {
+ ...typography.subhead,
+ color: colors.text,
+ marginLeft: spacing.sm,
+ },
+ emptyTeaser: {
+ ...typography.footnote,
+ fontStyle: 'italic',
+ color: colors.textTertiary,
+ textAlign: 'center',
+ },
+ footerLoader: {
+ paddingVertical: spacing.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/app/(app)/my-answers.tsx b/apps/mobile/app/(app)/my-answers.tsx
index eed4372..e424e8e 100644
--- a/apps/mobile/app/(app)/my-answers.tsx
+++ b/apps/mobile/app/(app)/my-answers.tsx
@@ -1,386 +1,518 @@
-import {
- View,
- Text,
- StyleSheet,
- SectionList,
- RefreshControl,
- TouchableOpacity,
- ActivityIndicator,
- Platform,
- useWindowDimensions,
-} from "react-native";
-import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
-import type { ResponseWithQuestion } from "../../src/store";
-import { useCallback, useState } from "react";
-import { useFocusEffect, useRouter } from "expo-router";
-import { Ionicons } from "@expo/vector-icons";
-import { LinearGradient } from "expo-linear-gradient";
-import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
-import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
-import { useAmbientOrbAnimation } from "../../src/hooks";
-import { colors, spacing, typography, radius } from "../../src/theme";
-import { ResponseCard } from "../../src/components/responses/ResponseCard";
-import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
-
-// Premium color palette
-const ACCENT = colors.premium.gold;
-const ACCENT_RGBA = "rgba(212, 175, 55, ";
-const ROSE = colors.premium.rose;
-const ROSE_RGBA = "rgba(232, 164, 174, ";
-
-const MAX_CONTENT_WIDTH = 500;
-
-type GroupByOption = "pack" | "date" | "answer";
-
-export default function MyAnswersScreen() {
- const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
- const { user } = useAuthStore();
- const router = useRouter();
- const { width } = useWindowDimensions();
- const isWideScreen = width > MAX_CONTENT_WIDTH;
-
- // State for edit sheet
- const [selectedResponse, setSelectedResponse] = useState(null);
- const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
-
- // Ambient orb breathing animations
- const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
-
- // Fetch responses on focus
- useFocusEffect(
- useCallback(() => {
- fetchResponses();
- }, [])
- );
-
- const sections = groupResponses(responses, groupBy);
-
- const handleEditPress = (response: ResponseWithQuestion) => {
- setSelectedResponse(response);
- setIsEditSheetVisible(true);
- };
-
- const handleEditClose = () => {
- setIsEditSheetVisible(false);
- setSelectedResponse(null);
- };
-
- const handleEditSuccess = () => {
- // Refetch responses to get updated data
- fetchResponses();
- };
-
- const renderSectionHeader = ({ section }: { section: { title: string } }) => (
-
- {section.title}
-
- );
-
- const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
- handleEditPress(item)}
- onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
- />
- );
-
- const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
- setGroupBy(option)}
- >
-
- {label}
-
-
- );
-
- if (!user) {
- return (
-
-
-
-
-
- );
- }
-
- return (
-
- {/* Ambient Orbs */}
-
-
-
-
-
-
-
-
- {/* Header */}
-
- {/* Back button */}
- router.back()}>
-
-
-
-
- REVIEW
- My Answers
-
-
- {/* Count badge */}
-
-
-
- {responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
-
-
-
-
- {/* Group by buttons */}
-
-
-
-
-
-
-
- {isLoading && responses.length === 0 ? (
-
-
-
- ) : (
- item.id}
- contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
- showsVerticalScrollIndicator={false}
- stickySectionHeadersEnabled={false}
- refreshControl={
-
- }
- ListEmptyComponent={
-
-
-
-
- No Answers Yet
-
- Start swiping on questions to build your answer history. You can
- come back here to review and change your answers anytime.
-
- router.push("/(app)/swipe")}
- style={{ marginTop: spacing.lg }}
- >
- Start Swiping
-
-
- }
- />
- )}
-
-
- {/* Edit Response Sheet */}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- loadingContainer: {
- flex: 1,
- justifyContent: "center",
- alignItems: "center",
- },
- // Ambient orbs
- ambientOrb: {
- position: "absolute",
- width: 300,
- height: 300,
- borderRadius: 150,
- },
- orbTopRight: {
- top: 60,
- right: -40,
- },
- orbBottomLeft: {
- bottom: 180,
- left: -40,
- },
- orbGradient: {
- width: "100%",
- height: "100%",
- borderRadius: 150,
- },
- // Header
- header: {
- paddingTop: 60,
- paddingHorizontal: spacing.lg,
- paddingBottom: spacing.md,
- },
- headerWide: {
- alignSelf: "center",
- width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
- },
- backButton: {
- position: "absolute",
- top: 60,
- left: spacing.md,
- zIndex: 10,
- width: 40,
- height: 40,
- borderRadius: 20,
- backgroundColor: "rgba(255, 255, 255, 0.1)",
- justifyContent: "center",
- alignItems: "center",
- },
- headerContent: {
- alignItems: "center",
- },
- headerLabel: {
- ...typography.caption2,
- fontWeight: "600",
- letterSpacing: 3,
- color: ACCENT,
- marginBottom: spacing.xs,
- },
- headerTitle: {
- ...typography.largeTitle,
- color: colors.text,
- textAlign: "center",
- },
- countBadgePremium: {
- flexDirection: "row",
- alignItems: "center",
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radius.full,
- borderWidth: 1,
- borderColor: `${ACCENT_RGBA}0.2)`,
- gap: spacing.xs,
- },
- countTextPremium: {
- ...typography.caption2,
- fontWeight: "600",
- letterSpacing: 2,
- color: ACCENT,
- },
- // Group by buttons
- groupByContainer: {
- flexDirection: "row",
- justifyContent: "center",
- gap: spacing.sm,
- marginTop: spacing.md,
- },
- groupByButton: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radius.full,
- backgroundColor: "rgba(255, 255, 255, 0.05)",
- borderWidth: 1,
- borderColor: "rgba(255, 255, 255, 0.1)",
- },
- groupByButtonActive: {
- backgroundColor: `${ACCENT_RGBA}0.15)`,
- borderColor: `${ACCENT_RGBA}0.3)`,
- },
- groupByText: {
- ...typography.caption1,
- color: colors.textSecondary,
- fontWeight: "500",
- },
- groupByTextActive: {
- color: ACCENT,
- fontWeight: "600",
- },
- // Section header
- sectionHeader: {
- paddingVertical: spacing.sm,
- paddingHorizontal: spacing.xs,
- marginTop: spacing.md,
- marginBottom: spacing.xs,
- },
- sectionTitle: {
- ...typography.title3,
- color: colors.text,
- },
- // List
- list: {
- padding: spacing.lg,
- paddingTop: spacing.sm,
- paddingBottom: Platform.OS === "ios" ? 120 : 100,
- },
- listWide: {
- alignSelf: "center",
- width: "100%",
- maxWidth: MAX_CONTENT_WIDTH,
- },
- // Empty state
- emptyContent: {
- width: "100%",
- alignItems: "center",
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xxl,
- },
- emptyIcon: {
- width: 80,
- height: 80,
- borderRadius: 40,
- backgroundColor: `${ACCENT_RGBA}0.1)`,
- justifyContent: "center",
- alignItems: "center",
- marginBottom: spacing.lg,
- },
- emptyTitle: {
- ...typography.title2,
- color: colors.text,
- marginBottom: spacing.sm,
- },
- emptyDescription: {
- ...typography.body,
- color: colors.textSecondary,
- textAlign: "center",
- paddingHorizontal: spacing.md,
- },
-});
+import {
+ View,
+ Text,
+ StyleSheet,
+ SectionList,
+ RefreshControl,
+ TouchableOpacity,
+ ActivityIndicator,
+ Platform,
+ useWindowDimensions,
+} from "react-native";
+import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
+import type { ResponseWithQuestion } from "../../src/store";
+import { useCallback, useState, useEffect } from "react";
+import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
+import { LinearGradient } from "expo-linear-gradient";
+import Animated, {
+ FadeIn,
+ FadeInUp,
+ useSharedValue,
+ useAnimatedScrollHandler,
+ useAnimatedStyle,
+ interpolate,
+ Extrapolation,
+} from "react-native-reanimated";
+import { BlurView } from "expo-blur";
+import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
+import { useAmbientOrbAnimation } from "../../src/hooks";
+import { colors, spacing, typography, radius } from "../../src/theme";
+import { ResponseCard } from "../../src/components/responses/ResponseCard";
+import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
+
+// Premium color palette
+const ACCENT = colors.premium.gold;
+const ACCENT_RGBA = "rgba(212, 175, 55, ";
+const ROSE = colors.premium.rose;
+const ROSE_RGBA = "rgba(232, 164, 174, ";
+
+const MAX_CONTENT_WIDTH = 500;
+const NAV_BAR_HEIGHT = 44;
+const STATUS_BAR_HEIGHT = 60;
+const HEADER_SCROLL_DISTANCE = 100;
+
+const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
+
+type GroupByOption = "pack" | "date" | "answer";
+
+export default function MyAnswersScreen() {
+ const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
+ const { user } = useAuthStore();
+ const router = useRouter();
+ const params = useLocalSearchParams();
+ const { width } = useWindowDimensions();
+ const isWideScreen = width > MAX_CONTENT_WIDTH;
+
+ const handleBack = () => {
+ const returnTo = (params.returnTo as string) || "/(app)/swipe";
+ router.push(returnTo as any);
+ };
+
+ // State for edit sheet
+ const [selectedResponse, setSelectedResponse] = useState(null);
+ const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
+
+ // Scroll animation
+ const scrollY = useSharedValue(0);
+ const scrollHandler = useAnimatedScrollHandler({
+ onScroll: (event) => {
+ scrollY.value = event.contentOffset.y;
+ },
+ });
+
+ const heroStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.7],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ const scale = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE],
+ [1, 0.95],
+ Extrapolation.CLAMP
+ );
+ return { opacity, transform: [{ scale }] };
+ });
+
+ const compactHeaderStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
+ const navBarBackgroundStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.8],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
+ // Ambient orb breathing animations
+ const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
+
+ // Fetch responses on focus
+ useFocusEffect(
+ useCallback(() => {
+ fetchResponses(true);
+ }, [])
+ );
+
+ const handleLoadMore = useCallback(() => {
+ if (!isLoading && !isLoadingMore && hasMore) {
+ fetchResponses(false);
+ }
+ }, [isLoading, isLoadingMore, hasMore]);
+
+ const sections = groupResponses(responses, groupBy);
+
+ const handleEditPress = (response: ResponseWithQuestion) => {
+ setSelectedResponse(response);
+ setIsEditSheetVisible(true);
+ };
+
+ const handleEditClose = () => {
+ setIsEditSheetVisible(false);
+ setSelectedResponse(null);
+ };
+
+ const handleEditSuccess = () => {
+ // Refetch responses to get updated data
+ fetchResponses(true);
+ };
+
+ const renderSectionHeader = ({ section }: { section: any }) => (
+
+ {section.title}
+
+ );
+
+ const renderItem = ({ item, index }: { item: any; index: number }) => (
+ handleEditPress(item)}
+ onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
+ />
+ );
+
+ const renderFooter = () => {
+ if (!isLoadingMore) return null;
+ return (
+
+
+
+ );
+ };
+
+ const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
+ setGroupBy(option)}
+ >
+
+ {label}
+
+
+ );
+
+ if (!user) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Ambient Orbs */}
+
+
+
+
+
+
+
+
+ {/* Fixed Nav Bar */}
+
+
+ {Platform.OS === "ios" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ My Answers
+
+
+
+ {isLoading && responses.length === 0 ? (
+
+
+
+ ) : (
+ item.id}
+ contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
+ showsVerticalScrollIndicator={false}
+ stickySectionHeadersEnabled={false}
+ onScroll={scrollHandler}
+ scrollEventThrottle={16}
+ refreshControl={
+ fetchResponses(true)}
+ tintColor={colors.primary}
+ colors={[colors.primary]}
+ progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
+ />
+ }
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={2}
+ ListFooterComponent={renderFooter}
+ ListHeaderComponent={
+
+
+ REVIEW
+ My Answers
+
+
+ {/* Count badge */}
+
+
+
+ {totalCount ?? responses.length} {(totalCount ?? responses.length) === 1 ? "ANSWER" : "ANSWERS"}
+
+
+
+
+ {/* Group by buttons */}
+
+
+
+
+
+
+ }
+ ListEmptyComponent={
+
+
+
+
+ No Answers Yet
+
+ Start swiping on questions to build your answer history. You can
+ come back here to review and change your answers anytime.
+
+ router.push("/(app)/swipe")}
+ style={{ marginTop: spacing.lg }}
+ >
+ Start Swiping
+
+
+ }
+ />
+ )}
+
+
+ {/* Edit Response Sheet */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ // Ambient orbs
+ ambientOrb: {
+ position: "absolute",
+ width: 300,
+ height: 300,
+ borderRadius: 150,
+ },
+ orbTopRight: {
+ top: 60,
+ right: -40,
+ },
+ orbBottomLeft: {
+ bottom: 180,
+ left: -40,
+ },
+ orbGradient: {
+ width: "100%",
+ height: "100%",
+ borderRadius: 150,
+ },
+ // Fixed Nav Bar
+ navBar: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: STATUS_BAR_HEIGHT - 10,
+ paddingHorizontal: spacing.md,
+ zIndex: 100,
+ },
+ navBarBackground: {
+ ...StyleSheet.absoluteFillObject,
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
+ overflow: "hidden",
+ },
+ navBarBackButton: {
+ position: "absolute",
+ top: STATUS_BAR_HEIGHT - 5,
+ left: spacing.md,
+ zIndex: 10,
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
+ justifyContent: "center",
+ alignItems: "center",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ },
+ navBarTitle: {
+ ...typography.headline,
+ color: colors.text,
+ textAlign: "center",
+ },
+ // Header (Hero)
+ header: {
+ paddingTop: STATUS_BAR_HEIGHT + spacing.md,
+ paddingHorizontal: spacing.lg,
+ paddingBottom: spacing.md,
+ alignItems: 'center',
+ },
+ headerWide: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ headerContent: {
+ alignItems: "center",
+ },
+ headerLabel: {
+ ...typography.caption2,
+ fontWeight: "600",
+ letterSpacing: 3,
+ color: ACCENT,
+ marginBottom: spacing.xs,
+ },
+ headerTitle: {
+ ...typography.largeTitle,
+ color: colors.text,
+ textAlign: "center",
+ },
+ countBadgePremium: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.full,
+ borderWidth: 1,
+ borderColor: `${ACCENT_RGBA}0.2)`,
+ gap: spacing.xs,
+ },
+ countTextPremium: {
+ ...typography.caption2,
+ fontWeight: "600",
+ letterSpacing: 2,
+ color: ACCENT,
+ },
+ // Group by buttons
+ groupByContainer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: spacing.sm,
+ marginTop: spacing.md,
+ },
+ groupByButton: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.full,
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ },
+ groupByButtonActive: {
+ backgroundColor: `${ACCENT_RGBA}0.15)`,
+ borderColor: `${ACCENT_RGBA}0.3)`,
+ },
+ groupByText: {
+ ...typography.caption1,
+ color: colors.textSecondary,
+ fontWeight: "500",
+ },
+ groupByTextActive: {
+ color: ACCENT,
+ fontWeight: "600",
+ },
+ // Section header
+ sectionHeader: {
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.xs,
+ marginTop: spacing.md,
+ marginBottom: spacing.xs,
+ },
+ sectionTitle: {
+ ...typography.title3,
+ color: colors.text,
+ },
+ // List
+ list: {
+ padding: spacing.lg,
+ paddingTop: 0, // Header handles top spacing
+ paddingBottom: Platform.OS === "ios" ? 120 : 100,
+ },
+ listWide: {
+ alignSelf: "center",
+ width: "100%",
+ maxWidth: MAX_CONTENT_WIDTH,
+ },
+ // Empty state
+ emptyContent: {
+ width: "100%",
+ alignItems: "center",
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xxl,
+ },
+ emptyIcon: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: `${ACCENT_RGBA}0.1)`,
+ justifyContent: "center",
+ alignItems: "center",
+ marginBottom: spacing.lg,
+ },
+ emptyTitle: {
+ ...typography.title2,
+ color: colors.text,
+ marginBottom: spacing.sm,
+ },
+ emptyDescription: {
+ ...typography.body,
+ color: colors.textSecondary,
+ textAlign: "center",
+ paddingHorizontal: spacing.md,
+ },
+ footerLoader: {
+ paddingVertical: spacing.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx
index 352e35f..6077a83 100755
--- a/apps/mobile/app/(app)/swipe.tsx
+++ b/apps/mobile/app/(app)/swipe.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
-import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
+import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
@@ -186,15 +186,6 @@ export default function SwipeScreen() {
}
}, [enabledPackIds]);
- // Fisher-Yates shuffle for stable randomization
- const shuffleArray = (array: T[]): T[] => {
- const shuffled = [...array];
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- return shuffled;
- };
const fetchQuestions = async () => {
// Race protection: increment request ID and track current request
@@ -231,26 +222,10 @@ export default function SwipeScreen() {
setGapInfo(null);
}
- // First shuffle to randomize, then stable sort by priority scores
- const shuffled = shuffleArray(filtered);
- const sorted = shuffled.sort((a: any, b: any) => {
- let scoreA = 0;
- let scoreB = 0;
-
- if (a.partner_answered && a.is_two_part) scoreA += 1.5;
- else if (a.partner_answered) scoreA += 0.7;
- else if (a.is_two_part) scoreA += 0.4;
-
- if (b.partner_answered && b.is_two_part) scoreB += 1.5;
- else if (b.partner_answered) scoreB += 0.7;
- else if (b.is_two_part) scoreB += 0.4;
-
- return scoreB - scoreA;
- });
-
+ // Server provides ordering (daily seeded random with priority)
// Only update state if still the current request
if (currentRequestId === fetchRequestId.current) {
- setQuestions(sorted);
+ setQuestions(filtered);
}
} catch (error) {
if (currentRequestId === fetchRequestId.current) {
@@ -655,6 +630,14 @@ export default function SwipeScreen() {
style={styles.hintContainer}
>
Swipe or tap to answer
+
+ router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
+ style={styles.editLinkButton}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+ Edit previous answers
+
{/* Bottom spacing for tab bar */}
@@ -799,6 +782,18 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
letterSpacing: 0.5,
},
+ editLinkButton: {
+ marginTop: spacing.md,
+ paddingVertical: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ },
+ editLinkText: {
+ ...typography.caption2,
+ color: colors.premium.gold,
+ textDecorationLine: 'underline',
+ opacity: 0.8,
+ letterSpacing: 0.5,
+ },
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},
diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts
index 19331fe..95678a7 100644
--- a/apps/mobile/src/__tests__/stores/matchStore.test.ts
+++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts
@@ -10,6 +10,7 @@ function createThenableQuery(result: any) {
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
+ range: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx
index 7a6ef35..3ba95a2 100644
--- a/apps/mobile/src/features/profile/ProfileScreen.tsx
+++ b/apps/mobile/src/features/profile/ProfileScreen.tsx
@@ -1,8 +1,16 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
-import Animated, { FadeInDown } from 'react-native-reanimated';
+import Animated, {
+ FadeInDown,
+ useSharedValue,
+ useAnimatedScrollHandler,
+ useAnimatedStyle,
+ interpolate,
+ Extrapolation
+} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
+import { BlurView } from 'expo-blur';
import { GradientBackground, GlassCard } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
@@ -22,6 +30,11 @@ import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings
const MAX_CONTENT_WIDTH = 500;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
+const NAV_BAR_HEIGHT = 44;
+const STATUS_BAR_HEIGHT = 60;
+const HEADER_SCROLL_DISTANCE = 100;
+
+const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
export function ProfileScreen() {
const { width } = useWindowDimensions();
@@ -56,20 +69,81 @@ export function ProfileScreen() {
});
};
+ // Scroll animation
+ const scrollY = useSharedValue(0);
+ const scrollHandler = useAnimatedScrollHandler({
+ onScroll: (event) => {
+ scrollY.value = event.contentOffset.y;
+ },
+ });
+
+ const heroStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.7],
+ [1, 0],
+ Extrapolation.CLAMP
+ );
+ const scale = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE],
+ [1, 0.95],
+ Extrapolation.CLAMP
+ );
+ return { opacity, transform: [{ scale }] };
+ });
+
+ const compactHeaderStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
+ const navBarBackgroundStyle = useAnimatedStyle(() => {
+ const opacity = interpolate(
+ scrollY.value,
+ [0, HEADER_SCROLL_DISTANCE * 0.8],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ return { opacity };
+ });
+
return (
-
+
+ {Platform.OS === "ios" ? (
+
+ ) : (
+
+ )}
+
+
+
+ Settings
+
+
+
+
{/* Header title */}
YOUR SAUCI
Settings
@@ -305,7 +379,7 @@ export function ProfileScreen() {
-
+
{/* Modals */}
Promise;
- markAsSeen: (matchId: string) => Promise;
- markAllAsSeen: () => Promise;
- addMatch: (match: Match) => void;
- updateMatchUnreadCount: (matchId: string, delta: number) => void;
- clearMatches: () => void;
-}
-
-export const useMatchStore = create((set, get) => ({
- matches: [],
- newMatchesCount: 0,
- isLoading: false,
- error: null,
-
- fetchMatches: async () => {
- const userId = useAuthStore.getState().user?.id;
- const coupleId = useAuthStore.getState().user?.couple_id;
-
- // Early return if no couple - user isn't paired yet
- if (!coupleId) {
- set({ matches: [], newMatchesCount: 0, isLoading: false });
- return;
- }
-
- set({ isLoading: true, error: null });
-
- try {
- const { data: matches, error: matchError } = await supabase
- .from("matches")
- .select(`
- *,
- question:questions(*)
- `)
- .eq("couple_id", coupleId)
- .order("created_at", { ascending: false });
-
- if (matchError) throw matchError;
- if (!matches) {
- set({ matches: [], newMatchesCount: 0, isLoading: false });
- return;
- }
-
- // Fetch all responses for these questions to determine who answered first
- const questionIds = matches.map(m => m.question_id);
- let responses: any[] = [];
- if (questionIds.length > 0) {
- const { data } = await supabase
- .from("responses")
- .select("*")
- .in("question_id", questionIds);
- responses = data ?? [];
- }
-
- // Fetch unread message counts per match
- const matchIds = matches.map(m => m.id);
- let unreadCounts: Record = {};
-
- if (userId && matchIds.length > 0) {
- const { data: unreadMessages, error } = await supabase
- .from("messages")
- .select("match_id")
- .in("match_id", matchIds)
- .neq("user_id", userId)
- .is("read_at", null);
-
- if (error) {
- console.error("Error fetching unread messages:", error);
- }
-
- // Count unread messages per match
- unreadMessages?.forEach(msg => {
- unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
- });
- }
-
- const data = matches.map(match => ({
- ...match,
- responses: responses.filter(r => r.question_id === match.question_id),
- unreadCount: unreadCounts[match.id] || 0
- }));
-
- // Sort: unread messages first, then by most recent
- const sortedData = data.sort((a, b) => {
- // First priority: matches with unread messages
- if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
- if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
-
- // Second priority: most recent first
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
- });
-
- const newCount = sortedData.filter((m) => m.is_new).length;
- set({ matches: sortedData, newMatchesCount: newCount, isLoading: false });
- } catch (err) {
- console.error("Error fetching matches:", err);
- set({ error: "Failed to load matches", isLoading: false });
- }
- },
-
- markAsSeen: async (matchId) => {
- await supabase
- .from("matches")
- .update({ is_new: false })
- .eq("id", matchId);
-
- const matches = get().matches.map((m) =>
- m.id === matchId ? { ...m, is_new: false } : m
- );
- const newCount = matches.filter((m) => m.is_new).length;
- set({ matches, newMatchesCount: newCount });
- },
-
- markAllAsSeen: async () => {
- const newMatches = get().matches.filter((m) => m.is_new);
- if (newMatches.length === 0) return;
-
- const newMatchIds = newMatches.map((m) => m.id);
- await supabase
- .from("matches")
- .update({ is_new: false })
- .in("id", newMatchIds);
-
- const matches = get().matches.map((m) =>
- m.is_new ? { ...m, is_new: false } : m
- );
- set({ matches, newMatchesCount: 0 });
- },
-
- addMatch: (match) => {
- set((state) => ({
- matches: [match, ...state.matches],
- newMatchesCount: state.newMatchesCount + 1,
- }));
- },
-
- updateMatchUnreadCount: (matchId, delta) => {
- set((state) => ({
- matches: state.matches.map((m) =>
- m.id === matchId
- ? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
- : m
- ),
- }));
- },
-
- clearMatches: () => {
- set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
- },
-}));
+import { create } from "zustand";
+import { supabase } from "../lib/supabase";
+import type { Match } from "@/types";
+import { useAuthStore } from "./authStore";
+
+const BATCH_SIZE = 20;
+
+interface MatchState {
+ matches: Match[];
+ newMatchesCount: number;
+ totalCount: number | null;
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ page: number;
+ hasMore: boolean;
+ error: string | null;
+ fetchMatches: (refresh?: boolean) => Promise;
+ markAsSeen: (matchId: string) => Promise;
+ markAllAsSeen: () => Promise;
+ addMatch: (match: Match) => void;
+ updateMatchUnreadCount: (matchId: string, delta: number) => void;
+ clearMatches: () => void;
+}
+
+export const useMatchStore = create((set, get) => ({
+ matches: [],
+ newMatchesCount: 0,
+ totalCount: null,
+ isLoading: false,
+ isLoadingMore: false,
+ page: 0,
+ hasMore: true,
+ error: null,
+
+ fetchMatches: async (refresh = false) => {
+ const userId = useAuthStore.getState().user?.id;
+ const coupleId = useAuthStore.getState().user?.couple_id;
+
+ // Early return if no couple - user isn't paired yet
+ if (!coupleId) {
+ set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
+ return;
+ }
+
+ const state = get();
+ if (state.isLoading || (state.isLoadingMore && !refresh)) return;
+
+ if (refresh) {
+ set({ isLoading: true, error: null, page: 0, hasMore: true });
+ } else {
+ if (!state.hasMore) return;
+ set({ isLoadingMore: true });
+ }
+
+ try {
+ const currentPage = refresh ? 0 : state.page;
+ const from = currentPage * BATCH_SIZE;
+ const to = from + BATCH_SIZE - 1;
+
+ // Fetch total count on refresh
+ let totalCount = state.totalCount;
+ if (refresh) {
+ const { count, error: countError } = await supabase
+ .from("matches")
+ .select("*", { count: "exact", head: true })
+ .eq("couple_id", coupleId);
+
+ if (!countError) {
+ totalCount = count ?? 0;
+ }
+ }
+
+ const { data: matches, error: matchError } = await supabase
+ .from("matches")
+ .select(`
+ *,
+ question:questions(*)
+ `)
+ .eq("couple_id", coupleId)
+ .order("created_at", { ascending: false })
+ .range(from, to);
+
+ if (matchError) throw matchError;
+ if (!matches || matches.length === 0) {
+ if (refresh) {
+ set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
+ } else {
+ set({ isLoadingMore: false, hasMore: false });
+ }
+ return;
+ }
+
+ // Fetch all responses for these questions to determine who answered first
+ const questionIds = matches.map(m => m.question_id);
+ let responses: any[] = [];
+ if (questionIds.length > 0) {
+ const { data } = await supabase
+ .from("responses")
+ .select("*")
+ .in("question_id", questionIds);
+ responses = data ?? [];
+ }
+
+ // Fetch unread message counts per match
+ const matchIds = matches.map(m => m.id);
+ let unreadCounts: Record = {};
+
+ if (userId && matchIds.length > 0) {
+ const { data: unreadMessages, error } = await supabase
+ .from("messages")
+ .select("match_id")
+ .in("match_id", matchIds)
+ .neq("user_id", userId)
+ .is("read_at", null);
+
+ if (error) {
+ console.error("Error fetching unread messages:", error);
+ }
+
+ // Count unread messages per match
+ unreadMessages?.forEach(msg => {
+ unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
+ });
+ }
+
+ const data = matches.map(match => ({
+ ...match,
+ responses: responses.filter(r => r.question_id === match.question_id),
+ unreadCount: unreadCounts[match.id] || 0
+ }));
+
+ // Sort: unread messages first, then by most recent
+ // Note: Since we fetch by created_at desc, and then sort by unread in memory,
+ // pagination might be slightly inconsistent if unread messages are scattered.
+ // However, implementing complex sort in Supabase with computed columns is harder.
+ // For now, we accept that the sort is applied within the fetched batch.
+ const sortedData = data.sort((a, b) => {
+ // First priority: matches with unread messages
+ if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
+ if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
+
+ // Second priority: most recent first
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
+ });
+
+ // Calculate new matches count - this is tricky with pagination
+ // Ideally we should have a separate query for count, but we'll just count in current batch
+ const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
+
+ set((state) => ({
+ matches: refresh ? sortedData : [...state.matches, ...sortedData],
+ newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
+ totalCount: refresh ? totalCount : state.totalCount,
+ isLoading: false,
+ isLoadingMore: false,
+ page: currentPage + 1,
+ hasMore: matches.length === BATCH_SIZE
+ }));
+ } catch (err) {
+ console.error("Error fetching matches:", err);
+ set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
+ }
+ },
+
+ markAsSeen: async (matchId) => {
+ await supabase
+ .from("matches")
+ .update({ is_new: false })
+ .eq("id", matchId);
+
+ const matches = get().matches.map((m) =>
+ m.id === matchId ? { ...m, is_new: false } : m
+ );
+ const newCount = matches.filter((m) => m.is_new).length;
+ set({ matches, newMatchesCount: newCount });
+ },
+
+ markAllAsSeen: async () => {
+ const newMatches = get().matches.filter((m) => m.is_new);
+ if (newMatches.length === 0) return;
+
+ const newMatchIds = newMatches.map((m) => m.id);
+ await supabase
+ .from("matches")
+ .update({ is_new: false })
+ .in("id", newMatchIds);
+
+ const matches = get().matches.map((m) =>
+ m.is_new ? { ...m, is_new: false } : m
+ );
+ set({ matches, newMatchesCount: 0 });
+ },
+
+ addMatch: (match) => {
+ set((state) => ({
+ matches: [match, ...state.matches],
+ newMatchesCount: state.newMatchesCount + 1,
+ totalCount: (state.totalCount ?? 0) + 1,
+ }));
+ },
+
+ updateMatchUnreadCount: (matchId, delta) => {
+ set((state) => ({
+ matches: state.matches.map((m) =>
+ m.id === matchId
+ ? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
+ : m
+ ),
+ }));
+ },
+
+clearMatches: () => {
+ set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
+ },
+}));
diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts
index cb30705..9ee53ea 100644
--- a/apps/mobile/src/store/responsesStore.ts
+++ b/apps/mobile/src/store/responsesStore.ts
@@ -1,278 +1,327 @@
-import { create } from "zustand";
-import { supabase } from "../lib/supabase";
-import { invokeWithAuthRetry } from "../lib/authErrorHandler";
-import type { Question, QuestionPack, AnswerType } from "@/types";
-import { useAuthStore } from "./authStore";
-import { useMatchStore } from "./matchStore";
-
-// Response with joined question and pack data
-export interface ResponseWithQuestion {
- id: string;
- question_id: string;
- answer: AnswerType;
- created_at: string;
- question: Question & {
- pack: Pick;
- };
- has_match: boolean;
- match_id?: string;
- partner_answered: boolean;
-}
-
-// Result from update-response edge function
-export interface UpdateResponseResult {
- success: boolean;
- requires_confirmation?: boolean;
- match_id?: string;
- message_count?: number;
- new_match?: Record | null;
- match_deleted?: boolean;
- match_type_updated?: boolean;
- error?: string;
-}
-
-export type GroupByOption = "pack" | "date" | "answer";
-
-interface ResponsesState {
- responses: ResponseWithQuestion[];
- isLoading: boolean;
- groupBy: GroupByOption;
- fetchResponses: () => Promise;
- updateResponse: (
- questionId: string,
- newAnswer: AnswerType,
- confirmDelete?: boolean
- ) => Promise;
- setGroupBy: (groupBy: GroupByOption) => void;
- clearResponses: () => void;
-}
-
-export const useResponsesStore = create((set, get) => ({
- responses: [],
- isLoading: false,
- groupBy: "pack",
-
- fetchResponses: async () => {
- const userId = useAuthStore.getState().user?.id;
- const coupleId = useAuthStore.getState().user?.couple_id;
-
- if (!userId || !coupleId) return;
-
- set({ isLoading: true });
-
- try {
- // Fetch all user's responses with questions and packs
- const { data: responses, error: responsesError } = await supabase
- .from("responses")
- .select(`
- id,
- question_id,
- answer,
- created_at,
- question:questions(
- id,
- text,
- partner_text,
- intensity,
- pack_id,
- pack:question_packs(id, name, icon)
- )
- `)
- .eq("user_id", userId)
- .order("created_at", { ascending: false });
-
- if (responsesError) {
- console.error("Error fetching responses:", responsesError);
- set({ isLoading: false });
- return;
- }
-
- if (!responses) {
- set({ responses: [], isLoading: false });
- return;
- }
-
- // Get question IDs for match lookup
- const questionIds = responses.map((r) => r.question_id);
-
- // Fetch matches for these questions
- const { data: matches } = await supabase
- .from("matches")
- .select("id, question_id")
- .eq("couple_id", coupleId)
- .in("question_id", questionIds);
-
- // Create a map of question_id -> match_id
- const matchMap = new Map();
- matches?.forEach((m) => matchMap.set(m.question_id, m.id));
-
- // Fetch partner's responses to check if they've answered
- const { data: partnerResponses } = await supabase
- .from("responses")
- .select("question_id")
- .eq("couple_id", coupleId)
- .neq("user_id", userId)
- .in("question_id", questionIds);
-
- // Create a set of question IDs that partner has answered
- const partnerAnsweredSet = new Set(
- partnerResponses?.map((r) => r.question_id) || []
- );
-
- // Transform responses with match and partner info
- const transformedResponses: ResponseWithQuestion[] = responses
- .filter((r) => {
- // Supabase returns single relations as objects, but TypeScript may infer arrays
- const question = r.question as any;
- return question && question.pack;
- })
- .map((r) => {
- // Cast to any to handle Supabase's nested select typing
- const question = r.question as any;
- const pack = question.pack as any;
-
- return {
- id: r.id,
- question_id: r.question_id,
- answer: r.answer as AnswerType,
- created_at: r.created_at,
- question: {
- id: question.id,
- text: question.text,
- partner_text: question.partner_text,
- intensity: question.intensity,
- pack_id: question.pack_id,
- created_at: question.created_at || "",
- pack: {
- id: pack.id,
- name: pack.name,
- icon: pack.icon,
- },
- },
- has_match: matchMap.has(r.question_id),
- match_id: matchMap.get(r.question_id),
- partner_answered: partnerAnsweredSet.has(r.question_id),
- };
- });
-
- set({ responses: transformedResponses, isLoading: false });
- } catch (error) {
- console.error("Error in fetchResponses:", error);
- set({ isLoading: false });
- }
- },
-
- updateResponse: async (
- questionId: string,
- newAnswer: AnswerType,
- confirmDelete = false
- ): Promise => {
- const { data, error } = await invokeWithAuthRetry("update-response", {
- body: {
- question_id: questionId,
- new_answer: newAnswer,
- confirm_delete_match: confirmDelete,
- },
- });
-
- if (error) {
- console.error("Error updating response:", error);
- return { success: false, error: error.message || "Failed to update response" };
- }
-
- const result = data as UpdateResponseResult;
-
- // If update was successful (not just requiring confirmation), update local state
- if (result.success && !result.requires_confirmation) {
- // Update local response state
- set((state) => ({
- responses: state.responses.map((r) => {
- if (r.question_id === questionId) {
- return {
- ...r,
- answer: newAnswer,
- // Update match status based on result
- has_match: result.match_deleted
- ? false
- : result.new_match
- ? true
- : r.has_match,
- match_id: result.match_deleted
- ? undefined
- : result.new_match
- ? (result.new_match.id as string)
- : r.match_id,
- };
- }
- return r;
- }),
- }));
-
- // Refresh matches store if match was created, deleted, or type updated
- if (result.match_deleted || result.new_match || result.match_type_updated) {
- useMatchStore.getState().fetchMatches();
- }
- }
-
- return result;
- },
-
- setGroupBy: (groupBy: GroupByOption) => {
- set({ groupBy });
- },
-
- clearResponses: () => {
- set({ responses: [], isLoading: false, groupBy: "pack" });
- },
-}));
-
-// Helper function to group responses
-export function groupResponses(
- responses: ResponseWithQuestion[],
- groupBy: GroupByOption
-): { title: string; data: ResponseWithQuestion[] }[] {
- if (groupBy === "pack") {
- // Group by pack name
- const groups = new Map();
- responses.forEach((r) => {
- const packName = r.question.pack.name;
- if (!groups.has(packName)) {
- groups.set(packName, []);
- }
- groups.get(packName)!.push(r);
- });
- return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
- }
-
- if (groupBy === "answer") {
- // Group by answer type
- const yesResponses = responses.filter((r) => r.answer === "yes");
- const maybeResponses = responses.filter((r) => r.answer === "maybe");
- const noResponses = responses.filter((r) => r.answer === "no");
-
- const sections = [];
- if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
- if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
- if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
- return sections;
- }
-
- if (groupBy === "date") {
- // Group by date (day)
- const groups = new Map();
- responses.forEach((r) => {
- const date = new Date(r.created_at).toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
- if (!groups.has(date)) {
- groups.set(date, []);
- }
- groups.get(date)!.push(r);
- });
- return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
- }
-
- return [{ title: "All Responses", data: responses }];
-}
+import { create } from "zustand";
+import { supabase } from "../lib/supabase";
+import { invokeWithAuthRetry } from "../lib/authErrorHandler";
+import type { Question, QuestionPack, AnswerType } from "@/types";
+import { useAuthStore } from "./authStore";
+import { useMatchStore } from "./matchStore";
+
+// Response with joined question and pack data
+export interface ResponseWithQuestion {
+ id: string;
+ question_id: string;
+ answer: AnswerType;
+ created_at: string;
+ question: Question & {
+ pack: Pick;
+ };
+ has_match: boolean;
+ match_id?: string;
+ partner_answered: boolean;
+}
+
+// Result from update-response edge function
+export interface UpdateResponseResult {
+ success: boolean;
+ requires_confirmation?: boolean;
+ match_id?: string;
+ message_count?: number;
+ new_match?: Record | null;
+ match_deleted?: boolean;
+ match_type_updated?: boolean;
+ error?: string;
+}
+
+export type GroupByOption = "pack" | "date" | "answer";
+
+const BATCH_SIZE = 20;
+
+interface ResponsesState {
+ responses: ResponseWithQuestion[];
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ groupBy: GroupByOption;
+ hasMore: boolean;
+ page: number;
+ totalCount: number | null;
+ fetchResponses: (refresh?: boolean) => Promise;
+ updateResponse: (
+ questionId: string,
+ newAnswer: AnswerType,
+ confirmDelete?: boolean
+ ) => Promise;
+ setGroupBy: (groupBy: GroupByOption) => void;
+ clearResponses: () => void;
+}
+
+export const useResponsesStore = create((set, get) => ({
+ responses: [],
+ isLoading: false,
+ isLoadingMore: false,
+ groupBy: "pack",
+ hasMore: true,
+ page: 0,
+ totalCount: null,
+
+ fetchResponses: async (refresh = false) => {
+ const userId = useAuthStore.getState().user?.id;
+ const coupleId = useAuthStore.getState().user?.couple_id;
+
+ if (!userId || !coupleId) return;
+
+ const state = get();
+ if (state.isLoading || (state.isLoadingMore && !refresh)) return;
+
+ if (refresh) {
+ set({ isLoading: true, page: 0, hasMore: true });
+ } else {
+ if (!state.hasMore) return;
+ set({ isLoadingMore: true });
+ }
+
+ try {
+ const currentPage = refresh ? 0 : state.page;
+ const from = currentPage * BATCH_SIZE;
+ const to = from + BATCH_SIZE - 1;
+
+ // Fetch total count on refresh
+ let totalCount = state.totalCount;
+ if (refresh) {
+ const { count, error: countError } = await supabase
+ .from("responses")
+ .select("*", { count: "exact", head: true })
+ .eq("user_id", userId);
+
+ if (!countError) {
+ totalCount = count ?? 0;
+ }
+ }
+
+ // Fetch all user's responses with questions and packs
+ const { data: responses, error: responsesError } = await supabase
+ .from("responses")
+ .select(`
+ id,
+ question_id,
+ answer,
+ created_at,
+ question:questions(
+ id,
+ text,
+ partner_text,
+ intensity,
+ pack_id,
+ pack:question_packs(id, name, icon)
+ )
+ `)
+ .eq("user_id", userId)
+ .order("created_at", { ascending: false })
+ .range(from, to);
+
+ if (responsesError) {
+ console.error("Error fetching responses:", responsesError);
+ set({ isLoading: false, isLoadingMore: false });
+ return;
+ }
+
+ if (!responses || responses.length === 0) {
+ if (refresh) {
+ set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
+ } else {
+ set({ isLoadingMore: false, hasMore: false });
+ }
+ return;
+ }
+
+ // Get question IDs for match lookup
+ const questionIds = responses.map((r) => r.question_id);
+
+ // Fetch matches for these questions
+ const { data: matches } = await supabase
+ .from("matches")
+ .select("id, question_id")
+ .eq("couple_id", coupleId)
+ .in("question_id", questionIds);
+
+ // Create a map of question_id -> match_id
+ const matchMap = new Map();
+ matches?.forEach((m) => matchMap.set(m.question_id, m.id));
+
+ // Fetch partner's responses to check if they've answered
+ const { data: partnerResponses } = await supabase
+ .from("responses")
+ .select("question_id")
+ .eq("couple_id", coupleId)
+ .neq("user_id", userId)
+ .in("question_id", questionIds);
+
+ // Create a set of question IDs that partner has answered
+ const partnerAnsweredSet = new Set(
+ partnerResponses?.map((r) => r.question_id) || []
+ );
+
+ // Transform responses with match and partner info
+ const transformedResponses: ResponseWithQuestion[] = responses
+ .filter((r) => {
+ // Supabase returns single relations as objects, but TypeScript may infer arrays
+ const question = r.question as any;
+ return question && question.pack;
+ })
+ .map((r) => {
+ // Cast to any to handle Supabase's nested select typing
+ const question = r.question as any;
+ const pack = question.pack as any;
+
+ return {
+ id: r.id,
+ question_id: r.question_id,
+ answer: r.answer as AnswerType,
+ created_at: r.created_at,
+ question: {
+ id: question.id,
+ text: question.text,
+ partner_text: question.partner_text,
+ intensity: question.intensity,
+ pack_id: question.pack_id,
+ created_at: question.created_at || "",
+ pack: {
+ id: pack.id,
+ name: pack.name,
+ icon: pack.icon,
+ },
+ },
+ has_match: matchMap.has(r.question_id),
+ match_id: matchMap.get(r.question_id),
+ partner_answered: partnerAnsweredSet.has(r.question_id),
+ };
+ });
+
+ set((state) => ({
+ responses: refresh
+ ? transformedResponses
+ : [...state.responses, ...transformedResponses],
+ totalCount: refresh ? totalCount : state.totalCount,
+ isLoading: false,
+ isLoadingMore: false,
+ page: currentPage + 1,
+ hasMore: responses.length === BATCH_SIZE
+ }));
+ } catch (error) {
+ console.error("Error in fetchResponses:", error);
+ set({ isLoading: false, isLoadingMore: false });
+ }
+ },
+
+ updateResponse: async (
+ questionId: string,
+ newAnswer: AnswerType,
+ confirmDelete = false
+ ): Promise => {
+ const { data, error } = await invokeWithAuthRetry("update-response", {
+ body: {
+ question_id: questionId,
+ new_answer: newAnswer,
+ confirm_delete_match: confirmDelete,
+ },
+ });
+
+ if (error) {
+ console.error("Error updating response:", error);
+ return { success: false, error: error.message || "Failed to update response" };
+ }
+
+ const result = data as UpdateResponseResult;
+
+ // If update was successful (not just requiring confirmation), update local state
+ if (result.success && !result.requires_confirmation) {
+ // Update local response state
+ set((state) => ({
+ responses: state.responses.map((r) => {
+ if (r.question_id === questionId) {
+ return {
+ ...r,
+ answer: newAnswer,
+ // Update match status based on result
+ has_match: result.match_deleted
+ ? false
+ : result.new_match
+ ? true
+ : r.has_match,
+ match_id: result.match_deleted
+ ? undefined
+ : result.new_match
+ ? (result.new_match.id as string)
+ : r.match_id,
+ };
+ }
+ return r;
+ }),
+ }));
+
+ // Refresh matches store if match was created, deleted, or type updated
+ if (result.match_deleted || result.new_match || result.match_type_updated) {
+ useMatchStore.getState().fetchMatches();
+ }
+ }
+
+ return result;
+ },
+
+ setGroupBy: (groupBy: GroupByOption) => {
+ set({ groupBy });
+ },
+
+clearResponses: () => {
+ set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
+ },
+}));
+
+// Helper function to group responses
+export function groupResponses(
+ responses: ResponseWithQuestion[],
+ groupBy: GroupByOption
+): { title: string; data: ResponseWithQuestion[] }[] {
+ if (groupBy === "pack") {
+ // Group by pack name
+ const groups = new Map();
+ responses.forEach((r) => {
+ const packName = r.question.pack.name;
+ if (!groups.has(packName)) {
+ groups.set(packName, []);
+ }
+ groups.get(packName)!.push(r);
+ });
+ return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
+ }
+
+ if (groupBy === "answer") {
+ // Group by answer type
+ const yesResponses = responses.filter((r) => r.answer === "yes");
+ const maybeResponses = responses.filter((r) => r.answer === "maybe");
+ const noResponses = responses.filter((r) => r.answer === "no");
+
+ const sections = [];
+ if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
+ if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
+ if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
+ return sections;
+ }
+
+ if (groupBy === "date") {
+ // Group by date (day)
+ const groups = new Map();
+ responses.forEach((r) => {
+ const date = new Date(r.created_at).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ if (!groups.has(date)) {
+ groups.set(date, []);
+ }
+ groups.get(date)!.push(r);
+ });
+ return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
+ }
+
+ return [{ title: "All Responses", data: responses }];
+}
diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts
index a7e9639..87a4938 100644
--- a/apps/mobile/src/test/setup.ts
+++ b/apps/mobile/src/test/setup.ts
@@ -39,6 +39,7 @@ jest.mock('@supabase/supabase-js', () => {
is: jest.fn(),
in: jest.fn(),
order: jest.fn(),
+ range: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
insert: jest.fn(),